feat(dm): WebSocket 계약 모델을 추가한다

This commit is contained in:
2026-06-18 17:41:31 +09:00
parent 0e03a1a14a
commit e76562067f
2 changed files with 283 additions and 0 deletions

View File

@@ -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"
}
}

View File

@@ -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", "")
}