feat(user-creator-chat): SSE REST 경계를 제거한다

This commit is contained in:
2026-06-19 02:45:17 +09:00
parent 6949d3e482
commit 8fa8d12667
7 changed files with 138 additions and 252 deletions

View File

@@ -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)
}
}

View File

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

View File

@@ -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
}