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

This commit is contained in:
2026-06-10 18:48:27 +09:00
parent 406c377d13
commit 56f110c548
4 changed files with 931 additions and 0 deletions

View File

@@ -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<DmChatMessageUiItem> = 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<DmChatRoomUiState>()
val chatRoomStateLiveData: LiveData<DmChatRoomUiState>
get() = _chatRoomStateLiveData
private val _finishEventLiveData = MutableLiveData<Boolean>()
val finishEventLiveData: LiveData<Boolean>
get() = _finishEventLiveData
private val _prependedMessageCountLiveData = MutableLiveData(0)
val prependedMessageCountLiveData: LiveData<Int>
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<DmChatRoomOpenResponse>) {
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<DmChatMessagesPageResponse>) {
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<SendDmChatMessageResponse>
) {
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<DmChatMessageUiItem>.deduplicateSentMessage(messageId: Long?): List<DmChatMessageUiItem> {
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<CreateDmChatRoomResponse>.requireData(): CreateDmChatRoomResponse {
if (success && data != null) return data
throw IllegalStateException(message)
}
}

View File

@@ -16,3 +16,19 @@ data class DmChatMessageUiItem(
val createdAt: Long, val createdAt: Long,
val status: DmChatMessageStatus 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<DmChatMessageUiItem>,
val hasMore: Boolean,
val nextCursor: Long?,
val isLoadingOlder: Boolean = false
) : DmChatRoomUiState()
data class Error(val message: String?) : DmChatRoomUiState()
}

View File

@@ -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<ApiResponse<DmChatMessagesPageResponse>>()
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<ApiResponse<DmChatMessagesPageResponse>>
) = enqueueMessages(subject)
}

View File

@@ -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<ApiResponse<SendDmChatMessageResponse>>()
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<ApiResponse<SendDmChatMessageResponse>>()
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<ApiResponse<SendDmChatMessageResponse>>()
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<ApiResponse<SendDmChatMessageResponse>>()
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<DmChatMessageResponse> = 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<DmChatMessageResponse>,
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 <T> LiveData<T>.requireValue(): T? {
var value: T? = null
val observer = Observer<T> { 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<CreateCall>()
val openCalls = mutableListOf<OpenCall>()
val messagesCalls = mutableListOf<MessagesCall>()
val sendCalls = mutableListOf<SendCall>()
private val createResponses = ArrayDeque<Single<ApiResponse<CreateDmChatRoomResponse>>>()
private val openResponses = ArrayDeque<Single<ApiResponse<DmChatRoomOpenResponse>>>()
private val messagesResponses = ArrayDeque<Single<ApiResponse<DmChatMessagesPageResponse>>>()
private val sendResponses = ArrayDeque<Single<ApiResponse<SendDmChatMessageResponse>>>()
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<ApiResponse<DmChatMessagesPageResponse>>) {
messagesResponses.addLast(response)
}
fun enqueueSend(response: Single<ApiResponse<SendDmChatMessageResponse>>) {
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<ApiResponse<CreateDmChatRoomResponse>> {
createCalls.add(CreateCall(authHeader, request))
return createResponses.removeFirst()
}
override fun openDmChatRoom(
authHeader: String,
roomId: Long,
limit: Int
): Single<ApiResponse<DmChatRoomOpenResponse>> {
openCalls.add(OpenCall(authHeader, roomId, limit))
return openResponses.removeFirst()
}
override fun getDmChatMessages(
authHeader: String,
roomId: Long,
cursor: Long?,
limit: Int
): Single<ApiResponse<DmChatMessagesPageResponse>> {
messagesCalls.add(MessagesCall(authHeader, roomId, cursor, limit))
return messagesResponses.removeFirst()
}
override fun sendDmTextMessage(
authHeader: String,
roomId: Long,
request: SendDmTextMessageRequest
): Single<ApiResponse<SendDmChatMessageResponse>> {
sendCalls.add(SendCall(authHeader, roomId, request))
return sendResponses.removeFirst()
}
override fun disconnectRealtime(
authHeader: String,
roomId: Long
): Single<ApiResponse<Boolean>> = Single.just(ApiResponse(success = true, data = true))
}