From 56f110c548a5ec7d2d7f4d057cfff34b4e91db73 Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 10 Jun 2026 18:48:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat):=20DM=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20ViewModel=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/main/chat/dm/DmChatRoomViewModel.kt | 308 ++++++++++++ .../v2/main/chat/dm/model/DmChatUiModels.kt | 16 + .../main/chat/dm/DmChatPaginationStateTest.kt | 156 ++++++ .../main/chat/dm/DmChatRoomViewModelTest.kt | 451 ++++++++++++++++++ 4 files changed, 931 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatPaginationStateTest.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt new file mode 100644 index 00000000..8756e46d --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt @@ -0,0 +1,308 @@ +package kr.co.vividnext.sodalive.v2.main.chat.dm + +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.base.BaseViewModel +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomResponse +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessagesPageResponse +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRoomOpenResponse +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmChatMessageResponse +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageUiItem +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.mergeByMessageId +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.sortByCreatedAtAndMessageId +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItem +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItems + +class DmChatRoomViewModel( + private val repository: DmChatRepository +) : BaseViewModel() { + + private var currentRoomId: Long = 0L + private var opponentNickname: String = "" + private var opponentProfileImageUrl: String = "" + private var currentMessages: List = emptyList() + private var hasMore: Boolean = false + private var nextCursor: Long? = null + private var isLoadingOlder: Boolean = false + private var isSending: Boolean = false + private var localMessageSequence: Long = 0L + + private val _chatRoomStateLiveData = MutableLiveData() + val chatRoomStateLiveData: LiveData + get() = _chatRoomStateLiveData + + private val _finishEventLiveData = MutableLiveData() + val finishEventLiveData: LiveData + get() = _finishEventLiveData + + private val _prependedMessageCountLiveData = MutableLiveData(0) + val prependedMessageCountLiveData: LiveData + get() = _prependedMessageCountLiveData + + fun enter(roomId: Long, creatorId: Long) { + when { + roomId > 0L -> openRoom(roomId) + creatorId > 0L -> createRoomAndOpen(creatorId) + else -> _finishEventLiveData.value = true + } + } + + fun loadOlderMessages() { + val roomId = currentRoomId + val cursor = nextCursor + if (roomId <= 0L || !hasMore || cursor == null || isLoadingOlder) return + + isLoadingOlder = true + emitContent() + + compositeDisposable.add( + repository.getMessages( + token = authToken(), + roomId = roomId, + cursor = cursor + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { handleOlderMessagesResult(it) }, + { + isLoadingOlder = false + it.message?.let { message -> Logger.e(message) } + emitContent() + } + ) + ) + } + + fun sendText(text: String) { + val trimmed = text.trim() + if (trimmed.isBlank() || currentRoomId <= 0L || isSending) return + + val localId = nextLocalId() + val localItem = DmChatMessageUiItem( + messageId = null, + localId = localId, + mine = true, + textMessage = trimmed, + senderNickname = "", + senderProfileImageUrl = "", + createdAt = System.currentTimeMillis(), + status = DmChatMessageStatus.SENDING + ) + currentMessages = currentMessages + localItem + isSending = true + emitContent() + + sendLocalMessage(localId = localId, text = trimmed) + } + + fun retry(localId: String) { + val failedItem = currentMessages.firstOrNull { + it.localId == localId && it.status == DmChatMessageStatus.FAILED + } ?: return + if (isSending || currentRoomId <= 0L) return + + currentMessages = currentMessages.map { + if (it.localId == localId) it.copy(status = DmChatMessageStatus.SENDING) else it + } + isSending = true + emitContent() + + sendLocalMessage(localId = localId, text = failedItem.textMessage) + } + + fun onRealtimeMessage(message: DmChatMessageResponse) { + val item = message.toUiItem() ?: return + currentMessages = currentMessages.mergeByMessageId(listOf(item)) + emitContent() + } + + fun syncLatestMessagesAfterReconnect() { + val roomId = currentRoomId + if (roomId <= 0L) return + + compositeDisposable.add( + repository.getMessages( + token = authToken(), + roomId = roomId, + cursor = null + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + val data = it.data + if (it.success && data != null) { + currentMessages = currentMessages.mergeByMessageId(data.messages.toUiItems()) + emitContent() + } + }, + { it.message?.let { message -> Logger.e(message) } } + ) + ) + } + + private fun createRoomAndOpen(creatorId: Long) { + _chatRoomStateLiveData.value = DmChatRoomUiState.Loading + compositeDisposable.add( + repository.createOrGetRoom(token = authToken(), creatorId = creatorId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .flatMap { response -> + val data = response.requireData() + repository.openRoom(token = authToken(), roomId = data.roomId) + } + .subscribe( + { handleOpenRoomResult(it) }, + { handleError(it) } + ) + ) + } + + private fun openRoom(roomId: Long) { + _chatRoomStateLiveData.value = DmChatRoomUiState.Loading + compositeDisposable.add( + repository.openRoom(token = authToken(), roomId = roomId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { handleOpenRoomResult(it) }, + { handleError(it) } + ) + ) + } + + private fun sendLocalMessage(localId: String, text: String) { + compositeDisposable.add( + repository.sendTextMessage( + token = authToken(), + roomId = currentRoomId, + textMessage = text + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { handleSendResult(localId, it) }, + { + it.message?.let { message -> Logger.e(message) } + markLocalMessageFailed(localId) + } + ) + ) + } + + private fun handleOpenRoomResult(response: ApiResponse) { + val data = response.data + if (!response.success || data == null) { + showError(response.message) + return + } + + currentRoomId = data.roomId + opponentNickname = data.opponentNickname + opponentProfileImageUrl = data.opponentProfileImageUrl + currentMessages = data.messages.toUiItems().sortByCreatedAtAndMessageId() + hasMore = data.hasMore + nextCursor = data.nextCursor + isLoadingOlder = false + emitContent() + } + + private fun handleOlderMessagesResult(response: ApiResponse) { + isLoadingOlder = false + val data = response.data + if (!response.success || data == null) { + emitContent() + return + } + + val beforeIds = currentMessages.mapNotNull { it.messageId }.toSet() + currentMessages = currentMessages.mergeByMessageId(data.messages.toUiItems()) + hasMore = data.hasMore + nextCursor = data.nextCursor + _prependedMessageCountLiveData.value = currentMessages.count { + it.messageId != null && it.messageId !in beforeIds + } + emitContent() + } + + private fun handleSendResult( + localId: String, + response: ApiResponse + ) { + val message = response.data?.message + val sentItem = if (response.success && message != null) message.toUiItem() else null + if (sentItem == null) { + markLocalMessageFailed(localId) + return + } + + isSending = false + currentMessages = currentMessages.map { + if (it.localId == localId) sentItem else it + }.deduplicateSentMessage(sentItem.messageId).sortByCreatedAtAndMessageId() + emitContent() + } + + private fun List.deduplicateSentMessage(messageId: Long?): List { + if (messageId == null) return this + var found = false + return filter { item -> + if (item.messageId != messageId) return@filter true + if (found) return@filter false + found = true + true + } + } + + private fun markLocalMessageFailed(localId: String) { + isSending = false + currentMessages = currentMessages.map { + if (it.localId == localId) it.copy(status = DmChatMessageStatus.FAILED) else it + } + emitContent() + } + + private fun handleError(throwable: Throwable) { + throwable.message?.let { Logger.e(it) } + showError(throwable.message) + } + + private fun showError(message: String?) { + _chatRoomStateLiveData.value = DmChatRoomUiState.Error(message) + } + + private fun emitContent() { + if (currentRoomId <= 0L) return + _chatRoomStateLiveData.value = DmChatRoomUiState.Content( + roomId = currentRoomId, + opponentNickname = opponentNickname, + opponentProfileImageUrl = opponentProfileImageUrl, + messages = currentMessages, + hasMore = hasMore, + nextCursor = nextCursor, + isLoadingOlder = isLoadingOlder + ) + } + + private fun nextLocalId(): String { + localMessageSequence += 1L + return "local-$localMessageSequence" + } + + private fun authToken(): String = SharedPreferenceManager.token + + private fun ApiResponse.requireData(): CreateDmChatRoomResponse { + if (success && data != null) return data + throw IllegalStateException(message) + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt index 302778ac..596038e1 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt @@ -16,3 +16,19 @@ data class DmChatMessageUiItem( val createdAt: Long, val status: DmChatMessageStatus ) + +sealed class DmChatRoomUiState { + data object Loading : DmChatRoomUiState() + + data class Content( + val roomId: Long, + val opponentNickname: String, + val opponentProfileImageUrl: String, + val messages: List, + val hasMore: Boolean, + val nextCursor: Long?, + val isLoadingOlder: Boolean = false + ) : DmChatRoomUiState() + + data class Error(val message: String?) : DmChatRoomUiState() +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatPaginationStateTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatPaginationStateTest.kt new file mode 100644 index 00000000..c55eb774 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatPaginationStateTest.kt @@ -0,0 +1,156 @@ +package kr.co.vividnext.sodalive.v2.main.chat.dm + +import android.app.Application +import android.content.Context +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.common.ApiResponse +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest.Companion.message +import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest.Companion.messagesPage +import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest.Companion.openResponse +import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest.Companion.requireValue +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessagesPageResponse +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState +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 DmChatPaginationStateTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var api: FakeDmChatApi + private lateinit var viewModel: DmChatRoomViewModel + + @Before + fun setUp() { + setImmediateRxSchedulers() + SharedPreferenceManager.resetForTest() + SharedPreferenceManager.init(context) + SharedPreferenceManager.token = "test-token" + api = FakeDmChatApi() + viewModel = DmChatRoomViewModel(repository = DmChatRepository(api)) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + SharedPreferenceManager.resetForTest() + } + + @Test + fun `hasMore가 false이면 과거 메시지를 요청하지 않는다`() { + api.enqueueOpenSuccess(openResponse(roomId = 10L, hasMore = false, nextCursor = 1L)) + viewModel.enter(roomId = 10L, creatorId = 0L) + + viewModel.loadOlderMessages() + + assertTrue(api.messagesCalls.isEmpty()) + } + + @Test + fun `nextCursor가 null이면 과거 메시지를 요청하지 않는다`() { + api.enqueueOpenSuccess(openResponse(roomId = 10L, hasMore = true, nextCursor = null)) + viewModel.enter(roomId = 10L, creatorId = 0L) + + viewModel.loadOlderMessages() + + assertTrue(api.messagesCalls.isEmpty()) + } + + @Test + fun `과거 메시지 로딩 중 중복 요청은 무시한다`() { + val pendingOlder = SingleSubject.create>() + api.enqueueOpenSuccess(openResponse(roomId = 10L, hasMore = true, nextCursor = 50L)) + viewModel.enter(roomId = 10L, creatorId = 0L) + api.enqueueMessagesSuccessSubject(pendingOlder) + + viewModel.loadOlderMessages() + viewModel.loadOlderMessages() + + assertEquals(1, api.messagesCalls.size) + val loadingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertTrue(loadingState.isLoadingOlder) + + pendingOlder.onSuccess(ApiResponse(success = true, data = messagesPage(messages = emptyList()))) + } + + @Test + fun `과거 메시지 요청 실패는 로딩 상태를 해제하고 기존 목록을 유지한다`() { + api.enqueueOpenSuccess( + openResponse( + roomId = 10L, + messages = listOf(message(messageId = 2L, createdAt = 200L, textMessage = "현재")), + hasMore = true, + nextCursor = 50L + ) + ) + viewModel.enter(roomId = 10L, creatorId = 0L) + api.enqueueMessages(Single.error(IllegalStateException("network"))) + + viewModel.loadOlderMessages() + + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertFalse(state.isLoadingOlder) + assertEquals(listOf(2L), state.messages.map { it.messageId }) + assertEquals(1, api.messagesCalls.size) + } + + @Test + fun `과거 메시지는 cursor로 요청하고 기존 목록 상단에 prepend하며 중복을 제거한다`() { + api.enqueueOpenSuccess( + openResponse( + roomId = 10L, + messages = listOf(message(messageId = 2L, createdAt = 200L, textMessage = "현재")), + hasMore = true, + nextCursor = 50L + ) + ) + api.enqueueMessagesSuccess( + messagesPage( + messages = listOf( + message(messageId = 1L, createdAt = 100L, textMessage = "과거"), + message(messageId = 2L, createdAt = 200L, textMessage = "중복") + ), + hasMore = false, + nextCursor = null + ) + ) + viewModel.enter(roomId = 10L, creatorId = 0L) + + viewModel.loadOlderMessages() + + assertEquals(listOf(MessagesCall("Bearer test-token", 10L, 50L, 20)), api.messagesCalls) + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(listOf(1L, 2L), state.messages.map { it.messageId }) + assertEquals(listOf("과거", "현재"), state.messages.map { it.textMessage }) + assertFalse(state.hasMore) + assertEquals(1, viewModel.prependedMessageCountLiveData.requireValue()) + } + + private fun setImmediateRxSchedulers() { + val trampoline = { _: Scheduler -> Schedulers.trampoline() } + RxJavaPlugins.setIoSchedulerHandler(trampoline) + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } + } + + private fun FakeDmChatApi.enqueueMessagesSuccessSubject( + subject: SingleSubject> + ) = enqueueMessages(subject) +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt new file mode 100644 index 00000000..bd7a9d03 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt @@ -0,0 +1,451 @@ +package kr.co.vividnext.sodalive.v2.main.chat.dm + +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.common.ApiResponse +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomRequest +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomResponse +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatApi +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessagesPageResponse +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRoomOpenResponse +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmChatMessageResponse +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmTextMessageRequest +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState +import org.junit.After +import org.junit.Assert.assertEquals +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 DmChatRoomViewModelTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var api: FakeDmChatApi + private lateinit var viewModel: DmChatRoomViewModel + + @Before + fun setUp() { + setImmediateRxSchedulers() + SharedPreferenceManager.resetForTest() + SharedPreferenceManager.init(context) + SharedPreferenceManager.token = "test-token" + api = FakeDmChatApi() + viewModel = DmChatRoomViewModel(repository = DmChatRepository(api)) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + SharedPreferenceManager.resetForTest() + } + + @Test + fun `roomId 진입은 create 없이 openRoom을 호출하고 메시지를 정렬한다`() { + api.enqueueOpenSuccess( + openResponse( + roomId = 10L, + messages = listOf( + message(messageId = 2L, createdAt = 200L, textMessage = "둘"), + message(messageId = 1L, createdAt = 100L, textMessage = "하나") + ), + hasMore = true, + nextCursor = 1L + ) + ) + + viewModel.enter(roomId = 10L, creatorId = 0L) + + assertTrue(api.createCalls.isEmpty()) + assertEquals(listOf(OpenCall("Bearer test-token", 10L, 20)), api.openCalls) + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(10L, state.roomId) + assertEquals("상대", state.opponentNickname) + assertEquals(listOf(1L, 2L), state.messages.map { it.messageId }) + assertTrue(state.hasMore) + assertEquals(1L, state.nextCursor) + } + + @Test + fun `creatorId 진입은 createOrGetRoom 후 반환 roomId로 openRoom을 호출한다`() { + api.enqueueCreateSuccess(CreateDmChatRoomResponse(roomId = 12L)) + api.enqueueOpenSuccess(openResponse(roomId = 12L)) + + viewModel.enter(roomId = 0L, creatorId = 99L) + + assertEquals(listOf(CreateCall("Bearer test-token", CreateDmChatRoomRequest(creatorId = 99L))), api.createCalls) + assertEquals(listOf(OpenCall("Bearer test-token", 12L, 20)), api.openCalls) + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(12L, state.roomId) + } + + @Test + fun `유효하지 않은 진입 값은 종료 이벤트를 발행한다`() { + viewModel.enter(roomId = 0L, creatorId = 0L) + + assertTrue(viewModel.finishEventLiveData.requireValue() == true) + assertTrue(api.createCalls.isEmpty()) + assertTrue(api.openCalls.isEmpty()) + } + + @Test + fun `blank 전송은 요청하지 않고 pending 메시지를 추가하지 않는다`() { + api.enqueueOpenSuccess(openResponse(roomId = 10L)) + viewModel.enter(roomId = 10L, creatorId = 0L) + + viewModel.sendText(" ") + + assertTrue(api.sendCalls.isEmpty()) + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertTrue(state.messages.isEmpty()) + } + + @Test + fun `전송 직후 pending을 추가하고 성공 시 서버 메시지로 교체한다`() { + val pendingSend = SingleSubject.create>() + api.enqueueOpenSuccess(openResponse(roomId = 10L)) + api.enqueueSend(pendingSend) + viewModel.enter(roomId = 10L, creatorId = 0L) + + viewModel.sendText(" 안녕 ") + val sendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + + assertEquals(listOf(SendCall("Bearer test-token", 10L, SendDmTextMessageRequest("안녕"))), api.sendCalls) + assertEquals(DmChatMessageStatus.SENDING, sendingState.messages.single().status) + assertEquals("안녕", sendingState.messages.single().textMessage) + + pendingSend.onSuccess( + ApiResponse( + success = true, + data = SendDmChatMessageResponse( + message = message(messageId = 30L, mine = true, textMessage = "안녕"), + deliveredRealtime = true, + pushSent = false + ) + ) + ) + val sentState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(listOf(30L), sentState.messages.map { it.messageId }) + assertEquals(DmChatMessageStatus.SENT, sentState.messages.single().status) + } + + @Test + fun `전송 중 새 전송 중복 요청은 무시한다`() { + val pendingSend = SingleSubject.create>() + api.enqueueOpenSuccess(openResponse(roomId = 10L)) + api.enqueueSend(pendingSend) + viewModel.enter(roomId = 10L, creatorId = 0L) + + viewModel.sendText("안녕") + viewModel.sendText("안녕") + + assertEquals(1, api.sendCalls.size) + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(1, state.messages.size) + } + + @Test + fun `전송 실패는 pending 메시지를 FAILED로 바꾸고 retry 성공 시 교체한다`() { + api.enqueueOpenSuccess(openResponse(roomId = 10L)) + api.enqueueSend(Single.error(IllegalStateException("network"))) + viewModel.enter(roomId = 10L, creatorId = 0L) + + viewModel.sendText("안녕") + val failedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + val failedItem = failedState.messages.single() + assertEquals(DmChatMessageStatus.FAILED, failedItem.status) + + api.enqueueSendSuccess(message(messageId = 40L, mine = true, textMessage = "안녕")) + viewModel.retry(failedItem.localId!!) + + val retriedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(listOf(40L), retriedState.messages.map { it.messageId }) + assertEquals(DmChatMessageStatus.SENT, retriedState.messages.single().status) + } + + @Test + fun `retry 중 SSE echo가 먼저 와도 성공 교체 후 messageId 중복을 남기지 않는다`() { + val pendingRetry = SingleSubject.create>() + api.enqueueOpenSuccess(openResponse(roomId = 10L)) + api.enqueueSend(Single.error(IllegalStateException("network"))) + viewModel.enter(roomId = 10L, creatorId = 0L) + viewModel.sendText("안녕") + val failedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + val failedItem = failedState.messages.single() + api.enqueueSend(pendingRetry) + + viewModel.retry(failedItem.localId!!) + viewModel.onRealtimeMessage(message(messageId = 45L, mine = true, textMessage = "안녕")) + pendingRetry.onSuccess( + ApiResponse( + success = true, + data = SendDmChatMessageResponse( + message = message(messageId = 45L, mine = true, textMessage = "안녕"), + deliveredRealtime = true, + pushSent = false + ) + ) + ) + + val retriedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(listOf(45L), retriedState.messages.map { it.messageId }) + assertEquals(1, retriedState.messages.size) + } + + @Test + fun `SSE 메시지는 messageId 중복을 제거하고 최신 메시지를 추가한다`() { + api.enqueueOpenSuccess(openResponse(roomId = 10L, messages = listOf(message(messageId = 1L, textMessage = "기존")))) + viewModel.enter(roomId = 10L, creatorId = 0L) + + viewModel.onRealtimeMessage(message(messageId = 1L, textMessage = "중복")) + viewModel.onRealtimeMessage(message(messageId = 2L, createdAt = 200L, textMessage = "신규")) + + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(listOf(1L, 2L), state.messages.map { it.messageId }) + assertEquals(listOf("기존", "신규"), state.messages.map { it.textMessage }) + } + + @Test + fun `SSE echo가 전송 성공보다 먼저 와도 성공 교체 후 messageId 중복을 남기지 않는다`() { + val pendingSend = SingleSubject.create>() + api.enqueueOpenSuccess(openResponse(roomId = 10L)) + api.enqueueSend(pendingSend) + viewModel.enter(roomId = 10L, creatorId = 0L) + + viewModel.sendText("안녕") + viewModel.onRealtimeMessage(message(messageId = 50L, mine = true, textMessage = "안녕")) + pendingSend.onSuccess( + ApiResponse( + success = true, + data = SendDmChatMessageResponse( + message = message(messageId = 50L, mine = true, textMessage = "안녕"), + deliveredRealtime = true, + pushSent = false + ) + ) + ) + + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(listOf(50L), state.messages.map { it.messageId }) + assertEquals(1, state.messages.size) + } + + @Test + fun `재연결 후 최신 메시지 동기화는 getMessages 결과를 병합한다`() { + api.enqueueOpenSuccess(openResponse(roomId = 10L, messages = listOf(message(messageId = 1L, textMessage = "기존")))) + api.enqueueMessagesSuccess( + messagesPage( + messages = listOf(message(messageId = 2L, createdAt = 200L, textMessage = "동기화")) + ) + ) + viewModel.enter(roomId = 10L, creatorId = 0L) + + viewModel.syncLatestMessagesAfterReconnect() + + assertEquals(listOf(MessagesCall("Bearer test-token", 10L, null, 20)), api.messagesCalls) + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(listOf(1L, 2L), state.messages.map { it.messageId }) + } + + @Test + fun `재연결 후 최신 메시지 동기화 실패는 기존 메시지를 유지한다`() { + api.enqueueOpenSuccess(openResponse(roomId = 10L, messages = listOf(message(messageId = 1L, textMessage = "기존")))) + api.enqueueMessages(Single.error(IllegalStateException("network"))) + viewModel.enter(roomId = 10L, creatorId = 0L) + + viewModel.syncLatestMessagesAfterReconnect() + + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(listOf(1L), state.messages.map { it.messageId }) + assertEquals(listOf("기존"), state.messages.map { it.textMessage }) + } + + private fun setImmediateRxSchedulers() { + val trampoline = { _: Scheduler -> Schedulers.trampoline() } + RxJavaPlugins.setIoSchedulerHandler(trampoline) + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } + } + + companion object { + fun openResponse( + roomId: Long, + messages: List = emptyList(), + hasMore: Boolean = false, + nextCursor: Long? = null + ) = DmChatRoomOpenResponse( + roomId = roomId, + opponentNickname = "상대", + opponentProfileImageUrl = "https://example.com/profile.png", + messages = messages, + hasMore = hasMore, + nextCursor = nextCursor + ) + + fun messagesPage( + messages: List, + hasMore: Boolean = false, + nextCursor: Long? = null + ) = DmChatMessagesPageResponse( + messages = messages, + hasMore = hasMore, + nextCursor = nextCursor + ) + + fun message( + messageId: Long, + messageType: String = "TEXT", + mine: Boolean = false, + createdAt: Long = 100L, + textMessage: String? = "메시지" + ) = DmChatMessageResponse( + messageId = messageId, + messageType = messageType, + mine = mine, + createdAt = createdAt, + textMessage = textMessage, + voiceMessageUrl = null, + senderId = if (mine) 1L else 2L, + senderNickname = if (mine) "나" else "상대", + senderProfileImageUrl = "https://example.com/profile.png" + ) + + fun LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { value = it } + observeForever(observer) + removeObserver(observer) + return value + } + } +} + +data class CreateCall( + val authHeader: String, + val request: CreateDmChatRoomRequest +) + +data class OpenCall( + val authHeader: String, + val roomId: Long, + val limit: Int +) + +data class MessagesCall( + val authHeader: String, + val roomId: Long, + val cursor: Long?, + val limit: Int +) + +data class SendCall( + val authHeader: String, + val roomId: Long, + val request: SendDmTextMessageRequest +) + +class FakeDmChatApi : DmChatApi { + val createCalls = mutableListOf() + val openCalls = mutableListOf() + val messagesCalls = mutableListOf() + val sendCalls = mutableListOf() + + private val createResponses = ArrayDeque>>() + private val openResponses = ArrayDeque>>() + private val messagesResponses = ArrayDeque>>() + private val sendResponses = ArrayDeque>>() + + fun enqueueCreateSuccess(response: CreateDmChatRoomResponse) { + createResponses.addLast(Single.just(ApiResponse(success = true, data = response))) + } + + fun enqueueOpenSuccess(response: DmChatRoomOpenResponse) { + openResponses.addLast(Single.just(ApiResponse(success = true, data = response))) + } + + fun enqueueMessagesSuccess(response: DmChatMessagesPageResponse) { + messagesResponses.addLast(Single.just(ApiResponse(success = true, data = response))) + } + + fun enqueueMessages(response: Single>) { + messagesResponses.addLast(response) + } + + fun enqueueSend(response: Single>) { + sendResponses.addLast(response) + } + + fun enqueueSendSuccess(message: DmChatMessageResponse) { + sendResponses.addLast( + Single.just( + ApiResponse( + success = true, + data = SendDmChatMessageResponse( + message = message, + deliveredRealtime = true, + pushSent = false + ) + ) + ) + ) + } + + override fun createDmChatRoom( + authHeader: String, + request: CreateDmChatRoomRequest + ): Single> { + createCalls.add(CreateCall(authHeader, request)) + return createResponses.removeFirst() + } + + override fun openDmChatRoom( + authHeader: String, + roomId: Long, + limit: Int + ): Single> { + openCalls.add(OpenCall(authHeader, roomId, limit)) + return openResponses.removeFirst() + } + + override fun getDmChatMessages( + authHeader: String, + roomId: Long, + cursor: Long?, + limit: Int + ): Single> { + messagesCalls.add(MessagesCall(authHeader, roomId, cursor, limit)) + return messagesResponses.removeFirst() + } + + override fun sendDmTextMessage( + authHeader: String, + roomId: Long, + request: SendDmTextMessageRequest + ): Single> { + sendCalls.add(SendCall(authHeader, roomId, request)) + return sendResponses.removeFirst() + } + + override fun disconnectRealtime( + authHeader: String, + roomId: Long + ): Single> = Single.just(ApiResponse(success = true, data = true)) +}