refactor(dm): 채팅 화면 전송을 WebSocket으로 전환한다
This commit is contained in:
@@ -52,7 +52,7 @@ class DmChatRoomActivity : BaseActivity<ActivityDmChatRoomBinding>(
|
|||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
isStarted = false
|
isStarted = false
|
||||||
viewModel.disconnectRealtime()
|
viewModel.leaveRealtime()
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import kr.co.vividnext.sodalive.base.BaseViewModel
|
|||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
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.CreateDmChatRoomResponse
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatEventClient
|
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse
|
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.DmChatMessagesPageResponse
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
|
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.DmChatRoomOpenResponse
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmChatMessageResponse
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketClient
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketEvent
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus
|
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.DmChatMessageUiItem
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
|
||||||
@@ -46,7 +46,6 @@ class DmChatRoomViewModel(
|
|||||||
private var shouldReconnectRealtime: Boolean = false
|
private var shouldReconnectRealtime: Boolean = false
|
||||||
private var currentAuthToken: String = ""
|
private var currentAuthToken: String = ""
|
||||||
private var currentRealtimeToken: String = ""
|
private var currentRealtimeToken: String = ""
|
||||||
private var isDisconnecting: Boolean = false
|
|
||||||
private var reconnectDisposable: Disposable? = null
|
private var reconnectDisposable: Disposable? = null
|
||||||
private var localMessageSequence: Long = 0L
|
private var localMessageSequence: Long = 0L
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
@@ -187,16 +186,11 @@ class DmChatRoomViewModel(
|
|||||||
shouldReconnectRealtime = true
|
shouldReconnectRealtime = true
|
||||||
reconnectDisposable?.dispose()
|
reconnectDisposable?.dispose()
|
||||||
reconnectDisposable = null
|
reconnectDisposable = null
|
||||||
repository.connectRealtime(
|
repository.connectSocket(
|
||||||
token = token,
|
token = token,
|
||||||
roomId = roomId,
|
listener = object : DmChatSocketClient.Listener {
|
||||||
listener = object : DmChatEventClient.Listener {
|
override fun onEvent(event: DmChatSocketEvent) {
|
||||||
override fun onConnected() {
|
scheduleRealtimeCallback { handleSocketEvent(event, token) }
|
||||||
scheduleRealtimeCallback { syncLatestMessagesAfterReconnect(token = token) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(message: DmChatMessageResponse) {
|
|
||||||
scheduleRealtimeCallback { onRealtimeMessage(message) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(throwable: Throwable) {
|
override fun onFailure(throwable: Throwable) {
|
||||||
@@ -208,9 +202,10 @@ class DmChatRoomViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
repository.sendJoinRoom(roomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnectRealtime() {
|
fun leaveRealtime() {
|
||||||
val roomId = currentRoomId
|
val roomId = currentRoomId
|
||||||
if (roomId <= 0L) return
|
if (roomId <= 0L) return
|
||||||
|
|
||||||
@@ -219,22 +214,8 @@ class DmChatRoomViewModel(
|
|||||||
isRealtimeConnected = false
|
isRealtimeConnected = false
|
||||||
reconnectDisposable?.dispose()
|
reconnectDisposable?.dispose()
|
||||||
reconnectDisposable = null
|
reconnectDisposable = null
|
||||||
repository.cancelRealtime()
|
repository.sendLeaveRoom(roomId)
|
||||||
if (isDisconnecting) return
|
repository.closeSocket()
|
||||||
|
|
||||||
isDisconnecting = true
|
|
||||||
compositeDisposable.add(
|
|
||||||
repository.disconnectRealtime(token = authToken(), roomId = roomId)
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(
|
|
||||||
{ isDisconnecting = false },
|
|
||||||
{
|
|
||||||
isDisconnecting = false
|
|
||||||
it.message?.let { message -> Logger.e(message) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleRealtimeReconnect() {
|
private fun scheduleRealtimeReconnect() {
|
||||||
@@ -266,7 +247,7 @@ class DmChatRoomViewModel(
|
|||||||
mainHandler.removeCallbacksAndMessages(null)
|
mainHandler.removeCallbacksAndMessages(null)
|
||||||
reconnectDisposable?.dispose()
|
reconnectDisposable?.dispose()
|
||||||
reconnectDisposable = null
|
reconnectDisposable = null
|
||||||
repository.cancelRealtime()
|
repository.closeSocket()
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,22 +283,12 @@ class DmChatRoomViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun sendLocalMessage(localId: String, text: String) {
|
private fun sendLocalMessage(localId: String, text: String) {
|
||||||
compositeDisposable.add(
|
val sent = repository.sendSocketText(
|
||||||
repository.sendTextMessage(
|
|
||||||
token = authToken(),
|
|
||||||
roomId = currentRoomId,
|
roomId = currentRoomId,
|
||||||
|
requestId = localId,
|
||||||
textMessage = text
|
textMessage = text
|
||||||
)
|
)
|
||||||
.subscribeOn(Schedulers.io())
|
if (!sent) markLocalMessageFailed(localId)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(
|
|
||||||
{ handleSendResult(localId, it) },
|
|
||||||
{
|
|
||||||
it.message?.let { message -> Logger.e(message) }
|
|
||||||
markLocalMessageFailed(localId)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleOpenRoomResult(response: ApiResponse<DmChatRoomOpenResponse>) {
|
private fun handleOpenRoomResult(response: ApiResponse<DmChatRoomOpenResponse>) {
|
||||||
@@ -356,12 +327,18 @@ class DmChatRoomViewModel(
|
|||||||
emitContent()
|
emitContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleSendResult(
|
private fun handleSocketEvent(event: DmChatSocketEvent, token: String) {
|
||||||
localId: String,
|
when (event) {
|
||||||
response: ApiResponse<SendDmChatMessageResponse>
|
DmChatSocketEvent.Joined -> syncLatestMessagesAfterReconnect(token = token)
|
||||||
) {
|
is DmChatSocketEvent.Message -> onRealtimeMessage(event.message)
|
||||||
val message = response.data?.message
|
is DmChatSocketEvent.SendAck -> handleSendAck(event.requestId, event.message)
|
||||||
val sentItem = if (response.success && message != null) message.toUiItem() else null
|
is DmChatSocketEvent.Error -> event.requestId?.let { markLocalMessageFailed(it) }
|
||||||
|
DmChatSocketEvent.Pong -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSendAck(localId: String, message: DmChatMessageResponse) {
|
||||||
|
val sentItem = message.toUiItem()
|
||||||
if (sentItem == null) {
|
if (sentItem == null) {
|
||||||
markLocalMessageFailed(localId)
|
markLocalMessageFailed(localId)
|
||||||
return
|
return
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ class DmChatRoomActivitySourceTest {
|
|||||||
assertTrue(source.contains("roomOpenedEventLiveData.observe(this)"))
|
assertTrue(source.contains("roomOpenedEventLiveData.observe(this)"))
|
||||||
assertTrue(source.contains("if (it.consume() == true) connectRealtimeIfStarted()"))
|
assertTrue(source.contains("if (it.consume() == true) connectRealtimeIfStarted()"))
|
||||||
assertTrue(source.contains("connectRealtimeIfStarted()"))
|
assertTrue(source.contains("connectRealtimeIfStarted()"))
|
||||||
assertTrue(source.contains("viewModel.disconnectRealtime()"))
|
assertTrue(source.contains("viewModel.leaveRealtime()"))
|
||||||
assertFalse(source.contains("if (isStarted) viewModel.connectRealtime()"))
|
assertFalse(source.contains("if (isStarted) viewModel.connectRealtime()"))
|
||||||
assertTrue(!source.contains("character_type_badge"))
|
assertTrue(!source.contains("character_type_badge"))
|
||||||
assertTrue(!source.contains("notice_container"))
|
assertTrue(!source.contains("notice_container"))
|
||||||
|
|||||||
+138
-200
@@ -6,13 +6,14 @@ import android.os.Looper
|
|||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonParser
|
||||||
import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins
|
import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins
|
||||||
import io.reactivex.rxjava3.core.Scheduler
|
import io.reactivex.rxjava3.core.Scheduler
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
import io.reactivex.rxjava3.schedulers.TestScheduler
|
import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||||
import io.reactivex.rxjava3.subjects.SingleSubject
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
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.CreateDmChatRoomRequest
|
||||||
@@ -20,14 +21,16 @@ 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.DmChatApi
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse
|
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.DmChatMessagesPageResponse
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatEventClient
|
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRealtimeClient
|
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
|
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.DmChatRoomOpenResponse
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmChatMessageResponse
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketClient
|
||||||
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.DmChatMessageStatus
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okio.ByteString
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -47,7 +50,8 @@ class DmChatRoomViewModelTest {
|
|||||||
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
private lateinit var api: FakeDmChatApi
|
private lateinit var api: FakeDmChatApi
|
||||||
private lateinit var realtimeClient: FakeDmChatRealtimeClient
|
private lateinit var socketFactory: FakeWebSocketFactory
|
||||||
|
private lateinit var socketClient: DmChatSocketClient
|
||||||
private lateinit var reconnectScheduler: TestScheduler
|
private lateinit var reconnectScheduler: TestScheduler
|
||||||
private lateinit var viewModel: DmChatRoomViewModel
|
private lateinit var viewModel: DmChatRoomViewModel
|
||||||
|
|
||||||
@@ -58,10 +62,16 @@ class DmChatRoomViewModelTest {
|
|||||||
SharedPreferenceManager.init(context)
|
SharedPreferenceManager.init(context)
|
||||||
SharedPreferenceManager.token = "test-token"
|
SharedPreferenceManager.token = "test-token"
|
||||||
api = FakeDmChatApi()
|
api = FakeDmChatApi()
|
||||||
realtimeClient = FakeDmChatRealtimeClient()
|
socketFactory = FakeWebSocketFactory()
|
||||||
|
socketClient = DmChatSocketClient(
|
||||||
|
okHttpClient = OkHttpClient(),
|
||||||
|
gson = Gson(),
|
||||||
|
baseUrl = "https://api.example.com",
|
||||||
|
webSocketFactory = socketFactory::newWebSocket
|
||||||
|
)
|
||||||
reconnectScheduler = TestScheduler()
|
reconnectScheduler = TestScheduler()
|
||||||
viewModel = DmChatRoomViewModel(
|
viewModel = DmChatRoomViewModel(
|
||||||
repository = DmChatRepository(api, realtimeClient),
|
repository = DmChatRepository(api, socketClient),
|
||||||
reconnectScheduler = reconnectScheduler,
|
reconnectScheduler = reconnectScheduler,
|
||||||
tokenProvider = { "test-token" }
|
tokenProvider = { "test-token" }
|
||||||
)
|
)
|
||||||
@@ -148,35 +158,27 @@ class DmChatRoomViewModelTest {
|
|||||||
|
|
||||||
viewModel.sendText(" ")
|
viewModel.sendText(" ")
|
||||||
|
|
||||||
assertTrue(api.sendCalls.isEmpty())
|
assertTrue(socketFactory.webSocket.sentTexts.isEmpty())
|
||||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
assertTrue(state.messages.isEmpty())
|
assertTrue(state.messages.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `전송 직후 pending을 추가하고 성공 시 서버 메시지로 교체한다`() {
|
fun `전송 직후 pending을 추가하고 성공 시 서버 메시지로 교체한다`() {
|
||||||
val pendingSend = SingleSubject.create<ApiResponse<SendDmChatMessageResponse>>()
|
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
api.enqueueSend(pendingSend)
|
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
viewModel.sendText(" 안녕 ")
|
viewModel.sendText(" 안녕 ")
|
||||||
val sendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val sendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
|
val localId = sendingState.messages.single().localId!!
|
||||||
|
|
||||||
assertEquals(listOf(SendCall("Bearer test-token", 10L, SendDmTextMessageRequest("안녕"))), api.sendCalls)
|
assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(1).get("type").asString)
|
||||||
|
assertEquals(localId, socketFactory.webSocket.sentJsonAt(1).getAsJsonObject("payload").get("requestId").asString)
|
||||||
assertEquals(DmChatMessageStatus.SENDING, sendingState.messages.single().status)
|
assertEquals(DmChatMessageStatus.SENDING, sendingState.messages.single().status)
|
||||||
assertEquals("안녕", sendingState.messages.single().textMessage)
|
assertEquals("안녕", sendingState.messages.single().textMessage)
|
||||||
|
|
||||||
pendingSend.onSuccess(
|
socketFactory.emitAck(localId, message(messageId = 30L, mine = true, textMessage = "안녕"))
|
||||||
ApiResponse(
|
|
||||||
success = true,
|
|
||||||
data = SendDmChatMessageResponse(
|
|
||||||
message = message(messageId = 30L, mine = true, textMessage = "안녕"),
|
|
||||||
deliveredRealtime = true,
|
|
||||||
pushSent = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val sentState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val sentState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
assertEquals(listOf(30L), sentState.messages.map { it.messageId })
|
assertEquals(listOf(30L), sentState.messages.map { it.messageId })
|
||||||
assertEquals(DmChatMessageStatus.SENT, sentState.messages.single().status)
|
assertEquals(DmChatMessageStatus.SENT, sentState.messages.single().status)
|
||||||
@@ -184,15 +186,14 @@ class DmChatRoomViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `전송 중 새 전송 중복 요청은 무시한다`() {
|
fun `전송 중 새 전송 중복 요청은 무시한다`() {
|
||||||
val pendingSend = SingleSubject.create<ApiResponse<SendDmChatMessageResponse>>()
|
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
api.enqueueSend(pendingSend)
|
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
viewModel.sendText("안녕")
|
viewModel.sendText("안녕")
|
||||||
viewModel.sendText("안녕")
|
viewModel.sendText("안녕")
|
||||||
|
|
||||||
assertEquals(1, api.sendCalls.size)
|
assertEquals(2, socketFactory.webSocket.sentTexts.size)
|
||||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
assertEquals(1, state.messages.size)
|
assertEquals(1, state.messages.size)
|
||||||
}
|
}
|
||||||
@@ -200,16 +201,18 @@ class DmChatRoomViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `전송 실패는 pending 메시지를 FAILED로 바꾸고 retry 성공 시 교체한다`() {
|
fun `전송 실패는 pending 메시지를 FAILED로 바꾸고 retry 성공 시 교체한다`() {
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
api.enqueueSend(Single.error(IllegalStateException("network")))
|
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.webSocket.sendResult = false
|
||||||
|
|
||||||
viewModel.sendText("안녕")
|
viewModel.sendText("안녕")
|
||||||
val failedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val failedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
val failedItem = failedState.messages.single()
|
val failedItem = failedState.messages.single()
|
||||||
assertEquals(DmChatMessageStatus.FAILED, failedItem.status)
|
assertEquals(DmChatMessageStatus.FAILED, failedItem.status)
|
||||||
|
|
||||||
api.enqueueSendSuccess(message(messageId = 40L, mine = true, textMessage = "안녕"))
|
socketFactory.webSocket.sendResult = true
|
||||||
viewModel.retry(failedItem.localId!!)
|
viewModel.retry(failedItem.localId!!)
|
||||||
|
socketFactory.emitAck(failedItem.localId, message(messageId = 40L, mine = true, textMessage = "안녕"))
|
||||||
|
|
||||||
val retriedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val retriedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
assertEquals(listOf(40L), retriedState.messages.map { it.messageId })
|
assertEquals(listOf(40L), retriedState.messages.map { it.messageId })
|
||||||
@@ -218,27 +221,18 @@ class DmChatRoomViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `retry 중 SSE echo가 먼저 와도 성공 교체 후 messageId 중복을 남기지 않는다`() {
|
fun `retry 중 SSE echo가 먼저 와도 성공 교체 후 messageId 중복을 남기지 않는다`() {
|
||||||
val pendingRetry = SingleSubject.create<ApiResponse<SendDmChatMessageResponse>>()
|
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
api.enqueueSend(Single.error(IllegalStateException("network")))
|
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.webSocket.sendResult = false
|
||||||
viewModel.sendText("안녕")
|
viewModel.sendText("안녕")
|
||||||
val failedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val failedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
val failedItem = failedState.messages.single()
|
val failedItem = failedState.messages.single()
|
||||||
api.enqueueSend(pendingRetry)
|
|
||||||
|
|
||||||
|
socketFactory.webSocket.sendResult = true
|
||||||
viewModel.retry(failedItem.localId!!)
|
viewModel.retry(failedItem.localId!!)
|
||||||
viewModel.onRealtimeMessage(message(messageId = 45L, mine = true, textMessage = "안녕"))
|
viewModel.onRealtimeMessage(message(messageId = 45L, mine = true, textMessage = "안녕"))
|
||||||
pendingRetry.onSuccess(
|
socketFactory.emitAck(failedItem.localId, message(messageId = 45L, mine = true, textMessage = "안녕"))
|
||||||
ApiResponse(
|
|
||||||
success = true,
|
|
||||||
data = SendDmChatMessageResponse(
|
|
||||||
message = message(messageId = 45L, mine = true, textMessage = "안녕"),
|
|
||||||
deliveredRealtime = true,
|
|
||||||
pushSent = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val retriedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val retriedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
assertEquals(listOf(45L), retriedState.messages.map { it.messageId })
|
assertEquals(listOf(45L), retriedState.messages.map { it.messageId })
|
||||||
@@ -260,23 +254,14 @@ class DmChatRoomViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `SSE echo가 전송 성공보다 먼저 와도 성공 교체 후 messageId 중복을 남기지 않는다`() {
|
fun `SSE echo가 전송 성공보다 먼저 와도 성공 교체 후 messageId 중복을 남기지 않는다`() {
|
||||||
val pendingSend = SingleSubject.create<ApiResponse<SendDmChatMessageResponse>>()
|
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
api.enqueueSend(pendingSend)
|
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
viewModel.sendText("안녕")
|
viewModel.sendText("안녕")
|
||||||
|
val localId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content).messages.single().localId!!
|
||||||
viewModel.onRealtimeMessage(message(messageId = 50L, mine = true, textMessage = "안녕"))
|
viewModel.onRealtimeMessage(message(messageId = 50L, mine = true, textMessage = "안녕"))
|
||||||
pendingSend.onSuccess(
|
socketFactory.emitAck(localId, message(messageId = 50L, mine = true, textMessage = "안녕"))
|
||||||
ApiResponse(
|
|
||||||
success = true,
|
|
||||||
data = SendDmChatMessageResponse(
|
|
||||||
message = message(messageId = 50L, mine = true, textMessage = "안녕"),
|
|
||||||
deliveredRealtime = true,
|
|
||||||
pushSent = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
assertEquals(listOf(50L), state.messages.map { it.messageId })
|
assertEquals(listOf(50L), state.messages.map { it.messageId })
|
||||||
@@ -316,11 +301,10 @@ class DmChatRoomViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `roomId가 없으면 realtime 연결과 disconnect를 요청하지 않는다`() {
|
fun `roomId가 없으면 realtime 연결과 disconnect를 요청하지 않는다`() {
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
viewModel.disconnectRealtime()
|
viewModel.leaveRealtime()
|
||||||
|
|
||||||
assertTrue(realtimeClient.connectCalls.isEmpty())
|
assertTrue(socketFactory.connectCalls.isEmpty())
|
||||||
assertEquals(0, realtimeClient.cancelCalls)
|
assertEquals(0, socketFactory.closeCount)
|
||||||
assertTrue(api.disconnectCalls.isEmpty())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -339,9 +323,9 @@ class DmChatRoomViewModelTest {
|
|||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
realtimeClient.listener?.onConnected()
|
socketFactory.emitJoined()
|
||||||
|
|
||||||
assertEquals(listOf(RealtimeConnectCall("test-token", 10L)), realtimeClient.connectCalls)
|
assertEquals(listOf(RealtimeConnectCall("test-token", 10L)), socketFactory.connectCalls)
|
||||||
assertEquals(listOf(MessagesCall("Bearer test-token", 10L, null, 20)), api.messagesCalls)
|
assertEquals(listOf(MessagesCall("Bearer test-token", 10L, null, 20)), api.messagesCalls)
|
||||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
assertEquals(listOf(1L, 2L), state.messages.map { it.messageId })
|
assertEquals(listOf(1L, 2L), state.messages.map { it.messageId })
|
||||||
@@ -355,7 +339,7 @@ class DmChatRoomViewModelTest {
|
|||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
assertEquals(listOf(RealtimeConnectCall("test-token", 10L)), realtimeClient.connectCalls)
|
assertEquals(listOf(RealtimeConnectCall("test-token", 10L)), socketFactory.connectCalls)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -380,20 +364,15 @@ class DmChatRoomViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `disconnect 진행 중 빠른 reconnect 시 crash 없이 connect를 허용한다`() {
|
fun `disconnect 진행 중 빠른 reconnect 시 crash 없이 connect를 허용한다`() {
|
||||||
val pendingDisconnect = SingleSubject.create<ApiResponse<Boolean>>()
|
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
api.enqueueDisconnect(pendingDisconnect)
|
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
viewModel.disconnectRealtime()
|
viewModel.leaveRealtime()
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
assertEquals(2, realtimeClient.connectCalls.size)
|
assertEquals(2, socketFactory.connectCalls.size)
|
||||||
assertEquals(1, realtimeClient.cancelCalls)
|
assertTrue(socketFactory.closeCount >= 1)
|
||||||
assertEquals(listOf(DisconnectCall("Bearer test-token", 10L)), api.disconnectCalls)
|
|
||||||
|
|
||||||
pendingDisconnect.onSuccess(ApiResponse(success = true, data = true))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -402,7 +381,7 @@ class DmChatRoomViewModelTest {
|
|||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
realtimeClient.listener?.onMessage(message(messageId = 3L, textMessage = "실시간"))
|
socketFactory.emitMessage(message(messageId = 3L, textMessage = "실시간"))
|
||||||
|
|
||||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
assertEquals(listOf(3L), state.messages.map { it.messageId })
|
assertEquals(listOf(3L), state.messages.map { it.messageId })
|
||||||
@@ -416,9 +395,9 @@ class DmChatRoomViewModelTest {
|
|||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
val beforeCallbackSize = viewModel.compositeDisposable.size()
|
val beforeCallbackSize = viewModel.compositeDisposable.size()
|
||||||
|
|
||||||
realtimeClient.listener?.onMessage(message(messageId = 3L, textMessage = "실시간1"))
|
socketFactory.emitMessage(message(messageId = 3L, textMessage = "실시간1"))
|
||||||
realtimeClient.listener?.onMessage(message(messageId = 4L, textMessage = "실시간2"))
|
socketFactory.emitMessage(message(messageId = 4L, textMessage = "실시간2"))
|
||||||
realtimeClient.listener?.onMessage(message(messageId = 5L, textMessage = "실시간3"))
|
socketFactory.emitMessage(message(messageId = 5L, textMessage = "실시간3"))
|
||||||
|
|
||||||
assertEquals(beforeCallbackSize, viewModel.compositeDisposable.size())
|
assertEquals(beforeCallbackSize, viewModel.compositeDisposable.size())
|
||||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
@@ -434,8 +413,7 @@ class DmChatRoomViewModelTest {
|
|||||||
assertTrue(source.contains("scheduleRealtimeCallback"))
|
assertTrue(source.contains("scheduleRealtimeCallback"))
|
||||||
assertTrue(source.contains("Looper.myLooper() == Looper.getMainLooper()"))
|
assertTrue(source.contains("Looper.myLooper() == Looper.getMainLooper()"))
|
||||||
assertTrue(source.contains("mainHandler.post { action() }"))
|
assertTrue(source.contains("mainHandler.post { action() }"))
|
||||||
assertTrue(source.contains("scheduleRealtimeCallback { syncLatestMessagesAfterReconnect(token = token) }"))
|
assertTrue(source.contains("scheduleRealtimeCallback { handleSocketEvent(event, token) }"))
|
||||||
assertTrue(source.contains("scheduleRealtimeCallback { onRealtimeMessage(message) }"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -447,16 +425,16 @@ class DmChatRoomViewModelTest {
|
|||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
realtimeClient.listener?.onFailure(IllegalStateException("network"))
|
socketFactory.emitFailure(IllegalStateException("network"))
|
||||||
reconnectScheduler.advanceTimeBy(2999L, TimeUnit.MILLISECONDS)
|
reconnectScheduler.advanceTimeBy(2999L, TimeUnit.MILLISECONDS)
|
||||||
|
|
||||||
assertEquals(1, realtimeClient.connectCalls.size)
|
assertEquals(1, socketFactory.connectCalls.size)
|
||||||
|
|
||||||
reconnectScheduler.advanceTimeBy(1L, TimeUnit.MILLISECONDS)
|
reconnectScheduler.advanceTimeBy(1L, TimeUnit.MILLISECONDS)
|
||||||
assertEquals(2, realtimeClient.connectCalls.size)
|
assertEquals(2, socketFactory.connectCalls.size)
|
||||||
assertEquals(RealtimeConnectCall("test-token", 10L), realtimeClient.connectCalls[1])
|
assertEquals(RealtimeConnectCall("test-token", 10L), socketFactory.connectCalls[1])
|
||||||
|
|
||||||
realtimeClient.listener?.onConnected()
|
socketFactory.emitJoined()
|
||||||
assertEquals(listOf(MessagesCall("Bearer test-token", 10L, null, 20)), api.messagesCalls)
|
assertEquals(listOf(MessagesCall("Bearer test-token", 10L, null, 20)), api.messagesCalls)
|
||||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
assertEquals(listOf(1L, 2L), state.messages.map { it.messageId })
|
assertEquals(listOf(1L, 2L), state.messages.map { it.messageId })
|
||||||
@@ -479,94 +457,79 @@ class DmChatRoomViewModelTest {
|
|||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
realtimeClient.listener?.onFailure(IllegalStateException("network-1"))
|
socketFactory.emitFailure(IllegalStateException("network-1"))
|
||||||
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
|
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
|
||||||
realtimeClient.listener?.onFailure(IllegalStateException("network-2"))
|
socketFactory.emitFailure(IllegalStateException("network-2"))
|
||||||
reconnectScheduler.advanceTimeBy(2999L, TimeUnit.MILLISECONDS)
|
reconnectScheduler.advanceTimeBy(2999L, TimeUnit.MILLISECONDS)
|
||||||
|
|
||||||
assertEquals(2, realtimeClient.connectCalls.size)
|
assertEquals(2, socketFactory.connectCalls.size)
|
||||||
|
|
||||||
reconnectScheduler.advanceTimeBy(1L, TimeUnit.MILLISECONDS)
|
reconnectScheduler.advanceTimeBy(1L, TimeUnit.MILLISECONDS)
|
||||||
|
|
||||||
assertEquals(3, realtimeClient.connectCalls.size)
|
assertEquals(3, socketFactory.connectCalls.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `disconnect는 예약된 SSE 재연결을 취소한다`() {
|
fun `leave는 예약된 SSE 재연결을 취소한다`() {
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
realtimeClient.listener?.onFailure(IllegalStateException("network"))
|
socketFactory.emitFailure(IllegalStateException("network"))
|
||||||
viewModel.disconnectRealtime()
|
viewModel.leaveRealtime()
|
||||||
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
|
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
|
||||||
|
|
||||||
assertEquals(1, realtimeClient.connectCalls.size)
|
assertEquals(1, socketFactory.connectCalls.size)
|
||||||
assertEquals(1, realtimeClient.cancelCalls)
|
assertTrue(socketFactory.closeCount >= 1)
|
||||||
assertEquals(listOf(DisconnectCall("Bearer test-token", 10L)), api.disconnectCalls)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `예약 재연결 실행 후 main callback 전 disconnect되면 새 SSE 연결을 만들지 않는다`() {
|
fun `예약 재연결 실행 후 main callback 전 leave되면 새 SSE 연결을 만들지 않는다`() {
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
realtimeClient.listener?.onFailure(IllegalStateException("network"))
|
socketFactory.emitFailure(IllegalStateException("network"))
|
||||||
viewModel.disconnectRealtime()
|
viewModel.leaveRealtime()
|
||||||
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
|
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
|
||||||
|
|
||||||
assertEquals(listOf(RealtimeConnectCall("test-token", 10L)), realtimeClient.connectCalls)
|
assertEquals(listOf(RealtimeConnectCall("test-token", 10L)), socketFactory.connectCalls)
|
||||||
assertEquals(1, realtimeClient.cancelCalls)
|
assertTrue(socketFactory.closeCount >= 1)
|
||||||
assertEquals(listOf(DisconnectCall("Bearer test-token", 10L)), api.disconnectCalls)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `realtime disconnect 중 중복 요청은 무시하고 완료 후 다시 요청할 수 있다`() {
|
fun `realtime leave 중 중복 요청은 close를 반복할 수 있다`() {
|
||||||
val pendingDisconnect = SingleSubject.create<ApiResponse<Boolean>>()
|
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
api.enqueueDisconnect(pendingDisconnect)
|
|
||||||
api.enqueueDisconnectSuccess()
|
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
|
||||||
viewModel.disconnectRealtime()
|
viewModel.leaveRealtime()
|
||||||
viewModel.disconnectRealtime()
|
viewModel.leaveRealtime()
|
||||||
|
|
||||||
assertEquals(2, realtimeClient.cancelCalls)
|
assertEquals(0, socketFactory.closeCount)
|
||||||
assertEquals(listOf(DisconnectCall("Bearer test-token", 10L)), api.disconnectCalls)
|
viewModel.leaveRealtime()
|
||||||
|
|
||||||
pendingDisconnect.onSuccess(ApiResponse(success = true, data = true))
|
assertEquals(0, socketFactory.closeCount)
|
||||||
viewModel.disconnectRealtime()
|
|
||||||
|
|
||||||
assertEquals(3, realtimeClient.cancelCalls)
|
|
||||||
assertEquals(2, api.disconnectCalls.size)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `disconnect API 진행 중 다시 background로 가면 새 SSE 연결도 cancel하고 API 중복 호출은 하지 않는다`() {
|
fun `leave 후 다시 background로 가면 새 소켓도 close한다`() {
|
||||||
val pendingDisconnect = SingleSubject.create<ApiResponse<Boolean>>()
|
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
api.enqueueDisconnect(pendingDisconnect)
|
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
viewModel.disconnectRealtime()
|
viewModel.leaveRealtime()
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
viewModel.disconnectRealtime()
|
viewModel.leaveRealtime()
|
||||||
|
|
||||||
assertEquals(2, realtimeClient.cancelCalls)
|
assertEquals(2, socketFactory.connectCalls.size)
|
||||||
assertEquals(listOf(DisconnectCall("Bearer test-token", 10L)), api.disconnectCalls)
|
|
||||||
|
|
||||||
pendingDisconnect.onSuccess(ApiResponse(success = true, data = true))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `realtime disconnect 실패는 채팅 상태를 Error로 바꾸지 않는다`() {
|
fun `realtime leave는 채팅 상태를 Error로 바꾸지 않는다`() {
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L, messages = listOf(message(messageId = 1L, textMessage = "기존"))))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L, messages = listOf(message(messageId = 1L, textMessage = "기존"))))
|
||||||
api.enqueueDisconnect(Single.error(IllegalStateException("network")))
|
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
|
||||||
viewModel.disconnectRealtime()
|
viewModel.leaveRealtime()
|
||||||
|
|
||||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
assertEquals(listOf(1L), state.messages.map { it.messageId })
|
assertEquals(listOf(1L), state.messages.map { it.messageId })
|
||||||
@@ -574,18 +537,15 @@ class DmChatRoomViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `onCleared는 realtime 연결과 disposable을 정리한다`() {
|
fun `onCleared는 realtime 연결과 disposable을 정리한다`() {
|
||||||
val pendingDisconnect = SingleSubject.create<ApiResponse<Boolean>>()
|
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
api.enqueueDisconnect(pendingDisconnect)
|
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
viewModel.disconnectRealtime()
|
viewModel.leaveRealtime()
|
||||||
|
|
||||||
viewModel.invokeOnCleared()
|
viewModel.invokeOnCleared()
|
||||||
|
|
||||||
assertEquals(2, realtimeClient.cancelCalls)
|
assertEquals(1, socketFactory.closeCount)
|
||||||
assertTrue(viewModel.compositeDisposable.isDisposed)
|
assertTrue(viewModel.compositeDisposable.isDisposed)
|
||||||
assertTrue(pendingDisconnect.hasObservers().not())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -594,12 +554,12 @@ class DmChatRoomViewModelTest {
|
|||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
realtimeClient.listener?.onFailure(IllegalStateException("network"))
|
socketFactory.emitFailure(IllegalStateException("network"))
|
||||||
viewModel.invokeOnCleared()
|
viewModel.invokeOnCleared()
|
||||||
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
|
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
|
||||||
|
|
||||||
assertEquals(listOf(RealtimeConnectCall("test-token", 10L)), realtimeClient.connectCalls)
|
assertEquals(listOf(RealtimeConnectCall("test-token", 10L)), socketFactory.connectCalls)
|
||||||
assertEquals(1, realtimeClient.cancelCalls)
|
assertEquals(1, socketFactory.closeCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -610,7 +570,7 @@ class DmChatRoomViewModelTest {
|
|||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
Thread {
|
Thread {
|
||||||
realtimeClient.listener?.onMessage(message(messageId = 99L, textMessage = "제거 대상"))
|
socketFactory.emitMessage(message(messageId = 99L, textMessage = "제거 대상"))
|
||||||
postedLatch.countDown()
|
postedLatch.countDown()
|
||||||
}.start()
|
}.start()
|
||||||
assertTrue(postedLatch.await(2, TimeUnit.SECONDS))
|
assertTrue(postedLatch.await(2, TimeUnit.SECONDS))
|
||||||
@@ -712,17 +672,6 @@ data class MessagesCall(
|
|||||||
val limit: Int
|
val limit: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SendCall(
|
|
||||||
val authHeader: String,
|
|
||||||
val roomId: Long,
|
|
||||||
val request: SendDmTextMessageRequest
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DisconnectCall(
|
|
||||||
val authHeader: String,
|
|
||||||
val roomId: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RealtimeConnectCall(
|
data class RealtimeConnectCall(
|
||||||
val token: String,
|
val token: String,
|
||||||
val roomId: Long
|
val roomId: Long
|
||||||
@@ -732,14 +681,10 @@ class FakeDmChatApi : DmChatApi {
|
|||||||
val createCalls = mutableListOf<CreateCall>()
|
val createCalls = mutableListOf<CreateCall>()
|
||||||
val openCalls = mutableListOf<OpenCall>()
|
val openCalls = mutableListOf<OpenCall>()
|
||||||
val messagesCalls = mutableListOf<MessagesCall>()
|
val messagesCalls = mutableListOf<MessagesCall>()
|
||||||
val sendCalls = mutableListOf<SendCall>()
|
|
||||||
val disconnectCalls = mutableListOf<DisconnectCall>()
|
|
||||||
|
|
||||||
private val createResponses = ArrayDeque<Single<ApiResponse<CreateDmChatRoomResponse>>>()
|
private val createResponses = ArrayDeque<Single<ApiResponse<CreateDmChatRoomResponse>>>()
|
||||||
private val openResponses = ArrayDeque<Single<ApiResponse<DmChatRoomOpenResponse>>>()
|
private val openResponses = ArrayDeque<Single<ApiResponse<DmChatRoomOpenResponse>>>()
|
||||||
private val messagesResponses = ArrayDeque<Single<ApiResponse<DmChatMessagesPageResponse>>>()
|
private val messagesResponses = ArrayDeque<Single<ApiResponse<DmChatMessagesPageResponse>>>()
|
||||||
private val sendResponses = ArrayDeque<Single<ApiResponse<SendDmChatMessageResponse>>>()
|
|
||||||
private val disconnectResponses = ArrayDeque<Single<ApiResponse<Boolean>>>()
|
|
||||||
|
|
||||||
fun enqueueCreateSuccess(response: CreateDmChatRoomResponse) {
|
fun enqueueCreateSuccess(response: CreateDmChatRoomResponse) {
|
||||||
createResponses.addLast(Single.just(ApiResponse(success = true, data = response)))
|
createResponses.addLast(Single.just(ApiResponse(success = true, data = response)))
|
||||||
@@ -757,33 +702,6 @@ class FakeDmChatApi : DmChatApi {
|
|||||||
messagesResponses.addLast(response)
|
messagesResponses.addLast(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun enqueueSend(response: Single<ApiResponse<SendDmChatMessageResponse>>) {
|
|
||||||
sendResponses.addLast(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enqueueDisconnect(response: Single<ApiResponse<Boolean>>) {
|
|
||||||
disconnectResponses.addLast(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enqueueDisconnectSuccess() {
|
|
||||||
disconnectResponses.addLast(Single.just(ApiResponse(success = true, data = true)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enqueueSendSuccess(message: DmChatMessageResponse) {
|
|
||||||
sendResponses.addLast(
|
|
||||||
Single.just(
|
|
||||||
ApiResponse(
|
|
||||||
success = true,
|
|
||||||
data = SendDmChatMessageResponse(
|
|
||||||
message = message,
|
|
||||||
deliveredRealtime = true,
|
|
||||||
pushSent = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createDmChatRoom(
|
override fun createDmChatRoom(
|
||||||
authHeader: String,
|
authHeader: String,
|
||||||
request: CreateDmChatRoomRequest
|
request: CreateDmChatRoomRequest
|
||||||
@@ -810,41 +728,61 @@ class FakeDmChatApi : DmChatApi {
|
|||||||
messagesCalls.add(MessagesCall(authHeader, roomId, cursor, limit))
|
messagesCalls.add(MessagesCall(authHeader, roomId, cursor, limit))
|
||||||
return messagesResponses.removeFirst()
|
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>> {
|
|
||||||
disconnectCalls.add(DisconnectCall(authHeader, roomId))
|
|
||||||
return disconnectResponses.removeFirstOrNull() ?: Single.just(ApiResponse(success = true, data = true))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeDmChatRealtimeClient : DmChatRealtimeClient {
|
class FakeWebSocketFactory {
|
||||||
|
val webSocket = FakeWebSocket()
|
||||||
val connectCalls = mutableListOf<RealtimeConnectCall>()
|
val connectCalls = mutableListOf<RealtimeConnectCall>()
|
||||||
var cancelCalls = 0
|
var webSocketListener: WebSocketListener? = null
|
||||||
var listener: DmChatEventClient.Listener? = null
|
val closeCount: Int
|
||||||
|
get() = webSocket.closeCount
|
||||||
|
|
||||||
override fun connect(
|
fun newWebSocket(request: Request, listener: WebSocketListener): WebSocket {
|
||||||
token: String,
|
val token = request.header("Authorization")?.removePrefix("Bearer ").orEmpty()
|
||||||
roomId: Long,
|
connectCalls.add(RealtimeConnectCall(token = token, roomId = 10L))
|
||||||
listener: DmChatEventClient.Listener
|
webSocketListener = listener
|
||||||
) {
|
return webSocket
|
||||||
connectCalls.add(RealtimeConnectCall(token, roomId))
|
|
||||||
this.listener = listener
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun cancel() {
|
fun emitJoined() {
|
||||||
cancelCalls += 1
|
webSocketListener?.onMessage(webSocket, "{\"type\":\"JOINED\",\"payload\":{}}")
|
||||||
listener = null
|
}
|
||||||
|
|
||||||
|
fun emitMessage(message: DmChatMessageResponse) {
|
||||||
|
val json = Gson().toJson(message)
|
||||||
|
webSocketListener?.onMessage(webSocket, "{\"type\":\"MESSAGE\",\"payload\":{\"message\":$json}}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitAck(requestId: String, message: DmChatMessageResponse) {
|
||||||
|
val json = Gson().toJson(message)
|
||||||
|
webSocketListener?.onMessage(
|
||||||
|
webSocket,
|
||||||
|
"{\"type\":\"SEND_ACK\",\"payload\":{\"requestId\":\"$requestId\",\"message\":$json}}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitFailure(throwable: Throwable) {
|
||||||
|
webSocketListener?.onFailure(webSocket, throwable, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FakeWebSocket : WebSocket {
|
||||||
|
val sentTexts = mutableListOf<String>()
|
||||||
|
var sendResult = true
|
||||||
|
var closeCount = 0
|
||||||
|
|
||||||
|
override fun request(): Request = Request.Builder().url("wss://example.com").build()
|
||||||
|
override fun queueSize(): Long = 0L
|
||||||
|
override fun send(text: String): Boolean {
|
||||||
|
sentTexts += text
|
||||||
|
return sendResult
|
||||||
|
}
|
||||||
|
override fun send(bytes: ByteString): Boolean = sendResult
|
||||||
|
override fun close(code: Int, reason: String?): Boolean {
|
||||||
|
closeCount += 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
override fun cancel() = Unit
|
||||||
|
|
||||||
|
fun sentJsonAt(index: Int) = JsonParser.parseString(sentTexts[index]).asJsonObject
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user