feat(creator): 채널 홈 ViewModel을 추가한다

This commit is contained in:
2026-06-13 17:20:06 +09:00
parent a355838039
commit 3027934295
3 changed files with 391 additions and 0 deletions

View File

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

View File

@@ -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<CreatorChannelHomeUiState>()
val homeStateLiveData: LiveData<CreatorChannelHomeUiState>
get() = _homeStateLiveData
private val _toastLiveData = MutableLiveData<ToastMessage?>()
val toastLiveData: LiveData<ToastMessage?>
get() = _toastLiveData
private val _chatRoomIdLiveData = MutableLiveData<CreatorChannelEvent<Long>>()
val chatRoomIdLiveData: LiveData<CreatorChannelEvent<Long>>
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<out T>(private val value: T) {
private var consumed: Boolean = false
fun consume(): T? {
if (consumed) return null
consumed = true
return value
}
}

View File

@@ -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<ApiResponse<Any>>()
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<ApiResponse<CreateChatRoomResponse>>()
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 <T> LiveData<T>.requireValue(): T? {
var value: T? = null
val observer = Observer<T> { value = it }
observeForever(observer)
removeObserver(observer)
return value
}
}