feat(chat): 채팅방 ViewModel을 추가한다

This commit is contained in:
2026-06-10 12:00:27 +09:00
parent ed8a0e9a09
commit bb17f0014a
3 changed files with 453 additions and 0 deletions

View File

@@ -0,0 +1,297 @@
package kr.co.vividnext.sodalive.v2.main.chat
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.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomApi
import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomListItemResponse
import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomListPageResponse
import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomRepository
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomFilter
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiState
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 ChatMainViewModelTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var api: FakeChatRoomApi
private lateinit var viewModel: ChatMainViewModel
@Before
fun setUp() {
setImmediateRxSchedulers()
SharedPreferenceManager.resetForTest()
SharedPreferenceManager.init(context)
SharedPreferenceManager.token = "test-token"
api = FakeChatRoomApi()
viewModel = ChatMainViewModel(
repository = ChatRoomRepository(api)
)
}
@After
fun tearDown() {
RxJavaPlugins.reset()
RxAndroidPlugins.reset()
SharedPreferenceManager.resetForTest()
}
@Test
fun `초기 첫 페이지 로드는 ALL filter와 null cursor로 요청한다`() {
api.enqueueSuccess(page(rooms = listOf(room(roomId = 1L))))
viewModel.loadFirstPage()
assertEquals(listOf(ApiCall("Bearer test-token", "ALL", null)), api.calls)
}
@Test
fun `다른 filter를 선택하면 기존 목록을 비우고 첫 페이지를 요청한다`() {
api.enqueueSuccess(page(rooms = listOf(room(roomId = 1L))))
viewModel.loadFirstPage()
api.enqueueSuccess(page(rooms = listOf(room(roomId = 2L, chatType = "DM"))))
viewModel.selectFilter(ChatRoomFilter.DM)
assertEquals(ApiCall("Bearer test-token", "DM", null), api.calls.last())
val state = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Content
assertEquals(listOf(2L), state.items.map { it.roomId })
}
@Test
fun `현재 선택된 filter를 다시 선택하면 API를 재호출하지 않는다`() {
api.enqueueSuccess(page(rooms = listOf(room(roomId = 1L))))
viewModel.loadFirstPage()
viewModel.selectFilter(ChatRoomFilter.ALL)
assertEquals(1, api.calls.size)
}
@Test
fun `첫 페이지 success와 rooms가 있으면 Content를 emit한다`() {
api.enqueueSuccess(page(rooms = listOf(room(roomId = 10L, targetName = "크리에이터"))))
viewModel.loadFirstPage()
val state = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Content
assertFalse(state.isAppending)
assertEquals(1, state.items.size)
assertEquals(10L, state.items[0].roomId)
assertEquals("크리에이터", state.items[0].targetName)
}
@Test
fun `첫 페이지 success와 rooms가 비어 있으면 Empty를 emit한다`() {
api.enqueueSuccess(page(rooms = emptyList()))
viewModel.loadFirstPage()
val state = viewModel.chatRoomStateLiveData.requireValue()
assertTrue(state is ChatRoomListUiState.Empty)
}
@Test
fun `다음 cursor가 있으면 다음 페이지를 요청하고 기존 목록에 append한다`() {
api.enqueueSuccess(page(rooms = listOf(room(roomId = 1L)), hasMore = true, nextCursor = "next-1"))
viewModel.loadFirstPage()
api.enqueueSuccess(page(rooms = listOf(room(roomId = 2L)), hasMore = false, nextCursor = null))
viewModel.loadNextPage()
val state = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Content
assertEquals(ApiCall("Bearer test-token", "ALL", "next-1"), api.calls.last())
assertEquals(listOf(1L, 2L), state.items.map { it.roomId })
assertFalse(state.isAppending)
}
@Test
fun `hasMore가 false이면 다음 페이지 API를 호출하지 않는다`() {
api.enqueueSuccess(page(rooms = listOf(room(roomId = 1L)), hasMore = false, nextCursor = null))
viewModel.loadFirstPage()
viewModel.loadNextPage()
assertEquals(1, api.calls.size)
}
@Test
fun `append loading 중 중복 다음 페이지 요청은 무시한다`() {
api.enqueueSuccess(page(rooms = listOf(room(roomId = 1L)), hasMore = true, nextCursor = "next-1"))
viewModel.loadFirstPage()
val pendingNextPage = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
api.enqueue(pendingNextPage)
viewModel.loadNextPage()
viewModel.loadNextPage()
assertEquals(2, api.calls.size)
val appendingState = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Content
assertTrue(appendingState.isAppending)
pendingNextPage.onSuccess(ApiResponse(true, page(rooms = listOf(room(roomId = 2L))), null))
val completedState = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Content
assertEquals(listOf(1L, 2L), completedState.items.map { it.roomId })
}
@Test
fun `filter 변경 전 첫 페이지 응답이 늦게 도착하면 현재 filter 목록을 덮어쓰지 않는다`() {
val pendingAllPage = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
api.enqueue(pendingAllPage)
viewModel.loadFirstPage(ChatRoomFilter.ALL)
api.enqueueSuccess(page(rooms = listOf(room(roomId = 2L, chatType = "DM"))))
viewModel.selectFilter(ChatRoomFilter.DM)
pendingAllPage.onSuccess(ApiResponse(true, page(rooms = listOf(room(roomId = 1L))), null))
val state = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Content
assertEquals(listOf(2L), state.items.map { it.roomId })
assertEquals(ApiCall("Bearer test-token", "DM", null), api.calls.last())
}
@Test
fun `filter 변경 전 다음 페이지 응답이 늦게 도착하면 현재 filter 목록에 append하지 않는다`() {
api.enqueueSuccess(page(rooms = listOf(room(roomId = 1L)), hasMore = true, nextCursor = "next-1"))
viewModel.loadFirstPage(ChatRoomFilter.ALL)
val pendingNextPage = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
api.enqueue(pendingNextPage)
viewModel.loadNextPage()
api.enqueueSuccess(page(rooms = listOf(room(roomId = 3L, chatType = "DM"))))
viewModel.selectFilter(ChatRoomFilter.DM)
pendingNextPage.onSuccess(ApiResponse(true, page(rooms = listOf(room(roomId = 2L))), null))
val state = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Content
assertEquals(listOf(3L), state.items.map { it.roomId })
}
@Test
fun `API failure는 Error와 unknown error toast를 emit한다`() {
api.enqueueSuccess(ApiResponse(false, null, "failed"))
viewModel.loadFirstPage()
val state = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Error
assertEquals("failed", state.message)
assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId)
}
@Test
fun `data null 응답은 Error와 unknown error toast를 emit한다`() {
api.enqueueSuccess(ApiResponse(true, null, null))
viewModel.loadFirstPage()
val state = viewModel.chatRoomStateLiveData.requireValue()
assertTrue(state is ChatRoomListUiState.Error)
assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId)
}
@Test
fun `Throwable은 Error와 unknown error toast를 emit한다`() {
api.enqueue(Single.error(IllegalStateException("network")))
viewModel.loadFirstPage()
val state = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.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 page(
rooms: List<ChatRoomListItemResponse>,
hasMore: Boolean = false,
nextCursor: String? = null
) = ChatRoomListPageResponse(
rooms = rooms,
hasMore = hasMore,
nextCursor = nextCursor
)
private fun room(
roomId: Long,
chatType: String = "AI",
targetName: String = "상대방",
targetImageUrl: String = "https://example.com/profile.png",
lastMessage: String = "마지막 메시지",
lastMessageAt: String = "2026-06-09T12:00:00Z"
) = ChatRoomListItemResponse(
roomId = roomId,
chatType = chatType,
targetName = targetName,
targetImageUrl = targetImageUrl,
lastMessage = lastMessage,
lastMessageAt = lastMessageAt
)
private fun <T> LiveData<T>.requireValue(): T? {
var value: T? = null
val observer = Observer<T> { value = it }
observeForever(observer)
removeObserver(observer)
return value
}
private data class ApiCall(
val token: String,
val filter: String,
val cursor: String?
)
private class FakeChatRoomApi : ChatRoomApi {
val calls = mutableListOf<ApiCall>()
private val responses = ArrayDeque<Single<ApiResponse<ChatRoomListPageResponse>>>()
fun enqueueSuccess(response: ApiResponse<ChatRoomListPageResponse>) {
enqueue(Single.just(response))
}
fun enqueueSuccess(page: ChatRoomListPageResponse) {
enqueueSuccess(ApiResponse(true, page, null))
}
fun enqueue(response: Single<ApiResponse<ChatRoomListPageResponse>>) {
responses.addLast(response)
}
override fun getChatRooms(
authHeader: String,
filter: String,
cursor: String?
): Single<ApiResponse<ChatRoomListPageResponse>> {
calls.add(ApiCall(authHeader, filter, cursor))
return responses.removeFirst()
}
}
}