feat(user-creator-chat): WebSocket room handler를 구현한다

This commit is contained in:
2026-06-18 23:00:43 +09:00
parent 2d13f8dee7
commit 7080a03166
5 changed files with 396 additions and 9 deletions

View File

@@ -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>): String {
return captor.capture() ?: ""
}
private fun room(id: Long) = UserCreatorChatRoom().apply {
this.id = id
}

View File

@@ -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() {

View File

@@ -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<WebSocketSession>(), 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<WebSocketSession>(), 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<String, Any>(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"
)
}