feat(creator-channel): FanTalk 상세 상태를 구현한다

This commit is contained in:
2026-07-09 07:43:59 +09:00
parent 6916459c9e
commit 04dcb3f09f
2 changed files with 564 additions and 0 deletions

View File

@@ -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}"
}

View File

@@ -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
}
}