test #426
@@ -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.UserCreatorChatMessageRepository
|
||||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository
|
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.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.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
@@ -43,6 +46,8 @@ class UserCreatorChatService(
|
|||||||
private val memberRepository: MemberRepository,
|
private val memberRepository: MemberRepository,
|
||||||
private val blockMemberRepository: BlockMemberRepository,
|
private val blockMemberRepository: BlockMemberRepository,
|
||||||
private val realtimeService: UserCreatorChatRealtimeService,
|
private val realtimeService: UserCreatorChatRealtimeService,
|
||||||
|
private val presenceService: UserCreatorChatPresenceService,
|
||||||
|
private val roomMessageBroker: UserCreatorChatRoomMessageBroker,
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
private val s3Uploader: S3Uploader,
|
private val s3Uploader: S3Uploader,
|
||||||
@@ -114,17 +119,30 @@ class UserCreatorChatService(
|
|||||||
): SendUserCreatorChatMessageResponse {
|
): SendUserCreatorChatMessageResponse {
|
||||||
if (request.textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request")
|
if (request.textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
val context = resolveSendContext(member, roomId)
|
val context = resolveSendContext(member, roomId)
|
||||||
val message = messageRepository.save(
|
val message = saveTextMessage(context, request.textMessage)
|
||||||
UserCreatorChatMessage(
|
|
||||||
chatRoom = context.room,
|
|
||||||
participant = context.senderParticipant,
|
|
||||||
messageType = UserCreatorChatMessageType.TEXT,
|
|
||||||
textMessage = request.textMessage
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return deliverMessage(message, member, context.opponentParticipant)
|
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
|
@Transactional
|
||||||
fun sendVoiceMessage(
|
fun sendVoiceMessage(
|
||||||
member: Member,
|
member: Member,
|
||||||
@@ -162,6 +180,21 @@ class UserCreatorChatService(
|
|||||||
realtimeService.disconnect(roomId, member.id!!)
|
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 {
|
private fun resolveSendContext(member: Member, roomId: Long): SendContext {
|
||||||
val room = findRoom(roomId)
|
val room = findRoom(roomId)
|
||||||
val senderParticipant = requireParticipant(roomId, member.id!!)
|
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) {
|
private fun validateRecipient(sender: Member, recipient: Member) {
|
||||||
if (!recipient.isActive) throw SodaException(messageKey = "message.error.recipient_inactive")
|
if (!recipient.isActive) throw SodaException(messageKey = "message.error.recipient_inactive")
|
||||||
if (recipient.memberKind == MemberKind.AI_CHARACTER) {
|
if (recipient.memberKind == MemberKind.AI_CHARACTER) {
|
||||||
|
|||||||
@@ -1,7 +1,117 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
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.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
|
import org.springframework.web.socket.handler.TextWebSocketHandler
|
||||||
|
|
||||||
@Component
|
@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<String, Any>()
|
||||||
|
)
|
||||||
|
} 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.repository.UserCreatorChatRoomRepository
|
||||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatRealtimeService
|
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.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.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
@@ -34,6 +36,8 @@ class UserCreatorChatServiceTest {
|
|||||||
private lateinit var memberRepository: MemberRepository
|
private lateinit var memberRepository: MemberRepository
|
||||||
private lateinit var blockMemberRepository: BlockMemberRepository
|
private lateinit var blockMemberRepository: BlockMemberRepository
|
||||||
private lateinit var realtimeService: UserCreatorChatRealtimeService
|
private lateinit var realtimeService: UserCreatorChatRealtimeService
|
||||||
|
private lateinit var presenceService: UserCreatorChatPresenceService
|
||||||
|
private lateinit var roomMessageBroker: UserCreatorChatRoomMessageBroker
|
||||||
private lateinit var eventPublisher: ApplicationEventPublisher
|
private lateinit var eventPublisher: ApplicationEventPublisher
|
||||||
private lateinit var service: UserCreatorChatService
|
private lateinit var service: UserCreatorChatService
|
||||||
|
|
||||||
@@ -45,6 +49,8 @@ class UserCreatorChatServiceTest {
|
|||||||
memberRepository = Mockito.mock(MemberRepository::class.java)
|
memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||||
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
|
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
|
||||||
realtimeService = Mockito.mock(UserCreatorChatRealtimeService::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)
|
eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||||
|
|
||||||
service = UserCreatorChatService(
|
service = UserCreatorChatService(
|
||||||
@@ -54,6 +60,8 @@ class UserCreatorChatServiceTest {
|
|||||||
memberRepository = memberRepository,
|
memberRepository = memberRepository,
|
||||||
blockMemberRepository = blockMemberRepository,
|
blockMemberRepository = blockMemberRepository,
|
||||||
realtimeService = realtimeService,
|
realtimeService = realtimeService,
|
||||||
|
presenceService = presenceService,
|
||||||
|
roomMessageBroker = roomMessageBroker,
|
||||||
applicationEventPublisher = eventPublisher,
|
applicationEventPublisher = eventPublisher,
|
||||||
objectMapper = ObjectMapper(),
|
objectMapper = ObjectMapper(),
|
||||||
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||||
@@ -215,6 +223,34 @@ class UserCreatorChatServiceTest {
|
|||||||
Mockito.verifyNoInteractions(eventPublisher)
|
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
|
@Test
|
||||||
@DisplayName("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다")
|
@DisplayName("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다")
|
||||||
fun shouldGetPreviousMessagesWithDefaultLimitWhenCursorExists() {
|
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 {
|
private fun room(id: Long) = UserCreatorChatRoom().apply {
|
||||||
this.id = id
|
this.id = id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
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.jwt.TokenProvider
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
@@ -27,6 +29,18 @@ class UserCreatorChatWebSocketConfigTest @Autowired constructor(
|
|||||||
@MockBean
|
@MockBean
|
||||||
private lateinit var tokenProvider: TokenProvider
|
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
|
@Test
|
||||||
@DisplayName("유저-크리에이터 채팅 WebSocket handler를 지정 경로와 인증 interceptor, origin 제한으로 등록한다")
|
@DisplayName("유저-크리에이터 채팅 WebSocket handler를 지정 경로와 인증 interceptor, origin 제한으로 등록한다")
|
||||||
fun shouldRegisterUserCreatorChatWebSocketHandler() {
|
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