feat(user-creator-chat): 유저 크리에이터 채팅방을 추가한다

유저와 크리에이터 간 텍스트/음성 메시지, SSE presence, 조건부 푸시 흐름을 신규 도메인으로 분리한다.
This commit is contained in:
2026-05-13 18:02:11 +09:00
parent 6e22198b6f
commit 1daf67fa49
13 changed files with 1318 additions and 0 deletions

View File

@@ -0,0 +1,264 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.fcm.FcmEvent
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 org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
import java.time.LocalDateTime
import java.util.Optional
class UserCreatorChatServiceTest {
private lateinit var roomRepository: UserCreatorChatRoomRepository
private lateinit var participantRepository: UserCreatorChatParticipantRepository
private lateinit var messageRepository: UserCreatorChatMessageRepository
private lateinit var memberRepository: MemberRepository
private lateinit var blockMemberRepository: BlockMemberRepository
private lateinit var realtimeService: UserCreatorChatRealtimeService
private lateinit var eventPublisher: ApplicationEventPublisher
private lateinit var service: UserCreatorChatService
@BeforeEach
fun setUp() {
roomRepository = Mockito.mock(UserCreatorChatRoomRepository::class.java)
participantRepository = Mockito.mock(UserCreatorChatParticipantRepository::class.java)
messageRepository = Mockito.mock(UserCreatorChatMessageRepository::class.java)
memberRepository = Mockito.mock(MemberRepository::class.java)
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
realtimeService = Mockito.mock(UserCreatorChatRealtimeService::class.java)
eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
service = UserCreatorChatService(
roomRepository = roomRepository,
participantRepository = participantRepository,
messageRepository = messageRepository,
memberRepository = memberRepository,
blockMemberRepository = blockMemberRepository,
realtimeService = realtimeService,
applicationEventPublisher = eventPublisher,
objectMapper = ObjectMapper(),
s3Uploader = Mockito.mock(S3Uploader::class.java),
bucket = "test-bucket",
cloudFrontHost = "https://cdn.test"
)
}
@Test
@DisplayName("활성 유저-크리에이터 방이 없으면 새 방과 참여자를 생성한다")
fun shouldCreateRoomAndParticipantsWhenActiveRoomDoesNotExist() {
val user = member(1L, "user")
val creator = member(2L, "creator")
Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(creator))
Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(null)
Mockito.`when`(roomRepository.save(Mockito.any(UserCreatorChatRoom::class.java))).thenAnswer { invocation ->
(invocation.arguments[0] as UserCreatorChatRoom).apply { id = 10L }
}
val response = service.createOrGetRoom(user, 2L)
assertEquals(10L, response.roomId)
val roomCaptor = ArgumentCaptor.forClass(UserCreatorChatRoom::class.java)
Mockito.verify(roomRepository).save(roomCaptor.capture())
Mockito.verify(participantRepository, Mockito.times(2)).save(Mockito.any(UserCreatorChatParticipant::class.java))
}
@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("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다")
fun shouldGetPreviousMessagesWithDefaultLimitWhenCursorExists() {
val user = member(1L, "user")
val creator = member(2L, "creator")
val room = room(10L)
val userParticipant = participant(100L, room, user)
val creatorParticipant = participant(101L, room, creator)
val olderMessage = textMessage(
id = 298L,
room = room,
participant = userParticipant,
text = "older",
createdAt = LocalDateTime.of(2026, 5, 13, 10, 0)
)
val newerMessage = textMessage(
id = 299L,
room = room,
participant = creatorParticipant,
text = "newer",
createdAt = LocalDateTime.of(2026, 5, 13, 10, 1)
)
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(userParticipant)
Mockito.`when`(
messageRepository.findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
room,
300L,
PageRequest.of(0, 20)
)
).thenReturn(listOf(newerMessage, olderMessage))
Mockito.`when`(messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, 298L)).thenReturn(true)
val response = service.getMessages(user, roomId = 10L, cursor = 300L)
assertEquals(listOf(298L, 299L), response.messages.map { it.messageId })
assertEquals(listOf("older", "newer"), response.messages.map { it.textMessage })
assertTrue(response.hasMore)
assertEquals(298L, response.nextCursor)
Mockito.verify(messageRepository).findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
room,
300L,
PageRequest.of(0, 20)
)
}
@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 {
return Mockito.any(UserCreatorChatMessageItemDto::class.java) ?: UserCreatorChatMessageItemDto(
messageId = 0L,
messageType = "TEXT",
mine = false,
createdAt = 0L,
textMessage = null,
voiceMessageUrl = null,
senderId = 0L,
senderNickname = "",
senderProfileImageUrl = ""
)
}
private fun room(id: Long) = UserCreatorChatRoom().apply {
this.id = id
}
private fun participant(
id: Long,
room: UserCreatorChatRoom,
member: Member
) = UserCreatorChatParticipant(chatRoom = room, member = member).apply { this.id = id }
private fun textMessage(
id: Long,
room: UserCreatorChatRoom,
participant: UserCreatorChatParticipant,
text: String,
createdAt: LocalDateTime
) = UserCreatorChatMessage(
chatRoom = room,
participant = participant,
messageType = UserCreatorChatMessageType.TEXT,
textMessage = text
).apply {
this.id = id
this.createdAt = createdAt
}
}