From 34266abd9052cf0f52f73d8fbe3cdddc000e8936 Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 8 Jul 2026 03:44:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20FanTalk=20=EA=B8=80=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channel/data/CreatorChannelRepository.kt | 7 + .../CreatorChannelFanTalkWriteViewModel.kt | 89 ++++++++++ ...CreatorChannelFanTalkWriteViewModelTest.kt | 160 ++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/write/CreatorChannelFanTalkWriteViewModel.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/write/CreatorChannelFanTalkWriteViewModelTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelRepository.kt index 2c448a9a..bd11ed31 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelRepository.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelRepository.kt @@ -160,4 +160,11 @@ class CreatorChannelRepository( request = PutModifyCheersRequest(cheersId = fanTalkId, isActive = false), token = token ) + + fun writeFanTalk(creatorId: Long, content: String, token: String) = explorerRepository.writeCheers( + parentCheersId = null, + creatorId = creatorId, + content = content, + token = token + ) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/write/CreatorChannelFanTalkWriteViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/write/CreatorChannelFanTalkWriteViewModel.kt new file mode 100644 index 00000000..05db87a7 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/write/CreatorChannelFanTalkWriteViewModel.kt @@ -0,0 +1,89 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.write + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.orhanobut.logger.Logger +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +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.SharedPreferenceManager +import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository + +class CreatorChannelFanTalkWriteViewModel( + private val repository: CreatorChannelRepository +) : BaseViewModel() { + + private val _contentLiveData = MutableLiveData("") + val contentLiveData: LiveData + get() = _contentLiveData + + private val _isSendEnabledLiveData = MutableLiveData(false) + val isSendEnabledLiveData: LiveData + get() = _isSendEnabledLiveData + + private val _contentLengthLiveData = MutableLiveData(0) + val contentLengthLiveData: LiveData + get() = _contentLengthLiveData + + private val _isLoadingLiveData = MutableLiveData(false) + val isLoadingLiveData: LiveData + get() = _isLoadingLiveData + + private val _finishEventLiveData = MutableLiveData(false) + val finishEventLiveData: LiveData + get() = _finishEventLiveData + + private val _toastEventLiveData = MutableLiveData() + val toastEventLiveData: LiveData + get() = _toastEventLiveData + + fun onContentChanged(content: String) { + val limitedContent = content.take(MAX_CONTENT_LENGTH) + _contentLiveData.value = limitedContent + _contentLengthLiveData.value = limitedContent.length + _isSendEnabledLiveData.value = limitedContent.trim().isNotEmpty() + } + + fun submit(creatorId: Long) { + val trimmedContent = _contentLiveData.value.orEmpty().trim() + if (creatorId <= 0L) return + if (_isLoadingLiveData.value == true) return + if (trimmedContent.isEmpty()) return + + _isLoadingLiveData.value = true + compositeDisposable.add( + repository.writeFanTalk( + creatorId = creatorId, + content = trimmedContent, + token = "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> + _isLoadingLiveData.value = false + if (response.success) { + _finishEventLiveData.value = true + } else { + _toastEventLiveData.value = response.message ?: unknownErrorMessage() + } + }, + { error -> + _isLoadingLiveData.value = false + error.message?.let { Logger.e(it) } + _toastEventLiveData.value = unknownErrorMessage() + } + ) + ) + } + + private fun unknownErrorMessage(): String { + return SodaLiveApplicationHolder.get().getString(R.string.common_error_unknown) + } + + companion object { + const val MAX_CONTENT_LENGTH: Int = 500 + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/write/CreatorChannelFanTalkWriteViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/write/CreatorChannelFanTalkWriteViewModelTest.kt new file mode 100644 index 00000000..ba64da50 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/write/CreatorChannelFanTalkWriteViewModelTest.kt @@ -0,0 +1,160 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.write + +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.common.ApiResponse +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository +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 CreatorChannelFanTalkWriteViewModelTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var repository: CreatorChannelRepository + private lateinit var viewModel: CreatorChannelFanTalkWriteViewModel + + @Before + fun setUp() { + setImmediateRxSchedulers() + SharedPreferenceManager.resetForTest() + SharedPreferenceManager.init(context) + SharedPreferenceManager.token = "test-token" + repository = org.mockito.kotlin.mock() + viewModel = CreatorChannelFanTalkWriteViewModel(repository) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + SharedPreferenceManager.resetForTest() + } + + @Test + fun `초기 입력값은 빈 값이고 보내기 버튼은 비활성화된다`() { + assertEquals("", viewModel.contentLiveData.requireValue()) + assertEquals(false, viewModel.isSendEnabledLiveData.requireValue()) + assertEquals(0, viewModel.contentLengthLiveData.requireValue()) + assertEquals(false, viewModel.isLoadingLiveData.requireValue()) + } + + @Test + fun `공백과 줄바꿈만 입력하면 보내기 버튼은 비활성화된다`() { + viewModel.onContentChanged(" \n \t") + + assertEquals(" \n \t", viewModel.contentLiveData.requireValue()) + assertEquals(5, viewModel.contentLengthLiveData.requireValue()) + assertEquals(false, viewModel.isSendEnabledLiveData.requireValue()) + } + + @Test + fun `trim 결과가 비어 있지 않으면 보내기 버튼은 활성화된다`() { + viewModel.onContentChanged(" 응원합니다 ") + + assertEquals(true, viewModel.isSendEnabledLiveData.requireValue()) + } + + @Test + fun `500자를 초과하면 입력값과 글자수는 500자로 제한된다`() { + val content = "가".repeat(501) + + viewModel.onContentChanged(content) + + assertEquals("가".repeat(500), viewModel.contentLiveData.requireValue()) + assertEquals(500, viewModel.contentLengthLiveData.requireValue()) + } + + @Test + fun `비활성 상태에서 submit하면 repository를 호출하지 않는다`() { + viewModel.submit(creatorId = 100L) + + verify(repository, never()).writeFanTalk(any(), any(), any()) + } + + @Test + fun `creatorId가 유효하지 않으면 repository를 호출하지 않는다`() { + viewModel.onContentChanged("응원합니다") + + viewModel.submit(creatorId = 0L) + + verify(repository, never()).writeFanTalk(any(), any(), any()) + } + + @Test + fun `활성 상태에서 submit하면 trim content와 토큰으로 FanTalk 등록 API를 호출한다`() { + whenever(repository.writeFanTalk(100L, "응원합니다", "Bearer test-token")) + .thenReturn(Single.just(ApiResponse(true, null, null))) + viewModel.onContentChanged(" 응원합니다 ") + + viewModel.submit(creatorId = 100L) + + verify(repository).writeFanTalk(100L, "응원합니다", "Bearer test-token") + assertTrue(viewModel.finishEventLiveData.requireValue() == true) + assertEquals(false, viewModel.isLoadingLiveData.requireValue()) + } + + @Test + fun `등록 중에는 중복 submit을 무시한다`() { + whenever(repository.writeFanTalk(100L, "응원합니다", "Bearer test-token")) + .thenReturn(Single.never()) + viewModel.onContentChanged("응원합니다") + + viewModel.submit(creatorId = 100L) + viewModel.submit(creatorId = 100L) + + verify(repository, times(1)).writeFanTalk(100L, "응원합니다", "Bearer test-token") + assertEquals(true, viewModel.isLoadingLiveData.requireValue()) + } + + @Test + fun `등록 실패 시 입력값을 유지하고 서버 message를 우선 toast로 emit한다`() { + whenever(repository.writeFanTalk(100L, "응원합니다", "Bearer test-token")) + .thenReturn(Single.just(ApiResponse(false, null, "서버 실패"))) + viewModel.onContentChanged(" 응원합니다 ") + + viewModel.submit(creatorId = 100L) + + assertEquals(" 응원합니다 ", viewModel.contentLiveData.requireValue()) + assertEquals("서버 실패", viewModel.toastEventLiveData.requireValue()) + assertEquals(false, viewModel.isLoadingLiveData.requireValue()) + assertFalse(viewModel.finishEventLiveData.requireValue() == true) + } + + private fun setImmediateRxSchedulers() { + val trampoline = { _: Scheduler -> Schedulers.trampoline() } + RxJavaPlugins.setIoSchedulerHandler(trampoline) + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } + } + + private fun LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { value = it } + observeForever(observer) + removeObserver(observer) + return value + } +}