diff --git a/src/main/kotlin/com/moa/entity/notification/NotificationType.kt b/src/main/kotlin/com/moa/entity/notification/NotificationType.kt index 15a9074..8c1a529 100644 --- a/src/main/kotlin/com/moa/entity/notification/NotificationType.kt +++ b/src/main/kotlin/com/moa/entity/notification/NotificationType.kt @@ -6,8 +6,7 @@ enum class NotificationType( ) { CLOCK_IN("출근 했어요!", "지금부터 급여가 쌓일 예정이에요."), CLOCK_OUT("퇴근 했어요!", "오늘 ₩%s 벌었어요."), - PAYDAY("오늘은 월급날이에요!", "한 달 동안 고생 많으셨어요."), - PUBLIC_HOLIDAY("오늘은 공휴일이에요!", "오늘 하루 푹 쉬세요."); + PAYDAY("오늘은 월급날이에요!", "한 달 동안 고생 많으셨어요."); fun getBody(vararg args: Any): String { return String.format(body, *args) diff --git a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt index 4e66d25..1e7a1e0 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt @@ -38,6 +38,12 @@ class NotificationDispatchService( .tag("reason", reason) .register(meterRegistry) + private fun messageFallbackCounter(type: String, reason: String): Counter = Counter.builder(METRIC_MESSAGE_FALLBACK) + .description("메시지 빌드 시 fallback으로 대체된 알림 수 (FCM 발송 성공 여부와 별개)") + .tag("notification_type", type) + .tag("reason", reason) + .register(meterRegistry) + @Timed(value = "moa.notification.dispatch", histogram = true) fun processNotifications(date: LocalDate, currentTime: LocalTime) { val pendingLogs = notificationLogRepository @@ -73,7 +79,13 @@ class NotificationDispatchService( } try { val publicHolidays = holidaysByMonth[YearMonth.from(notification.scheduledDate)] ?: emptySet() - val data = notificationMessageBuilder.buildMessage(notification, publicHolidays).toData() + val result = notificationMessageBuilder.buildMessage(notification, publicHolidays) + when (result) { + is NotificationMessageBuildResult.Fallback -> + messageFallbackCounter(typeName, result.reason).increment() + is NotificationMessageBuildResult.Normal -> Unit + } + val data = result.message.toData() tokens.forEach { dispatchItems.add(DispatchItem(notification, it.token, data)) } } catch (e: Exception) { notification.status = NotificationStatus.FAILED @@ -105,6 +117,7 @@ class NotificationDispatchService( companion object { private const val METRIC_ATTEMPTS = "moa.notification.dispatch.attempts" private const val METRIC_FAILED = "moa.notification.dispatch.failed" + private const val METRIC_MESSAGE_FALLBACK = "moa.notification.message.fallback" private const val REASON_NO_TOKEN = "no_token" private const val REASON_BUILD = "build" private const val REASON_FCM = "fcm" diff --git a/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt index d751961..2527790 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt @@ -2,6 +2,7 @@ package com.moa.service.notification import com.moa.entity.notification.NotificationLog import com.moa.entity.notification.NotificationType +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.math.BigDecimal import java.math.RoundingMode @@ -13,40 +14,80 @@ import java.util.* class NotificationMessageBuilder( private val notificationEarningsService: NotificationEarningsService, ) { + private val log = LoggerFactory.getLogger(javaClass) fun buildMessage( notification: NotificationLog, publicHolidays: Set, - ): NotificationMessage { + ): NotificationMessageBuildResult { val title = notification.notificationType.title - val body = when (notification.notificationType) { - NotificationType.CLOCK_IN -> notification.notificationType.body - NotificationType.CLOCK_OUT -> buildClockOutBody(notification, publicHolidays) - NotificationType.PAYDAY -> notification.notificationType.body - NotificationType.PUBLIC_HOLIDAY -> notification.notificationType.body + return when (notification.notificationType) { + NotificationType.CLOCK_IN, NotificationType.PAYDAY -> + NotificationMessageBuildResult.Normal( + NotificationMessage(title, notification.notificationType.body, notification.notificationType), + ) + + NotificationType.CLOCK_OUT -> buildClockOutResult(notification, publicHolidays) } - return NotificationMessage(title, body, notification.notificationType) } - private fun buildClockOutBody( + private fun buildClockOutResult( notification: NotificationLog, publicHolidays: Set, - ): String { - val earnings = notificationEarningsService.calculateTodayEarnings( - notification.memberId, - notification.scheduledDate, - publicHolidays, - ) + ): NotificationMessageBuildResult { + val earnings = try { + notificationEarningsService.calculateTodayEarnings( + notification.memberId, + notification.scheduledDate, + publicHolidays, + ) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw e + } catch (e: Exception) { + log.error( + "Fallback message used — earnings calculation failed for notification {}, member {}", + notification.id, + notification.memberId, + e, + ) + return fallbackResult(notification, REASON_EARNINGS_ERROR) + } + if (earnings == BigDecimal.ZERO) { - return CLOCK_OUT_FALLBACK_BODY + log.warn( + "Fallback message used — zero earnings for notification {}, member {}", + notification.id, + notification.memberId, + ) + return fallbackResult(notification, REASON_ZERO_EARNINGS) } + val formatted = NumberFormat.getNumberInstance(Locale.KOREA) .format(earnings.setScale(0, RoundingMode.HALF_UP)) - return notification.notificationType.getBody(formatted) + return NotificationMessageBuildResult.Normal( + NotificationMessage( + notification.notificationType.title, + notification.notificationType.getBody(formatted), + notification.notificationType, + ), + ) } + private fun fallbackResult(notification: NotificationLog, reason: String): NotificationMessageBuildResult.Fallback = + NotificationMessageBuildResult.Fallback( + message = NotificationMessage( + notification.notificationType.title, + CLOCK_OUT_FALLBACK_BODY, + notification.notificationType, + ), + reason = reason, + ) + companion object { private const val CLOCK_OUT_FALLBACK_BODY = "오늘도 수고하셨어요!" + const val REASON_ZERO_EARNINGS = "zero_earnings" + const val REASON_EARNINGS_ERROR = "earnings_error" } } @@ -57,3 +98,16 @@ data class NotificationMessage(val title: String, val body: String, val type: No "type" to type.name, ) } + +sealed class NotificationMessageBuildResult { + abstract val message: NotificationMessage + + data class Normal( + override val message: NotificationMessage, + ) : NotificationMessageBuildResult() + + data class Fallback( + override val message: NotificationMessage, + val reason: String, + ) : NotificationMessageBuildResult() +} diff --git a/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt b/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt index 538d8c2..e093c74 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt @@ -65,7 +65,6 @@ class NotificationSyncService( } NotificationType.PAYDAY -> Unit - NotificationType.PUBLIC_HOLIDAY -> Unit } } log.info("Synced pending notifications for member {} on {}", memberId, date) diff --git a/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt index 3a8e618..9f66076 100644 --- a/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt +++ b/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt @@ -19,11 +19,7 @@ import java.time.LocalDate import java.time.LocalTime /** - * Step 5 — 비즈니스 메트릭 단위 테스트. - * - * - Classicist 스타일: out-of-process 의존성(Repository, FcmService, MessageBuilder)만 mock, - * `SimpleMeterRegistry`는 실물 사용. - * - 단언은 메트릭 레지스트리의 최종 상태로만. + * 비즈니스 메트릭 단위 테스트. */ class NotificationDispatchServiceMetricsTest { @@ -63,7 +59,9 @@ class NotificationDispatchServiceMetricsTest { private val messageBuilder = mockk().apply { every { buildMessage(any(), any()) } answers { val n = firstArg() - NotificationMessage("title", "body", n.notificationType) + NotificationMessageBuildResult.Normal( + NotificationMessage("title", "body", n.notificationType), + ) } } private val registry = SimpleMeterRegistry() @@ -111,6 +109,29 @@ class NotificationDispatchServiceMetricsTest { assertThat(failed?.count()).isEqualTo(1.0) } + @Test + fun `fallback 메시지로 빌드된 알림은 message_fallback 카운터를 증가시키고 정상 발송된다`() { + notificationLogs += notificationLog(memberId = 1L, type = NotificationType.CLOCK_OUT) + fcmTokens += FcmToken(memberId = 1L, token = "tok-1") + every { messageBuilder.buildMessage(any(), any()) } answers { + val n = firstArg() + NotificationMessageBuildResult.Fallback( + NotificationMessage("title", "오늘도 수고하셨어요!", n.notificationType), + reason = NotificationMessageBuilder.REASON_EARNINGS_ERROR, + ) + } + + sut.processNotifications(TODAY, NOON) + + val fallback = registry.find("moa.notification.message.fallback") + .tag("notification_type", "CLOCK_OUT").tag("reason", "earnings_error").counter() + val failed = registry.find("moa.notification.dispatch.failed") + .tag("notification_type", "CLOCK_OUT").counter() + assertThat(fallback?.count()).isEqualTo(1.0) + assertThat(failed).isNull() + assertThat(notificationLogs[0].status).isEqualTo(NotificationStatus.SENT) + } + @Test fun `FCM 결과가 false인 알림은 failed 카운터를 reason=fcm 태그로 증가시킨다`() { notificationLogs += notificationLog(memberId = 1L, type = NotificationType.PAYDAY) diff --git a/src/test/kotlin/com/moa/service/notification/NotificationMessageBuilderTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationMessageBuilderTest.kt new file mode 100644 index 0000000..98c1d93 --- /dev/null +++ b/src/test/kotlin/com/moa/service/notification/NotificationMessageBuilderTest.kt @@ -0,0 +1,90 @@ +package com.moa.service.notification + +import com.moa.common.exception.NotFoundException +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationType +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalTime + +class NotificationMessageBuilderTest { + + private val earningsService = mockk() + private val sut = NotificationMessageBuilder(earningsService) + + @Test + fun `CLOCK_IN 알림은 정적 본문을 반환하고 Normal 결과를 만든다`() { + val result = sut.buildMessage(notificationLog(NotificationType.CLOCK_IN), emptySet()) + + assertThat(result).isInstanceOf(NotificationMessageBuildResult.Normal::class.java) + assertThat(result.message.body).isEqualTo(NotificationType.CLOCK_IN.body) + } + + @Test + fun `PAYDAY 알림은 정적 본문을 반환하고 Normal 결과를 만든다`() { + val result = sut.buildMessage(notificationLog(NotificationType.PAYDAY), emptySet()) + + assertThat(result).isInstanceOf(NotificationMessageBuildResult.Normal::class.java) + assertThat(result.message.body).isEqualTo(NotificationType.PAYDAY.body) + } + + @Test + fun `CLOCK_OUT earnings 계산 성공 시 포맷된 금액 본문의 Normal 결과를 만든다`() { + every { earningsService.calculateTodayEarnings(any(), any(), any()) } returns BigDecimal("123456") + + val result = sut.buildMessage(notificationLog(NotificationType.CLOCK_OUT), emptySet()) + + assertThat(result).isInstanceOf(NotificationMessageBuildResult.Normal::class.java) + assertThat(result.message.body).contains("123,456") + } + + @Test + fun `CLOCK_OUT earnings 가 0원이면 zero_earnings reason의 Fallback 결과를 만든다`() { + every { earningsService.calculateTodayEarnings(any(), any(), any()) } returns BigDecimal.ZERO + + val result = sut.buildMessage(notificationLog(NotificationType.CLOCK_OUT), emptySet()) + + assertThat(result).isInstanceOf(NotificationMessageBuildResult.Fallback::class.java) + val fallback = result as NotificationMessageBuildResult.Fallback + assertThat(fallback.reason).isEqualTo(NotificationMessageBuilder.REASON_ZERO_EARNINGS) + assertThat(fallback.message.body).isEqualTo("오늘도 수고하셨어요!") + } + + @Test + fun `CLOCK_OUT earnings 계산이 예외를 던지면 earnings_error reason의 Fallback 결과를 만든다`() { + every { earningsService.calculateTodayEarnings(any(), any(), any()) } throws NotFoundException() + + val result = sut.buildMessage(notificationLog(NotificationType.CLOCK_OUT), emptySet()) + + assertThat(result).isInstanceOf(NotificationMessageBuildResult.Fallback::class.java) + val fallback = result as NotificationMessageBuildResult.Fallback + assertThat(fallback.reason).isEqualTo(NotificationMessageBuilder.REASON_EARNINGS_ERROR) + assertThat(fallback.message.body).isEqualTo("오늘도 수고하셨어요!") + } + + @Test + fun `CLOCK_OUT earnings 계산이 InterruptedException을 던지면 그대로 전파하고 인터럽트 플래그를 복원한다`() { + every { earningsService.calculateTodayEarnings(any(), any(), any()) } throws InterruptedException("cancel") + + try { + assertThat(Thread.interrupted()).isFalse() // 시작 시 클린한 상태 보장 + org.junit.jupiter.api.assertThrows { + sut.buildMessage(notificationLog(NotificationType.CLOCK_OUT), emptySet()) + } + assertThat(Thread.currentThread().isInterrupted).isTrue() + } finally { + Thread.interrupted() // 다른 테스트에 영향 안 가도록 클리어 + } + } + + private fun notificationLog(type: NotificationType) = NotificationLog( + memberId = 1L, + notificationType = type, + scheduledDate = LocalDate.of(2026, 5, 26), + scheduledTime = LocalTime.NOON, + ) +}