diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt index a3fe8629..e4fc4696 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt @@ -26,6 +26,9 @@ import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatRoomOpenRe import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository +import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceService +import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker +import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageType import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.PageRequest @@ -43,6 +46,8 @@ class UserCreatorChatService( private val memberRepository: MemberRepository, private val blockMemberRepository: BlockMemberRepository, private val realtimeService: UserCreatorChatRealtimeService, + private val presenceService: UserCreatorChatPresenceService, + private val roomMessageBroker: UserCreatorChatRoomMessageBroker, private val applicationEventPublisher: ApplicationEventPublisher, private val objectMapper: ObjectMapper, private val s3Uploader: S3Uploader, @@ -114,17 +119,30 @@ class UserCreatorChatService( ): SendUserCreatorChatMessageResponse { if (request.textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request") val context = resolveSendContext(member, roomId) - val message = messageRepository.save( - UserCreatorChatMessage( - chatRoom = context.room, - participant = context.senderParticipant, - messageType = UserCreatorChatMessageType.TEXT, - textMessage = request.textMessage - ) - ) + val message = saveTextMessage(context, request.textMessage) return deliverMessage(message, member, context.opponentParticipant) } + @Transactional + fun sendTextMessageByWebSocket(memberId: Long, roomId: Long, textMessage: String): UserCreatorChatMessageItemDto { + if (textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request") + val senderParticipant = validateParticipant(roomId, memberId) + val sender = senderParticipant.member + val context = resolveSendContext(sender, roomId) + val message = saveTextMessage(context, textMessage) + val senderMessage = toMessageItemDto(message, sender) + val opponent = context.opponentParticipant.member + if (presenceService.hasPresence(roomId, opponent.id!!)) { + val opponentMessage = toMessageItemDto(message, opponent) + roomMessageBroker.publish( + roomId = roomId, + memberId = opponent.id!!, + payload = websocketMessagePayload(UserCreatorChatWebSocketMessageType.MESSAGE, roomId, opponentMessage) + ) + } + return senderMessage + } + @Transactional fun sendVoiceMessage( member: Member, @@ -162,6 +180,21 @@ class UserCreatorChatService( realtimeService.disconnect(roomId, member.id!!) } + fun validateParticipant(roomId: Long, memberId: Long): UserCreatorChatParticipant { + return requireParticipant(roomId, memberId) + } + + private fun saveTextMessage(context: SendContext, textMessage: String): UserCreatorChatMessage { + return messageRepository.save( + UserCreatorChatMessage( + chatRoom = context.room, + participant = context.senderParticipant, + messageType = UserCreatorChatMessageType.TEXT, + textMessage = textMessage + ) + ) + } + private fun resolveSendContext(member: Member, roomId: Long): SendContext { val room = findRoom(roomId) val senderParticipant = requireParticipant(roomId, member.id!!) @@ -208,6 +241,21 @@ class UserCreatorChatService( ) } + private fun websocketMessagePayload( + type: UserCreatorChatWebSocketMessageType, + roomId: Long, + payload: UserCreatorChatMessageItemDto + ): String { + return objectMapper.writeValueAsString( + mapOf( + "type" to type, + "requestId" to null, + "roomId" to roomId, + "payload" to payload + ) + ) + } + private fun validateRecipient(sender: Member, recipient: Member) { if (!recipient.isActive) throw SodaException(messageKey = "message.error.recipient_inactive") if (recipient.memberKind == MemberKind.AI_CHARACTER) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt index 30958ef7..db7d244f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt @@ -1,7 +1,117 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService import org.springframework.stereotype.Component +import org.springframework.web.socket.CloseStatus +import org.springframework.web.socket.TextMessage +import org.springframework.web.socket.WebSocketSession import org.springframework.web.socket.handler.TextWebSocketHandler @Component -class UserCreatorChatWebSocketHandler : TextWebSocketHandler() +class UserCreatorChatWebSocketHandler( + private val service: UserCreatorChatService, + private val presenceService: UserCreatorChatPresenceService, + private val sessionRegistry: UserCreatorChatWebSocketSessionRegistry, + private val objectMapper: ObjectMapper +) : TextWebSocketHandler() { + override fun handleTextMessage(session: WebSocketSession, message: TextMessage) { + val request = objectMapper.readValue(message.payload, UserCreatorChatWebSocketMessage::class.java) + when (request.type) { + UserCreatorChatWebSocketMessageType.JOIN_ROOM -> handleJoinRoom(session, request) + UserCreatorChatWebSocketMessageType.SEND_TEXT -> handleSendText(session, request) + else -> sendError(session, request, "common.error.invalid_request") + } + } + + private fun handleJoinRoom(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) { + try { + val memberId = memberId(session) + service.validateParticipant(roomId = request.roomId, memberId = memberId) + clearJoinedRoom(session) + sessionRegistry.register(roomId = request.roomId, memberId = memberId, session = session) + presenceService.markJoined(roomId = request.roomId, memberId = memberId, sessionId = session.id) + session.attributes[JOINED_ROOM_ID_ATTRIBUTE] = request.roomId + sendEnvelope( + session, + UserCreatorChatWebSocketMessageType.JOINED, + request.requestId, + request.roomId, + emptyMap() + ) + } catch (e: SodaException) { + sendError(session, request, e.messageKey ?: "common.error.invalid_request") + session.close(CloseStatus.POLICY_VIOLATION) + } + } + + private fun handleSendText(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) { + try { + if (session.attributes[JOINED_ROOM_ID_ATTRIBUTE] != request.roomId) { + throw SodaException(messageKey = "chat.room.join_required") + } + val textMessage = request.payload["textMessage"]?.asText().orEmpty() + val message = service.sendTextMessageByWebSocket( + memberId = memberId(session), + roomId = request.roomId, + textMessage = textMessage + ) + sendEnvelope(session, UserCreatorChatWebSocketMessageType.SEND_ACK, request.requestId, request.roomId, message) + } catch (e: SodaException) { + sendError(session, request, e.messageKey ?: "common.error.invalid_request") + } + } + + override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) { + clearJoinedRoom(session) + } + + private fun memberId(session: WebSocketSession): Long { + return session.attributes[UserCreatorChatWebSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE] as? Long + ?: throw SodaException(messageKey = "common.error.bad_credentials") + } + + private fun clearJoinedRoom(session: WebSocketSession) { + val roomId = session.attributes.remove(JOINED_ROOM_ID_ATTRIBUTE) as? Long + if (roomId != null) { + presenceService.markLeft(roomId = roomId, memberId = memberId(session), sessionId = session.id) + } + sessionRegistry.remove(session.id) + } + + private fun sendError(session: WebSocketSession, request: UserCreatorChatWebSocketMessage, messageKey: String) { + sendEnvelope( + session, + UserCreatorChatWebSocketMessageType.ERROR, + request.requestId, + request.roomId, + mapOf("messageKey" to messageKey) + ) + } + + private fun sendEnvelope( + session: WebSocketSession, + type: UserCreatorChatWebSocketMessageType, + requestId: String?, + roomId: Long, + payload: Any + ) { + session.sendMessage( + TextMessage( + objectMapper.writeValueAsString( + mapOf( + "type" to type, + "requestId" to requestId, + "roomId" to roomId, + "payload" to payload + ) + ) + ) + ) + } + + companion object { + private const val JOINED_ROOM_ID_ATTRIBUTE = "userCreatorChatJoinedRoomId" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt index 6eea1f47..5435f22c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt @@ -14,6 +14,8 @@ import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatPar import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatRealtimeService import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService +import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceService +import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -34,6 +36,8 @@ class UserCreatorChatServiceTest { private lateinit var memberRepository: MemberRepository private lateinit var blockMemberRepository: BlockMemberRepository private lateinit var realtimeService: UserCreatorChatRealtimeService + private lateinit var presenceService: UserCreatorChatPresenceService + private lateinit var roomMessageBroker: UserCreatorChatRoomMessageBroker private lateinit var eventPublisher: ApplicationEventPublisher private lateinit var service: UserCreatorChatService @@ -45,6 +49,8 @@ class UserCreatorChatServiceTest { memberRepository = Mockito.mock(MemberRepository::class.java) blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java) realtimeService = Mockito.mock(UserCreatorChatRealtimeService::class.java) + presenceService = Mockito.mock(UserCreatorChatPresenceService::class.java) + roomMessageBroker = Mockito.mock(UserCreatorChatRoomMessageBroker::class.java) eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) service = UserCreatorChatService( @@ -54,6 +60,8 @@ class UserCreatorChatServiceTest { memberRepository = memberRepository, blockMemberRepository = blockMemberRepository, realtimeService = realtimeService, + presenceService = presenceService, + roomMessageBroker = roomMessageBroker, applicationEventPublisher = eventPublisher, objectMapper = ObjectMapper(), s3Uploader = Mockito.mock(S3Uploader::class.java), @@ -215,6 +223,34 @@ class UserCreatorChatServiceTest { Mockito.verifyNoInteractions(eventPublisher) } + @Test + @DisplayName("WebSocket 텍스트 전송은 상대방 presence가 있으면 메시지를 저장하고 broker로 MESSAGE를 발행하며 푸시를 보내지 않는다") + fun shouldPublishWebSocketMessageWithoutPushWhenOpponentPresenceExists() { + val user = member(1L, "user") + val creator = member(2L, "creator") + val room = room(10L) + val senderParticipant = participant(100L, room, user) + val recipientParticipant = participant(101L, room, creator) + Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) + Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant) + Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant) + Mockito.`when`(presenceService.hasPresence(10L, 2L)).thenReturn(true) + Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 203L } + } + + val response = service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello") + + assertEquals(203L, response.messageId) + assertEquals("hello", response.textMessage) + assertTrue(response.mine) + val payloadCaptor = ArgumentCaptor.forClass(String::class.java) + Mockito.verify(roomMessageBroker).publish(Mockito.eq(10L), Mockito.eq(2L), captureString(payloadCaptor)) + assertTrue(payloadCaptor.value.contains("\"type\":\"MESSAGE\"")) + assertTrue(payloadCaptor.value.contains("\"messageId\":203")) + Mockito.verifyNoInteractions(eventPublisher) + } + @Test @DisplayName("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다") fun shouldGetPreviousMessagesWithDefaultLimitWhenCursorExists() { @@ -291,6 +327,10 @@ class UserCreatorChatServiceTest { ) } + private fun captureString(captor: ArgumentCaptor): String { + return captor.capture() ?: "" + } + private fun room(id: Long) = UserCreatorChatRoom().apply { this.id = id } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfigTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfigTest.kt index 9eb65e94..9c841ed6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfigTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfigTest.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket +import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue @@ -27,6 +29,18 @@ class UserCreatorChatWebSocketConfigTest @Autowired constructor( @MockBean private lateinit var tokenProvider: TokenProvider + @MockBean + private lateinit var service: UserCreatorChatService + + @MockBean + private lateinit var presenceService: UserCreatorChatPresenceService + + @MockBean + private lateinit var sessionRegistry: UserCreatorChatWebSocketSessionRegistry + + @MockBean + private lateinit var objectMapper: ObjectMapper + @Test @DisplayName("유저-크리에이터 채팅 WebSocket handler를 지정 경로와 인증 interceptor, origin 제한으로 등록한다") fun shouldRegisterUserCreatorChatWebSocketHandler() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt new file mode 100644 index 00000000..74765dab --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt @@ -0,0 +1,175 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto +import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.web.socket.CloseStatus +import org.springframework.web.socket.TextMessage +import org.springframework.web.socket.WebSocketSession + +class UserCreatorChatWebSocketHandlerTest { + private val service = Mockito.mock(UserCreatorChatService::class.java) + private val presenceService = Mockito.mock(UserCreatorChatPresenceService::class.java) + private val sessionRegistry = UserCreatorChatWebSocketSessionRegistry() + private val objectMapper = ObjectMapper().findAndRegisterModules() + private val handler = UserCreatorChatWebSocketHandler( + service = service, + presenceService = presenceService, + sessionRegistry = sessionRegistry, + objectMapper = objectMapper + ) + + @Test + @DisplayName("JOIN_ROOM은 참여자 검증 후 local session과 Redis presence를 등록하고 JOINED를 응답한다") + fun shouldJoinRoomAndRegisterPresenceWhenParticipantIsValid() { + val session = session("session-1", memberId = 1L) + + handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}")) + + Mockito.verify(service).validateParticipant(roomId = 10L, memberId = 1L) + assertEquals(listOf(session), sessionRegistry.findSessions(roomId = 10L, memberId = 1L)) + Mockito.verify(presenceService).markJoined(roomId = 10L, memberId = 1L, sessionId = "session-1") + val response = sentJson(session) + assertEquals("JOINED", response["type"].asText()) + assertEquals("join-1", response["requestId"].asText()) + assertEquals(10L, response["roomId"].asLong()) + assertTrue(response["payload"].isObject) + } + + @Test + @DisplayName("JOIN_ROOM 요청자가 참여자가 아니면 ERROR 응답 후 WebSocket을 닫는다") + fun shouldSendErrorAndCloseWhenJoinRoomParticipantIsInvalid() { + val session = session("session-1", memberId = 1L) + Mockito.doThrow(SodaException(messageKey = "chat.room.invalid_access")) + .`when`(service) + .validateParticipant(roomId = 10L, memberId = 1L) + + handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}")) + + val response = sentJson(session) + assertEquals("ERROR", response["type"].asText()) + assertEquals("join-1", response["requestId"].asText()) + assertEquals(10L, response["roomId"].asLong()) + assertEquals("chat.room.invalid_access", response["payload"]["messageKey"].asText()) + Mockito.verify(session).close(CloseStatus.POLICY_VIOLATION) + } + + @Test + @DisplayName("SEND_TEXT는 메시지 저장 후 sender에게 SEND_ACK를 응답한다") + fun shouldSendAckToSenderWhenTextMessageIsSaved() { + val session = session("session-1", memberId = 1L) + Mockito.`when`(service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello")) + .thenReturn(messageItem()) + handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}")) + Mockito.clearInvocations(session) + + handler.handleMessage( + session, + textMessage("SEND_TEXT", "send-1", 10L, "{\"textMessage\":\"hello\"}") + ) + + val response = sentJson(session) + assertEquals("SEND_ACK", response["type"].asText()) + assertEquals("send-1", response["requestId"].asText()) + assertEquals(10L, response["roomId"].asLong()) + assertEquals(200L, response["payload"]["messageId"].asLong()) + assertEquals("hello", response["payload"]["textMessage"].asText()) + assertTrue(response["payload"]["mine"].asBoolean()) + } + + @Test + @DisplayName("SEND_TEXT는 JOIN_ROOM 완료 전이면 저장하지 않고 ERROR를 응답한다") + fun shouldRejectSendTextBeforeJoinRoom() { + val session = session("session-1", memberId = 1L) + + handler.handleMessage( + session, + textMessage("SEND_TEXT", "send-1", 10L, "{\"textMessage\":\"hello\"}") + ) + + val response = sentJson(session) + assertEquals("ERROR", response["type"].asText()) + assertEquals("send-1", response["requestId"].asText()) + assertEquals(10L, response["roomId"].asLong()) + assertEquals("chat.room.join_required", response["payload"]["messageKey"].asText()) + Mockito.verify(service, Mockito.never()).sendTextMessageByWebSocket( + Mockito.anyLong(), + Mockito.anyLong(), + Mockito.anyString() + ) + } + + @Test + @DisplayName("같은 session이 다른 방에 JOIN_ROOM하면 기존 방 presence를 제거하고 새 방만 등록한다") + fun shouldRemovePreviousPresenceWhenSessionJoinsAnotherRoom() { + val session = session("session-1", memberId = 1L) + handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}")) + + handler.handleMessage(session, textMessage("JOIN_ROOM", "join-2", 20L, "{}")) + + Mockito.verify(presenceService).markLeft(roomId = 10L, memberId = 1L, sessionId = "session-1") + Mockito.verify(presenceService).markJoined(roomId = 20L, memberId = 1L, sessionId = "session-1") + assertEquals(emptyList(), sessionRegistry.findSessions(roomId = 10L, memberId = 1L)) + assertEquals(listOf(session), sessionRegistry.findSessions(roomId = 20L, memberId = 1L)) + } + + @Test + @DisplayName("WebSocket close 시 local session과 Redis presence를 제거한다") + fun shouldRemoveLocalSessionAndPresenceWhenConnectionCloses() { + val session = session("session-1", memberId = 1L) + handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}")) + + handler.afterConnectionClosed(session, CloseStatus.NORMAL) + + Mockito.verify(presenceService).markLeft(roomId = 10L, memberId = 1L, sessionId = "session-1") + assertEquals(emptyList(), sessionRegistry.findSessions(roomId = 10L, memberId = 1L)) + } + + private fun textMessage(type: String, requestId: String, roomId: Long, payload: String): TextMessage { + return TextMessage( + """ + { + "type": "$type", + "requestId": "$requestId", + "roomId": $roomId, + "payload": $payload + } + """.trimIndent() + ) + } + + private fun session(id: String, memberId: Long): WebSocketSession { + val session = Mockito.mock(WebSocketSession::class.java) + Mockito.`when`(session.id).thenReturn(id) + val attributes = mutableMapOf(UserCreatorChatWebSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE to memberId) + Mockito.`when`(session.attributes).thenReturn(attributes) + Mockito.`when`(session.isOpen).thenReturn(true) + return session + } + + private fun sentJson(session: WebSocketSession): JsonNode { + val captor = ArgumentCaptor.forClass(TextMessage::class.java) + Mockito.verify(session).sendMessage(captor.capture()) + return objectMapper.readTree(captor.value.payload) + } + + private fun messageItem() = UserCreatorChatMessageItemDto( + messageId = 200L, + messageType = "TEXT", + mine = true, + createdAt = 1781690401000L, + textMessage = "hello", + voiceMessageUrl = null, + senderId = 1L, + senderNickname = "user", + senderProfileImageUrl = "https://cdn.test/profile/user.png" + ) +}