feat(fcm): 푸시 알림함 저장 및 카테고리 조회를 지원한다

This commit is contained in:
2026-03-11 19:33:07 +09:00
parent f5c3c62e68
commit f69ace570a
23 changed files with 1309 additions and 12 deletions

View File

@@ -0,0 +1,128 @@
package kr.co.vividnext.sodalive.fcm.notification
import kr.co.vividnext.sodalive.common.SodaExceptionHandler
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.data.domain.PageRequest
import org.springframework.data.web.PageableHandlerMethodArgumentResolver
import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
class PushNotificationControllerTest {
private lateinit var pushNotificationService: PushNotificationService
private lateinit var controller: PushNotificationController
private lateinit var mockMvc: MockMvc
@BeforeEach
fun setup() {
pushNotificationService = Mockito.mock(PushNotificationService::class.java)
controller = PushNotificationController(pushNotificationService)
mockMvc = MockMvcBuilders
.standaloneSetup(controller)
.setControllerAdvice(SodaExceptionHandler(LangContext(), SodaMessageSource()))
.setCustomArgumentResolvers(
AuthenticationPrincipalArgumentResolver(),
PageableHandlerMethodArgumentResolver()
)
.build()
}
@Test
fun shouldReturnErrorResponseWhenRequesterIsAnonymous() {
// given/when: 인증 없이 알림 목록 API를 호출한다.
mockMvc.perform(
get("/push/notification/list")
.param("page", "0")
.param("size", "5")
)
// then: 공통 인증 실패 응답이 반환되어야 한다.
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("로그인 정보를 확인해주세요."))
}
@Test
fun shouldForwardPageableAndCategoryToServiceWhenListApiIsCalled() {
// given: 인증 사용자와 서비스 응답을 준비한다.
val member = createMember(id = 8L, role = MemberRole.USER, nickname = "viewer")
val response = GetPushNotificationListResponse(
totalCount = 1L,
items = listOf(
PushNotificationListItem(
id = 10L,
senderNickname = "creator",
senderProfileImage = "https://cdn.test/profile/default-profile.png",
message = "새 알림",
category = "live",
deepLink = "voiceon://live/10",
sentAt = "2026-03-11T10:00:00"
)
)
)
Mockito.`when`(
pushNotificationService.getNotificationList(
member = member,
pageable = PageRequest.of(2, 5),
category = "live"
)
).thenReturn(response)
// when: 컨트롤러 메서드를 직접 호출한다.
val apiResponse = controller.getNotificationList(
member = member,
pageable = PageRequest.of(2, 5),
category = "live"
)
// then: pageable/category/member가 그대로 서비스에 전달되어야 한다.
assertEquals(true, apiResponse.success)
assertEquals(1L, apiResponse.data!!.totalCount)
assertEquals("live", apiResponse.data!!.items[0].category)
Mockito.verify(pushNotificationService).getNotificationList(
member = member,
pageable = PageRequest.of(2, 5),
category = "live"
)
}
@Test
fun shouldForwardMemberToCategoryApiService() {
// given: 인증 사용자와 카테고리 응답을 준비한다.
val member = createMember(id = 21L, role = MemberRole.USER, nickname = "user")
val response = GetPushNotificationCategoryResponse(categories = listOf("live", "content"))
Mockito.`when`(pushNotificationService.getAvailableCategories(member)).thenReturn(response)
// when: 컨트롤러 메서드를 직접 호출한다.
val apiResponse = controller.getAvailableCategories(member)
// then: 서비스 응답이 ApiResponse.ok로 반환되어야 한다.
assertEquals(true, apiResponse.success)
assertEquals(listOf("live", "content"), apiResponse.data!!.categories)
Mockito.verify(pushNotificationService).getAvailableCategories(member)
}
private fun createMember(id: Long, role: MemberRole, nickname: String): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
member.id = id
return member
}
}

View File

