feat(fcm): 수신 언어별 크리에이터 이름을 알림에 반영한다
This commit is contained in:
@@ -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<FcmSendListener>(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<FcmSendListener>(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<FcmSendListener>(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<FcmSendListener>(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<FcmSendListener>(listener).send(event)
|
||||
val pushes = outgoingPushes()
|
||||
|
||||
assertEquals(null, event.senderMemberId)
|
||||
assertEquals(null, event.titleMemberId)
|
||||
assertEquals(emptyMap<Int, Long>(), 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<String, Pair<String, String>> = Mockito.mockingDetails(fcmService).invocations
|
||||
.filter { it.method.name == "send" }
|
||||
.associate { invocation ->
|
||||
val tokens = invocation.getArgument<List<String>>(0)
|
||||
tokens.single() to Pair(invocation.getArgument<String>(1), invocation.getArgument<String>(2))
|
||||
}
|
||||
}
|
||||
+10
-5
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user