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

@@ -8,6 +8,7 @@ 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.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
@@ -92,6 +93,7 @@ class AdminAuditionService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.IN_PROGRESS_AUDITION,
category = PushNotificationCategory.AUDITION,
titleKey = "admin.audition.fcm.title.new",
messageKey = "admin.audition.fcm.message.new",
args = listOf(audition.title),

View File

@@ -16,6 +16,7 @@ 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.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
@@ -329,8 +330,10 @@ class AdminLiveService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CANCEL_LIVE,
category = PushNotificationCategory.LIVE,
title = room.member!!.nickname,
messageKey = "live.room.fcm.message.canceled",
senderMemberId = room.member!!.id,
args = listOf(room.title),
pushTokens = pushTokens,
roomId = room.id,

View File

@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.auth.AuthRepository
@@ -78,6 +79,7 @@ class ChargeEventService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.MESSAGE,
title = chargeEvent.title,
messageKey = "can.charge.event.additional_can_paid",
args = listOf(additionalCan),
@@ -101,6 +103,7 @@ class ChargeEventService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.MESSAGE,
titleKey = "can.charge.event.first_title",
messageKey = "can.charge.event.additional_can_paid",
args = listOf(additionalCan),

View File

@@ -31,6 +31,7 @@ import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
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.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
@@ -460,8 +461,10 @@ class AudioContentService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.CONTENT,
titleKey = "content.notification.upload_complete_title",
message = audioContent.title,
senderMemberId = audioContent.member!!.id,
recipients = listOf(audioContent.member!!.id!!),
isAuth = null,
contentId = contentId,
@@ -476,8 +479,10 @@ class AudioContentService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.UPLOAD_CONTENT,
category = PushNotificationCategory.CONTENT,
title = audioContent.member!!.nickname,
messageKey = "content.notification.uploaded_message",
senderMemberId = audioContent.member!!.id,
args = listOf(audioContent.title),
isAuth = audioContent.isAdult,
contentId = contentId,
@@ -500,8 +505,10 @@ class AudioContentService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.UPLOAD_CONTENT,
category = PushNotificationCategory.CONTENT,
title = audioContent.member!!.nickname,
messageKey = "content.notification.uploaded_message",
senderMemberId = audioContent.member!!.id,
args = listOf(audioContent.title),
isAuth = audioContent.isAdult,
contentId = audioContent.id!!,

View File

