From 3027934295463bc3e64ed3c8becb4551b175e6bf Mon Sep 17 00:00:00 2001 From: klaus Date: Sat, 13 Jun 2026 17:20:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=B1=84=EB=84=90=20=ED=99=88?= =?UTF-8?q?=20ViewModel=EC=9D=84=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 --- .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 2 + .../channel/CreatorChannelHomeViewModel.kt | 141 ++++++++++ .../CreatorChannelHomeViewModelTest.kt | 248 ++++++++++++++++++ 3 files changed, 391 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModel.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModelTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt index acaf7ec3..97aeed66 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt @@ -176,6 +176,7 @@ import kr.co.vividnext.sodalive.user.UserViewModel import kr.co.vividnext.sodalive.user.find_password.FindPasswordViewModel import kr.co.vividnext.sodalive.user.login.LoginViewModel import kr.co.vividnext.sodalive.user.signup.SignUpViewModel +import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelHomeViewModel import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelHomeApi import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelHomeRepository import kr.co.vividnext.sodalive.v2.main.MainV2ViewModel @@ -407,6 +408,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { DmChatRoomViewModel(get()) } viewModel { HomeCreatorRankingViewModel(get()) } viewModel { HomeRecommendationViewModel(get()) } + viewModel { CreatorChannelHomeViewModel(get()) } viewModel { PushNotificationListViewModel(get()) } viewModel { CharacterTabViewModel(get()) } viewModel { CharacterDetailViewModel(get()) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModel.kt new file mode 100644 index 00000000..e5123da5 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModel.kt @@ -0,0 +1,141 @@ +package kr.co.vividnext.sodalive.v2.creator.channel + +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.ToastMessage +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelHomeRepository +import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeUiState +import kr.co.vividnext.sodalive.v2.creator.channel.model.toUiContent + +class CreatorChannelHomeViewModel( + private val repository: CreatorChannelHomeRepository +) : BaseViewModel() { + + private val _homeStateLiveData = MutableLiveData() + val homeStateLiveData: LiveData + get() = _homeStateLiveData + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private val _chatRoomIdLiveData = MutableLiveData>() + val chatRoomIdLiveData: LiveData> + get() = _chatRoomIdLiveData + + private var isFollowInProgress = false + private var isCreateChatRoomInProgress = false + + fun loadHome(creatorId: Long) { + if (creatorId <= 0) return + + _homeStateLiveData.value = CreatorChannelHomeUiState.Loading + compositeDisposable.add( + repository.getHome(creatorId = creatorId, token = authToken()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + val data = it.data + if (it.success && data != null) { + _homeStateLiveData.value = data.toUiContent() + } else { + showUnknownError(it.message) + } + }, + { + it.message?.let { message -> Logger.e(message) } + showUnknownError(it.message) + } + ) + ) + } + + fun follow(follow: Boolean, notify: Boolean) { + val content = _homeStateLiveData.value as? CreatorChannelHomeUiState.Content ?: return + if (isFollowInProgress) return + + isFollowInProgress = true + compositeDisposable.add( + repository.followCreator( + creatorId = content.header.creatorId, + follow = follow, + notify = notify, + token = authToken() + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + isFollowInProgress = false + if (it.success) { + _homeStateLiveData.value = content.copy( + header = content.header.copy(isFollow = follow, isNotify = notify) + ) + } else { + showUnknownErrorToast() + } + }, + { + isFollowInProgress = false + it.message?.let { message -> Logger.e(message) } + showUnknownErrorToast() + } + ) + ) + } + + fun createChatRoom(characterId: Long) { + if (characterId <= 0 || isCreateChatRoomInProgress) return + + isCreateChatRoomInProgress = true + compositeDisposable.add( + repository.createChatRoom(characterId = characterId, token = authToken()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + isCreateChatRoomInProgress = false + val data = it.data + if (it.success && data != null) { + _chatRoomIdLiveData.value = CreatorChannelEvent(data.chatRoomId) + } else { + showUnknownErrorToast() + } + }, + { + isCreateChatRoomInProgress = false + it.message?.let { message -> Logger.e(message) } + showUnknownErrorToast() + } + ) + ) + } + + private fun showUnknownError(message: String?) { + _homeStateLiveData.value = CreatorChannelHomeUiState.Error(message = message) + showUnknownErrorToast() + } + + private fun showUnknownErrorToast() { + _toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown) + } + + private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}" +} + +class CreatorChannelEvent(private val value: T) { + private var consumed: Boolean = false + + fun consume(): T? { + if (consumed) return null + consumed = true + return value + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModelTest.kt new file mode 100644 index 00000000..8397c97e --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModelTest.kt @@ -0,0 +1,248 @@ +package kr.co.vividnext.sodalive.v2.creator.channel + +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 io.reactivex.rxjava3.subjects.SingleSubject +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.chat.talk.room.CreateChatRoomResponse +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.v2.common.CreatorActivityType +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelActivityResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelCreatorResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelFanTalkSummaryResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelHomeRepository +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelHomeResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSnsResponse +import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeUiState +import org.junit.After +import org.junit.Assert.assertEquals +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.eq +import org.mockito.kotlin.mock +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 CreatorChannelHomeViewModelTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var repository: CreatorChannelHomeRepository + private lateinit var viewModel: CreatorChannelHomeViewModel + + @Before + fun setUp() { + setImmediateRxSchedulers() + SharedPreferenceManager.resetForTest() + SharedPreferenceManager.init(context) + SharedPreferenceManager.token = "test-token" + repository = mock() + viewModel = CreatorChannelHomeViewModel(repository) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + SharedPreferenceManager.resetForTest() + } + + @Test + fun `creatorId가 0 이하이면 홈 API를 호출하지 않는다`() { + viewModel.loadHome(0L) + + verify(repository, never()).getHome(any(), any()) + } + + @Test + fun `유효한 creatorId 로드는 bearer token으로 홈 API를 호출하고 Content를 emit한다`() { + whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, response(), null))) + + viewModel.loadHome(100L) + + val state = viewModel.homeStateLiveData.requireValue() as CreatorChannelHomeUiState.Content + assertEquals(100L, state.header.creatorId) + verify(repository).getHome(100L, "Bearer test-token") + } + + @Test + fun `홈 API failure는 Error와 unknown toast를 emit한다`() { + whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(false, null, "failed"))) + + viewModel.loadHome(100L) + + assertTrue(viewModel.homeStateLiveData.requireValue() is CreatorChannelHomeUiState.Error) + assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId) + } + + @Test + fun `홈 API null data는 Error와 unknown toast를 emit한다`() { + whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, null, null))) + + viewModel.loadHome(100L) + + assertTrue(viewModel.homeStateLiveData.requireValue() is CreatorChannelHomeUiState.Error) + assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId) + } + + @Test + fun `홈 API throwable은 Error와 unknown toast를 emit한다`() { + whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.error(IllegalStateException("network"))) + + viewModel.loadHome(100L) + + val state = viewModel.homeStateLiveData.requireValue() as CreatorChannelHomeUiState.Error + assertEquals("network", state.message) + assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId) + } + + @Test + fun `팔로우 성공은 현재 content header의 follow와 notify를 갱신한다`() { + whenever(repository.getHome(100L, "Bearer test-token")).thenReturn( + Single.just(ApiResponse(true, response(isFollow = false), null)) + ) + whenever(repository.followCreator(100L, true, true, "Bearer test-token")).thenReturn( + Single.just(ApiResponse(true, Any(), null)) + ) + viewModel.loadHome(100L) + + viewModel.follow(follow = true, notify = true) + + val state = viewModel.homeStateLiveData.requireValue() as CreatorChannelHomeUiState.Content + assertTrue(state.header.isFollow) + assertTrue(state.header.isNotify) + } + + @Test + fun `팔로우 진행 중 중복 요청은 무시한다`() { + val pending = SingleSubject.create>() + whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, response(), null))) + whenever(repository.followCreator(100L, false, false, "Bearer test-token")).thenReturn(pending) + viewModel.loadHome(100L) + + viewModel.follow(follow = false, notify = false) + viewModel.follow(follow = false, notify = false) + + verify(repository, times(1)).followCreator(100L, false, false, "Bearer test-token") + pending.onSuccess(ApiResponse(true, Any(), null)) + } + + @Test + fun `팔로우 실패는 현재 content를 유지하고 unknown toast만 emit한다`() { + whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, response(), null))) + whenever(repository.followCreator(100L, false, false, "Bearer test-token")).thenReturn( + Single.just(ApiResponse(false, null, "failed")) + ) + viewModel.loadHome(100L) + + viewModel.follow(follow = false, notify = false) + + val state = viewModel.homeStateLiveData.requireValue() as CreatorChannelHomeUiState.Content + assertEquals(100L, state.header.creatorId) + assertTrue(state.header.isFollow) + assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId) + } + + @Test + fun `채팅방 생성은 characterId가 유효할 때만 호출하고 chatRoomId를 emit한다`() { + whenever(repository.createChatRoom(200L, "Bearer test-token")) + .thenReturn(Single.just(ApiResponse(true, CreateChatRoomResponse(chatRoomId = 300L), null))) + + viewModel.createChatRoom(0L) + viewModel.createChatRoom(200L) + + verify(repository, never()).createChatRoom(eq(0L), any()) + verify(repository).createChatRoom(200L, "Bearer test-token") + val event = viewModel.chatRoomIdLiveData.requireValue() + assertEquals(300L, event?.consume()) + assertEquals(null, event?.consume()) + } + + @Test + fun `채팅방 생성 진행 중 중복 요청은 무시한다`() { + val pending = SingleSubject.create>() + whenever(repository.createChatRoom(200L, "Bearer test-token")).thenReturn(pending) + + viewModel.createChatRoom(200L) + viewModel.createChatRoom(200L) + + verify(repository, times(1)).createChatRoom(200L, "Bearer test-token") + pending.onSuccess(ApiResponse(true, CreateChatRoomResponse(chatRoomId = 300L), null)) + } + + @Test + fun `채팅방 생성 실패는 현재 content를 유지하고 unknown toast만 emit한다`() { + whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, response(), null))) + whenever(repository.createChatRoom(200L, "Bearer test-token")).thenReturn( + Single.just(ApiResponse(false, null, "failed")) + ) + viewModel.loadHome(100L) + + viewModel.createChatRoom(200L) + + val state = viewModel.homeStateLiveData.requireValue() as CreatorChannelHomeUiState.Content + assertEquals(100L, state.header.creatorId) + assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId) + } + + private fun setImmediateRxSchedulers() { + val trampoline = { _: Scheduler -> Schedulers.trampoline() } + RxJavaPlugins.setIoSchedulerHandler(trampoline) + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } + } + + private fun response(isFollow: Boolean = true) = CreatorChannelHomeResponse( + creator = CreatorChannelCreatorResponse( + creatorId = 100L, + characterId = 200L, + nickname = "소다", + profileImageUrl = "https://example.com/profile.png", + followerCount = 1234, + isAiChatAvailable = true, + isDmAvailable = true, + isFollow = isFollow, + isNotify = false + ), + currentLive = CreatorChannelLiveResponse(1L, "라이브", null, "2026-06-11T12:00:00Z", 0, false), + latestAudioContent = CreatorChannelAudioContentResponse(1L, "오디오", null, null, 0, true, false, null, null), + channelDonations = emptyList(), + notices = emptyList(), + schedules = listOf(CreatorChannelScheduleResponse("2026-06-11T12:00:00Z", "일정", CreatorActivityType.Live, 1L)), + audioContents = emptyList(), + series = emptyList(), + communities = emptyList(), + fanTalk = CreatorChannelFanTalkSummaryResponse(0, null), + introduce = "", + activity = CreatorChannelActivityResponse(null, "D+1", 1, 2, 3, 4, 5), + sns = CreatorChannelSnsResponse("", "", "", "", "") + ) + + private fun LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { value = it } + observeForever(observer) + removeObserver(observer) + return value + } +}