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 new file mode 100644 index 00000000..0ae42429 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt @@ -0,0 +1,115 @@ +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.JsonSyntaxException +import com.google.gson.annotations.SerializedName + +@Keep +data class DmChatSocketEnvelope( + @SerializedName("type") val type: String, + @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("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? +) + +enum class DmChatSocketClientType(val value: String) { + JOIN_ROOM("JOIN_ROOM"), + LEAVE_ROOM("LEAVE_ROOM"), + SEND_TEXT("SEND_TEXT"), + PING("PING") +} + +sealed class DmChatSocketEvent { + data object Joined : DmChatSocketEvent() + data class Message(val message: DmChatMessageResponse) : DmChatSocketEvent() + data class SendAck( + val requestId: String, + val message: DmChatMessageResponse + ) : DmChatSocketEvent() + data class Error( + val requestId: String?, + val code: String?, + val message: String? + ) : 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 + else -> null + } + } catch (e: JsonSyntaxException) { + null + } catch (e: IllegalStateException) { + null + } catch (e: NullPointerException) { + null + } + + private fun parseMessage(payload: JsonObject?): DmChatSocketEvent.Message? { + val messagePayload = gson.fromJson(payload, DmChatSocketMessagePayload::class.java) ?: return null + return DmChatSocketEvent.Message(messagePayload.message) + } + + private fun parseSendAck(payload: JsonObject?): DmChatSocketEvent.SendAck? { + val ackPayload = gson.fromJson(payload, DmChatSocketSendAckPayload::class.java) ?: return null + return DmChatSocketEvent.SendAck( + requestId = ackPayload.requestId, + message = ackPayload.message + ) + } + + private fun parseError(payload: JsonObject?): DmChatSocketEvent.Error? { + val errorPayload = gson.fromJson(payload, DmChatSocketErrorPayload::class.java) ?: return null + return DmChatSocketEvent.Error( + requestId = errorPayload.requestId, + code = errorPayload.code, + message = errorPayload.message + ) + } + + private companion object { + const val TYPE_JOINED = "JOINED" + const val TYPE_MESSAGE = "MESSAGE" + const val TYPE_SEND_ACK = "SEND_ACK" + const val TYPE_ERROR = "ERROR" + const val TYPE_PONG = "PONG" + } +} 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 new file mode 100644 index 00000000..2c25e967 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt @@ -0,0 +1,168 @@ +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 +import org.junit.Assert.assertNull +import org.junit.Test + +class DmChatSocketParserTest { + + private val parser = DmChatSocketParser(Gson()) + + @Test + fun `JOINED type은 joined event로 파싱된다`() { + val event = parser.parse( + """ + { + "type": "JOINED", + "payload": { "roomId": 10 } + } + """.trimIndent() + ) + + assertEquals(DmChatSocketEvent.Joined, event) + } + + @Test + fun `MESSAGE type은 DM 메시지 payload로 파싱된다`() { + val event = parser.parse( + """ + { + "type": "MESSAGE", + "payload": { "message": ${messageJson()} } + } + """.trimIndent() + ) + + val message = requireMessage(event) + assertEquals(10L, message.messageId) + assertEquals("안녕하세요", message.textMessage) + } + + @Test + fun `SEND_ACK type은 requestId와 서버 확정 메시지로 파싱된다`() { + val event = parser.parse( + """ + { + "type": "SEND_ACK", + "payload": { + "requestId": "request-1", + "message": ${messageJson(messageId = 11L, textMessage = "확정")} + } + } + """.trimIndent() + ) + + val ack = event as? DmChatSocketEvent.SendAck + requireNotNull(ack) + assertEquals("request-1", ack.requestId) + assertEquals(11L, ack.message.messageId) + assertEquals("확정", ack.message.textMessage) + } + + @Test + fun `ERROR type은 nullable requestId와 code message를 보존한다`() { + 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 + } + } + """.trimIndent() + ) + + val error = event as? DmChatSocketEvent.Error + requireNotNull(error) + assertEquals("request-1", error.requestId) + assertNull(error.code) + assertNull(error.message) + } + + @Test + fun `PONG type은 pong event로 파싱된다`() { + val event = parser.parse( + """ + { + "type": "PONG", + "payload": {} + } + """.trimIndent() + ) + + assertEquals(DmChatSocketEvent.Pong, event) + } + + @Test + fun `알 수 없는 type은 null로 무시된다`() { + val event = parser.parse( + """ + { + "type": "UNKNOWN", + "payload": {} + } + """.trimIndent() + ) + + assertNull(event) + } + + @Test + fun `잘못된 JSON은 null로 무시된다`() { + val event = parser.parse("{not-json}") + + 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 = "안녕하세요" + ): String = + """ + { + "messageId": $messageId, + "messageType": "TEXT", + "mine": false, + "createdAt": 1000, + "textMessage": "$textMessage", + "voiceMessageUrl": null, + "senderId": 20, + "senderNickname": "크리에이터", + "senderProfileImageUrl": "https://example.com/profile.png" + } + """.trimIndent().replace("\n", "") +}