feat(creator-channel): FanTalk 상세 상태를 구현한다
This commit is contained in:
@@ -0,0 +1,244 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import com.orhanobut.logger.Logger
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kr.co.vividnext.sodalive.base.BaseViewModel
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||||
|
import kr.co.vividnext.sodalive.common.ToastMessage
|
||||||
|
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent
|
||||||
|
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.toFanTalkReplyUiModel
|
||||||
|
|
||||||
|
class CreatorChannelFanTalkDetailViewModel(
|
||||||
|
private val repository: CreatorChannelRepository,
|
||||||
|
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
private val _detailStateLiveData = MutableLiveData<CreatorChannelFanTalkDetailUiState>()
|
||||||
|
val detailStateLiveData: LiveData<CreatorChannelFanTalkDetailUiState>
|
||||||
|
get() = _detailStateLiveData
|
||||||
|
|
||||||
|
private val _replacementModalEventLiveData =
|
||||||
|
MutableLiveData<CreatorChannelEvent<CreatorChannelFanTalkReplacementModalUiModel>>()
|
||||||
|
val replacementModalEventLiveData: LiveData<CreatorChannelEvent<CreatorChannelFanTalkReplacementModalUiModel>>
|
||||||
|
get() = _replacementModalEventLiveData
|
||||||
|
|
||||||
|
private val _replyChangedEventLiveData = MutableLiveData(false)
|
||||||
|
val replyChangedEventLiveData: LiveData<Boolean>
|
||||||
|
get() = _replyChangedEventLiveData
|
||||||
|
|
||||||
|
private val _toastLiveData = MutableLiveData<CreatorChannelEvent<ToastMessage>>()
|
||||||
|
val toastLiveData: LiveData<CreatorChannelEvent<ToastMessage>>
|
||||||
|
get() = _toastLiveData
|
||||||
|
|
||||||
|
private var creatorId: Long = 0L
|
||||||
|
private var isSubmitting: Boolean = false
|
||||||
|
private var pendingReplacementContent: String? = null
|
||||||
|
|
||||||
|
fun load(creatorId: Long, payload: CreatorChannelFanTalkDetailPayload) {
|
||||||
|
this.creatorId = creatorId
|
||||||
|
val parentFanTalk = payload.toUiModel()
|
||||||
|
_detailStateLiveData.value = CreatorChannelFanTalkDetailUiState.Content(
|
||||||
|
parentFanTalk = parentFanTalk,
|
||||||
|
reply = parentFanTalk.reply
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateReplyInput(input: String) {
|
||||||
|
val content = currentContent() ?: return
|
||||||
|
_detailStateLiveData.value = content.copy(replyInput = input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitReply() {
|
||||||
|
val content = currentContent() ?: return
|
||||||
|
val trimmedContent = content.replyInput.trim()
|
||||||
|
if (trimmedContent.isEmpty() || isSubmitting) return
|
||||||
|
|
||||||
|
val editingTarget = content.editingTarget
|
||||||
|
if (editingTarget is CreatorChannelFanTalkDetailEditTarget.Reply) {
|
||||||
|
modifyReply(content, editingTarget.replyFanTalkId, trimmedContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val reply = content.reply
|
||||||
|
if (reply == null) {
|
||||||
|
writeReply(content, trimmedContent)
|
||||||
|
} else {
|
||||||
|
if (reply.fanTalkId <= 0L) {
|
||||||
|
showFailureToast()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingReplacementContent = trimmedContent
|
||||||
|
_replacementModalEventLiveData.value = CreatorChannelEvent(CreatorChannelFanTalkReplacementModalUiModel())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmReplacement() {
|
||||||
|
val content = currentContent() ?: return
|
||||||
|
val reply = content.reply ?: return
|
||||||
|
val replacementContent = pendingReplacementContent ?: content.replyInput.trim()
|
||||||
|
if (replacementContent.isBlank() || isSubmitting) return
|
||||||
|
if (reply.fanTalkId <= 0L) {
|
||||||
|
pendingReplacementContent = null
|
||||||
|
showFailureToast()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modifyReply(content, reply.fanTalkId, replacementContent)
|
||||||
|
pendingReplacementContent = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelReplacement() {
|
||||||
|
pendingReplacementContent = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeReplyChangedEvent() {
|
||||||
|
_replyChangedEventLiveData.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelReplyEdit() {
|
||||||
|
val content = currentContent() ?: return
|
||||||
|
_detailStateLiveData.value = content.copy(replyInput = "", editingTarget = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startReplyEdit() {
|
||||||
|
val content = currentContent() ?: return
|
||||||
|
val reply = content.reply ?: return
|
||||||
|
_detailStateLiveData.value = content.copy(
|
||||||
|
replyInput = reply.content,
|
||||||
|
editingTarget = CreatorChannelFanTalkDetailEditTarget.Reply(reply.fanTalkId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteReply() {
|
||||||
|
val content = currentContent() ?: return
|
||||||
|
val reply = content.reply ?: return
|
||||||
|
if (isSubmitting) return
|
||||||
|
|
||||||
|
submitMutation(content, repository.deleteFanTalkReply(reply.fanTalkId, authToken())) { current ->
|
||||||
|
current.copy(reply = null, replyInput = "", editingTarget = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeReply(content: CreatorChannelFanTalkDetailUiState.Content, replyContent: String) {
|
||||||
|
if (creatorId <= 0L || content.parentFanTalk.fanTalkId <= 0L) return
|
||||||
|
|
||||||
|
isSubmitting = true
|
||||||
|
_detailStateLiveData.value = content.copy(isSubmitting = true)
|
||||||
|
compositeDisposable.add(
|
||||||
|
repository.writeFanTalkReply(
|
||||||
|
fanTalkId = content.parentFanTalk.fanTalkId,
|
||||||
|
creatorId = creatorId,
|
||||||
|
content = replyContent,
|
||||||
|
token = authToken()
|
||||||
|
)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ response -> handleWriteReplyResponse(response) },
|
||||||
|
{ error -> handleMutationError(error) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun modifyReply(
|
||||||
|
content: CreatorChannelFanTalkDetailUiState.Content,
|
||||||
|
replyFanTalkId: Long,
|
||||||
|
replyContent: String
|
||||||
|
) {
|
||||||
|
if (replyFanTalkId <= 0L) return
|
||||||
|
|
||||||
|
submitMutation(
|
||||||
|
content = content,
|
||||||
|
request = repository.modifyFanTalkReply(replyFanTalkId, replyContent, authToken())
|
||||||
|
) { current ->
|
||||||
|
current.copy(
|
||||||
|
reply = current.reply?.copy(content = replyContent),
|
||||||
|
replyInput = "",
|
||||||
|
editingTarget = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun submitMutation(
|
||||||
|
content: CreatorChannelFanTalkDetailUiState.Content,
|
||||||
|
request: Single<ApiResponse<CreatorChannelFanTalkReplyResponse>>,
|
||||||
|
onSuccess: (CreatorChannelFanTalkDetailUiState.Content) -> CreatorChannelFanTalkDetailUiState.Content
|
||||||
|
) {
|
||||||
|
isSubmitting = true
|
||||||
|
_detailStateLiveData.value = content.copy(isSubmitting = true)
|
||||||
|
compositeDisposable.add(
|
||||||
|
request
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ response -> handleMutationResponse(response, onSuccess) },
|
||||||
|
{ error -> handleMutationError(error) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleWriteReplyResponse(response: ApiResponse<CreatorChannelFanTalkReplyResponse>) {
|
||||||
|
isSubmitting = false
|
||||||
|
val current = currentContent() ?: return
|
||||||
|
if (response.success) {
|
||||||
|
_detailStateLiveData.value = current.copy(
|
||||||
|
reply = response.data?.toFanTalkReplyUiModel(relativeTimeTextFormatter) ?: current.reply,
|
||||||
|
replyInput = "",
|
||||||
|
isSubmitting = false,
|
||||||
|
editingTarget = null
|
||||||
|
)
|
||||||
|
_replyChangedEventLiveData.value = true
|
||||||
|
} else {
|
||||||
|
showFailureToast(response.message)
|
||||||
|
_detailStateLiveData.value = current.copy(isSubmitting = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMutationError(error: Throwable) {
|
||||||
|
isSubmitting = false
|
||||||
|
error.message?.let { Logger.e(it) }
|
||||||
|
val current = currentContent() ?: return
|
||||||
|
showFailureToast()
|
||||||
|
_detailStateLiveData.value = current.copy(isSubmitting = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMutationResponse(
|
||||||
|
response: ApiResponse<CreatorChannelFanTalkReplyResponse>,
|
||||||
|
onSuccess: (CreatorChannelFanTalkDetailUiState.Content) -> CreatorChannelFanTalkDetailUiState.Content
|
||||||
|
) {
|
||||||
|
isSubmitting = false
|
||||||
|
val current = currentContent() ?: return
|
||||||
|
if (response.success) {
|
||||||
|
_detailStateLiveData.value = onSuccess(current).copy(isSubmitting = false)
|
||||||
|
_replyChangedEventLiveData.value = true
|
||||||
|
} else {
|
||||||
|
showFailureToast(response.message)
|
||||||
|
_detailStateLiveData.value = current.copy(isSubmitting = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showFailureToast(message: String? = null) {
|
||||||
|
_toastLiveData.value = CreatorChannelEvent(
|
||||||
|
if (message.isNullOrBlank()) {
|
||||||
|
ToastMessage(resId = R.string.common_error_unknown)
|
||||||
|
} else {
|
||||||
|
ToastMessage(message = message)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentContent(): CreatorChannelFanTalkDetailUiState.Content? {
|
||||||
|
return _detailStateLiveData.value as? CreatorChannelFanTalkDetailUiState.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
|
||||||
|
}
|
||||||
@@ -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