diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt index 537d3227..b3d49acf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Propagation @@ -56,7 +57,9 @@ class FcmEvent( val commentParentId: Long? = null, val myMemberId: Long? = null, val isAvailableJoinCreator: Boolean? = null, - val genderRestriction: GenderRestriction? = null + val genderRestriction: GenderRestriction? = null, + val titleMemberId: Long? = null, + val nameArgumentMemberIds: Map = emptyMap() ) @Component @@ -65,7 +68,8 @@ class FcmSendListener( private val memberRepository: MemberRepository, private val contentCommentRepository: AudioContentCommentRepository, private val messageSource: SodaMessageSource, - private val pushNotificationService: PushNotificationService + private val pushNotificationService: PushNotificationService, + private val localizedNameService: CreatorLocalizedNameService ) { @Async @TransactionalEventListener @@ -169,18 +173,26 @@ class FcmSendListener( creatorId: Long? = null, auditionId: Long? = null ) { - val tokensByLang = pushTokens.groupBy { it.languageCode } + val tokensByLang = pushTokens.groupBy { Lang.fromAcceptLanguage(it.languageCode) } + val nameMemberIds = listOfNotNull(fcmEvent.senderMemberId, fcmEvent.titleMemberId) + .plus(fcmEvent.nameArgumentMemberIds.values) + .distinct() - for ((langCode, tokens) in tokensByLang) { - val lang = Lang.fromAcceptLanguage(langCode) - val title = translate(fcmEvent.titleKey, fcmEvent.title, lang, fcmEvent.args) - val message = translate(fcmEvent.messageKey, fcmEvent.message, lang, fcmEvent.args) + for ((lang, tokens) in tokensByLang) { + val names = localizedNameService.findLocalizedNames(nameMemberIds, lang) + val args = fcmEvent.args.mapIndexed { index, argument -> + fcmEvent.nameArgumentMemberIds[index]?.let { names[it] } ?: argument + } + val defaultTitle = fcmEvent.titleMemberId?.let { names[it] } ?: fcmEvent.title + val title = translate(fcmEvent.titleKey, defaultTitle, lang, args) + val message = translate(fcmEvent.messageKey, fcmEvent.message, lang, args) pushNotificationService.saveNotification( fcmEvent = fcmEvent, languageCode = lang.code, translatedMessage = message, - recipientPushTokens = tokens + recipientPushTokens = tokens, + localizedSenderNickname = fcmEvent.senderMemberId?.let { names[it] } ) val tokensByOS = tokens.groupBy { it.deviceType } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt index ec34350c..0f348000 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt @@ -36,7 +36,8 @@ class PushNotificationService( fcmEvent: FcmEvent, languageCode: String, translatedMessage: String, - recipientPushTokens: List + recipientPushTokens: List, + localizedSenderNickname: String? ) { if (recipientPushTokens.isEmpty()) return @@ -58,7 +59,7 @@ class PushNotificationService( ) val notification = PushNotificationList( - senderNicknameSnapshot = senderSnapshot.nickname, + senderNicknameSnapshot = localizedSenderNickname ?: senderSnapshot.nickname, senderProfileImageSnapshot = senderSnapshot.profileImage, message = translatedMessage, category = category, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmSendListenerLocalizedNameTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmSendListenerLocalizedNameTest.kt new file mode 100644 index 00000000..26c3a603 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmSendListenerLocalizedNameTest.kt @@ -0,0 +1,287 @@ +package kr.co.vividnext.sodalive.fcm + +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.comment.AudioContentComment +import kr.co.vividnext.sodalive.content.comment.AudioContentCommentService +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationList +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationListRepository +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationRecipientChunk +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.live.reservation.LiveReservation +import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.live.room.LiveRoomRepository +import kr.co.vividnext.sodalive.live.room.LiveRoomService +import kr.co.vividnext.sodalive.live.room.cancel.CancelLiveRequest +import kr.co.vividnext.sodalive.member.Member +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.gift.application.GiftPushService +import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.event.ApplicationEvents +import org.springframework.test.context.event.RecordApplicationEvents +import org.springframework.test.util.AopTestUtils +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "spring.cache.type=none", + "jwt.secret=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZg==" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@RecordApplicationEvents +@Transactional +class FcmSendListenerLocalizedNameTest @Autowired constructor( + private val listener: FcmSendListener, + private val chatService: UserCreatorChatService, + private val liveRoomService: LiveRoomService, + private val commentService: AudioContentCommentService, + private val giftPushService: GiftPushService, + private val memberRepository: MemberRepository, + private val pushTokenRepository: PushTokenRepository, + private val notificationRepository: PushNotificationListRepository, + private val roomRepository: LiveRoomRepository, + private val reservationRepository: LiveReservationRepository, + private val jdbcTemplate: JdbcTemplate, + private val langContext: LangContext, + private val entityManager: EntityManager +) { + @Autowired + private lateinit var applicationEvents: ApplicationEvents + + @MockBean + private lateinit var fcmService: FcmService + + @MockBean + private lateinit var s3Uploader: S3Uploader + + @BeforeEach + fun setUpRequest() { + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest())) + langContext.setLang(Lang.EN) + } + + @AfterEach + fun resetRequest() { + RequestContextHolder.resetRequestAttributes() + } + + @Test + @DisplayName("실제 DM 이벤트의 이름 인자와 255자 발신자 스냅샷을 토큰 언어별로 독립 렌더링한다") + fun shouldRenderDmNameArgumentAndSenderSnapshotForEachTokenLanguage() { + val creator = savedMember("dm-creator", MemberRole.CREATOR) + val recipient = savedMember("dm-recipient") + val japaneseName = "名".repeat(255) + saveAlias(creator, japaneseName) + saveTokens(recipient) + val roomId = chatService.createOrGetRoom(creator, recipient.id!!).roomId + + chatService.sendTextMessageByWebSocket(creator.id!!, roomId, "dm-creator 본문은 그대로", Lang.EN) + val event = publishedEvent() + AopTestUtils.getUltimateTargetObject(listener).send(event) + entityManager.flush() + entityManager.clear() + + val pushes = outgoingPushes() + val snapshots = notificationRepository.findAll().associateBy { it.languageCode } + assertAll( + { assertEquals(2, pushes.size) }, + { assertEquals("DM", pushes.getValue("token-ja").first) }, + { assertEquals("${japaneseName}からDMが届きました。", pushes.getValue("token-ja").second) }, + { assertEquals("DM", pushes.getValue("token-ko").first) }, + { assertEquals("dm-creator님으로 부터 DM이 도착했습니다.", pushes.getValue("token-ko").second) }, + { assertEquals(japaneseName, snapshots.getValue("ja").senderNicknameSnapshot) }, + { assertEquals("dm-creator", snapshots.getValue("ko").senderNicknameSnapshot) }, + { assertEquals(pushes.getValue("token-ja").second, snapshots.getValue("ja").message) }, + { assertEquals(pushes.getValue("token-ko").second, snapshots.getValue("ko").message) }, + { assertEquals(listOf("dm-creator"), event.args) }, + { assertEquals("dm-creator", memberRepository.findById(creator.id!!).orElseThrow().nickname) } + ) + } + + @Test + @DisplayName("실제 라이브 취소 이벤트의 제목만 현지화하고 같은 문자열인 방 제목 인자는 보존한다") + fun shouldLocalizeLiveSenderTitleWithoutReplacingRoomTitleArgument() { + val creator = savedMember("creator.*$", MemberRole.CREATOR) + val recipient = savedMember("live-recipient") + saveAlias(creator, "日本の配信者") + saveTokens(recipient) + val room = roomRepository.saveAndFlush( + LiveRoom( + title = "creator.*$", + notice = "notice", + beginDateTime = LocalDateTime.now().plusDays(1), + numberOfPeople = 10, + isAdult = false + ).apply { member = creator } + ) + reservationRepository.saveAndFlush( + LiveReservation().apply { + member = recipient + this.room = room + } + ) + + liveRoomService.cancelLive(CancelLiveRequest(room.id!!, "취소 사유"), creator) + val event = publishedEvent() + AopTestUtils.getUltimateTargetObject(listener).send(event) + + val pushes = outgoingPushes() + val snapshots = notificationRepository.findAll().associateBy { it.languageCode } + assertAll( + { assertEquals("日本の配信者", pushes.getValue("token-ja").first) }, + { assertEquals("ライブ取消: creator.*$", pushes.getValue("token-ja").second) }, + { assertEquals("creator.*$", pushes.getValue("token-ko").first) }, + { assertEquals("라이브 취소 : creator.*$", pushes.getValue("token-ko").second) }, + { assertEquals("日本の配信者", snapshots.getValue("ja").senderNicknameSnapshot) }, + { assertEquals("creator.*$", snapshots.getValue("ko").senderNicknameSnapshot) }, + { assertEquals("creator.*$", event.title) }, + { assertEquals(listOf("creator.*$"), event.args) } + ) + } + + @Test + @DisplayName("발신자 ID가 없는 과거 알림은 현재 이름 변경 뒤 신규 알림을 발송해도 이름과 본문이 불변이다") + fun shouldPreserveHistoricalNotificationWithoutSenderId() { + val creator = savedMember("historical-creator", MemberRole.CREATOR) + val recipient = savedMember("historical-recipient") + saveTokens(recipient) + val historical = notificationRepository.saveAndFlush( + PushNotificationList( + senderNicknameSnapshot = "過去の名前", + message = "過去の配信通知", + category = PushNotificationCategory.LIVE, + languageCode = "ja" + ).apply { addRecipientChunk(PushNotificationRecipientChunk(listOf(recipient.id!!))) } + ) + saveAlias(creator, "現在の名前") + creator.nickname = "changed-original" + entityManager.flush() + entityManager.clear() + + val roomId = chatService.createOrGetRoom(creator, recipient.id!!).roomId + chatService.sendTextMessageByWebSocket(creator.id!!, roomId, "새 알림 본문", Lang.EN) + AopTestUtils.getUltimateTargetObject(listener).send(publishedEvent()) + entityManager.flush() + entityManager.clear() + val stored = notificationRepository.findById(historical.id!!).orElseThrow() + + assertEquals(3L, notificationRepository.count()) + assertEquals("過去の名前", stored.senderNicknameSnapshot) + assertEquals("過去の配信通知", stored.message) + } + + @ParameterizedTest + @EnumSource(value = MemberRole::class, names = ["CREATOR", "USER"]) + @DisplayName("실제 오디오 답글은 부모 이름 제목과 행위자 snapshot을 분리하고 콘텐츠 제목 인자를 보존한다") + fun shouldKeepReplyTitleOwnerSeparateFromActualSender(senderRole: MemberRole) { + val parentAuthor = savedMember("parent-author", MemberRole.CREATOR) + val sender = savedMember("reply-sender", senderRole) + saveAlias(parentAuthor, "親コメントの作者") + saveAlias(sender, "返信した作者") + saveTokens(parentAuthor) + val theme = AudioContentTheme("theme", "theme.png") + entityManager.persist(theme) + val content = AudioContent(title = "parent-author", detail = "detail", languageCode = "en").apply { + member = parentAuthor + this.theme = theme + } + entityManager.persist(content) + val parent = AudioContentComment("parent", "en").apply { + member = parentAuthor + audioContent = content + } + entityManager.persist(parent) + entityManager.flush() + + commentService.registerComment(sender, "reply", content.id!!, parent.id, languageCode = "en") + val event = publishedEvent() + AopTestUtils.getUltimateTargetObject(listener).send(event) + val pushes = outgoingPushes() + val snapshots = notificationRepository.findAll().associateBy { it.languageCode } + + assertEquals(parentAuthor.id, event.titleMemberId) + assertEquals(sender.id, event.senderMemberId) + assertEquals("親コメントの作者", pushes.getValue("token-ja").first) + assertEquals("コメントに返信しました: parent-author", pushes.getValue("token-ja").second) + assertEquals("parent-author", pushes.getValue("token-ko").first) + assertEquals("reply-sender", snapshots.getValue("ko").senderNicknameSnapshot) + assertEquals( + if (senderRole == MemberRole.CREATOR) "返信した作者" else "reply-sender", + snapshots.getValue("ja").senderNicknameSnapshot + ) + assertEquals(listOf("parent-author"), event.args) + } + + @Test + @DisplayName("실제 기프트 알림의 자유 제목과 본문은 수신자 별칭이나 토큰 언어로 이름 치환하지 않는다") + fun shouldPreserveGiftTextAndDefaultSenderSnapshotWithoutNameMetadata() { + val recipient = savedMember("운송장 번호를 입력해 주세요", MemberRole.CREATOR) + saveAlias(recipient, "名前ではない通知") + saveTokens(recipient) + + giftPushService.sendTrackingDeadlineReminder("gift-001", recipient.id!!) + val event = publishedEvent() + AopTestUtils.getUltimateTargetObject(listener).send(event) + val pushes = outgoingPushes() + + assertEquals(null, event.senderMemberId) + assertEquals(null, event.titleMemberId) + assertEquals(emptyMap(), event.nameArgumentMemberIds) + assertEquals("운송장 번호를 입력해 주세요", pushes.getValue("token-ja").first) + assertEquals(pushes.getValue("token-ko"), pushes.getValue("token-ja")) + notificationRepository.findAll().forEach { assertEquals(event.title, it.senderNicknameSnapshot) } + } + + private fun publishedEvent(): FcmEvent = applicationEvents.stream(FcmEvent::class.java).toList().single() + + private fun savedMember(nickname: String, role: MemberRole = MemberRole.USER): Member = memberRepository.saveAndFlush( + Member(email = "$nickname@localized-fcm.test", password = "pw", nickname = nickname, role = role) + ) + + private fun saveAlias(member: Member, displayName: String) { + jdbcTemplate.update( + "INSERT INTO creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "VALUES (?, 'JA', ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + displayName + ) + } + + private fun saveTokens(member: Member) { + pushTokenRepository.saveAndFlush(PushToken("token-ja", "aos", "ja-JP").apply { this.member = member }) + pushTokenRepository.saveAndFlush(PushToken("token-ko", "ios", "ko-KR").apply { this.member = member }) + } + + private fun outgoingPushes(): Map> = Mockito.mockingDetails(fcmService).invocations + .filter { it.method.name == "send" } + .associate { invocation -> + val tokens = invocation.getArgument>(0) + tokens.single() to Pair(invocation.getArgument(1), invocation.getArgument(2)) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt index c8235db0..6736b609 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt @@ -71,7 +71,8 @@ class PushNotificationServiceTest { fcmEvent = event, languageCode = "ko", translatedMessage = "테스트 메시지", - recipientPushTokens = pushTokens + recipientPushTokens = pushTokens, + localizedSenderNickname = null ) // then: 수신자 없음 규칙에 따라 저장이 발생하지 않아야 한다. @@ -95,7 +96,8 @@ class PushNotificationServiceTest { fcmEvent = event, languageCode = "ko", translatedMessage = "시스템 알림", - recipientPushTokens = pushTokens + recipientPushTokens = pushTokens, + localizedSenderNickname = null ) // then: SYSTEM 카테고리 보정 결과에 따라 저장이 발생하지 않아야 한다. @@ -124,7 +126,8 @@ class PushNotificationServiceTest { fcmEvent = event, languageCode = "ko", translatedMessage = "라이브가 시작되었습니다.", - recipientPushTokens = pushTokens + recipientPushTokens = pushTokens, + localizedSenderNickname = null ) // then: 보정된 LIVE 카테고리로 저장되어야 한다. @@ -163,7 +166,8 @@ class PushNotificationServiceTest { fcmEvent = event, languageCode = "ko", translatedMessage = "라이브가 시작되었습니다.", - recipientPushTokens = pushTokens + recipientPushTokens = pushTokens, + localizedSenderNickname = null ) // then: 발송자 스냅샷/딥링크/카테고리/언어와 수신자 청크가 정확히 저장되어야 한다. @@ -206,7 +210,8 @@ class PushNotificationServiceTest { fcmEvent = event, languageCode = "ko", translatedMessage = "새 댓글이 등록되었습니다.", - recipientPushTokens = pushTokens + recipientPushTokens = pushTokens, + localizedSenderNickname = null ) val captor = ArgumentCaptor.forClass(PushNotificationList::class.java)