fix(chat): DM realtime 정리 누락을 보정한다

This commit is contained in:
2026-06-11 12:05:45 +09:00
parent 841ed5f6f8
commit a6d8cd54f3
2 changed files with 94 additions and 2 deletions

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.main.chat.dm
import android.app.Application
import android.content.Context
import android.os.Looper
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.test.core.app.ApplicationProvider
@@ -33,8 +34,11 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Shadows.shadowOf
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.lang.reflect.Method
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@RunWith(RobolectricTestRunner::class)
@@ -386,6 +390,22 @@ class DmChatRoomViewModelTest {
assertEquals(listOf("실시간"), state.messages.map { it.textMessage })
}
@Test
fun `realtime message callback은 완료된 Disposable을 누적하지 않는다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
val beforeCallbackSize = viewModel.compositeDisposable.size()
realtimeClient.listener?.onMessage(message(messageId = 3L, textMessage = "실시간1"))
realtimeClient.listener?.onMessage(message(messageId = 4L, textMessage = "실시간2"))
realtimeClient.listener?.onMessage(message(messageId = 5L, textMessage = "실시간3"))
assertEquals(beforeCallbackSize, viewModel.compositeDisposable.size())
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertEquals(listOf(3L, 4L, 5L), state.messages.map { it.messageId })
}
@Test
fun `realtime listener callback은 main thread scheduler로 상태를 갱신한다`() {
val source = projectFile(
@@ -393,7 +413,8 @@ class DmChatRoomViewModelTest {
).readText()
assertTrue(source.contains("scheduleRealtimeCallback"))
assertTrue(source.contains("AndroidSchedulers.mainThread().scheduleDirect"))
assertTrue(source.contains("Looper.myLooper() == Looper.getMainLooper()"))
assertTrue(source.contains("mainHandler.post { action() }"))
assertTrue(source.contains("scheduleRealtimeCallback { syncLatestMessagesAfterReconnect(token = token) }"))
assertTrue(source.contains("scheduleRealtimeCallback { onRealtimeMessage(message) }"))
}
@@ -532,6 +553,56 @@ class DmChatRoomViewModelTest {
assertEquals(listOf(1L), state.messages.map { it.messageId })
}
@Test
fun `onCleared는 realtime 연결과 disposable을 정리한다`() {
val pendingDisconnect = SingleSubject.create<ApiResponse<Boolean>>()
api.enqueueOpenSuccess(openResponse(roomId = 10L))
api.enqueueDisconnect(pendingDisconnect)
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
viewModel.disconnectRealtime()
viewModel.invokeOnCleared()
assertEquals(2, realtimeClient.cancelCalls)
assertTrue(viewModel.compositeDisposable.isDisposed)
assertTrue(pendingDisconnect.hasObservers().not())
}
@Test
fun `onCleared는 예약된 realtime 재연결을 취소한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
realtimeClient.listener?.onFailure(IllegalStateException("network"))
viewModel.invokeOnCleared()
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
assertEquals(listOf(RealtimeConnectCall("test-token", 10L)), realtimeClient.connectCalls)
assertEquals(1, realtimeClient.cancelCalls)
}
@Test
fun `onCleared는 예약된 main handler callback 실행을 막는다`() {
val postedLatch = CountDownLatch(1)
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
Thread {
realtimeClient.listener?.onMessage(message(messageId = 99L, textMessage = "제거 대상"))
postedLatch.countDown()
}.start()
assertTrue(postedLatch.await(2, TimeUnit.SECONDS))
viewModel.invokeOnCleared()
shadowOf(Looper.getMainLooper()).idle()
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertTrue(state.messages.isEmpty())
}
private fun projectFile(relativePath: String): java.io.File {
val candidates = listOf(java.io.File(relativePath), java.io.File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
@@ -595,6 +666,12 @@ class DmChatRoomViewModelTest {
removeObserver(observer)
return value
}
fun DmChatRoomViewModel.invokeOnCleared() {
val method: Method = DmChatRoomViewModel::class.java.getDeclaredMethod("onCleared")
method.isAccessible = true
method.invoke(this)
}
}
}