fix(dm): MESSAGE 선도착 ACK 처리를 보정한다

This commit is contained in:
2026-06-18 22:57:36 +09:00
parent 2c1eb03e5f
commit 482d517145
4 changed files with 141 additions and 21 deletions

View File

@@ -388,6 +388,86 @@ class DmChatRoomViewModelTest {
assertEquals(1, state.messages.size)
}
@Test
fun `MESSAGE requestId가 SEND_ACK보다 먼저 오면 pending local item을 확정한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
viewModel.sendText("선도착")
val pendingItem = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
.messages.single()
val requestId = pendingItem.requestId!!
socketFactory.emitMessage(requestId, message(messageId = 60L, mine = true, textMessage = "선도착"))
socketFactory.emitAck(requestId, message(messageId = 60L, mine = true, textMessage = "선도착"))
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertEquals(listOf(60L), state.messages.map { it.messageId })
assertEquals(listOf(pendingItem.localId), state.messages.map { it.localId })
assertEquals(listOf(DmChatMessageStatus.SENT), state.messages.map { it.status })
}
@Test
fun `MESSAGE requestId가 없으면 ACK 후도착 시 같은 messageId 중복을 남기지 않는다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
viewModel.sendText("중복 방지")
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
.messages.single().requestId!!
socketFactory.emitMessage(message(messageId = 61L, mine = true, textMessage = "중복 방지"))
socketFactory.emitAck(requestId, message(messageId = 61L, mine = true, textMessage = "중복 방지"))
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertEquals(listOf(61L), state.messages.map { it.messageId })
assertEquals(1, state.messages.size)
}
@Test
fun `timeout 실패 후 늦은 SEND_ACK가 오면 같은 local item을 성공으로 복구한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
viewModel.sendText("늦은 ACK")
val pendingItem = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
.messages.single()
val requestId = pendingItem.requestId!!
reconnectScheduler.advanceTimeBy(10L, TimeUnit.SECONDS)
shadowOf(Looper.getMainLooper()).idle()
socketFactory.emitAck(requestId, message(messageId = 62L, mine = true, textMessage = "늦은 ACK"))
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertEquals(listOf(62L), state.messages.map { it.messageId })
assertEquals(listOf(pendingItem.localId), state.messages.map { it.localId })
assertEquals(listOf(DmChatMessageStatus.SENT), state.messages.map { it.status })
}
@Test
fun `retry 성공 후 이전 timeout request ACK는 같은 local item을 덮어쓰지 않는다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
viewModel.sendText("재시도 ACK")
val firstState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
val failedItem = firstState.messages.single()
val firstRequestId = failedItem.requestId!!
reconnectScheduler.advanceTimeBy(10L, TimeUnit.SECONDS)
shadowOf(Looper.getMainLooper()).idle()
viewModel.retry(failedItem.localId!!)
val retryRequestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
.messages.single().requestId!!
socketFactory.emitAck(retryRequestId, message(messageId = 70L, mine = true, textMessage = "최신 ACK"))
socketFactory.emitAck(firstRequestId, message(messageId = 69L, mine = true, textMessage = "이전 ACK"))
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertEquals(listOf(70L), state.messages.map { it.messageId })
assertEquals(listOf("최신 ACK"), state.messages.map { it.textMessage })
assertEquals(listOf(failedItem.localId), state.messages.map { it.localId })
}
@Test
fun `재연결 후 최신 메시지 동기화는 getMessages 결과를 병합한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L, messages = listOf(message(messageId = 1L, textMessage = "기존"))))
@@ -621,20 +701,6 @@ class DmChatRoomViewModelTest {
assertTrue(socketFactory.closeCount >= 1)
}
@Test
fun `realtime leave 중 중복 요청은 close를 반복할 수 있다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.leaveRealtime()
viewModel.leaveRealtime()
assertEquals(0, socketFactory.closeCount)
viewModel.leaveRealtime()
assertEquals(0, socketFactory.closeCount)
}
@Test
fun `leave 후 다시 background로 가면 새 소켓도 close한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
@@ -883,6 +949,14 @@ class FakeWebSocketFactory {
webSocketListener?.onMessage(webSocket, "{\"type\":\"MESSAGE\",\"payload\":{\"message\":$json}}")
}
fun emitMessage(requestId: String, message: DmChatMessageResponse) {
val json = Gson().toJson(message)
webSocketListener?.onMessage(
webSocket,
"{\"type\":\"MESSAGE\",\"payload\":{\"requestId\":\"$requestId\",\"message\":$json}}"
)
}
fun emitAck(requestId: String, message: DmChatMessageResponse) {
val json = Gson().toJson(message)
webSocketListener?.onMessage(

View File

@@ -42,6 +42,26 @@ class DmChatSocketParserTest {
assertEquals("안녕하세요", message.textMessage)
}
@Test
fun `MESSAGE type은 nullable requestId를 보존한다`() {
val event = parser.parse(
"""
{
"type": "MESSAGE",
"payload": {
"requestId": "request-1",
"message": ${messageJson(messageId = 12L, textMessage = "선도착")}
}
}
""".trimIndent()
)
val messageEvent = event as? DmChatSocketEvent.Message
requireNotNull(messageEvent)
assertEquals("request-1", messageEvent.requestId)
assertEquals(12L, messageEvent.message.messageId)
}
@Test
fun `SEND_ACK type은 requestId와 서버 확정 메시지로 파싱된다`() {
val event = parser.parse(