diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt index 57b66eeb..02248ca4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt @@ -4,7 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomRequest -import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService import org.springframework.http.MediaType import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -42,16 +41,6 @@ class UserCreatorChatController( ApiResponse.ok(service.openRoom(member, roomId, limit)) } - @PostMapping("/{roomId}/events/disconnect") - fun disconnectRealtime( - @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, - @PathVariable roomId: Long - ) = run { - if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - service.disconnectRealtime(member, roomId) - ApiResponse.ok(true) - } - @GetMapping("/{roomId}/messages") fun getMessages( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @@ -63,25 +52,6 @@ class UserCreatorChatController( ApiResponse.ok(service.getMessages(member, roomId, cursor, limit)) } - @GetMapping("/{roomId}/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) - fun connectEvents( - @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, - @PathVariable roomId: Long - ) = run { - if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - service.connect(member, roomId) - } - - @PostMapping("/{roomId}/messages/text") - fun sendTextMessage( - @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, - @PathVariable roomId: Long, - @RequestBody request: SendUserCreatorTextMessageRequest - ) = run { - if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - ApiResponse.ok(service.sendTextMessage(member, roomId, request)) - } - @PostMapping("/{roomId}/messages/voice", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) fun sendVoiceMessage( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt index 1a03efdd..3e8b8dbc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt @@ -8,10 +8,6 @@ data class CreateUserCreatorChatRoomResponse( val roomId: Long ) -data class SendUserCreatorTextMessageRequest( - val textMessage: String -) - data class SendUserCreatorVoiceMessageRequest( val recipientId: Long? = null ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatRealtimeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatRealtimeService.kt deleted file mode 100644 index b401128d..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatRealtimeService.kt +++ /dev/null @@ -1,87 +0,0 @@ -package kr.co.vividnext.sodalive.v2.usercreatorchat.service - -import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto -import org.springframework.data.redis.core.StringRedisTemplate -import org.springframework.stereotype.Service -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter -import java.io.IOException -import java.time.Duration -import java.util.concurrent.ConcurrentHashMap - -@Service -class UserCreatorChatRealtimeService( - private val stringRedisTemplate: StringRedisTemplate -) { - private val emitters = ConcurrentHashMap() - - fun connect(roomId: Long, memberId: Long): SseEmitter { - val emitter = SseEmitter(SSE_TIMEOUT_MILLIS) - val key = emitterKey(roomId, memberId) - emitters[key] = emitter - markPresent(roomId, memberId) - - emitter.onCompletion { disconnect(roomId, memberId) } - emitter.onTimeout { disconnect(roomId, memberId) } - emitter.onError { disconnect(roomId, memberId) } - - sendConnectEvent(emitter) - return emitter - } - - fun disconnect(roomId: Long, memberId: Long) { - emitters.remove(emitterKey(roomId, memberId)) - stringRedisTemplate.delete(presenceKey(roomId, memberId)) - } - - fun isMemberInRoom(roomId: Long, memberId: Long): Boolean { - return stringRedisTemplate.hasKey(presenceKey(roomId, memberId)) - } - - fun sendMessage(roomId: Long, memberId: Long, message: UserCreatorChatMessageItemDto): Boolean { - val emitter = emitters[emitterKey(roomId, memberId)] ?: return false - return try { - emitter.send( - SseEmitter.event() - .id(message.messageId.toString()) - .name("message") - .reconnectTime(SSE_RECONNECT_MILLIS) - .data(message) - ) - markPresent(roomId, memberId) - true - } catch (_: IOException) { - disconnect(roomId, memberId) - false - } catch (_: IllegalStateException) { - disconnect(roomId, memberId) - false - } - } - - private fun sendConnectEvent(emitter: SseEmitter) { - try { - emitter.send( - SseEmitter.event() - .name("connected") - .reconnectTime(SSE_RECONNECT_MILLIS) - .data("connected") - ) - } catch (e: IOException) { - emitter.completeWithError(e) - } - } - - private fun markPresent(roomId: Long, memberId: Long) { - stringRedisTemplate.opsForValue().set(presenceKey(roomId, memberId), "1", Duration.ofSeconds(PRESENCE_TTL_SECONDS)) - } - - private fun emitterKey(roomId: Long, memberId: Long) = "$roomId:$memberId" - - private fun presenceKey(roomId: Long, memberId: Long) = "v2:user-creator-chat:presence:$roomId:$memberId" - - companion object { - private const val SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L - private const val SSE_RECONNECT_MILLIS = 3000L - private const val PRESENCE_TTL_SECONDS = 60L - } -} 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 71e6ac06..41b656ac 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 @@ -18,7 +18,6 @@ import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomResponse import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorChatMessageResponse -import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorVoiceMessageRequest import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessagesPageResponse @@ -45,7 +44,6 @@ class UserCreatorChatService( private val messageRepository: UserCreatorChatMessageRepository, private val memberRepository: MemberRepository, private val blockMemberRepository: BlockMemberRepository, - private val realtimeService: UserCreatorChatRealtimeService, private val presenceService: UserCreatorChatPresenceService, private val roomMessageBroker: UserCreatorChatRoomMessageBroker, private val applicationEventPublisher: ApplicationEventPublisher, @@ -111,18 +109,6 @@ class UserCreatorChatService( ) } - @Transactional - fun sendTextMessage( - member: Member, - roomId: Long, - request: SendUserCreatorTextMessageRequest - ): SendUserCreatorChatMessageResponse { - if (request.textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request") - val context = resolveSendContext(member, roomId) - 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") @@ -169,17 +155,7 @@ class UserCreatorChatService( filePath = "user_creator_chat_voice/${message.id}/${generateFileName(prefix = "${message.id}-message-")}", metadata = metadata ) - return deliverMessage(message, member, context.opponentParticipant) - } - - fun connect(member: Member, roomId: Long) = run { - requireParticipant(roomId, member.id!!) - realtimeService.connect(roomId, member.id!!) - } - - fun disconnectRealtime(member: Member, roomId: Long) { - requireParticipant(roomId, member.id!!) - realtimeService.disconnect(roomId, member.id!!) + return deliverRestMessage(message, member, context.opponentParticipant) } fun validateParticipant(roomId: Long, memberId: Long): UserCreatorChatParticipant { @@ -206,17 +182,26 @@ class UserCreatorChatService( return SendContext(room, senderParticipant, opponentParticipant) } - private fun deliverMessage( + private fun deliverRestMessage( message: UserCreatorChatMessage, member: Member, opponentParticipant: UserCreatorChatParticipant ): SendUserCreatorChatMessageResponse { val opponent = opponentParticipant.member val item = toMessageItemDto(message, member) - val opponentPresent = realtimeService.isMemberInRoom(message.chatRoom.id!!, opponent.id!!) + val opponentPresent = presenceService.hasPresence(message.chatRoom.id!!, opponent.id!!) if (opponentPresent) { - val delivered = realtimeService.sendMessage(message.chatRoom.id!!, opponent.id!!, item) - return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = delivered, pushSent = false) + val opponentMessage = toMessageItemDto(message, opponent) + roomMessageBroker.publish( + roomId = message.chatRoom.id!!, + memberId = opponent.id!!, + payload = websocketMessagePayload( + UserCreatorChatWebSocketMessageType.MESSAGE, + message.chatRoom.id!!, + opponentMessage + ) + ) + return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = true, pushSent = false) } publishMessagePush(message, member, opponent) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt new file mode 100644 index 00000000..8b1e63e7 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt @@ -0,0 +1,26 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat + +import kr.co.vividnext.sodalive.v2.usercreatorchat.controller.UserCreatorChatController +import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders + +class UserCreatorChatControllerMappingTest { + private val mockMvc = MockMvcBuilders + .standaloneSetup(UserCreatorChatController(Mockito.mock(UserCreatorChatService::class.java))) + .build() + + @Test + fun shouldReturnNotFoundForRemovedSseAndTextMessageEndpoints() { + mockMvc.perform(get("/api/v2/user-creator-chat/rooms/10/events")) + .andExpect(status().isNotFound) + mockMvc.perform(post("/api/v2/user-creator-chat/rooms/10/events/disconnect")) + .andExpect(status().isNotFound) + mockMvc.perform(post("/api/v2/user-creator-chat/rooms/10/messages/text")) + .andExpect(status().isNotFound) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt index 057a6db4..339dd17b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt @@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.member.MemberKind import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer -import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest 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 @@ -58,7 +57,7 @@ class UserCreatorChatServiceIntegrationTest @Autowired constructor( } @Test - @DisplayName("AI 캐릭터용 Member가 참여한 기존 DM 방에는 메시지를 보낼 수 없다") + @DisplayName("AI 캐릭터용 Member가 참여한 기존 DM 방에는 WebSocket 텍스트 메시지를 보낼 수 없다") fun shouldRejectSendTextMessageWhenOpponentIsAiCharacterMember() { val user = memberRepository.save(Member(email = "dm-message-user@test.com", password = "pw", nickname = "user")) val creator = memberRepository.save( @@ -77,7 +76,7 @@ class UserCreatorChatServiceIntegrationTest @Autowired constructor( entityManager.clear() val exception = assertThrows(SodaException::class.java) { - service.sendTextMessage(user, room.id!!, SendUserCreatorTextMessageRequest("hello")) + service.sendTextMessageByWebSocket(user.id!!, room.id!!, "hello") } assertEquals("message.error.recipient_not_found", exception.messageKey) 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 dcaced8f..4caba97c 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 @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat +import com.amazonaws.services.s3.model.ObjectMetadata import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.fcm.FcmEvent @@ -7,12 +8,10 @@ import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository -import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto 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.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 @@ -26,6 +25,9 @@ import org.mockito.ArgumentCaptor import org.mockito.Mockito import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.PageRequest +import org.springframework.mock.web.MockMultipartFile +import java.io.ByteArrayInputStream +import java.io.InputStream import java.time.LocalDateTime import java.util.Optional @@ -35,10 +37,10 @@ class UserCreatorChatServiceTest { private lateinit var messageRepository: UserCreatorChatMessageRepository 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 s3Uploader: S3Uploader private lateinit var service: UserCreatorChatService @BeforeEach @@ -48,10 +50,10 @@ class UserCreatorChatServiceTest { messageRepository = Mockito.mock(UserCreatorChatMessageRepository::class.java) 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) + s3Uploader = Mockito.mock(S3Uploader::class.java) service = UserCreatorChatService( roomRepository = roomRepository, @@ -59,12 +61,11 @@ class UserCreatorChatServiceTest { messageRepository = messageRepository, memberRepository = memberRepository, blockMemberRepository = blockMemberRepository, - realtimeService = realtimeService, presenceService = presenceService, roomMessageBroker = roomMessageBroker, applicationEventPublisher = eventPublisher, objectMapper = ObjectMapper(), - s3Uploader = Mockito.mock(S3Uploader::class.java), + s3Uploader = s3Uploader, bucket = "test-bucket", cloudFrontHost = "https://cdn.test" ) @@ -144,85 +145,6 @@ class UserCreatorChatServiceTest { assertEquals("https://cdn.test/profile/default-profile.png", response.opponentProfileImageUrl) } - @Test - @DisplayName("상대방이 같은 방에 입장 중이면 텍스트 메시지를 실시간 전송하고 푸시를 보내지 않는다") - fun shouldSendRealtimeMessageWithoutPushWhenOpponentIsPresent() { - 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`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(true) - Mockito.`when`(realtimeService.sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem())).thenReturn(true) - Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> - (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 200L } - } - - val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello")) - - assertEquals(200L, response.message.messageId) - assertEquals("hello", response.message.textMessage) - assertTrue(response.deliveredRealtime) - assertFalse(response.pushSent) - Mockito.verify(realtimeService).sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem()) - Mockito.verifyNoInteractions(eventPublisher) - } - - @Test - @DisplayName("상대방이 같은 방에 없으면 텍스트 메시지를 저장하고 푸시 이벤트를 발행한다") - fun shouldPublishPushEventWhenOpponentIsNotPresent() { - 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`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(false) - Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> - (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 201L } - } - - val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello")) - - assertFalse(response.deliveredRealtime) - assertTrue(response.pushSent) - val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java) - Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) - assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type) - assertEquals(listOf(2L), eventCaptor.value.recipients) - assertEquals(201L, eventCaptor.value.messageId) - } - - @Test - @DisplayName("상대방이 입장 중이면 실시간 전송 실패 시에도 보완 푸시를 보내지 않는다") - fun shouldNotFallbackToPushWhenRealtimeDeliveryFailsForPresentOpponent() { - 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`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(true) - Mockito.`when`(realtimeService.sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem())) - .thenReturn(false) - Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> - (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 202L } - } - - val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello")) - - assertFalse(response.deliveredRealtime) - assertFalse(response.pushSent) - Mockito.verifyNoInteractions(eventPublisher) - } - @Test @DisplayName("WebSocket 텍스트 전송은 상대방 presence가 있으면 메시지를 저장하고 broker로 MESSAGE를 발행하며 푸시를 보내지 않는다") fun shouldPublishWebSocketMessageWithoutPushWhenOpponentPresenceExists() { @@ -279,6 +201,66 @@ class UserCreatorChatServiceTest { Mockito.verifyNoInteractions(roomMessageBroker) } + @Test + @DisplayName("음성 메시지 REST 전송은 상대방 presence가 있으면 WebSocket broker로 MESSAGE를 발행하고 푸시를 보내지 않는다") + fun shouldPublishVoiceMessageToWebSocketWhenOpponentPresenceExists() { + 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 = 205L } + } + givenVoiceUploadReturns("voice/205.m4a") + + val response = service.sendVoiceMessage(user, 10L, voiceFile(), "{}") + + assertEquals(205L, response.message.messageId) + assertTrue(response.deliveredRealtime) + assertFalse(response.pushSent) + 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("\"messageType\":\"VOICE\"")) + Mockito.verifyNoInteractions(eventPublisher) + } + + @Test + @DisplayName("음성 메시지 REST 전송은 상대방 presence가 없으면 푸시 이벤트를 발행한다") + fun shouldPublishPushEventForVoiceMessageWhenOpponentPresenceDoesNotExist() { + 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(false) + Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 206L } + } + givenVoiceUploadReturns("voice/206.m4a") + + val response = service.sendVoiceMessage(user, 10L, voiceFile(), "{}") + + assertFalse(response.deliveredRealtime) + assertTrue(response.pushSent) + val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java) + Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) + assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type) + assertEquals(listOf(2L), eventCaptor.value.recipients) + assertEquals(10L, eventCaptor.value.roomId) + assertEquals(206L, eventCaptor.value.messageId) + assertEquals("USER_CREATOR", eventCaptor.value.chatType) + Mockito.verifyNoInteractions(roomMessageBroker) + } + @Test @DisplayName("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다") fun shouldGetPreviousMessagesWithDefaultLimitWhenCursorExists() { @@ -325,20 +307,6 @@ class UserCreatorChatServiceTest { ) } - @Test - @DisplayName("실시간 연결 해제는 참여자를 제거하지 않고 presence만 해제한다") - fun shouldDisconnectRealtimeWithoutLeavingRoom() { - val user = member(1L, "user") - val room = room(10L) - val participant = participant(100L, room, user) - Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(participant) - - service.disconnectRealtime(user, 10L) - - Mockito.verify(realtimeService).disconnect(10L, 1L) - Mockito.verify(participantRepository, Mockito.never()).save(Mockito.any(UserCreatorChatParticipant::class.java)) - } - private fun member(id: Long, nickname: String) = Member(password = "pw", nickname = nickname).apply { this.id = id } private fun anyMessageItem(): UserCreatorChatMessageItemDto { @@ -359,6 +327,35 @@ class UserCreatorChatServiceTest { return captor.capture() ?: "" } + private fun voiceFile() = MockMultipartFile("voiceMessageFile", "voice.m4a", "audio/mp4", byteArrayOf(1, 2, 3)) + + private fun anyInputStream(): InputStream { + return Mockito.any(InputStream::class.java) ?: ByteArrayInputStream(ByteArray(0)) + } + + private fun anyObjectMetadata(): ObjectMetadata { + return Mockito.any(ObjectMetadata::class.java) ?: ObjectMetadata() + } + + private fun anyStringValue(): String { + return Mockito.anyString() ?: "" + } + + private fun eqString(value: String): String { + return Mockito.eq(value) ?: value + } + + private fun givenVoiceUploadReturns(path: String) { + Mockito.`when`( + s3Uploader.upload( + anyInputStream(), + eqString("test-bucket"), + anyStringValue(), + anyObjectMetadata() + ) + ).thenReturn(path) + } + private fun room(id: Long) = UserCreatorChatRoom().apply { this.id = id }