@@ -0,0 +1,336 @@
package kr.co.vividnext.sodalive.fcm.notification
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.PushTokenInfo
import kr.co.vividnext.sodalive.fcm.PushTokenRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import java.time.LocalDateTime
import java.util.Optional
class PushNotificationServiceTest {
private lateinit var pushNotificationListRepository: PushNotificationListRepository
private lateinit var pushTokenRepository: PushTokenRepository
private lateinit var memberRepository: MemberRepository
private lateinit var messageSource: SodaMessageSource
private lateinit var langContext: LangContext
private lateinit var service: PushNotificationService
@BeforeEach
fun setup() {
pushNotificationListRepository = Mockito.mock(PushNotificationListRepository::class.java)
pushTokenRepository = Mockito.mock(PushTokenRepository::class.java)
memberRepository = Mockito.mock(MemberRepository::class.java)
messageSource = Mockito.mock(SodaMessageSource::class.java)
langContext = LangContext()
mockPushNotificationCategoryMessages()
service = PushNotificationService(
pushNotificationListRepository = pushNotificationListRepository,
pushTokenRepository = pushTokenRepository,
memberRepository = memberRepository,
messageSource = messageSource,
langContext = langContext,
cloudFrontHost = "https://cdn.test",
serverEnv = "voiceon"
)
}
@Test
fun shouldNotSaveWhenRecipientMemberIdsAreEmpty() {
// given: 언어별 발송 대상 토큰은 있으나 회원 ID 매핑 결과가 비어있는 상황을 준비한다.
val event = FcmEvent(
type = FcmEventType.SEND_MESSAGE,
category = PushNotificationCategory.MESSAGE,
senderMemberId = 10L,
deepLinkValue = FcmDeepLinkValue.CONTENT,
deepLinkId = 77L
)
val pushTokens = listOf(PushTokenInfo(token = "token-1", deviceType = "aos", languageCode = "ko"))
Mockito.`when`(pushTokenRepository.findMemberIdsByTokenIn(listOf("token-1"))).thenReturn(emptyList())
// when: 알림 적재를 실행한다.
service.saveNotification(
fcmEvent = event,
languageCode = "ko",
translatedMessage = "테스트 메시지",
recipientPushTokens = pushTokens
)
// then: 수신자 없음 규칙에 따라 저장이 발생하지 않아야 한다.
Mockito.verify(pushNotificationListRepository, Mockito.never()).save(Mockito.any(PushNotificationList::class.java))
}
@Test
fun shouldSaveChunkedRecipientsAndSenderSnapshotWhenEventIsValid() {
// given: 1001명의 수신자를 가진 유효 이벤트를 준비한다.
val event = FcmEvent(
type = FcmEventType.START_LIVE,
category = PushNotificationCategory.LIVE,
senderMemberId = 500L,
deepLinkValue = FcmDeepLinkValue.LIVE,
deepLinkId = 300L
)
val pushTokens = listOf(
PushTokenInfo(token = "token-a", deviceType = "aos", languageCode = "ko"),
PushTokenInfo(token = "token-b", deviceType = "ios", languageCode = "ko")
)
val recipientMemberIds = (1L..1001L).toList()
val sender = createMember(id = 500L, role = MemberRole.CREATOR, nickname = "creator")
sender.profileImage = "profile/creator.png"
Mockito.`when`(pushTokenRepository.findMemberIdsByTokenIn(listOf("token-a", "token-b")))
.thenReturn(recipientMemberIds)
Mockito.`when`(memberRepository.findById(500L)).thenReturn(Optional.of(sender))
Mockito.`when`(pushNotificationListRepository.save(Mockito.any(PushNotificationList::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
// when: 알림 적재를 실행한다.
service.saveNotification(
fcmEvent = event,
languageCode = "ko",
translatedMessage = "라이브가 시작되었습니다.",
recipientPushTokens = pushTokens
)
// then: 발송자 스냅샷/딥링크/카테고리/언어와 수신자 청크가 정확히 저장되어야 한다.
val captor = ArgumentCaptor.forClass(PushNotificationList::class.java)
Mockito.verify(pushNotificationListRepository).save(captor.capture())
val saved = captor.value
assertEquals("creator", saved.senderNicknameSnapshot)
assertEquals("https://cdn.test/profile/creator.png", saved.senderProfileImageSnapshot)
assertEquals("라이브가 시작되었습니다.", saved.message)
assertEquals(PushNotificationCategory.LIVE, saved.category)
assertEquals("voiceon://live/300", saved.deepLink)
assertEquals("ko", saved.languageCode)
assertEquals(3, saved.recipientChunks.size)
assertEquals(500, saved.recipientChunks[0].recipientMemberIds.size)
assertEquals(500, saved.recipientChunks[1].recipientMemberIds.size)
assertEquals(1, saved.recipientChunks[2].recipientMemberIds.size)
}
@Test
fun shouldApplyLanguageAndOptionalCategoryWhenGettingNotificationList() {
// given: 현재 기기 언어를 EN으로 설정하고 목록 조회 결과를 준비한다.
langContext.setLang(Lang.EN)
val member = createMember(id = 7L, role = MemberRole.USER, nickname = "viewer")
val pageable = PageRequest.of(1, 2)
val rows = listOf(
PushNotificationListItem(
id = 100L,
senderNickname = "creator",
senderProfileImage = "https://cdn.test/profile/default-profile.png",
message = "new content",
category = "content",
deepLink = "voiceon://content/1",
sentAt = "2026-03-11T10:00:00"
)
)
Mockito.`when`(
pushNotificationListRepository.getNotificationList(
Mockito.anyLong(),
Mockito.anyString(),
Mockito.isNull(),
anyLocalDateTime(),
anyPageable()
)
).thenReturn(rows)
Mockito.`when`(
pushNotificationListRepository.getNotificationCount(
Mockito.anyLong(),
Mockito.anyString(),
Mockito.isNull(),
anyLocalDateTime()
)
).thenReturn(1L)
// when: 카테고리 미지정 상태로 목록 조회를 실행한다.
val response = service.getNotificationList(member = member, pageable = pageable, category = null)
// then: 언어 필터가 적용되고 전체 카테고리 기준으로 결과가 반환되어야 한다.
assertEquals(1L, response.totalCount)
assertEquals(1, response.items.size)
assertEquals("content", response.items[0].category)
}
@Test
fun shouldThrowWhenCategoryCodeIsInvalid() {
// given: 인증 사용자를 준비한다.
val member = createMember(id = 9L, role = MemberRole.USER, nickname = "member")
// when & then: 정의되지 않은 카테고리 코드는 예외를 발생시켜야 한다.
assertThrows(SodaException::class.java) {
service.getNotificationList(member = member, pageable = PageRequest.of(0, 10), category = "unknown")
}
}
@Test
fun shouldParseLocalizedCategoryLabelsWhenGettingNotificationList() {
// given: 다국어 카테고리 문자열 입력과 빈 조회 결과를 준비한다.
langContext.setLang(Lang.KO)
val member = createMember(id = 30L, role = MemberRole.USER, nickname = "user")
val pageable = PageRequest.of(0, 10)
Mockito.`when`(
pushNotificationListRepository.getNotificationCount(
Mockito.anyLong(),
Mockito.anyString(),
anyCategory(),
anyLocalDateTime()
)
).thenReturn(0L)
Mockito.`when`(
pushNotificationListRepository.getNotificationList(
Mockito.anyLong(),
Mockito.anyString(),
anyCategory(),
anyLocalDateTime(),
anyPageable()
)
).thenReturn(emptyList())
// when: ko/en/ja 카테고리 라벨을 각각 전달해 조회를 실행한다.
listOf("라이브", "Live", "ライブ").forEach { localizedCategory ->
service.getNotificationList(member = member, pageable = pageable, category = localizedCategory)
}
// then: 모두 LIVE 카테고리로 파싱되어 조회되어야 한다.
Mockito.verify(pushNotificationListRepository, Mockito.times(3)).getNotificationCount(
Mockito.anyLong(),
Mockito.anyString(),
Mockito.eq(PushNotificationCategory.LIVE),
anyLocalDateTime()
)
}
@Test
fun shouldTreatLocalizedAllCategoryAsNoFilterWhenGettingNotificationList() {
// given: 다국어 전체 카테고리 입력을 준비한다.
langContext.setLang(Lang.KO)
val member = createMember(id = 40L, role = MemberRole.USER, nickname = "user")
val pageable = PageRequest.of(0, 10)
Mockito.`when`(
pushNotificationListRepository.getNotificationCount(
Mockito.anyLong(),
Mockito.anyString(),
Mockito.isNull(),
anyLocalDateTime()
)
).thenReturn(0L)
Mockito.`when`(
pushNotificationListRepository.getNotificationList(
Mockito.anyLong(),
Mockito.anyString(),
Mockito.isNull(),
anyLocalDateTime(),
anyPageable()
)
).thenReturn(emptyList())
// when: ko/en/ja 전체 라벨로 조회를 실행한다.
listOf("전체", "All", "すべて").forEach { localizedAllCategory ->
service.getNotificationList(member = member, pageable = pageable, category = localizedAllCategory)
}
// then: 카테고리 필터 없이 전체 조회로 처리되어야 한다.
Mockito.verify(pushNotificationListRepository, Mockito.times(3)).getNotificationCount(
Mockito.anyLong(),
Mockito.anyString(),
Mockito.isNull(),
anyLocalDateTime()
)
}
@Test
fun shouldReturnAvailableCategoryLabelsForCurrentLanguage() {
// given: 현재 기기 언어를 JA로 설정하고 카테고리 조회 결과를 준비한다.
langContext.setLang(Lang.JA)
val member = createMember(id = 3L, role = MemberRole.USER, nickname = "user")
Mockito.`when`(
pushNotificationListRepository.getAvailableCategories(
Mockito.anyLong(),
Mockito.anyString(),
anyLocalDateTime()
)
).thenReturn(listOf(PushNotificationCategory.LIVE, PushNotificationCategory.MESSAGE))
Mockito.`when`(messageSource.getMessage("push.notification.category.all", Lang.JA)).thenReturn("すべて")
Mockito.`when`(messageSource.getMessage("push.notification.category.live", Lang.JA)).thenReturn("ライブ")
Mockito.`when`(messageSource.getMessage("push.notification.category.message", Lang.JA)).thenReturn("メッセージ")
// when: 카테고리 조회를 실행한다.
val response = service.getAvailableCategories(member)
// then: 현재 언어 기준 라벨 목록이 반환되어야 한다.
assertEquals(listOf("すべて", "ライブ", "メッセージ"), response.categories)
}
private fun createMember(id: Long, role: MemberRole, nickname: String): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
member.id = id
return member
}
private fun anyLocalDateTime(): LocalDateTime {
Mockito.any(LocalDateTime::class.java)
return LocalDateTime.MIN
}
private fun anyPageable(): Pageable {
Mockito.any(Pageable::class.java)
return PageRequest.of(0, 1)
}
private fun anyCategory(): PushNotificationCategory {
Mockito.any(PushNotificationCategory::class.java)
return PushNotificationCategory.LIVE
}
private fun mockPushNotificationCategoryMessages() {
val messages = mapOf(
"push.notification.category.all" to mapOf(Lang.KO to "전체", Lang.EN to "All", Lang.JA to "すべて"),
"push.notification.category.live" to mapOf(Lang.KO to "라이브", Lang.EN to "Live", Lang.JA to "ライブ"),
"push.notification.category.content" to mapOf(Lang.KO to "콘텐츠", Lang.EN to "Content", Lang.JA to "コンテンツ"),
"push.notification.category.community" to mapOf(Lang.KO to "커뮤니티", Lang.EN to "Community", Lang.JA to "コミュニティ"),
"push.notification.category.message" to mapOf(Lang.KO to "메시지", Lang.EN to "Message", Lang.JA to "メッセージ"),
"push.notification.category.audition" to mapOf(Lang.KO to "오디션", Lang.EN to "Audition", Lang.JA to "オーディション"),
"push.notification.category.system" to mapOf(Lang.KO to "시스템", Lang.EN to "System", Lang.JA to "システム")
)
Mockito.`when`(messageSource.getMessage(Mockito.anyString(), anyLang())).thenAnswer { invocation ->
val key = invocation.getArgument<String>(0)
val lang = invocation.getArgument<Lang>(1)
messages[key]?.get(lang)
}
}
private fun anyLang(): Lang {
Mockito.any(Lang::class.java)
return Lang.KO
}
}