diff --git a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt index 2660b35b..5e25f1f4 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt @@ -177,6 +177,7 @@ import kr.co.vividnext.sodalive.user.find_password.FindPasswordViewModel import kr.co.vividnext.sodalive.user.login.LoginViewModel import kr.co.vividnext.sodalive.user.signup.SignUpViewModel import kr.co.vividnext.sodalive.v2.main.MainV2ViewModel +import kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModel import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomApi import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomRepository import kr.co.vividnext.sodalive.v2.main.home.HomeCreatorRankingViewModel @@ -392,6 +393,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { SearchViewModel(get()) } viewModel { PointStatusViewModel(get()) } viewModel { HomeViewModel(get(), get()) } + viewModel { ChatMainViewModel(get()) } viewModel { HomeCreatorRankingViewModel(get()) } viewModel { HomeRecommendationViewModel(get()) } viewModel { PushNotificationListViewModel(get()) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModel.kt new file mode 100644 index 00000000..b48bb40d --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModel.kt @@ -0,0 +1,154 @@ +package kr.co.vividnext.sodalive.v2.main.chat + +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.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.ChatRoomListUiItem +import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiState +import kr.co.vividnext.sodalive.v2.main.chat.model.toUiItems + +class ChatMainViewModel( + private val repository: ChatRoomRepository +) : BaseViewModel() { + + private var currentFilter: ChatRoomFilter = ChatRoomFilter.ALL + private var currentItems: List = emptyList() + private var nextCursor: String? = null + private var hasMore: Boolean = false + private var requestGeneration: Long = 0L + + private val _chatRoomStateLiveData = MutableLiveData() + val chatRoomStateLiveData: LiveData + get() = _chatRoomStateLiveData + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + private val _isAppending = MutableLiveData(false) + val isAppending: LiveData + get() = _isAppending + + fun loadFirstPage(filter: ChatRoomFilter = currentFilter) { + currentFilter = filter + requestGeneration += 1L + val generation = requestGeneration + currentItems = emptyList() + nextCursor = null + hasMore = false + _isLoading.value = true + _isAppending.value = false + _chatRoomStateLiveData.value = ChatRoomListUiState.Loading + + compositeDisposable.add( + repository.getChatRooms( + token = authToken(), + filter = currentFilter.apiValue, + cursor = null + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + if (generation != requestGeneration) return@subscribe + _isLoading.value = false + val data = it.data + if (it.success && data != null) { + handleFirstPageSuccess(data) + } else { + showUnknownError(it.message) + } + }, + { + if (generation != requestGeneration) return@subscribe + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + showUnknownError(it.message) + } + ) + ) + } + + fun selectFilter(filter: ChatRoomFilter) { + if (filter == currentFilter) return + loadFirstPage(filter) + } + + fun loadNextPage() { + val cursor = nextCursor + if (_isLoading.value == true || _isAppending.value == true || !hasMore || cursor == null) return + + val generation = requestGeneration + _isAppending.value = true + _chatRoomStateLiveData.value = ChatRoomListUiState.Content( + items = currentItems, + isAppending = true + ) + + compositeDisposable.add( + repository.getChatRooms( + token = authToken(), + filter = currentFilter.apiValue, + cursor = cursor + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + if (generation != requestGeneration) return@subscribe + _isAppending.value = false + val data = it.data + if (it.success && data != null) { + handleNextPageSuccess(data) + } else { + showUnknownError(it.message) + } + }, + { + if (generation != requestGeneration) return@subscribe + _isAppending.value = false + it.message?.let { message -> Logger.e(message) } + showUnknownError(it.message) + } + ) + ) + } + + private fun handleFirstPageSuccess(data: ChatRoomListPageResponse) { + currentItems = data.rooms.toUiItems() + hasMore = data.hasMore + nextCursor = data.nextCursor + _chatRoomStateLiveData.value = if (currentItems.isEmpty()) { + ChatRoomListUiState.Empty + } else { + ChatRoomListUiState.Content(items = currentItems) + } + } + + private fun handleNextPageSuccess(data: ChatRoomListPageResponse) { + currentItems = currentItems + data.rooms.toUiItems() + hasMore = data.hasMore + nextCursor = data.nextCursor + _chatRoomStateLiveData.value = ChatRoomListUiState.Content(items = currentItems) + } + + private fun showUnknownError(message: String?) { + _chatRoomStateLiveData.value = ChatRoomListUiState.Error(message = message) + _toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown) + } + + private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}" +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModelTest.kt new file mode 100644 index 00000000..8f492381 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModelTest.kt @@ -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>() + 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>() + 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>() + 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, + 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 LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { 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() + private val responses = ArrayDeque>>() + + fun enqueueSuccess(response: ApiResponse) { + enqueue(Single.just(response)) + } + + fun enqueueSuccess(page: ChatRoomListPageResponse) { + enqueueSuccess(ApiResponse(true, page, null)) + } + + fun enqueue(response: Single>) { + responses.addLast(response) + } + + override fun getChatRooms( + authHeader: String, + filter: String, + cursor: String? + ): Single> { + calls.add(ApiCall(authHeader, filter, cursor)) + return responses.removeFirst() + } + } +}