feat(user-creator-chat): WebSocket room handler를 구현한다
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user