feat(home): 팔로잉 ViewModel을 추가한다

This commit is contained in:
2026-06-25 22:22:32 +09:00
parent 502bbd4f35
commit 2128bbf197
2 changed files with 244 additions and 0 deletions

View File

@@ -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<HomeFollowingUiState>()
val followingStateLiveData: LiveData<HomeFollowingUiState>
get() = _followingStateLiveData
private val _toastLiveData = MutableLiveData<ToastMessage?>()
val toastLiveData: LiveData<ToastMessage?>
get() = _toastLiveData
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
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)
}

View File

@@ -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<FollowingCreatorResponse> = 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 <T> LiveData<T>.requireValue(): T? {
var value: T? = null
val observer = Observer<T> { value = it }
observeForever(observer)
removeObserver(observer)
return value
}
private class FakeHomeFollowingApi : HomeFollowingApi {
val calls = mutableListOf<String?>()
private val responses = ArrayDeque<Single<ApiResponse<HomeFollowingTabResponse>>>()
fun enqueueSuccess(response: HomeFollowingTabResponse) {
enqueueSuccess(ApiResponse(true, response, null))
}
fun enqueueSuccess(response: ApiResponse<HomeFollowingTabResponse>) {
enqueue(Single.just(response))
}
fun enqueue(response: Single<ApiResponse<HomeFollowingTabResponse>>) {
responses.addLast(response)
}
override fun getFollowing(authHeader: String?): Single<ApiResponse<HomeFollowingTabResponse>> {
calls.add(authHeader)
return responses.removeFirst()
}
}
}