feat(creator-channel): FanTalk 상세 상태를 구현한다
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins
|
||||
import io.reactivex.rxjava3.core.Scheduler
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
|
||||
import kr.co.vividnext.sodalive.common.ToastMessage
|
||||
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkReplyUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkRightAction
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.times
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], application = Application::class)
|
||||
class CreatorChannelFanTalkDetailViewModelTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private lateinit var repository: CreatorChannelRepository
|
||||
private lateinit var viewModel: CreatorChannelFanTalkDetailViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
setImmediateRxSchedulers()
|
||||
SharedPreferenceManager.resetForTest()
|
||||
SodaLiveApplicationHolder.init(context as Application)
|
||||
SharedPreferenceManager.init(context)
|
||||
SharedPreferenceManager.token = "test-token"
|
||||
repository = org.mockito.kotlin.mock()
|
||||
viewModel = CreatorChannelFanTalkDetailViewModel(
|
||||
repository,
|
||||
UtcRelativeTimeTextFormatter { value -> "relative:$value" }
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
RxJavaPlugins.reset()
|
||||
RxAndroidPlugins.reset()
|
||||
SharedPreferenceManager.resetForTest()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payload로 받은 부모 팬톡과 첫 답글을 content state로 노출한다`() {
|
||||
val parent = fanTalk(reply = reply(201L, "기존 답글"))
|
||||
|
||||
viewModel.load(creatorId = 100L, payload = parent.toFanTalkDetailPayload())
|
||||
|
||||
val state = viewModel.detailStateLiveData.requireContent()
|
||||
assertEquals(parent, state.parentFanTalk)
|
||||
assertEquals(parent.reply, state.reply)
|
||||
assertEquals("", state.replyInput)
|
||||
assertFalse(state.isSendEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `공백 입력은 보내기 비활성 상태이고 submit해도 API를 호출하지 않는다`() {
|
||||
viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload())
|
||||
|
||||
viewModel.updateReplyInput(" \n \t")
|
||||
viewModel.submitReply()
|
||||
|
||||
val state = viewModel.detailStateLiveData.requireContent()
|
||||
assertEquals(" \n \t", state.replyInput)
|
||||
assertFalse(state.isSendEnabled)
|
||||
verify(repository, never()).writeFanTalkReply(any(), any(), any(), any())
|
||||
verify(repository, never()).modifyFanTalkReply(any(), any(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `답글이 없으면 submit 시 반환된 답글 데이터를 현재 state에 바인딩한다`() {
|
||||
whenever(repository.writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token"))
|
||||
.thenReturn(Single.just(ApiResponse(true, serverReply(301L, "새 답글"), null)))
|
||||
viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload())
|
||||
viewModel.updateReplyInput(" 새 답글 ")
|
||||
|
||||
viewModel.submitReply()
|
||||
|
||||
verify(repository).writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token")
|
||||
val state = viewModel.detailStateLiveData.requireContent()
|
||||
assertEquals("", state.replyInput)
|
||||
assertEquals(301L, state.reply?.fanTalkId)
|
||||
assertEquals(900L, state.reply?.writerId)
|
||||
assertEquals("server-creator", state.reply?.writerNickname)
|
||||
assertEquals("server-creator.png", state.reply?.writerProfileImageUrl)
|
||||
assertEquals("새 답글", state.reply?.content)
|
||||
assertEquals("relative:2026-07-09T01:02:03Z", state.reply?.createdAtText)
|
||||
assertEquals(null, viewModel.toastLiveData.requireValue()?.consume())
|
||||
assertTrue(viewModel.replyChangedEventLiveData.requireValue() == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `작성 성공 응답의 data가 null이어도 성공으로 처리하고 실패 toast를 노출하지 않는다`() {
|
||||
whenever(repository.writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token"))
|
||||
.thenReturn(Single.just(ApiResponse<CreatorChannelFanTalkReplyResponse>(true, null, null)))
|
||||
viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload())
|
||||
viewModel.updateReplyInput("새 답글")
|
||||
|
||||
viewModel.submitReply()
|
||||
|
||||
verify(repository).writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token")
|
||||
val state = viewModel.detailStateLiveData.requireContent()
|
||||
assertEquals("", state.replyInput)
|
||||
assertEquals(null, viewModel.toastLiveData.requireValue()?.consume())
|
||||
assertTrue(viewModel.replyChangedEventLiveData.requireValue() == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `작성 직후 서버 답글 id로 대체 submit과 수정 API를 진행한다`() {
|
||||
whenever(repository.writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token"))
|
||||
.thenReturn(Single.just(ApiResponse(true, serverReply(301L, "새 답글"), null)))
|
||||
whenever(repository.modifyFanTalkReply(301L, "다시 쓰기", "Bearer test-token"))
|
||||
.thenReturn(Single.just(ApiResponse<CreatorChannelFanTalkReplyResponse>(true, null, null)))
|
||||
viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload())
|
||||
viewModel.updateReplyInput("새 답글")
|
||||
viewModel.submitReply()
|
||||
|
||||
viewModel.updateReplyInput("다시 쓰기")
|
||||
viewModel.submitReply()
|
||||
viewModel.confirmReplacement()
|
||||
|
||||
verify(repository).modifyFanTalkReply(301L, "다시 쓰기", "Bearer test-token")
|
||||
assertEquals("다시 쓰기", viewModel.detailStateLiveData.requireContent().reply?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `기존 답글이 있으면 submit 시 대체 모달 event만 emit하고 확인 전에는 API를 호출하지 않는다`() {
|
||||
viewModel.load(creatorId = 100L, payload = fanTalk(reply = reply(201L, "기존 답글")).toFanTalkDetailPayload())
|
||||
viewModel.updateReplyInput(" 대체 답글 ")
|
||||
|
||||
viewModel.submitReply()
|
||||
|
||||
val event = viewModel.replacementModalEventLiveData.requireValue()?.consume()
|
||||
assertEquals(CreatorChannelFanTalkReplacementModalUiModel(), event)
|
||||
verify(repository, never()).writeFanTalkReply(any(), any(), any(), any())
|
||||
verify(repository, never()).modifyFanTalkReply(any(), any(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `대체 모달 확인 시 기존 답글 id에 trim content로 수정 wrapper를 호출한다`() {
|
||||
whenever(repository.modifyFanTalkReply(201L, "대체 답글", "Bearer test-token"))
|
||||
.thenReturn(Single.just(ApiResponse<CreatorChannelFanTalkReplyResponse>(true, null, null)))
|
||||
viewModel.load(creatorId = 100L, payload = fanTalk(reply = reply(201L, "기존 답글")).toFanTalkDetailPayload())
|
||||
viewModel.updateReplyInput(" 대체 답글 ")
|
||||
viewModel.submitReply()
|
||||
|
||||
viewModel.confirmReplacement()
|
||||
|
||||
verify(repository).modifyFanTalkReply(201L, "대체 답글", "Bearer test-token")
|
||||
val state = viewModel.detailStateLiveData.requireContent()
|
||||
assertEquals("", state.replyInput)
|
||||
assertEquals("대체 답글", state.reply?.content)
|
||||
assertTrue(viewModel.replyChangedEventLiveData.requireValue() == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `대체 모달 취소 시 입력값을 유지하고 API를 호출하지 않는다`() {
|
||||
viewModel.load(creatorId = 100L, payload = fanTalk(reply = reply(201L, "기존 답글")).toFanTalkDetailPayload())
|
||||
viewModel.updateReplyInput(" 대체 답글 ")
|
||||
viewModel.submitReply()
|
||||
|
||||
viewModel.cancelReplacement()
|
||||
|
||||
assertEquals(" 대체 답글 ", viewModel.detailStateLiveData.requireContent().replyInput)
|
||||
verify(repository, never()).writeFanTalkReply(any(), any(), any(), any())
|
||||
verify(repository, never()).modifyFanTalkReply(any(), any(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `등록 중에는 중복 submit을 막는다`() {
|
||||
whenever(repository.writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token"))
|
||||
.thenReturn(Single.never())
|
||||
viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload())
|
||||
viewModel.updateReplyInput("새 답글")
|
||||
|
||||
viewModel.submitReply()
|
||||
viewModel.submitReply()
|
||||
|
||||
verify(repository, times(1)).writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token")
|
||||
assertTrue(viewModel.detailStateLiveData.requireContent().isSubmitting)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `실패 시 입력값을 유지하고 서버 message를 우선 toast로 emit한다`() {
|
||||
whenever(repository.writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token"))
|
||||
.thenReturn(Single.just(ApiResponse<CreatorChannelFanTalkReplyResponse>(false, null, "서버 실패")))
|
||||
viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload())
|
||||
viewModel.updateReplyInput(" 새 답글 ")
|
||||
|
||||
viewModel.submitReply()
|
||||
|
||||
assertEquals(" 새 답글 ", viewModel.detailStateLiveData.requireContent().replyInput)
|
||||
assertEquals(ToastMessage(message = "서버 실패"), viewModel.toastLiveData.requireValue()?.consume())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `실패 message가 없으면 공통 오류 toast를 emit한다`() {
|
||||
whenever(repository.writeFanTalkReply(10L, 100L, "새 답글", "Bearer test-token"))
|
||||
.thenReturn(Single.just(ApiResponse<CreatorChannelFanTalkReplyResponse>(false, null, null)))
|
||||
viewModel.load(creatorId = 100L, payload = fanTalk(reply = null).toFanTalkDetailPayload())
|
||||
viewModel.updateReplyInput("새 답글")
|
||||
|
||||
viewModel.submitReply()
|
||||
|
||||
assertEquals(ToastMessage(resId = R.string.common_error_unknown), viewModel.toastLiveData.requireValue()?.consume())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `답글 수정 모드는 입력값을 채우고 submit 시 수정 wrapper를 호출한다`() {
|
||||
whenever(repository.modifyFanTalkReply(201L, "수정 답글", "Bearer test-token"))
|
||||
.thenReturn(Single.just(ApiResponse<CreatorChannelFanTalkReplyResponse>(true, null, null)))
|
||||
viewModel.load(creatorId = 100L, payload = fanTalk(reply = reply(201L, "기존 답글")).toFanTalkDetailPayload())
|
||||
|
||||
viewModel.startReplyEdit()
|
||||
viewModel.updateReplyInput(" 수정 답글 ")
|
||||
viewModel.submitReply()
|
||||
|
||||
verify(repository).modifyFanTalkReply(201L, "수정 답글", "Bearer test-token")
|
||||
val state = viewModel.detailStateLiveData.requireContent()
|
||||
assertEquals("", state.replyInput)
|
||||
assertEquals(null, state.editingTarget)
|
||||
assertEquals("수정 답글", state.reply?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `답글 삭제는 delete wrapper를 호출하고 성공 시 local reply를 제거한다`() {
|
||||
whenever(repository.deleteFanTalkReply(201L, "Bearer test-token"))
|
||||
.thenReturn(Single.just(ApiResponse<CreatorChannelFanTalkReplyResponse>(true, null, null)))
|
||||
viewModel.load(creatorId = 100L, payload = fanTalk(reply = reply(201L, "기존 답글")).toFanTalkDetailPayload())
|
||||
|
||||
viewModel.deleteReply()
|
||||
|
||||
verify(repository).deleteFanTalkReply(201L, "Bearer test-token")
|
||||
assertEquals(null, viewModel.detailStateLiveData.requireContent().reply)
|
||||
assertTrue(viewModel.replyChangedEventLiveData.requireValue() == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `대체 모달 UI 모델은 문구를 문자열 리소스 id로 제공한다`() {
|
||||
val modal = CreatorChannelFanTalkReplacementModalUiModel()
|
||||
|
||||
assertEquals(R.string.screen_user_profile_reply_edit, modal.titleResId)
|
||||
assertEquals(R.string.creator_channel_fantalk_reply_replace_description, modal.descriptionResId)
|
||||
assertEquals(R.string.cancel, modal.cancelResId)
|
||||
assertEquals(R.string.confirm, modal.confirmResId)
|
||||
}
|
||||
|
||||
private fun setImmediateRxSchedulers() {
|
||||
val trampoline = { _: Scheduler -> Schedulers.trampoline() }
|
||||
RxJavaPlugins.setIoSchedulerHandler(trampoline)
|
||||
RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
|
||||
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
|
||||
}
|
||||
|
||||
private fun serverReply(id: Long, content: String) = CreatorChannelFanTalkReplyResponse(
|
||||
fanTalkId = id,
|
||||
writerId = 900L,
|
||||
writerNickname = "server-creator",
|
||||
writerProfileImageUrl = "server-creator.png",
|
||||
content = content,
|
||||
createdAtUtc = "2026-07-09T01:02:03Z"
|
||||
)
|
||||
|
||||
private fun fanTalk(reply: CreatorChannelFanTalkReplyUiModel?) = CreatorChannelFanTalkUiModel(
|
||||
fanTalkId = 10L,
|
||||
writerId = 20L,
|
||||
writerNickname = "fan",
|
||||
writerProfileImageUrl = "fan.png",
|
||||
content = "응원 원문",
|
||||
createdAtText = "방금 전",
|
||||
reply = reply,
|
||||
rightAction = CreatorChannelFanTalkRightAction.OwnerMore(showEdit = false, showDelete = true)
|
||||
)
|
||||
|
||||
private fun reply(id: Long, content: String) = CreatorChannelFanTalkReplyUiModel(
|
||||
fanTalkId = id,
|
||||
writerId = 30L,
|
||||
writerNickname = "creator",
|
||||
writerProfileImageUrl = "creator.png",
|
||||
content = content,
|
||||
createdAtText = "방금 전"
|
||||
)
|
||||
|
||||
private fun LiveData<CreatorChannelFanTalkDetailUiState>.requireContent(): CreatorChannelFanTalkDetailUiState.Content {
|
||||
return requireValue() as CreatorChannelFanTalkDetailUiState.Content
|
||||
}
|
||||
|
||||
private fun <T> LiveData<T>.requireValue(): T? {
|
||||
var value: T? = null
|
||||
val observer = Observer<T> { value = it }
|
||||
observeForever(observer)
|
||||
removeObserver(observer)
|
||||
return value
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user