fix(dm): WebSocket heartbeat와 token 재연결을 보정한다

This commit is contained in:
2026-06-18 23:31:15 +09:00
parent a6485292e4
commit 8f69c1ab82
2 changed files with 190 additions and 5 deletions

View File

@@ -55,6 +55,7 @@ class DmChatRoomViewModelTest {
private lateinit var socketClient: DmChatSocketClient
private lateinit var reconnectScheduler: TestScheduler
private lateinit var viewModel: DmChatRoomViewModel
private var token: String = "test-token"
@Before
fun setUp() {
@@ -62,6 +63,7 @@ class DmChatRoomViewModelTest {
SharedPreferenceManager.resetForTest()
SharedPreferenceManager.init(context)
SharedPreferenceManager.token = "test-token"
token = "test-token"
api = FakeDmChatApi()
socketFactory = FakeWebSocketFactory()
socketClient = DmChatSocketClient(
@@ -74,7 +76,7 @@ class DmChatRoomViewModelTest {
viewModel = DmChatRoomViewModel(
repository = DmChatRepository(api, socketClient),
reconnectScheduler = reconnectScheduler,
tokenProvider = { "test-token" }
tokenProvider = { token }
)
}
@@ -651,7 +653,11 @@ class DmChatRoomViewModelTest {
).readText()
val compactSource = source.filterNot { it.isWhitespace() }
assertTrue(compactSource.contains("scheduleRealtimeCallback{if(shouldReconnectRealtime)connectRealtime(token=token)}"))
assertTrue(
compactSource.contains(
"scheduleRealtimeCallback{if(shouldReconnectRealtime)connectRealtime(token=authToken().ifBlank{token})}"
)
)
assertTrue(!compactSource.contains("scheduleDirect({connectRealtime(token=token)}"))
}
@@ -714,6 +720,109 @@ class DmChatRoomViewModelTest {
assertEquals(2, socketFactory.connectCalls.size)
}
@Test
fun `leave는 LEAVE_ROOM 전송 후 socket을 close하고 중복 호출은 무시한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
viewModel.leaveRealtime()
viewModel.leaveRealtime()
assertEquals(listOf("JOIN_ROOM", "LEAVE_ROOM"), socketFactory.webSocket.sentTexts.map { it.type() })
assertEquals(1, socketFactory.closeCount)
}
@Test
fun `JOINED 이후 heartbeat는 PING을 보내고 PONG 수신 시 연결을 유지한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
api.enqueueMessagesSuccess(messagesPage(messages = emptyList()))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
socketFactory.emitJoined()
reconnectScheduler.advanceTimeBy(30L, TimeUnit.SECONDS)
reconnectScheduler.advanceTimeBy(5L, TimeUnit.SECONDS)
socketFactory.emitPong()
reconnectScheduler.advanceTimeBy(24L, TimeUnit.SECONDS)
assertEquals(listOf("JOIN_ROOM", "PING"), socketFactory.webSocket.sentTexts.map { it.type() })
assertEquals(true, viewModel.isRealtimeConnectedForTest())
assertEquals(0, socketFactory.closeCount)
}
@Test
fun `heartbeat PONG timeout은 socket close 후 foreground 조건에서 reconnect를 예약한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
api.enqueueMessagesSuccess(messagesPage(messages = emptyList()))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
socketFactory.emitJoined()
reconnectScheduler.advanceTimeBy(30L, TimeUnit.SECONDS)
reconnectScheduler.advanceTimeBy(10L, TimeUnit.SECONDS)
reconnectScheduler.advanceTimeBy(2999L, TimeUnit.MILLISECONDS)
assertEquals(false, viewModel.isRealtimeConnectedForTest())
assertEquals(1, socketFactory.closeCount)
assertEquals(1, socketFactory.connectCalls.size)
reconnectScheduler.advanceTimeBy(1L, TimeUnit.MILLISECONDS)
assertEquals(2, socketFactory.connectCalls.size)
assertEquals("JOIN_ROOM", socketFactory.webSocket.sentTexts.lastJson().get("type").asString)
}
@Test
fun `leave는 heartbeat timeout과 reconnect 예약을 취소한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
api.enqueueMessagesSuccess(messagesPage(messages = emptyList()))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
socketFactory.emitJoined()
reconnectScheduler.advanceTimeBy(30L, TimeUnit.SECONDS)
viewModel.leaveRealtime()
reconnectScheduler.advanceTimeBy(13L, TimeUnit.SECONDS)
assertEquals(listOf(RealtimeConnectCall("test-token", 10L)), socketFactory.connectCalls)
assertEquals(listOf("JOIN_ROOM", "PING", "LEAVE_ROOM"), socketFactory.webSocket.sentTexts.map { it.type() })
assertEquals(1, socketFactory.closeCount)
}
@Test
fun `token이 변경되면 기존 socket을 close하고 새 token으로 다시 JOIN_ROOM을 보낸다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
api.enqueueMessagesSuccess(messagesPage(messages = emptyList()))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
socketFactory.emitJoined()
token = "new-token"
viewModel.connectRealtime()
assertEquals(
listOf(RealtimeConnectCall("test-token", 10L), RealtimeConnectCall("new-token", 10L)),
socketFactory.connectCalls
)
assertEquals(1, socketFactory.closeCount)
assertEquals("JOIN_ROOM", socketFactory.webSocket.sentTexts.lastJson().get("type").asString)
}
@Test
fun `leave 이후 token이 변경되어도 socket reconnect를 진행하지 않는다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
viewModel.leaveRealtime()
token = "new-token"
reconnectScheduler.advanceTimeBy(30L, TimeUnit.SECONDS)
assertEquals(listOf(RealtimeConnectCall("test-token", 10L)), socketFactory.connectCalls)
assertEquals(1, socketFactory.closeCount)
}
@Test
fun `realtime leave는 채팅 상태를 Error로 바꾸지 않는다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L, messages = listOf(message(messageId = 1L, textMessage = "기존"))))
@@ -972,6 +1081,10 @@ class FakeWebSocketFactory {
)
}
fun emitPong() {
webSocketListener?.onMessage(webSocket, "{\"type\":\"PONG\",\"payload\":{}}")
}
fun emitFailure(throwable: Throwable) {
webSocketListener?.onFailure(webSocket, throwable, null)
}
@@ -997,3 +1110,7 @@ class FakeWebSocket : WebSocket {
fun sentJsonAt(index: Int) = JsonParser.parseString(sentTexts[index]).asJsonObject
}
private fun String.type(): String = JsonParser.parseString(this).asJsonObject.get("type").asString
private fun List<String>.lastJson() = JsonParser.parseString(last()).asJsonObject