test #426
@@ -4,7 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
|||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomRequest
|
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 kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
@@ -42,16 +41,6 @@ class UserCreatorChatController(
|
|||||||
ApiResponse.ok(service.openRoom(member, roomId, limit))
|
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")
|
@GetMapping("/{roomId}/messages")
|
||||||
fun getMessages(
|
fun getMessages(
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
@@ -63,25 +52,6 @@ class UserCreatorChatController(
|
|||||||
ApiResponse.ok(service.getMessages(member, roomId, cursor, limit))
|
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])
|
@PostMapping("/{roomId}/messages/voice", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||||
fun sendVoiceMessage(
|
fun sendVoiceMessage(
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ data class CreateUserCreatorChatRoomResponse(
|
|||||||
val roomId: Long
|
val roomId: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SendUserCreatorTextMessageRequest(
|
|
||||||
val textMessage: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SendUserCreatorVoiceMessageRequest(
|
data class SendUserCreatorVoiceMessageRequest(
|
||||||
val recipientId: Long? = null
|
val recipientId: Long? = 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<String, SseEmitter>()
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.UserCreatorChatRoom
|
||||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomResponse
|
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.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.SendUserCreatorVoiceMessageRequest
|
||||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
|
||||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessagesPageResponse
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessagesPageResponse
|
||||||
@@ -45,7 +44,6 @@ class UserCreatorChatService(
|
|||||||
private val messageRepository: UserCreatorChatMessageRepository,
|
private val messageRepository: UserCreatorChatMessageRepository,
|
||||||
private val memberRepository: MemberRepository,
|
private val memberRepository: MemberRepository,
|
||||||
private val blockMemberRepository: BlockMemberRepository,
|
private val blockMemberRepository: BlockMemberRepository,
|
||||||
private val realtimeService: UserCreatorChatRealtimeService,
|
|
||||||
private val presenceService: UserCreatorChatPresenceService,
|
private val presenceService: UserCreatorChatPresenceService,
|
||||||
private val roomMessageBroker: UserCreatorChatRoomMessageBroker,
|
private val roomMessageBroker: UserCreatorChatRoomMessageBroker,
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
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
|
@Transactional
|
||||||
fun sendTextMessageByWebSocket(memberId: Long, roomId: Long, textMessage: String): UserCreatorChatMessageItemDto {
|
fun sendTextMessageByWebSocket(memberId: Long, roomId: Long, textMessage: String): UserCreatorChatMessageItemDto {
|
||||||
if (textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request")
|
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-")}",
|
filePath = "user_creator_chat_voice/${message.id}/${generateFileName(prefix = "${message.id}-message-")}",
|
||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
return deliverMessage(message, member, context.opponentParticipant)
|
return deliverRestMessage(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!!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun validateParticipant(roomId: Long, memberId: Long): UserCreatorChatParticipant {
|
fun validateParticipant(roomId: Long, memberId: Long): UserCreatorChatParticipant {
|
||||||
@@ -206,17 +182,26 @@ class UserCreatorChatService(
|
|||||||
return SendContext(room, senderParticipant, opponentParticipant)
|
return SendContext(room, senderParticipant, opponentParticipant)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deliverMessage(
|
private fun deliverRestMessage(
|
||||||
message: UserCreatorChatMessage,
|
message: UserCreatorChatMessage,
|
||||||
member: Member,
|
member: Member,
|
||||||
opponentParticipant: UserCreatorChatParticipant
|
opponentParticipant: UserCreatorChatParticipant
|
||||||
): SendUserCreatorChatMessageResponse {
|
): SendUserCreatorChatMessageResponse {
|
||||||
val opponent = opponentParticipant.member
|
val opponent = opponentParticipant.member
|
||||||
val item = toMessageItemDto(message, 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) {
|
if (opponentPresent) {
|
||||||
val delivered = realtimeService.sendMessage(message.chatRoom.id!!, opponent.id!!, item)
|
val opponentMessage = toMessageItemDto(message, opponent)
|
||||||
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = delivered, pushSent = false)
|
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)
|
publishMessagePush(message, member, opponent)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.member.MemberKind
|
|||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
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.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
|
||||||
@@ -58,7 +57,7 @@ class UserCreatorChatServiceIntegrationTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("AI 캐릭터용 Member가 참여한 기존 DM 방에는 메시지를 보낼 수 없다")
|
@DisplayName("AI 캐릭터용 Member가 참여한 기존 DM 방에는 WebSocket 텍스트 메시지를 보낼 수 없다")
|
||||||
fun shouldRejectSendTextMessageWhenOpponentIsAiCharacterMember() {
|
fun shouldRejectSendTextMessageWhenOpponentIsAiCharacterMember() {
|
||||||
val user = memberRepository.save(Member(email = "dm-message-user@test.com", password = "pw", nickname = "user"))
|
val user = memberRepository.save(Member(email = "dm-message-user@test.com", password = "pw", nickname = "user"))
|
||||||
val creator = memberRepository.save(
|
val creator = memberRepository.save(
|
||||||
@@ -77,7 +76,7 @@ class UserCreatorChatServiceIntegrationTest @Autowired constructor(
|
|||||||
entityManager.clear()
|
entityManager.clear()
|
||||||
|
|
||||||
val exception = assertThrows(SodaException::class.java) {
|
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)
|
assertEquals("message.error.recipient_not_found", exception.messageKey)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.usercreatorchat
|
package kr.co.vividnext.sodalive.v2.usercreatorchat
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
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.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
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.dto.UserCreatorChatMessageItemDto
|
||||||
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.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.UserCreatorChatPresenceService
|
||||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker
|
||||||
@@ -26,6 +25,9 @@ import org.mockito.ArgumentCaptor
|
|||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.data.domain.PageRequest
|
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.time.LocalDateTime
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
|
|
||||||
@@ -35,10 +37,10 @@ class UserCreatorChatServiceTest {
|
|||||||
private lateinit var messageRepository: UserCreatorChatMessageRepository
|
private lateinit var messageRepository: UserCreatorChatMessageRepository
|
||||||
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 presenceService: UserCreatorChatPresenceService
|
private lateinit var presenceService: UserCreatorChatPresenceService
|
||||||
private lateinit var roomMessageBroker: UserCreatorChatRoomMessageBroker
|
private lateinit var roomMessageBroker: UserCreatorChatRoomMessageBroker
|
||||||
private lateinit var eventPublisher: ApplicationEventPublisher
|
private lateinit var eventPublisher: ApplicationEventPublisher
|
||||||
|
private lateinit var s3Uploader: S3Uploader
|
||||||
private lateinit var service: UserCreatorChatService
|
private lateinit var service: UserCreatorChatService
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -48,10 +50,10 @@ class UserCreatorChatServiceTest {
|
|||||||
messageRepository = Mockito.mock(UserCreatorChatMessageRepository::class.java)
|
messageRepository = Mockito.mock(UserCreatorChatMessageRepository::class.java)
|
||||||
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)
|
|
||||||
presenceService = Mockito.mock(UserCreatorChatPresenceService::class.java)
|
presenceService = Mockito.mock(UserCreatorChatPresenceService::class.java)
|
||||||
roomMessageBroker = Mockito.mock(UserCreatorChatRoomMessageBroker::class.java)
|
roomMessageBroker = Mockito.mock(UserCreatorChatRoomMessageBroker::class.java)
|
||||||
eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||||
|
s3Uploader = Mockito.mock(S3Uploader::class.java)
|
||||||
|
|
||||||
service = UserCreatorChatService(
|
service = UserCreatorChatService(
|
||||||
roomRepository = roomRepository,
|
roomRepository = roomRepository,
|
||||||
@@ -59,12 +61,11 @@ class UserCreatorChatServiceTest {
|
|||||||
messageRepository = messageRepository,
|
messageRepository = messageRepository,
|
||||||
memberRepository = memberRepository,
|
memberRepository = memberRepository,
|
||||||
blockMemberRepository = blockMemberRepository,
|
blockMemberRepository = blockMemberRepository,
|
||||||
realtimeService = realtimeService,
|
|
||||||
presenceService = presenceService,
|
presenceService = presenceService,
|
||||||
roomMessageBroker = roomMessageBroker,
|
roomMessageBroker = roomMessageBroker,
|
||||||
applicationEventPublisher = eventPublisher,
|
applicationEventPublisher = eventPublisher,
|
||||||
objectMapper = ObjectMapper(),
|
objectMapper = ObjectMapper(),
|
||||||
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
s3Uploader = s3Uploader,
|
||||||
bucket = "test-bucket",
|
bucket = "test-bucket",
|
||||||
cloudFrontHost = "https://cdn.test"
|
cloudFrontHost = "https://cdn.test"
|
||||||
)
|
)
|
||||||
@@ -144,85 +145,6 @@ class UserCreatorChatServiceTest {
|
|||||||
assertEquals("https://cdn.test/profile/default-profile.png", response.opponentProfileImageUrl)
|
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
|
@Test
|
||||||
@DisplayName("WebSocket 텍스트 전송은 상대방 presence가 있으면 메시지를 저장하고 broker로 MESSAGE를 발행하며 푸시를 보내지 않는다")
|
@DisplayName("WebSocket 텍스트 전송은 상대방 presence가 있으면 메시지를 저장하고 broker로 MESSAGE를 발행하며 푸시를 보내지 않는다")
|
||||||
fun shouldPublishWebSocketMessageWithoutPushWhenOpponentPresenceExists() {
|
fun shouldPublishWebSocketMessageWithoutPushWhenOpponentPresenceExists() {
|
||||||
@@ -279,6 +201,66 @@ class UserCreatorChatServiceTest {
|
|||||||
Mockito.verifyNoInteractions(roomMessageBroker)
|
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
|
@Test
|
||||||
@DisplayName("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다")
|
@DisplayName("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다")
|
||||||
fun shouldGetPreviousMessagesWithDefaultLimitWhenCursorExists() {
|
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 member(id: Long, nickname: String) = Member(password = "pw", nickname = nickname).apply { this.id = id }
|
||||||
|
|
||||||
private fun anyMessageItem(): UserCreatorChatMessageItemDto {
|
private fun anyMessageItem(): UserCreatorChatMessageItemDto {
|
||||||
@@ -359,6 +327,35 @@ class UserCreatorChatServiceTest {
|
|||||||
return captor.capture() ?: ""
|
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 {
|
private fun room(id: Long) = UserCreatorChatRoom().apply {
|
||||||
this.id = id
|
this.id = id
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user