From 2128bbf197c25127c32b40622dd764fb0924b308 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 25 Jun 2026 22:22:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=ED=8C=94=EB=A1=9C=EC=9E=89=20Vie?= =?UTF-8?q?wModel=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 --- .../v2/main/home/HomeFollowingViewModel.kt | 68 +++++++ .../main/home/HomeFollowingViewModelTest.kt | 176 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModel.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModelTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModel.kt new file mode 100644 index 00000000..37b2497c --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModel.kt @@ -0,0 +1,68 @@ +package kr.co.vividnext.sodalive.v2.main.home + +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.common.UtcRelativeTimeTextFormatter +import kr.co.vividnext.sodalive.v2.main.home.data.HomeFollowingRepository +import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingUiState +import kr.co.vividnext.sodalive.v2.main.home.model.homeFollowingAuthHeader +import kr.co.vividnext.sodalive.v2.main.home.model.toUiState + +class HomeFollowingViewModel( + private val repository: HomeFollowingRepository, + private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter +) : BaseViewModel() { + + private val _followingStateLiveData = MutableLiveData() + val followingStateLiveData: LiveData + get() = _followingStateLiveData + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + fun loadFollowing() { + _isLoading.value = true + _followingStateLiveData.value = HomeFollowingUiState.Loading + + compositeDisposable.add( + repository.getFollowing(authHeader = authHeader()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + val data = it.data + if (it.success && data != null) { + _followingStateLiveData.value = data.toUiState(relativeTimeTextFormatter) + } else { + showUnknownError(it.message) + } + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + showUnknownError(it.message) + } + ) + ) + } + + private fun showUnknownError(message: String?) { + _followingStateLiveData.value = HomeFollowingUiState.Error(message = message) + _toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown) + } + + private fun authHeader(): String? = homeFollowingAuthHeader(SharedPreferenceManager.token) +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModelTest.kt new file mode 100644 index 00000000..7fbff51e --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModelTest.kt @@ -0,0 +1,176 @@ +package kr.co.vividnext.sodalive.v2.main.home + +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.UtcRelativeTimeTextFormatter +import kr.co.vividnext.sodalive.v2.main.home.data.FollowingCreatorResponse +import kr.co.vividnext.sodalive.v2.main.home.data.HomeFollowingApi +import kr.co.vividnext.sodalive.v2.main.home.data.HomeFollowingRepository +import kr.co.vividnext.sodalive.v2.main.home.data.HomeFollowingTabResponse +import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingUiState +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.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class HomeFollowingViewModelTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private val formatter = UtcRelativeTimeTextFormatter { "relative:$it" } + private lateinit var api: FakeHomeFollowingApi + private lateinit var viewModel: HomeFollowingViewModel + + @Before + fun setUp() { + setImmediateRxSchedulers() + SharedPreferenceManager.resetForTest() + SharedPreferenceManager.init(context) + SharedPreferenceManager.token = "test-token" + api = FakeHomeFollowingApi() + viewModel = HomeFollowingViewModel( + repository = HomeFollowingRepository(api), + relativeTimeTextFormatter = formatter + ) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + SharedPreferenceManager.resetForTest() + } + + @Test + fun `loadFollowing은 loading 후 content 상태를 발행한다`() { + api.enqueueSuccess(response(followingCreators = listOf(creator()))) + + viewModel.loadFollowing() + + val state = viewModel.followingStateLiveData.requireValue() as HomeFollowingUiState.Content + assertEquals(1L, state.followingCreators.items.single().creatorId) + assertFalse(viewModel.isLoading.requireValue() ?: true) + } + + @Test + fun `blank token이면 repository에 null auth header를 전달한다`() { + SharedPreferenceManager.token = " " + api.enqueueSuccess(response(followingCreators = listOf(creator()))) + + viewModel.loadFollowing() + + assertEquals(listOf(null), api.calls) + } + + @Test + fun `token이 있으면 repository에 Bearer auth header를 전달한다`() { + SharedPreferenceManager.token = "test-token" + api.enqueueSuccess(response(followingCreators = listOf(creator()))) + + viewModel.loadFollowing() + + assertEquals(listOf("Bearer test-token"), api.calls) + } + + @Test + fun `isLoginRequired true 응답은 login required 상태를 발행한다`() { + api.enqueueSuccess(response(isLoginRequired = true, followingCreators = listOf(creator()))) + + viewModel.loadFollowing() + + assertTrue(viewModel.followingStateLiveData.requireValue() is HomeFollowingUiState.LoginRequired) + } + + @Test + fun `API success이지만 data가 null이면 error 상태와 toast를 발행한다`() { + api.enqueueSuccess(ApiResponse(true, null, null)) + + viewModel.loadFollowing() + + assertTrue(viewModel.followingStateLiveData.requireValue() is HomeFollowingUiState.Error) + assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId) + } + + @Test + fun `API failure throwable이면 error 상태와 toast를 발행한다`() { + api.enqueue(Single.error(IllegalStateException("network"))) + + viewModel.loadFollowing() + val state = viewModel.followingStateLiveData.requireValue() as HomeFollowingUiState.Error + + assertEquals("network", state.message) + 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( + isLoginRequired: Boolean = false, + followingCreators: List = emptyList() + ) = HomeFollowingTabResponse( + isLoginRequired = isLoginRequired, + followingCreators = followingCreators, + onAirLives = emptyList(), + recentChats = emptyList(), + monthlySchedules = emptyList(), + recentNews = emptyList() + ) + + private fun creator() = FollowingCreatorResponse( + creatorId = 1L, + creatorNickname = "creator", + creatorProfileImageUrl = "https://example.com/creator.png" + ) + + private fun LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { value = it } + observeForever(observer) + removeObserver(observer) + return value + } + + private class FakeHomeFollowingApi : HomeFollowingApi { + val calls = mutableListOf() + private val responses = ArrayDeque>>() + + fun enqueueSuccess(response: HomeFollowingTabResponse) { + enqueueSuccess(ApiResponse(true, response, null)) + } + + fun enqueueSuccess(response: ApiResponse) { + enqueue(Single.just(response)) + } + + fun enqueue(response: Single>) { + responses.addLast(response) + } + + override fun getFollowing(authHeader: String?): Single> { + calls.add(authHeader) + return responses.removeFirst() + } + } +}