feat(dm): WebSocket envelope 계약을 보정한다
This commit is contained in:
@@ -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<ApiResponse<SendDmVoiceMessageResponse>> = 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
|
||||
@@ -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<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
|
||||
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<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
|
||||
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()
|
||||
|
||||
|
||||
@@ -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 = "안녕하세요"
|
||||
|
||||
Reference in New Issue
Block a user