feat(dm): WebSocket envelope 계약을 보정한다

This commit is contained in:
2026-07-11 22:02:40 +09:00
parent 46c3a02655
commit 2786e45c75
6 changed files with 328 additions and 151 deletions

View File

@@ -2,6 +2,9 @@ package kr.co.vividnext.sodalive.v2.main.chat.dm.data
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
class DmChatRepository( class DmChatRepository(
private val api: DmChatApi, private val api: DmChatApi,
@@ -37,6 +40,17 @@ class DmChatRepository(
limit = limit limit = limit
) )
fun sendVoiceMessage(
token: String,
roomId: Long,
voiceMessageFile: MultipartBody.Part
): Single<ApiResponse<SendDmVoiceMessageResponse>> = api.sendDmVoiceMessage(
authHeader = bearer(token),
roomId = roomId,
voiceMessageFile = voiceMessageFile,
request = VOICE_REQUEST_JSON.toRequestBody(JSON_MEDIA_TYPE.toMediaType())
)
fun connectSocket( fun connectSocket(
token: String, token: String,
listener: DmChatSocketClient.Listener listener: DmChatSocketClient.Listener
@@ -44,9 +58,21 @@ class DmChatRepository(
socketClient?.connect(token = token, listener = listener) 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( fun sendSocketText(
roomId: Long, roomId: Long,
@@ -58,7 +84,13 @@ class DmChatRepository(
textMessage = textMessage textMessage = textMessage
) ?: false ) ?: false
fun sendPing(): Boolean = socketClient?.sendPing() ?: false fun sendPing(
roomId: Long,
requestId: String
): Boolean = socketClient?.sendPing(
roomId = roomId,
requestId = requestId
) ?: false
fun closeSocket() { fun closeSocket() {
socketClient?.close() socketClient?.close()
@@ -68,5 +100,7 @@ class DmChatRepository(
private companion object { private companion object {
const val DEFAULT_LIMIT = 20 const val DEFAULT_LIMIT = 20
const val VOICE_REQUEST_JSON = "{\"recipientId\":null}"
const val JSON_MEDIA_TYPE = "application/json"
} }
} }

View File

@@ -1,18 +1,22 @@
package kr.co.vividnext.sodalive.v2.main.chat.dm.data package kr.co.vividnext.sodalive.v2.main.chat.dm.data
import androidx.annotation.Keep
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonObject import com.google.gson.JsonObject
import com.google.gson.annotations.SerializedName
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import java.io.IOException
class DmChatSocketClient( class DmChatSocketClient(
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val gson: Gson, private val gson: Gson,
private val baseUrl: String, 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 { interface Listener {
fun onEvent(event: DmChatSocketEvent) fun onEvent(event: DmChatSocketEvent)
@@ -40,8 +44,11 @@ class DmChatSocketClient(
.header(HEADER_AUTHORIZATION, bearer(token)) .header(HEADER_AUTHORIZATION, bearer(token))
.build() .build()
val socketListener = object : WebSocketListener() { val socketListener = object : WebSocketListener() {
private var closeNotified: Boolean = false
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
if (webSocket != activeSocket) return if (webSocket != activeSocket) return
rawJsonLogger(text)
parser.parse(text)?.let { event -> this@DmChatSocketClient.listener?.onEvent(event) } parser.parse(text)?.let { event -> this@DmChatSocketClient.listener?.onEvent(event) }
} }
@@ -49,19 +56,39 @@ class DmChatSocketClient(
if (webSocket != activeSocket) return if (webSocket != activeSocket) return
this@DmChatSocketClient.listener?.onFailure(t) 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 } 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, 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, type = DmChatSocketClientType.LEAVE_ROOM,
payload = DmChatSocketRoomPayload(roomId = roomId) requestId = requestId,
roomId = roomId,
payload = JsonObject()
) )
fun sendText( fun sendText(
@@ -70,15 +97,15 @@ class DmChatSocketClient(
textMessage: String textMessage: String
): Boolean = send( ): Boolean = send(
type = DmChatSocketClientType.SEND_TEXT, type = DmChatSocketClientType.SEND_TEXT,
payload = DmChatSocketSendTextPayload( requestId = requestId,
roomId = roomId, roomId = roomId,
requestId = requestId, payload = DmChatSocketSendTextPayload(textMessage = textMessage)
textMessage = textMessage
)
) )
fun sendPing(): Boolean = send( fun sendPing(roomId: Long, requestId: String): Boolean = send(
type = DmChatSocketClientType.PING, type = DmChatSocketClientType.PING,
requestId = requestId,
roomId = roomId,
payload = JsonObject() payload = JsonObject()
) )
@@ -93,10 +120,21 @@ class DmChatSocketClient(
private fun send( private fun send(
type: DmChatSocketClientType, type: DmChatSocketClientType,
requestId: String,
roomId: Long,
payload: Any payload: Any
): Boolean { ): Boolean {
val socket = webSocket ?: return false 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 private fun socketUrl(): String = baseUrl
@@ -106,9 +144,12 @@ class DmChatSocketClient(
private fun bearer(token: String) = "Bearer $token" private fun bearer(token: String) = "Bearer $token"
@Keep
private data class DmChatSocketOutboundEnvelope( private data class DmChatSocketOutboundEnvelope(
val type: String, @SerializedName("type") val type: String,
val payload: Any @SerializedName("requestId") val requestId: String,
@SerializedName("roomId") val roomId: Long,
@SerializedName("payload") val payload: Any
) )
private companion object { private companion object {

View File

@@ -9,38 +9,19 @@ import com.google.gson.annotations.SerializedName
@Keep @Keep
data class DmChatSocketEnvelope( data class DmChatSocketEnvelope(
@SerializedName("type") val type: String, @SerializedName("type") val type: String,
@SerializedName("requestId") val requestId: String?,
@SerializedName("roomId") val roomId: Long?,
@SerializedName("payload") val payload: JsonObject? @SerializedName("payload") val payload: JsonObject?
) )
@Keep
data class DmChatSocketRoomPayload(
@SerializedName("roomId") val roomId: Long
)
@Keep @Keep
data class DmChatSocketSendTextPayload( data class DmChatSocketSendTextPayload(
@SerializedName("roomId") val roomId: Long,
@SerializedName("requestId") val requestId: String,
@SerializedName("textMessage") val textMessage: 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 @Keep
data class DmChatSocketErrorPayload( data class DmChatSocketErrorPayload(
@SerializedName("requestId") val requestId: String?, @SerializedName("messageKey") val messageKey: String?
@SerializedName("code") val code: String?,
@SerializedName("message") val message: String?
) )
enum class DmChatSocketClientType(val value: String) { enum class DmChatSocketClientType(val value: String) {
@@ -51,32 +32,46 @@ enum class DmChatSocketClientType(val value: String) {
} }
sealed class DmChatSocketEvent { sealed class DmChatSocketEvent {
data object Joined : DmChatSocketEvent() data class Joined(
val requestId: String?,
val roomId: Long?
) : DmChatSocketEvent()
data class Message( data class Message(
val requestId: String?, val requestId: String?,
val roomId: Long?,
val message: DmChatMessageResponse val message: DmChatMessageResponse
) : DmChatSocketEvent() ) : DmChatSocketEvent()
data class SendAck( data class SendAck(
val requestId: String, val requestId: String,
val roomId: Long?,
val message: DmChatMessageResponse val message: DmChatMessageResponse
) : DmChatSocketEvent() ) : DmChatSocketEvent()
data class Error( data class Error(
val requestId: String?, val requestId: String?,
val code: String?, val roomId: Long?,
val message: String? val messageKey: String?
) : DmChatSocketEvent()
data class Pong(
val requestId: String?,
val roomId: Long?
) : DmChatSocketEvent() ) : DmChatSocketEvent()
data object Pong : DmChatSocketEvent()
} }
class DmChatSocketParser(private val gson: Gson) { class DmChatSocketParser(private val gson: Gson) {
fun parse(text: String): DmChatSocketEvent? = try { fun parse(text: String): DmChatSocketEvent? = try {
val envelope = gson.fromJson(text, DmChatSocketEnvelope::class.java) val envelope = gson.fromJson(text, DmChatSocketEnvelope::class.java)
when (envelope.type) { when (envelope.type) {
TYPE_JOINED -> DmChatSocketEvent.Joined TYPE_JOINED -> DmChatSocketEvent.Joined(
TYPE_MESSAGE -> parseMessage(envelope.payload) requestId = envelope.requestId,
TYPE_SEND_ACK -> parseSendAck(envelope.payload) roomId = envelope.roomId
TYPE_ERROR -> parseError(envelope.payload) )
TYPE_PONG -> DmChatSocketEvent.Pong 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 else -> null
} }
} catch (e: JsonSyntaxException) { } catch (e: JsonSyntaxException) {
@@ -87,28 +82,31 @@ class DmChatSocketParser(private val gson: Gson) {
null null
} }
private fun parseMessage(payload: JsonObject?): DmChatSocketEvent.Message? { private fun parseMessage(envelope: DmChatSocketEnvelope): DmChatSocketEvent.Message? {
val messagePayload = gson.fromJson(payload, DmChatSocketMessagePayload::class.java) ?: return null val message = gson.fromJson(envelope.payload, DmChatMessageResponse::class.java) ?: return null
return DmChatSocketEvent.Message( return DmChatSocketEvent.Message(
requestId = messagePayload.requestId, requestId = envelope.requestId,
message = messagePayload.message roomId = envelope.roomId,
message = message
) )
} }
private fun parseSendAck(payload: JsonObject?): DmChatSocketEvent.SendAck? { private fun parseSendAck(envelope: DmChatSocketEnvelope): DmChatSocketEvent.SendAck? {
val ackPayload = gson.fromJson(payload, DmChatSocketSendAckPayload::class.java) ?: return null val requestId = envelope.requestId ?: return null
val message = gson.fromJson(envelope.payload, DmChatMessageResponse::class.java) ?: return null
return DmChatSocketEvent.SendAck( return DmChatSocketEvent.SendAck(
requestId = ackPayload.requestId, requestId = requestId,
message = ackPayload.message roomId = envelope.roomId,
message = message
) )
} }
private fun parseError(payload: JsonObject?): DmChatSocketEvent.Error? { private fun parseError(envelope: DmChatSocketEnvelope): DmChatSocketEvent.Error? {
val errorPayload = gson.fromJson(payload, DmChatSocketErrorPayload::class.java) ?: return null val errorPayload = gson.fromJson(envelope.payload, DmChatSocketErrorPayload::class.java) ?: return null
return DmChatSocketEvent.Error( return DmChatSocketEvent.Error(
requestId = errorPayload.requestId, requestId = envelope.requestId,
code = errorPayload.code, roomId = envelope.roomId,
message = errorPayload.message messageKey = errorPayload.messageKey
) )
} }

View File

@@ -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.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.DmChatSocketClient 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 kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketEvent
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Request import okhttp3.Request
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
@@ -71,14 +76,30 @@ class DmChatRepositoryTest {
fun `socket send 메서드는 socket client envelope 전송에 위임한다`() { fun `socket send 메서드는 socket client envelope 전송에 위임한다`() {
repository.connectSocket(token = "test-token", listener = TestListener()) repository.connectSocket(token = "test-token", listener = TestListener())
assertTrue(repository.sendJoinRoom(roomId = 12L)) assertTrue(repository.sendJoinRoom(roomId = 12L, requestId = "join-request"))
assertTrue(repository.sendLeaveRoom(roomId = 12L)) assertTrue(repository.sendLeaveRoom(roomId = 12L, requestId = "leave-request"))
assertTrue(repository.sendSocketText(roomId = 12L, requestId = "request-1", textMessage = "안녕")) 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()) 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 @Test
fun `closeSocket은 socket close에 위임하고 미연결 전송은 false를 반환한다`() { fun `closeSocket은 socket close에 위임하고 미연결 전송은 false를 반환한다`() {
repository.connectSocket(token = "test-token", listener = TestListener()) repository.connectSocket(token = "test-token", listener = TestListener())
@@ -86,7 +107,7 @@ class DmChatRepositoryTest {
repository.closeSocket() repository.closeSocket()
assertEquals(1, socketFactory.webSocket.closeCount) assertEquals(1, socketFactory.webSocket.closeCount)
assertFalse(repository.sendPing()) assertFalse(repository.sendPing(roomId = 12L, requestId = "ping-request"))
} }
private class FakeDmChatApi : DmChatApi { private class FakeDmChatApi : DmChatApi {
@@ -95,6 +116,8 @@ class DmChatRepositoryTest {
var lastRoomId: Long? = null var lastRoomId: Long? = null
var lastCursor: Long? = null var lastCursor: Long? = null
var lastLimit: Int? = null var lastLimit: Int? = null
var lastVoiceMessageFile: MultipartBody.Part? = null
var lastVoiceRequestBody: RequestBody? = null
override fun createDmChatRoom( override fun createDmChatRoom(
authHeader: String, authHeader: String,
@@ -149,6 +172,44 @@ class DmChatRepositoryTest {
) )
) )
} }
override fun sendDmVoiceMessage(
authHeader: String,
roomId: Long,
voiceMessageFile: MultipartBody.Part,
request: RequestBody
): Single<ApiResponse<SendDmVoiceMessageResponse>> {
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 { private open class TestListener : DmChatSocketClient.Listener {

View File

@@ -44,31 +44,35 @@ class DmChatSocketClientTest {
} }
@Test @Test
fun `sendJoinRoom은 JOIN_ROOM envelope를 전송한다`() { fun `sendJoinRoom은 root requestId와 roomId를 포함한 JOIN_ROOM envelope를 전송한다`() {
val factory = FakeWebSocketFactory() val factory = FakeWebSocketFactory()
val client = connectedClient(factory) val client = connectedClient(factory)
assertTrue(client.sendJoinRoom(roomId = 10L)) assertTrue(client.sendJoinRoom(roomId = 10L, requestId = "join-request"))
val json = factory.webSocket.singleSentJson() val json = factory.webSocket.singleSentJson()
assertEquals("JOIN_ROOM", json.type()) 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 @Test
fun `sendLeaveRoom은 LEAVE_ROOM envelope를 전송한다`() { fun `sendLeaveRoom은 root requestId와 roomId를 포함한 LEAVE_ROOM envelope를 전송한다`() {
val factory = FakeWebSocketFactory() val factory = FakeWebSocketFactory()
val client = connectedClient(factory) val client = connectedClient(factory)
assertTrue(client.sendLeaveRoom(roomId = 10L)) assertTrue(client.sendLeaveRoom(roomId = 10L, requestId = "leave-request"))
val json = factory.webSocket.singleSentJson() val json = factory.webSocket.singleSentJson()
assertEquals("LEAVE_ROOM", json.type()) 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 @Test
fun `sendText는 SEND_TEXT envelope를 전송한다`() { fun `sendText는 root requestId roomId와 textMessage payload만 전송한다`() {
val factory = FakeWebSocketFactory() val factory = FakeWebSocketFactory()
val client = connectedClient(factory) val client = connectedClient(factory)
@@ -76,25 +80,28 @@ class DmChatSocketClientTest {
val json = factory.webSocket.singleSentJson() val json = factory.webSocket.singleSentJson()
assertEquals("SEND_TEXT", json.type()) assertEquals("SEND_TEXT", json.type())
assertEquals(10L, json.payload().get("roomId").asLong) assertEquals("request-1", json.requestId())
assertEquals("request-1", json.payload().get("requestId").asString) assertEquals(10L, json.roomId())
assertEquals(setOf("textMessage"), json.payload().keySet())
assertEquals("안녕하세요", json.payload().get("textMessage").asString) assertEquals("안녕하세요", json.payload().get("textMessage").asString)
} }
@Test @Test
fun `sendPing은 PING envelope를 전송한다`() { fun `sendPing은 root requestId와 roomId를 포함한 PING envelope를 전송한다`() {
val factory = FakeWebSocketFactory() val factory = FakeWebSocketFactory()
val client = connectedClient(factory) val client = connectedClient(factory)
assertTrue(client.sendPing()) assertTrue(client.sendPing(roomId = 10L, requestId = "ping-request"))
val json = factory.webSocket.singleSentJson() val json = factory.webSocket.singleSentJson()
assertEquals("PING", json.type()) assertEquals("PING", json.type())
assertEquals("ping-request", json.requestId())
assertEquals(10L, json.roomId())
assertEquals(0, json.payload().size()) assertEquals(0, json.payload().size())
} }
@Test @Test
fun `onMessage는 parser event를 listener로 전달한다`() { fun `onMessage는 direct payload parser event를 listener로 전달한다`() {
val factory = FakeWebSocketFactory() val factory = FakeWebSocketFactory()
var receivedEvent: DmChatSocketEvent? = null var receivedEvent: DmChatSocketEvent? = null
val client = client(factory = factory) val client = client(factory = factory)
@@ -111,6 +118,7 @@ class DmChatSocketClientTest {
val messageEvent = receivedEvent as? DmChatSocketEvent.Message val messageEvent = receivedEvent as? DmChatSocketEvent.Message
requireNotNull(messageEvent) requireNotNull(messageEvent)
assertEquals(10L, messageEvent.roomId)
assertEquals(10L, messageEvent.message.messageId) assertEquals(10L, messageEvent.message.messageId)
assertEquals("안녕하세요", messageEvent.message.textMessage) assertEquals("안녕하세요", messageEvent.message.textMessage)
} }
@@ -134,6 +142,8 @@ class DmChatSocketClientTest {
""" """
{ {
"type": "UNKNOWN", "type": "UNKNOWN",
"requestId": "request-1",
"roomId": 10,
"payload": {} "payload": {}
} }
""".trimIndent() """.trimIndent()
@@ -143,6 +153,23 @@ class DmChatSocketClientTest {
assertEquals(0, eventCount) assertEquals(0, eventCount)
} }
@Test
fun `send와 receive raw JSON을 token 없이 로그로 남긴다`() {
val factory = FakeWebSocketFactory()
val logs = mutableListOf<String>()
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 @Test
fun `onFailure는 현재 listener로 전달된다`() { fun `onFailure는 현재 listener로 전달된다`() {
val factory = FakeWebSocketFactory() val factory = FakeWebSocketFactory()
@@ -162,6 +189,27 @@ class DmChatSocketClientTest {
assertEquals("socket failed", failure?.message) assertEquals("socket failed", failure?.message)
} }
@Test
fun `onClosing과 onClosed는 현재 listener로 close failure를 한 번만 전달한다`() {
val factory = FakeWebSocketFactory()
val failures = mutableListOf<Throwable>()
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 @Test
fun `close는 socket을 정상 종료하고 listener를 해제한다`() { fun `close는 socket을 정상 종료하고 listener를 해제한다`() {
val factory = FakeWebSocketFactory() val factory = FakeWebSocketFactory()
@@ -185,7 +233,28 @@ class DmChatSocketClientTest {
assertEquals(1000, factory.webSocket.closeCode) assertEquals(1000, factory.webSocket.closeCode)
assertNull(factory.webSocket.closeReason) assertNull(factory.webSocket.closeReason)
assertEquals(0, eventCount) 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 = private fun connectedClient(factory: FakeWebSocketFactory): DmChatSocketClient =
@@ -193,12 +262,14 @@ class DmChatSocketClientTest {
private fun client( private fun client(
factory: FakeWebSocketFactory, factory: FakeWebSocketFactory,
baseUrl: String = "https://api.example.com" baseUrl: String = "https://api.example.com",
logger: (String) -> Unit = {}
): DmChatSocketClient = DmChatSocketClient( ): DmChatSocketClient = DmChatSocketClient(
okHttpClient = OkHttpClient(), okHttpClient = OkHttpClient(),
gson = gson, gson = gson,
baseUrl = baseUrl, baseUrl = baseUrl,
webSocketFactory = factory::newWebSocket webSocketFactory = factory::newWebSocket,
rawJsonLogger = logger
) )
private open class TestListener : DmChatSocketClient.Listener { private open class TestListener : DmChatSocketClient.Listener {
@@ -249,13 +320,19 @@ class DmChatSocketClientTest {
private fun JsonObject.type(): String = get("type").asString 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 JsonObject.payload(): JsonObject = getAsJsonObject("payload")
private fun messageEnvelope(): String = private fun messageEnvelope(): String =
""" """
{ {
"type": "MESSAGE", "type": "MESSAGE",
"payload": { "message": ${messageJson()} } "requestId": null,
"roomId": 10,
"payload": ${messageJson()}
} }
""".trimIndent() """.trimIndent()

View File

@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.v2.main.chat.dm package kr.co.vividnext.sodalive.v2.main.chat.dm
import com.google.gson.Gson 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.DmChatSocketEvent
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketParser import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketParser
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -13,65 +12,54 @@ class DmChatSocketParserTest {
private val parser = DmChatSocketParser(Gson()) private val parser = DmChatSocketParser(Gson())
@Test @Test
fun `JOINED type은 joined event로 파싱된다`() { fun `JOINED type은 root requestId와 roomId를 보존한다`() {
val event = parser.parse( val event = parser.parse(
""" """
{ {
"type": "JOINED", "type": "JOINED",
"payload": { "roomId": 10 } "requestId": "join-request",
"roomId": 10,
"payload": {}
} }
""".trimIndent() """.trimIndent()
) )
assertEquals(DmChatSocketEvent.Joined, event) val joined = event as? DmChatSocketEvent.Joined
requireNotNull(joined)
assertEquals("join-request", joined.requestId)
assertEquals(10L, joined.roomId)
} }
@Test @Test
fun `MESSAGE type은 DM 메시지 payload로 파싱다`() { fun `MESSAGE type은 direct MessageItem payload와 root roomId를 파싱다`() {
val event = parser.parse( val event = parser.parse(
""" """
{ {
"type": "MESSAGE", "type": "MESSAGE",
"payload": { "message": ${messageJson()} } "requestId": null,
} "roomId": 10,
""".trimIndent() "payload": ${messageJson()}
)
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 = "선도착")}
}
} }
""".trimIndent() """.trimIndent()
) )
val messageEvent = event as? DmChatSocketEvent.Message val messageEvent = event as? DmChatSocketEvent.Message
requireNotNull(messageEvent) requireNotNull(messageEvent)
assertEquals("request-1", messageEvent.requestId) assertNull(messageEvent.requestId)
assertEquals(12L, messageEvent.message.messageId) assertEquals(10L, messageEvent.roomId)
assertEquals(10L, messageEvent.message.messageId)
assertEquals("안녕하세요", messageEvent.message.textMessage)
} }
@Test @Test
fun `SEND_ACK type은 requestId와 서버 확정 메시지로 파싱다`() { fun `SEND_ACK type은 root requestId와 direct MessageItem payload를 파싱다`() {
val event = parser.parse( val event = parser.parse(
""" """
{ {
"type": "SEND_ACK", "type": "SEND_ACK",
"payload": { "requestId": "request-1",
"requestId": "request-1", "roomId": 10,
"message": ${messageJson(messageId = 11L, textMessage = "확정")} "payload": ${messageJson(messageId = 11L, textMessage = "확정")}
}
} }
""".trimIndent() """.trimIndent()
) )
@@ -79,43 +67,20 @@ class DmChatSocketParserTest {
val ack = event as? DmChatSocketEvent.SendAck val ack = event as? DmChatSocketEvent.SendAck
requireNotNull(ack) requireNotNull(ack)
assertEquals("request-1", ack.requestId) assertEquals("request-1", ack.requestId)
assertEquals(10L, ack.roomId)
assertEquals(11L, ack.message.messageId) assertEquals(11L, ack.message.messageId)
assertEquals("확정", ack.message.textMessage) assertEquals("확정", ack.message.textMessage)
} }
@Test @Test
fun `ERROR type은 nullable requestId와 code message를 보존한다`() { fun `ERROR type은 payload messageKey와 root requestId roomId를 보존한다`() {
val event = parser.parse( val event = parser.parse(
""" """
{ {
"type": "ERROR", "type": "ERROR",
"payload": { "requestId": "request-1",
"requestId": null, "roomId": 10,
"code": "INVALID_MESSAGE", "payload": { "messageKey": "chat.message.send_failed" }
"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() """.trimIndent()
) )
@@ -123,22 +88,27 @@ class DmChatSocketParserTest {
val error = event as? DmChatSocketEvent.Error val error = event as? DmChatSocketEvent.Error
requireNotNull(error) requireNotNull(error)
assertEquals("request-1", error.requestId) assertEquals("request-1", error.requestId)
assertNull(error.code) assertEquals(10L, error.roomId)
assertNull(error.message) assertEquals("chat.message.send_failed", error.messageKey)
} }
@Test @Test
fun `PONG type은 pong event로 파싱된다`() { fun `PONG type은 root requestId와 roomId를 보존한다`() {
val event = parser.parse( val event = parser.parse(
""" """
{ {
"type": "PONG", "type": "PONG",
"requestId": "ping-request",
"roomId": 10,
"payload": {} "payload": {}
} }
""".trimIndent() """.trimIndent()
) )
assertEquals(DmChatSocketEvent.Pong, event) val pong = event as? DmChatSocketEvent.Pong
requireNotNull(pong)
assertEquals("ping-request", pong.requestId)
assertEquals(10L, pong.roomId)
} }
@Test @Test
@@ -147,6 +117,8 @@ class DmChatSocketParserTest {
""" """
{ {
"type": "UNKNOWN", "type": "UNKNOWN",
"requestId": "request-1",
"roomId": 10,
"payload": {} "payload": {}
} }
""".trimIndent() """.trimIndent()
@@ -162,12 +134,6 @@ class DmChatSocketParserTest {
assertNull(event) assertNull(event)
} }
private fun requireMessage(event: DmChatSocketEvent?): DmChatMessageResponse {
val messageEvent = event as? DmChatSocketEvent.Message
requireNotNull(messageEvent)
return messageEvent.message
}
private fun messageJson( private fun messageJson(
messageId: Long = 10L, messageId: Long = 10L,
textMessage: String = "안녕하세요" textMessage: String = "안녕하세요"