feat(home): 팔로잉 ViewModel을 추가한다
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user