diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt index 8d68cfea..d4eac750 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt @@ -2,6 +2,9 @@ package kr.co.vividnext.sodalive.v2.main.chat.dm.data import io.reactivex.rxjava3.core.Single import kr.co.vividnext.sodalive.common.ApiResponse +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody class DmChatRepository( private val api: DmChatApi, @@ -37,6 +40,17 @@ class DmChatRepository( limit = limit ) + fun sendVoiceMessage( + token: String, + roomId: Long, + voiceMessageFile: MultipartBody.Part + ): Single> = api.sendDmVoiceMessage( + authHeader = bearer(token), + roomId = roomId, + voiceMessageFile = voiceMessageFile, + request = VOICE_REQUEST_JSON.toRequestBody(JSON_MEDIA_TYPE.toMediaType()) + ) + fun connectSocket( token: String, listener: DmChatSocketClient.Listener @@ -44,9 +58,21 @@ class DmChatRepository( socketClient?.connect(token = token, listener = listener) } - fun sendJoinRoom(roomId: Long): Boolean = socketClient?.sendJoinRoom(roomId = roomId) ?: false + fun sendJoinRoom( + roomId: Long, + requestId: String + ): Boolean = socketClient?.sendJoinRoom( + roomId = roomId, + requestId = requestId + ) ?: false - fun sendLeaveRoom(roomId: Long): Boolean = socketClient?.sendLeaveRoom(roomId = roomId) ?: false + fun sendLeaveRoom( + roomId: Long, + requestId: String + ): Boolean = socketClient?.sendLeaveRoom( + roomId = roomId, + requestId = requestId + ) ?: false fun sendSocketText( roomId: Long, @@ -58,7 +84,13 @@ class DmChatRepository( textMessage = textMessage ) ?: false - fun sendPing(): Boolean = socketClient?.sendPing() ?: false + fun sendPing( + roomId: Long, + requestId: String + ): Boolean = socketClient?.sendPing( + roomId = roomId, + requestId = requestId + ) ?: false fun closeSocket() { socketClient?.close() @@ -68,5 +100,7 @@ class DmChatRepository( private companion object { const val DEFAULT_LIMIT = 20 + const val VOICE_REQUEST_JSON = "{\"recipientId\":null}" + const val JSON_MEDIA_TYPE = "application/json" } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt index 028ded3a..7cf60605 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt @@ -1,18 +1,22 @@ package kr.co.vividnext.sodalive.v2.main.chat.dm.data +import androidx.annotation.Keep import com.google.gson.Gson import com.google.gson.JsonObject +import com.google.gson.annotations.SerializedName import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import java.io.IOException class DmChatSocketClient( private val okHttpClient: OkHttpClient, private val gson: Gson, private val baseUrl: String, - private val webSocketFactory: (Request, WebSocketListener) -> WebSocket = okHttpClient::newWebSocket + private val webSocketFactory: (Request, WebSocketListener) -> WebSocket = okHttpClient::newWebSocket, + private val rawJsonLogger: (String) -> Unit = {} ) { interface Listener { fun onEvent(event: DmChatSocketEvent) @@ -40,8 +44,11 @@ class DmChatSocketClient( .header(HEADER_AUTHORIZATION, bearer(token)) .build() val socketListener = object : WebSocketListener() { + private var closeNotified: Boolean = false + override fun onMessage(webSocket: WebSocket, text: String) { if (webSocket != activeSocket) return + rawJsonLogger(text) parser.parse(text)?.let { event -> this@DmChatSocketClient.listener?.onEvent(event) } } @@ -49,19 +56,39 @@ class DmChatSocketClient( if (webSocket != activeSocket) return this@DmChatSocketClient.listener?.onFailure(t) } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + notifySocketClosed(webSocket, code, reason) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + notifySocketClosed(webSocket, code, reason) + } + + private fun notifySocketClosed(webSocket: WebSocket, code: Int, reason: String) { + if (webSocket != activeSocket || closeNotified) return + closeNotified = true + this@DmChatSocketClient.listener?.onFailure( + IOException("WebSocket closed: code=$code reason=$reason") + ) + } } webSocket = webSocketFactory(request, socketListener).also { activeSocket = it } } - fun sendJoinRoom(roomId: Long): Boolean = send( + fun sendJoinRoom(roomId: Long, requestId: String): Boolean = send( type = DmChatSocketClientType.JOIN_ROOM, - payload = DmChatSocketRoomPayload(roomId = roomId) + requestId = requestId, + roomId = roomId, + payload = JsonObject() ) - fun sendLeaveRoom(roomId: Long): Boolean = send( + fun sendLeaveRoom(roomId: Long, requestId: String): Boolean = send( type = DmChatSocketClientType.LEAVE_ROOM, - payload = DmChatSocketRoomPayload(roomId = roomId) + requestId = requestId, + roomId = roomId, + payload = JsonObject() ) fun sendText( @@ -70,15 +97,15 @@ class DmChatSocketClient( textMessage: String ): Boolean = send( type = DmChatSocketClientType.SEND_TEXT, - payload = DmChatSocketSendTextPayload( - roomId = roomId, - requestId = requestId, - textMessage = textMessage - ) + requestId = requestId, + roomId = roomId, + payload = DmChatSocketSendTextPayload(textMessage = textMessage) ) - fun sendPing(): Boolean = send( + fun sendPing(roomId: Long, requestId: String): Boolean = send( type = DmChatSocketClientType.PING, + requestId = requestId, + roomId = roomId, payload = JsonObject() ) @@ -93,10 +120,21 @@ class DmChatSocketClient( private fun send( type: DmChatSocketClientType, + requestId: String, + roomId: Long, payload: Any ): Boolean { val socket = webSocket ?: return false - return socket.send(gson.toJson(DmChatSocketOutboundEnvelope(type = type.value, payload = payload))) + val rawJson = gson.toJson( + DmChatSocketOutboundEnvelope( + type = type.value, + requestId = requestId, + roomId = roomId, + payload = payload + ) + ) + rawJsonLogger(rawJson) + return socket.send(rawJson) } private fun socketUrl(): String = baseUrl @@ -106,9 +144,12 @@ class DmChatSocketClient( private fun bearer(token: String) = "Bearer $token" + @Keep private data class DmChatSocketOutboundEnvelope( - val type: String, - val payload: Any + @SerializedName("type") val type: String, + @SerializedName("requestId") val requestId: String, + @SerializedName("roomId") val roomId: Long, + @SerializedName("payload") val payload: Any ) private companion object { diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt index 2e05a612..1afde0d0 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt @@ -9,38 +9,19 @@ import com.google.gson.annotations.SerializedName @Keep data class DmChatSocketEnvelope( @SerializedName("type") val type: String, + @SerializedName("requestId") val requestId: String?, + @SerializedName("roomId") val roomId: Long?, @SerializedName("payload") val payload: JsonObject? ) -@Keep -data class DmChatSocketRoomPayload( - @SerializedName("roomId") val roomId: Long -) - @Keep data class DmChatSocketSendTextPayload( - @SerializedName("roomId") val roomId: Long, - @SerializedName("requestId") val requestId: String, @SerializedName("textMessage") val textMessage: String ) -@Keep -data class DmChatSocketMessagePayload( - @SerializedName("requestId") val requestId: String?, - @SerializedName("message") val message: DmChatMessageResponse -) - -@Keep -data class DmChatSocketSendAckPayload( - @SerializedName("requestId") val requestId: String, - @SerializedName("message") val message: DmChatMessageResponse -) - @Keep data class DmChatSocketErrorPayload( - @SerializedName("requestId") val requestId: String?, - @SerializedName("code") val code: String?, - @SerializedName("message") val message: String? + @SerializedName("messageKey") val messageKey: String? ) enum class DmChatSocketClientType(val value: String) { @@ -51,32 +32,46 @@ enum class DmChatSocketClientType(val value: String) { } sealed class DmChatSocketEvent { - data object Joined : DmChatSocketEvent() + data class Joined( + val requestId: String?, + val roomId: Long? + ) : DmChatSocketEvent() data class Message( val requestId: String?, + val roomId: Long?, val message: DmChatMessageResponse ) : DmChatSocketEvent() data class SendAck( val requestId: String, + val roomId: Long?, val message: DmChatMessageResponse ) : DmChatSocketEvent() data class Error( val requestId: String?, - val code: String?, - val message: String? + val roomId: Long?, + val messageKey: String? + ) : DmChatSocketEvent() + data class Pong( + val requestId: String?, + val roomId: Long? ) : DmChatSocketEvent() - data object Pong : DmChatSocketEvent() } class DmChatSocketParser(private val gson: Gson) { fun parse(text: String): DmChatSocketEvent? = try { val envelope = gson.fromJson(text, DmChatSocketEnvelope::class.java) when (envelope.type) { - TYPE_JOINED -> DmChatSocketEvent.Joined - TYPE_MESSAGE -> parseMessage(envelope.payload) - TYPE_SEND_ACK -> parseSendAck(envelope.payload) - TYPE_ERROR -> parseError(envelope.payload) - TYPE_PONG -> DmChatSocketEvent.Pong + TYPE_JOINED -> DmChatSocketEvent.Joined( + requestId = envelope.requestId, + roomId = envelope.roomId + ) + TYPE_MESSAGE -> parseMessage(envelope) + TYPE_SEND_ACK -> parseSendAck(envelope) + TYPE_ERROR -> parseError(envelope) + TYPE_PONG -> DmChatSocketEvent.Pong( + requestId = envelope.requestId, + roomId = envelope.roomId + ) else -> null } } catch (e: JsonSyntaxException) { @@ -87,28 +82,31 @@ class DmChatSocketParser(private val gson: Gson) { null } - private fun parseMessage(payload: JsonObject?): DmChatSocketEvent.Message? { - val messagePayload = gson.fromJson(payload, DmChatSocketMessagePayload::class.java) ?: return null + private fun parseMessage(envelope: DmChatSocketEnvelope): DmChatSocketEvent.Message? { + val message = gson.fromJson(envelope.payload, DmChatMessageResponse::class.java) ?: return null return DmChatSocketEvent.Message( - requestId = messagePayload.requestId, - message = messagePayload.message + requestId = envelope.requestId, + roomId = envelope.roomId, + message = message ) } - private fun parseSendAck(payload: JsonObject?): DmChatSocketEvent.SendAck? { - val ackPayload = gson.fromJson(payload, DmChatSocketSendAckPayload::class.java) ?: return null + private fun parseSendAck(envelope: DmChatSocketEnvelope): DmChatSocketEvent.SendAck? { + val requestId = envelope.requestId ?: return null + val message = gson.fromJson(envelope.payload, DmChatMessageResponse::class.java) ?: return null return DmChatSocketEvent.SendAck( - requestId = ackPayload.requestId, - message = ackPayload.message + requestId = requestId, + roomId = envelope.roomId, + message = message ) } - private fun parseError(payload: JsonObject?): DmChatSocketEvent.Error? { - val errorPayload = gson.fromJson(payload, DmChatSocketErrorPayload::class.java) ?: return null + private fun parseError(envelope: DmChatSocketEnvelope): DmChatSocketEvent.Error? { + val errorPayload = gson.fromJson(envelope.payload, DmChatSocketErrorPayload::class.java) ?: return null return DmChatSocketEvent.Error( - requestId = errorPayload.requestId, - code = errorPayload.code, - message = errorPayload.message + requestId = envelope.requestId, + roomId = envelope.roomId, + messageKey = errorPayload.messageKey ) } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt index afd65f47..7ab380a9 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt @@ -9,8 +9,13 @@ 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.DmChatSocketClient +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmVoiceMessageResponse import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketEvent +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody import okhttp3.OkHttpClient +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Request import okhttp3.WebSocket import okhttp3.WebSocketListener @@ -71,14 +76,30 @@ class DmChatRepositoryTest { fun `socket send 메서드는 socket client envelope 전송에 위임한다`() { repository.connectSocket(token = "test-token", listener = TestListener()) - assertTrue(repository.sendJoinRoom(roomId = 12L)) - assertTrue(repository.sendLeaveRoom(roomId = 12L)) + assertTrue(repository.sendJoinRoom(roomId = 12L, requestId = "join-request")) + assertTrue(repository.sendLeaveRoom(roomId = 12L, requestId = "leave-request")) assertTrue(repository.sendSocketText(roomId = 12L, requestId = "request-1", textMessage = "안녕")) - assertTrue(repository.sendPing()) + assertTrue(repository.sendPing(roomId = 12L, requestId = "ping-request")) assertEquals(listOf("JOIN_ROOM", "LEAVE_ROOM", "SEND_TEXT", "PING"), socketFactory.webSocket.sentTypes()) } + @Test + fun `sendVoiceMessage는 voice multipart 계약을 API에 위임한다`() { + val voicePart = MultipartBody.Part.createFormData( + "voiceMessageFile", + "voice.m4a", + "voice".toRequestBody("audio/mp4".toMediaType()) + ) + + repository.sendVoiceMessage(token = "test-token", roomId = 12L, voiceMessageFile = voicePart).blockingGet() + + assertEquals("Bearer test-token", api.lastAuthHeader) + assertEquals(12L, api.lastRoomId) + assertEquals(voicePart, api.lastVoiceMessageFile) + assertEquals("{\"recipientId\":null}", api.lastVoiceRequestBody?.bodyString()) + } + @Test fun `closeSocket은 socket close에 위임하고 미연결 전송은 false를 반환한다`() { repository.connectSocket(token = "test-token", listener = TestListener()) @@ -86,7 +107,7 @@ class DmChatRepositoryTest { repository.closeSocket() assertEquals(1, socketFactory.webSocket.closeCount) - assertFalse(repository.sendPing()) + assertFalse(repository.sendPing(roomId = 12L, requestId = "ping-request")) } private class FakeDmChatApi : DmChatApi { @@ -95,6 +116,8 @@ class DmChatRepositoryTest { var lastRoomId: Long? = null var lastCursor: Long? = null var lastLimit: Int? = null + var lastVoiceMessageFile: MultipartBody.Part? = null + var lastVoiceRequestBody: RequestBody? = null override fun createDmChatRoom( authHeader: String, @@ -149,6 +172,44 @@ class DmChatRepositoryTest { ) ) } + + override fun sendDmVoiceMessage( + authHeader: String, + roomId: Long, + voiceMessageFile: MultipartBody.Part, + request: RequestBody + ): Single> { + lastAuthHeader = authHeader + lastRoomId = roomId + lastVoiceMessageFile = voiceMessageFile + lastVoiceRequestBody = request + return Single.just( + ApiResponse( + success = true, + data = SendDmVoiceMessageResponse( + message = kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse( + messageId = 10L, + messageType = "VOICE", + mine = true, + createdAt = 1000L, + textMessage = null, + voiceMessageUrl = "https://example.com/voice.m4a", + senderId = 1L, + senderNickname = "나", + senderProfileImageUrl = "https://example.com/me.png" + ), + deliveredRealtime = true, + pushSent = false + ) + ) + ) + } + } + + private fun RequestBody.bodyString(): String { + val buffer = okio.Buffer() + writeTo(buffer) + return buffer.readUtf8() } private open class TestListener : DmChatSocketClient.Listener { diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt index 2aa85e6b..05f40fe0 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt @@ -44,31 +44,35 @@ class DmChatSocketClientTest { } @Test - fun `sendJoinRoom은 JOIN_ROOM envelope를 전송한다`() { + fun `sendJoinRoom은 root requestId와 roomId를 포함한 JOIN_ROOM envelope를 전송한다`() { val factory = FakeWebSocketFactory() val client = connectedClient(factory) - assertTrue(client.sendJoinRoom(roomId = 10L)) + assertTrue(client.sendJoinRoom(roomId = 10L, requestId = "join-request")) val json = factory.webSocket.singleSentJson() assertEquals("JOIN_ROOM", json.type()) - assertEquals(10L, json.payload().get("roomId").asLong) + assertEquals("join-request", json.requestId()) + assertEquals(10L, json.roomId()) + assertEquals(0, json.payload().size()) } @Test - fun `sendLeaveRoom은 LEAVE_ROOM envelope를 전송한다`() { + fun `sendLeaveRoom은 root requestId와 roomId를 포함한 LEAVE_ROOM envelope를 전송한다`() { val factory = FakeWebSocketFactory() val client = connectedClient(factory) - assertTrue(client.sendLeaveRoom(roomId = 10L)) + assertTrue(client.sendLeaveRoom(roomId = 10L, requestId = "leave-request")) val json = factory.webSocket.singleSentJson() assertEquals("LEAVE_ROOM", json.type()) - assertEquals(10L, json.payload().get("roomId").asLong) + assertEquals("leave-request", json.requestId()) + assertEquals(10L, json.roomId()) + assertEquals(0, json.payload().size()) } @Test - fun `sendText는 SEND_TEXT envelope를 전송한다`() { + fun `sendText는 root requestId roomId와 textMessage payload만 전송한다`() { val factory = FakeWebSocketFactory() val client = connectedClient(factory) @@ -76,25 +80,28 @@ class DmChatSocketClientTest { val json = factory.webSocket.singleSentJson() assertEquals("SEND_TEXT", json.type()) - assertEquals(10L, json.payload().get("roomId").asLong) - assertEquals("request-1", json.payload().get("requestId").asString) + assertEquals("request-1", json.requestId()) + assertEquals(10L, json.roomId()) + assertEquals(setOf("textMessage"), json.payload().keySet()) assertEquals("안녕하세요", json.payload().get("textMessage").asString) } @Test - fun `sendPing은 PING envelope를 전송한다`() { + fun `sendPing은 root requestId와 roomId를 포함한 PING envelope를 전송한다`() { val factory = FakeWebSocketFactory() val client = connectedClient(factory) - assertTrue(client.sendPing()) + assertTrue(client.sendPing(roomId = 10L, requestId = "ping-request")) val json = factory.webSocket.singleSentJson() assertEquals("PING", json.type()) + assertEquals("ping-request", json.requestId()) + assertEquals(10L, json.roomId()) assertEquals(0, json.payload().size()) } @Test - fun `onMessage는 parser event를 listener로 전달한다`() { + fun `onMessage는 direct payload parser event를 listener로 전달한다`() { val factory = FakeWebSocketFactory() var receivedEvent: DmChatSocketEvent? = null val client = client(factory = factory) @@ -111,6 +118,7 @@ class DmChatSocketClientTest { val messageEvent = receivedEvent as? DmChatSocketEvent.Message requireNotNull(messageEvent) + assertEquals(10L, messageEvent.roomId) assertEquals(10L, messageEvent.message.messageId) assertEquals("안녕하세요", messageEvent.message.textMessage) } @@ -134,6 +142,8 @@ class DmChatSocketClientTest { """ { "type": "UNKNOWN", + "requestId": "request-1", + "roomId": 10, "payload": {} } """.trimIndent() @@ -143,6 +153,23 @@ class DmChatSocketClientTest { assertEquals(0, eventCount) } + @Test + fun `send와 receive raw JSON을 token 없이 로그로 남긴다`() { + val factory = FakeWebSocketFactory() + val logs = mutableListOf() + val client = client(factory = factory, logger = logs::add) + client.connect(token = "secret-token", listener = TestListener()) + + client.sendText(roomId = 10L, requestId = "request-1", textMessage = "안녕하세요") + factory.listener?.onMessage(factory.webSocket, messageEnvelope()) + + assertTrue(logs.any { it.contains("SEND_TEXT") && it.contains("request-1") }) + assertTrue(logs.any { it.contains("MESSAGE") && it.contains("안녕하세요") }) + assertTrue(logs.none { it.contains("secret-token") }) + assertTrue(logs.none { it.contains("Bearer") }) + assertTrue(logs.none { it.contains("Authorization") }) + } + @Test fun `onFailure는 현재 listener로 전달된다`() { val factory = FakeWebSocketFactory() @@ -162,6 +189,27 @@ class DmChatSocketClientTest { assertEquals("socket failed", failure?.message) } + @Test + fun `onClosing과 onClosed는 현재 listener로 close failure를 한 번만 전달한다`() { + val factory = FakeWebSocketFactory() + val failures = mutableListOf() + val client = client(factory = factory) + client.connect( + token = "test-token", + listener = object : TestListener() { + override fun onFailure(throwable: Throwable) { + failures += throwable + } + } + ) + + factory.listener?.onClosing(factory.webSocket, 1001, "going away") + factory.listener?.onClosed(factory.webSocket, 1001, "going away") + + assertEquals(1, failures.size) + assertEquals("WebSocket closed: code=1001 reason=going away", failures.single().message) + } + @Test fun `close는 socket을 정상 종료하고 listener를 해제한다`() { val factory = FakeWebSocketFactory() @@ -185,7 +233,28 @@ class DmChatSocketClientTest { assertEquals(1000, factory.webSocket.closeCode) assertNull(factory.webSocket.closeReason) assertEquals(0, eventCount) - assertFalse(client.sendPing()) + assertFalse(client.sendPing(roomId = 10L, requestId = "ping-request")) + } + + @Test + fun `client close 이후 onClosed는 해제된 listener로 전달하지 않는다`() { + val factory = FakeWebSocketFactory() + var failureCount = 0 + val client = client(factory = factory) + client.connect( + token = "test-token", + listener = object : TestListener() { + override fun onFailure(throwable: Throwable) { + failureCount += 1 + } + } + ) + val oldListener = factory.listener + + client.close() + oldListener?.onClosed(factory.webSocket, 1000, "normal") + + assertEquals(0, failureCount) } private fun connectedClient(factory: FakeWebSocketFactory): DmChatSocketClient = @@ -193,12 +262,14 @@ class DmChatSocketClientTest { private fun client( factory: FakeWebSocketFactory, - baseUrl: String = "https://api.example.com" + baseUrl: String = "https://api.example.com", + logger: (String) -> Unit = {} ): DmChatSocketClient = DmChatSocketClient( okHttpClient = OkHttpClient(), gson = gson, baseUrl = baseUrl, - webSocketFactory = factory::newWebSocket + webSocketFactory = factory::newWebSocket, + rawJsonLogger = logger ) private open class TestListener : DmChatSocketClient.Listener { @@ -249,13 +320,19 @@ class DmChatSocketClientTest { private fun JsonObject.type(): String = get("type").asString + private fun JsonObject.requestId(): String = get("requestId").asString + + private fun JsonObject.roomId(): Long = get("roomId").asLong + private fun JsonObject.payload(): JsonObject = getAsJsonObject("payload") private fun messageEnvelope(): String = """ { "type": "MESSAGE", - "payload": { "message": ${messageJson()} } + "requestId": null, + "roomId": 10, + "payload": ${messageJson()} } """.trimIndent() diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt index eaf24c0e..e1acbc86 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.v2.main.chat.dm import com.google.gson.Gson -import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketEvent import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketParser import org.junit.Assert.assertEquals @@ -13,65 +12,54 @@ class DmChatSocketParserTest { private val parser = DmChatSocketParser(Gson()) @Test - fun `JOINED type은 joined event로 파싱된다`() { + fun `JOINED type은 root requestId와 roomId를 보존한다`() { val event = parser.parse( """ { "type": "JOINED", - "payload": { "roomId": 10 } + "requestId": "join-request", + "roomId": 10, + "payload": {} } """.trimIndent() ) - assertEquals(DmChatSocketEvent.Joined, event) + val joined = event as? DmChatSocketEvent.Joined + requireNotNull(joined) + assertEquals("join-request", joined.requestId) + assertEquals(10L, joined.roomId) } @Test - fun `MESSAGE type은 DM 메시지 payload로 파싱된다`() { + fun `MESSAGE type은 direct MessageItem payload와 root roomId를 파싱한다`() { val event = parser.parse( """ { "type": "MESSAGE", - "payload": { "message": ${messageJson()} } - } - """.trimIndent() - ) - - val message = requireMessage(event) - assertEquals(10L, message.messageId) - assertEquals("안녕하세요", message.textMessage) - } - - @Test - fun `MESSAGE type은 nullable requestId를 보존한다`() { - val event = parser.parse( - """ - { - "type": "MESSAGE", - "payload": { - "requestId": "request-1", - "message": ${messageJson(messageId = 12L, textMessage = "선도착")} - } + "requestId": null, + "roomId": 10, + "payload": ${messageJson()} } """.trimIndent() ) val messageEvent = event as? DmChatSocketEvent.Message requireNotNull(messageEvent) - assertEquals("request-1", messageEvent.requestId) - assertEquals(12L, messageEvent.message.messageId) + assertNull(messageEvent.requestId) + assertEquals(10L, messageEvent.roomId) + assertEquals(10L, messageEvent.message.messageId) + assertEquals("안녕하세요", messageEvent.message.textMessage) } @Test - fun `SEND_ACK type은 requestId와 서버 확정 메시지로 파싱된다`() { + fun `SEND_ACK type은 root requestId와 direct MessageItem payload를 파싱한다`() { val event = parser.parse( """ { "type": "SEND_ACK", - "payload": { - "requestId": "request-1", - "message": ${messageJson(messageId = 11L, textMessage = "확정")} - } + "requestId": "request-1", + "roomId": 10, + "payload": ${messageJson(messageId = 11L, textMessage = "확정")} } """.trimIndent() ) @@ -79,43 +67,20 @@ class DmChatSocketParserTest { val ack = event as? DmChatSocketEvent.SendAck requireNotNull(ack) assertEquals("request-1", ack.requestId) + assertEquals(10L, ack.roomId) assertEquals(11L, ack.message.messageId) assertEquals("확정", ack.message.textMessage) } @Test - fun `ERROR type은 nullable requestId와 code message를 보존한다`() { + fun `ERROR type은 payload messageKey와 root requestId roomId를 보존한다`() { val event = parser.parse( """ { "type": "ERROR", - "payload": { - "requestId": null, - "code": "INVALID_MESSAGE", - "message": "메시지를 전송할 수 없습니다" - } - } - """.trimIndent() - ) - - val error = event as? DmChatSocketEvent.Error - requireNotNull(error) - assertNull(error.requestId) - assertEquals("INVALID_MESSAGE", error.code) - assertEquals("메시지를 전송할 수 없습니다", error.message) - } - - @Test - fun `ERROR type은 nullable code와 message를 보존한다`() { - val event = parser.parse( - """ - { - "type": "ERROR", - "payload": { - "requestId": "request-1", - "code": null, - "message": null - } + "requestId": "request-1", + "roomId": 10, + "payload": { "messageKey": "chat.message.send_failed" } } """.trimIndent() ) @@ -123,22 +88,27 @@ class DmChatSocketParserTest { val error = event as? DmChatSocketEvent.Error requireNotNull(error) assertEquals("request-1", error.requestId) - assertNull(error.code) - assertNull(error.message) + assertEquals(10L, error.roomId) + assertEquals("chat.message.send_failed", error.messageKey) } @Test - fun `PONG type은 pong event로 파싱된다`() { + fun `PONG type은 root requestId와 roomId를 보존한다`() { val event = parser.parse( """ { "type": "PONG", + "requestId": "ping-request", + "roomId": 10, "payload": {} } """.trimIndent() ) - assertEquals(DmChatSocketEvent.Pong, event) + val pong = event as? DmChatSocketEvent.Pong + requireNotNull(pong) + assertEquals("ping-request", pong.requestId) + assertEquals(10L, pong.roomId) } @Test @@ -147,6 +117,8 @@ class DmChatSocketParserTest { """ { "type": "UNKNOWN", + "requestId": "request-1", + "roomId": 10, "payload": {} } """.trimIndent() @@ -162,12 +134,6 @@ class DmChatSocketParserTest { assertNull(event) } - private fun requireMessage(event: DmChatSocketEvent?): DmChatMessageResponse { - val messageEvent = event as? DmChatSocketEvent.Message - requireNotNull(messageEvent) - return messageEvent.message - } - private fun messageJson( messageId: Long = 10L, textMessage: String = "안녕하세요"