@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.order.OrderRepository
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.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
@@ -79,6 +80,7 @@ class AudioContentCommentService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CREATE_CONTENT_COMMENT,
category = PushNotificationCategory.CONTENT,
title = if (parent != null) {
parent.member!!.nickname
} else {
@@ -90,6 +92,7 @@ class AudioContentCommentService(
"content.comment.notification.new"
},
args = listOf(audioContent.title),
senderMemberId = member.id,
contentId = audioContentId,
commentParentId = parentId,
myMemberId = member.id,

View File

@@ -22,6 +22,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommuni
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.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
@@ -666,8 +667,10 @@ class ExplorerService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CHANGE_NOTICE,
category = PushNotificationCategory.COMMUNITY,
title = member.nickname,
messageKey = "explorer.notice.fcm.message",
senderMemberId = member.id,
creatorId = member.id!!,
deepLinkValue = FcmDeepLinkValue.CHANNEL,
deepLinkId = member.id!!

View File

@@ -20,6 +20,7 @@ import kr.co.vividnext.sodalive.extensions.getTimeAgoString
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.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
@@ -123,8 +124,10 @@ class CreatorCommunityService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CHANGE_NOTICE,
category = PushNotificationCategory.COMMUNITY,
title = member.nickname,
messageKey = "creator.community.fcm.new_post",
senderMemberId = member.id,
creatorId = member.id!!,
deepLinkValue = FcmDeepLinkValue.COMMUNITY,
deepLinkId = member.id!!

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.fcm
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.transaction.annotation.Transactional
@@ -21,6 +22,7 @@ class FcmController(private val applicationEventPublisher: ApplicationEventPubli
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.SYSTEM,
title = request.title,
isAuth = request.isAuth,
message = request.message,
@@ -31,6 +33,7 @@ class FcmController(private val applicationEventPublisher: ApplicationEventPubli
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.ALL,
category = PushNotificationCategory.SYSTEM,
title = request.title,
message = request.message,
isAuth = request.isAuth

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.fcm
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationService
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.room.GenderRestriction
@@ -27,10 +29,14 @@ enum class FcmDeepLinkValue(val value: String) {
class FcmEvent(
val type: FcmEventType,
val category: PushNotificationCategory? = null,
val title: String = "",
val message: String = "",
val titleKey: String? = null,
val messageKey: String? = null,
val senderMemberId: Long? = null,
val senderNicknameSnapshot: String? = null,
val senderProfileImageSnapshot: String? = null,
val args: List<Any> = listOf(),
val container: String = "",
val recipients: List<Long> = listOf(),
@@ -54,7 +60,8 @@ class FcmSendListener(
private val pushService: FcmService,
private val memberRepository: MemberRepository,
private val contentCommentRepository: AudioContentCommentRepository,
private val messageSource: SodaMessageSource
private val messageSource: SodaMessageSource,
private val pushNotificationService: PushNotificationService
) {
@Async
@TransactionalEventListener
@@ -165,6 +172,13 @@ class FcmSendListener(
val title = translate(fcmEvent.titleKey, fcmEvent.title, lang, fcmEvent.args)
val message = translate(fcmEvent.messageKey, fcmEvent.message, lang, fcmEvent.args)
pushNotificationService.saveNotification(
fcmEvent = fcmEvent,
languageCode = lang.code,
translatedMessage = message,
recipientPushTokens = tokens
)
val tokensByOS = tokens.groupBy { it.deviceType }
for ((os, osTokens) in tokensByOS) {
osTokens.map { it.token }.distinct().chunked(500).forEach { batch ->

View File

@@ -128,17 +128,7 @@ class FcmService(
}
private fun createDeepLink(deepLinkValue: FcmDeepLinkValue?, deepLinkId: Long?): String? {
if (deepLinkValue == null || deepLinkId == null) {
return null
}
val uriScheme = if (serverEnv.equals("voiceon", ignoreCase = true)) {
"voiceon"
} else {
"voiceon-test"
}
return "$uriScheme://${deepLinkValue.value}/$deepLinkId"
return buildDeepLink(serverEnv, deepLinkValue, deepLinkId)
}
fun sendPointGranted(tokens: List<String>, point: Int) {
@@ -206,4 +196,24 @@ class FcmService(
logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets")
}
}
companion object {
fun buildDeepLink(
serverEnv: String,
deepLinkValue: FcmDeepLinkValue?,
deepLinkId: Long?
): String? {
if (deepLinkValue == null || deepLinkId == null) {
return null
}
val uriScheme = if (serverEnv.equals("voiceon", ignoreCase = true)) {
"voiceon"
} else {
"voiceon-test"
}
return "$uriScheme://${deepLinkValue.value}/$deepLinkId"
}
}
}

View File

@@ -10,6 +10,7 @@ interface PushTokenQueryRepository {
fun findByToken(token: String): PushToken?
fun findByMemberId(memberId: Long): List<PushToken>
fun findByMemberIds(memberIds: List<Long>): List<PushToken>
fun findMemberIdsByTokenIn(tokens: List<String>): List<Long>
}
class PushTokenQueryRepositoryImpl(
@@ -36,4 +37,19 @@ class PushTokenQueryRepositoryImpl(
.where(pushToken.member.id.`in`(memberIds))
.fetch()
}
override fun findMemberIdsByTokenIn(tokens: List<String>): List<Long> {
if (tokens.isEmpty()) return emptyList()
return queryFactory
.select(pushToken.member.id)
.from(pushToken)
.where(
pushToken.token.`in`(tokens)
.and(pushToken.member.id.isNotNull)
)
.fetch()
.filterNotNull()
.distinct()
}
}

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.fcm.notification
enum class PushNotificationCategory(val code: String) {
LIVE("live"),
CONTENT("content"),
COMMUNITY("community"),
MESSAGE("message"),
AUDITION("audition"),
SYSTEM("system");
companion object {
fun fromCode(code: String): PushNotificationCategory? {
return values().find { it.code == code.lowercase() }
}
}
}

View File

@@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.fcm.notification
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/push/notification")
class PushNotificationController(
private val pushNotificationService: PushNotificationService
) {
@GetMapping("/list")
fun getNotificationList(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable,
@RequestParam("category", required = false) category: String?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(
pushNotificationService.getNotificationList(
member = member,
pageable = pageable,
category = category
)
)
}
@GetMapping("/categories")
fun getAvailableCategories(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(pushNotificationService.getAvailableCategories(member))
}
}

View File

@@ -0,0 +1,78 @@
package kr.co.vividnext.sodalive.fcm.notification
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.AttributeConverter
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Convert
import javax.persistence.Converter
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
import javax.persistence.Table
@Entity
@Table(name = "push_notification_list")
class PushNotificationList(
@Column(nullable = false)
var senderNicknameSnapshot: String,
@Column(nullable = true)
var senderProfileImageSnapshot: String? = null,
@Column(columnDefinition = "TEXT", nullable = false)
var message: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
var category: PushNotificationCategory,
@Column(nullable = true)
var deepLink: String? = null,
@Column(nullable = false, length = 8)
var languageCode: String
) : BaseEntity() {
@OneToMany(mappedBy = "notification", cascade = [CascadeType.ALL], orphanRemoval = true)
val recipientChunks: MutableList<PushNotificationRecipientChunk> = mutableListOf()
fun addRecipientChunk(chunk: PushNotificationRecipientChunk) {
chunk.notification = this
recipientChunks.add(chunk)
}
}
@Entity
@Table(name = "push_notification_recipient_chunk")
class PushNotificationRecipientChunk(
@Column(columnDefinition = "json", nullable = false)
@Convert(converter = PushNotificationRecipientChunkConverter::class)
var recipientMemberIds: List<Long>
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "notification_id", nullable = false)
var notification: PushNotificationList? = null
}
@Converter(autoApply = false)
class PushNotificationRecipientChunkConverter : AttributeConverter<List<Long>, String> {
override fun convertToDatabaseColumn(attribute: List<Long>?): String {
if (attribute == null) return "[]"
return objectMapper.writeValueAsString(attribute)
}
override fun convertToEntityAttribute(dbData: String?): List<Long> {
if (dbData.isNullOrBlank()) return emptyList()
return objectMapper.readValue(dbData)
}
companion object {
private val objectMapper = jacksonObjectMapper()
}
}

View File

@@ -0,0 +1,135 @@
package kr.co.vividnext.sodalive.fcm.notification
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.JPAExpressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.fcm.notification.QPushNotificationList.pushNotificationList
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
interface PushNotificationListRepository : JpaRepository<PushNotificationList, Long>, PushNotificationListQueryRepository
interface PushNotificationListQueryRepository {
fun getNotificationList(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime,
pageable: Pageable
): List<PushNotificationListItem>
fun getNotificationCount(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime
): Long
fun getAvailableCategories(
memberId: Long,
languageCode: String,
fromDateTime: LocalDateTime
): List<PushNotificationCategory>
}
@Repository
class PushNotificationListQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : PushNotificationListQueryRepository {
override fun getNotificationList(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime,
pageable: Pageable
): List<PushNotificationListItem> {
return queryFactory
.select(
QPushNotificationListItem(
pushNotificationList.id,
pushNotificationList.senderNicknameSnapshot,
pushNotificationList.senderProfileImageSnapshot,
pushNotificationList.message,
pushNotificationList.category,
pushNotificationList.deepLink,
pushNotificationList.createdAt
)
)
.from(pushNotificationList)
.where(
pushNotificationList.languageCode.eq(languageCode),
pushNotificationList.createdAt.goe(fromDateTime),
recipientContainsMember(memberId),
categoryEq(category)
)
.orderBy(pushNotificationList.createdAt.desc(), pushNotificationList.id.desc())
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.fetch()
}
override fun getNotificationCount(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime
): Long {
return queryFactory
.select(pushNotificationList.id.count())
.from(pushNotificationList)
.where(
pushNotificationList.languageCode.eq(languageCode),
pushNotificationList.createdAt.goe(fromDateTime),
recipientContainsMember(memberId),
categoryEq(category)
)
.fetchOne() ?: 0L
}
override fun getAvailableCategories(
memberId: Long,
languageCode: String,
fromDateTime: LocalDateTime
): List<PushNotificationCategory> {
return queryFactory
.select(pushNotificationList.category)
.distinct()
.from(pushNotificationList)
.where(
pushNotificationList.languageCode.eq(languageCode),
pushNotificationList.createdAt.goe(fromDateTime),
recipientContainsMember(memberId)
)
.fetch()
}
private fun categoryEq(category: PushNotificationCategory?): BooleanExpression? {
return if (category == null) {
null
} else {
pushNotificationList.category.eq(category)
}
}
private fun recipientContainsMember(memberId: Long): BooleanExpression {
val recipientChunk = QPushNotificationRecipientChunk("recipientChunk")
return JPAExpressions
.selectOne()
.from(recipientChunk)
.where(
recipientChunk.notification.id.eq(pushNotificationList.id)
.and(
Expressions.booleanTemplate(
"JSON_CONTAINS({0}, JSON_ARRAY({1}), '$')",
recipientChunk.recipientMemberIds,
memberId
)
)
)
.exists()
}
}

View File

@@ -0,0 +1,46 @@
package kr.co.vividnext.sodalive.fcm.notification
import com.querydsl.core.annotations.QueryProjection
import java.time.LocalDateTime
import java.time.ZoneId
data class GetPushNotificationListResponse(
val totalCount: Long,
val items: List<PushNotificationListItem>
)
data class PushNotificationListItem(
val id: Long,
val senderNickname: String,
val senderProfileImage: String?,
val message: String,
val category: String,
val deepLink: String?,
val sentAt: String
) {
@QueryProjection
constructor(
id: Long,
senderNickname: String,
senderProfileImage: String?,
message: String,
category: PushNotificationCategory,
deepLink: String?,
sentAt: LocalDateTime
) : this(
id = id,
senderNickname = senderNickname,
senderProfileImage = senderProfileImage,
message = message,
category = category.code,
deepLink = deepLink,
sentAt = sentAt
.atZone(ZoneId.of("UTC"))
.toInstant()
.toString()
)
}
data class GetPushNotificationCategoryResponse(
val categories: List<String>
)

View File

@@ -0,0 +1,218 @@
package kr.co.vividnext.sodalive.fcm.notification
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.FcmService
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 org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneOffset
@Service
@Transactional(readOnly = true)
class PushNotificationService(
private val pushNotificationListRepository: PushNotificationListRepository,
private val pushTokenRepository: PushTokenRepository,
private val memberRepository: MemberRepository,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String,
@Value("\${server.env}")
private val serverEnv: String
) {
@Transactional
fun saveNotification(
fcmEvent: FcmEvent,
languageCode: String,
translatedMessage: String,
recipientPushTokens: List<PushTokenInfo>
) {
if (recipientPushTokens.isEmpty()) return
val recipientMemberIds = pushTokenRepository
.findMemberIdsByTokenIn(recipientPushTokens.map { it.token })
.distinct()
// 최종 수신자 ID가 없으면 알림 리스트를 저장하지 않는다.
if (recipientMemberIds.isEmpty()) return
val category = resolveCategory(fcmEvent) ?: return
val senderSnapshot = resolveSenderSnapshot(fcmEvent)
val deepLink = FcmService.buildDeepLink(serverEnv, fcmEvent.deepLinkValue, fcmEvent.deepLinkId)
val notification = PushNotificationList(
senderNicknameSnapshot = senderSnapshot.nickname,
senderProfileImageSnapshot = senderSnapshot.profileImage,
message = translatedMessage,
category = category,
deepLink = deepLink,
languageCode = languageCode
)
recipientMemberIds
.chunked(RECIPIENT_CHUNK_SIZE)
.forEach { chunk ->
// 수신자는 JSON 배열 청크 단위로 분할 저장한다.
notification.addRecipientChunk(
PushNotificationRecipientChunk(
recipientMemberIds = chunk
)
)
}
pushNotificationListRepository.save(notification)
}
fun getNotificationList(
member: Member,
pageable: Pageable,
category: String?
): GetPushNotificationListResponse {
val parsedCategory = parseCategory(category)
val languageCode = langContext.lang.code
val fromDateTime = oneMonthAgoUtc()
val totalCount = pushNotificationListRepository.getNotificationCount(
memberId = member.id!!,
languageCode = languageCode,
category = parsedCategory,
fromDateTime = fromDateTime
)
val items = pushNotificationListRepository.getNotificationList(
memberId = member.id!!,
languageCode = languageCode,
category = parsedCategory,
fromDateTime = fromDateTime,
pageable = pageable
)
return GetPushNotificationListResponse(
totalCount = totalCount,
items = items
)
}
fun getAvailableCategories(member: Member): GetPushNotificationCategoryResponse {
val lang = langContext.lang
val localizedCategories = pushNotificationListRepository.getAvailableCategories(
memberId = member.id!!,
languageCode = lang.code,
fromDateTime = oneMonthAgoUtc()
).map { category ->
messageSource.getMessage("push.notification.category.${category.code}", lang) ?: category.code
}
val allCategoryLabel = messageSource.getMessage("push.notification.category.all", lang) ?: "전체"
val categories = listOf(allCategoryLabel) + localizedCategories
return GetPushNotificationCategoryResponse(categories = categories)
}
private fun parseCategory(category: String?): PushNotificationCategory? {
if (category.isNullOrBlank()) return null
val normalizedCategory = category.trim()
if (isAllCategory(normalizedCategory)) {
return null
}
PushNotificationCategory.fromCode(normalizedCategory)?.let { return it }
val parsedCategory = PushNotificationCategory.values().firstOrNull { pushCategory ->
supportedCategoryInputLangs.any { lang ->
val localizedLabel = messageSource.getMessage("push.notification.category.${pushCategory.code}", lang)
localizedLabel.equals(normalizedCategory, ignoreCase = true)
}
}
return parsedCategory ?: throw SodaException(messageKey = "common.error.invalid_request")
}
private fun isAllCategory(category: String): Boolean {
if (category.equals("all", ignoreCase = true)) return true
return supportedCategoryInputLangs.any { lang ->
val localizedLabel = messageSource.getMessage("push.notification.category.all", lang)
localizedLabel.equals(category, ignoreCase = true)
}
}
private fun oneMonthAgoUtc(): LocalDateTime {
return LocalDateTime.now(ZoneOffset.UTC).minusMonths(1)
}
private fun resolveCategory(fcmEvent: FcmEvent): PushNotificationCategory? {
// 이벤트에서 명시한 카테고리가 있으면 우선 사용하고, 없으면 타입 기반 기본값으로 보정한다.
return fcmEvent.category ?: when (fcmEvent.type) {
FcmEventType.CREATE_LIVE,
FcmEventType.START_LIVE,
FcmEventType.CANCEL_LIVE -> PushNotificationCategory.LIVE
FcmEventType.UPLOAD_CONTENT,
FcmEventType.CREATE_CONTENT_COMMENT -> PushNotificationCategory.CONTENT
FcmEventType.CHANGE_NOTICE -> PushNotificationCategory.COMMUNITY
FcmEventType.SEND_MESSAGE -> PushNotificationCategory.MESSAGE
FcmEventType.IN_PROGRESS_AUDITION -> PushNotificationCategory.AUDITION
FcmEventType.ALL,
FcmEventType.INDIVIDUAL -> PushNotificationCategory.SYSTEM
}
}
private fun resolveSenderSnapshot(fcmEvent: FcmEvent): SenderSnapshot {
if (!fcmEvent.senderNicknameSnapshot.isNullOrBlank()) {
return SenderSnapshot(
nickname = fcmEvent.senderNicknameSnapshot,
profileImage = fcmEvent.senderProfileImageSnapshot ?: defaultProfileImageUrl()
)
}
val sender = fcmEvent.senderMemberId?.let { memberRepository.findByIdOrNull(it) }
if (sender != null) {
return SenderSnapshot(
nickname = sender.nickname,
profileImage = toProfileImageUrl(sender.profileImage)
)
}
val fallbackNickname = fcmEvent.title.ifBlank { "" }
return SenderSnapshot(
nickname = fallbackNickname,
profileImage = defaultProfileImageUrl()
)
}
private fun toProfileImageUrl(profileImage: String?): String {
if (profileImage.isNullOrBlank()) return defaultProfileImageUrl()
return "$cloudFrontHost/$profileImage"
}
private fun defaultProfileImageUrl(): String {
return "$cloudFrontHost/profile/default-profile.png"
}
data class SenderSnapshot(
val nickname: String,
val profileImage: String
)
companion object {
private const val RECIPIENT_CHUNK_SIZE = 500
private val supportedCategoryInputLangs = listOf(Lang.KO, Lang.EN, Lang.JA)
}
}

View File

@@ -1114,6 +1114,44 @@ class SodaMessageSource {
)
)
private val pushNotificationMessages = 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 "システム"
)
)
private val noticeMessages = mapOf(
"notice.error.title_required" to mapOf(
Lang.KO to "제목을 입력하세요.",
@@ -2313,6 +2351,7 @@ class SodaMessageSource {
adminPointPolicyMessages,
adminMemberStatisticsMessages,
messageMessages,
pushNotificationMessages,
noticeMessages,
reportMessages,
imageValidationMessages,

View File

@@ -23,6 +23,7 @@ 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.PushTokenRepository
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
@@ -479,12 +480,14 @@ class LiveRoomService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CREATE_LIVE,
category = PushNotificationCategory.LIVE,
title = createdRoom.member!!.nickname,
messageKey = if (createdRoom.channelName != null) {
"live.room.fcm.message.started"
} else {
"live.room.fcm.message.reserved"
},
senderMemberId = createdRoom.member!!.id,
args = listOf(createdRoom.title),
isAuth = createdRoom.isAdult,
isAvailableJoinCreator = createdRoom.isAvailableJoinCreator,
@@ -658,8 +661,10 @@ class LiveRoomService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.START_LIVE,
category = PushNotificationCategory.LIVE,
title = room.member!!.nickname,
messageKey = "live.room.fcm.message.started",
senderMemberId = room.member!!.id,
args = listOf(room.title),
isAuth = room.isAdult,
isAvailableJoinCreator = room.isAvailableJoinCreator,
@@ -731,8 +736,10 @@ class LiveRoomService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CANCEL_LIVE,
category = PushNotificationCategory.LIVE,
title = room.member!!.nickname,
messageKey = "live.room.fcm.message.canceled",
senderMemberId = room.member!!.id,
args = listOf(room.title),
pushTokens = pushTokens,
roomId = room.id,

View File

@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
@@ -72,8 +73,10 @@ class MessageService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.SEND_MESSAGE,
category = PushNotificationCategory.MESSAGE,
titleKey = "message.fcm.title",
messageKey = "message.fcm.text_received",
senderMemberId = sender.id,
args = listOf(sender.nickname),
messageId = message.id
)
@@ -145,8 +148,10 @@ class MessageService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.SEND_MESSAGE,
category = PushNotificationCategory.MESSAGE,
titleKey = "message.fcm.title",
messageKey = "message.fcm.voice_received",
senderMemberId = sender.id,
args = listOf(sender.nickname),
messageId = message.id
)

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
}
}