feat(fcm): 푸시 알림함 저장 및 카테고리 조회를 지원한다
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user