feat(creator): 채널 홈 ViewModel을 추가한다
This commit is contained in:
@@ -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()) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user