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