diff --git a/src/main/kotlin/com/moa/common/config/MetricsConfig.kt b/src/main/kotlin/com/moa/common/config/MetricsConfig.kt index 20ed987..caf2fb5 100644 --- a/src/main/kotlin/com/moa/common/config/MetricsConfig.kt +++ b/src/main/kotlin/com/moa/common/config/MetricsConfig.kt @@ -1,12 +1,15 @@ package com.moa.common.config +import io.micrometer.core.aop.TimedAspect import io.micrometer.core.instrument.MeterRegistry import org.springframework.beans.factory.annotation.Value import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy @Configuration +@EnableAspectJAutoProxy(proxyTargetClass = true) class MetricsConfig( @Value("\${spring.application.name}") private val appName: String, @Value("\${app.deploy-color}") private val deployColor: String, @@ -20,4 +23,7 @@ class MetricsConfig( "app_version", appVersion, ) } + + @Bean + fun timedAspect(registry: MeterRegistry) = TimedAspect(registry) } diff --git a/src/main/kotlin/com/moa/service/FcmService.kt b/src/main/kotlin/com/moa/service/FcmService.kt index 379824a..8085065 100644 --- a/src/main/kotlin/com/moa/service/FcmService.kt +++ b/src/main/kotlin/com/moa/service/FcmService.kt @@ -3,15 +3,32 @@ package com.moa.service import com.google.firebase.messaging.* import com.moa.repository.FcmTokenRepository import com.moa.service.dto.FcmRequest +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @Service class FcmService( private val fcmTokenRepository: FcmTokenRepository, + private val meterRegistry: MeterRegistry, ) { private val log = LoggerFactory.getLogger(javaClass) + private val sendSuccess: Counter = Counter.builder(METRIC_SEND) + .description("FCM 메시지 단위 결과") + .tag("result", "success") + .register(meterRegistry) + private val sendFailure: Counter = Counter.builder(METRIC_SEND) + .description("FCM 메시지 단위 결과") + .tag("result", "failure") + .register(meterRegistry) + + private fun tokenInvalidated(errorCode: String): Counter = Counter.builder(METRIC_TOKEN_INVALIDATED) + .description("FCM 에러로 자동 삭제된 토큰 수") + .tag("error_code", errorCode) + .register(meterRegistry) + fun sendEach(requests: List): List { if (requests.isEmpty()) return emptyList() val results = ArrayList(requests.size) @@ -20,13 +37,17 @@ class FcmService( val messages = batch.map { buildMessage(it.token, it.data) } val response = FirebaseMessaging.getInstance().sendEach(messages) response.responses.forEachIndexed { i, sendResponse -> - if (!sendResponse.isSuccessful) { + if (sendResponse.isSuccessful) { + sendSuccess.increment() + } else { + sendFailure.increment() handleFcmException(sendResponse.exception, batch[i].token) } results.add(sendResponse.isSuccessful) } } catch (ex: Exception) { log.error("FCM batch send failed for {} messages", batch.size, ex) + sendFailure.increment(batch.size.toDouble()) repeat(batch.size) { results.add(false) } } } @@ -56,17 +77,23 @@ class FcmService( return } when (fcmEx.messagingErrorCode) { - MessagingErrorCode.UNREGISTERED, + MessagingErrorCode.UNREGISTERED -> { + log.warn("유효하지 않은 FCM 토큰 삭제: {}", token) + fcmTokenRepository.deleteByToken(token) + tokenInvalidated("unregistered").increment() + } MessagingErrorCode.INVALID_ARGUMENT -> { log.warn("유효하지 않은 FCM 토큰 삭제: {}", token) fcmTokenRepository.deleteByToken(token) + tokenInvalidated("invalid_argument").increment() } - else -> log.error("FCM 전송 실패: {}", token, fcmEx) } } companion object { private const val MAX_BATCH_SIZE = 500 + private const val METRIC_SEND = "moa.fcm.send" + private const val METRIC_TOKEN_INVALIDATED = "moa.fcm.token_invalidated" } } diff --git a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt index fe0f6ac..4e66d25 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt @@ -7,6 +7,9 @@ import com.moa.repository.NotificationLogRepository import com.moa.service.FcmService import com.moa.service.PublicHolidayService import com.moa.service.dto.FcmRequest +import io.micrometer.core.annotation.Timed +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.time.LocalDate @@ -20,9 +23,22 @@ class NotificationDispatchService( private val fcmService: FcmService, private val notificationMessageBuilder: NotificationMessageBuilder, private val publicHolidayService: PublicHolidayService, + private val meterRegistry: MeterRegistry, ) { private val log = LoggerFactory.getLogger(javaClass) + private fun attemptsCounter(type: String): Counter = Counter.builder(METRIC_ATTEMPTS) + .description("디스패치 시도된 알림의 누적 수 (이전에 PENDING 상태였던 행을 처리할 때마다 증가)") + .tag("notification_type", type) + .register(meterRegistry) + + private fun failedCounter(type: String, reason: String): Counter = Counter.builder(METRIC_FAILED) + .description("발송 실패한 알림 수") + .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 .findAllByScheduledDateAndScheduledTimeLessThanEqualAndStatus( @@ -33,6 +49,8 @@ class NotificationDispatchService( if (pendingLogs.isEmpty()) return log.info("Dispatching {} pending notifications", pendingLogs.size) + pendingLogs.groupingBy { it.notificationType.name }.eachCount() + .forEach { (type, n) -> attemptsCounter(type).increment(n.toDouble()) } val memberIds = pendingLogs.map { it.memberId }.distinct() val tokensByMemberId = fcmTokenRepository.findAllByMemberIdIn(memberIds) @@ -45,9 +63,11 @@ class NotificationDispatchService( val dispatchItems = mutableListOf() for (notification in pendingLogs) { + val typeName = notification.notificationType.name val tokens = tokensByMemberId[notification.memberId].orEmpty() if (tokens.isEmpty()) { notification.status = NotificationStatus.FAILED + failedCounter(typeName, REASON_NO_TOKEN).increment() log.warn("No FCM tokens for member {}, marking as FAILED", notification.memberId) continue } @@ -57,7 +77,13 @@ class NotificationDispatchService( tokens.forEach { dispatchItems.add(DispatchItem(notification, it.token, data)) } } catch (e: Exception) { notification.status = NotificationStatus.FAILED - log.error("Failed to build message for notification {}, member {}", notification.id, notification.memberId, e) + failedCounter(typeName, REASON_BUILD).increment() + log.error( + "Failed to build message for notification {}, member {}", + notification.id, + notification.memberId, + e + ) } } @@ -67,11 +93,22 @@ class NotificationDispatchService( if (success) dispatchItems[i].notification.status = NotificationStatus.SENT } pendingLogs.filter { it.status == NotificationStatus.PENDING } - .forEach { it.status = NotificationStatus.FAILED } + .forEach { + it.status = NotificationStatus.FAILED + failedCounter(it.notificationType.name, REASON_FCM).increment() + } } notificationLogRepository.saveAll(pendingLogs) } + + companion object { + private const val METRIC_ATTEMPTS = "moa.notification.dispatch.attempts" + private const val METRIC_FAILED = "moa.notification.dispatch.failed" + private const val REASON_NO_TOKEN = "no_token" + private const val REASON_BUILD = "build" + private const val REASON_FCM = "fcm" + } } private data class DispatchItem( diff --git a/src/test/kotlin/com/moa/service/notification/NotificationBatchServiceHolidayTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationBatchServiceHolidayTest.kt new file mode 100644 index 0000000..5c258f2 --- /dev/null +++ b/src/test/kotlin/com/moa/service/notification/NotificationBatchServiceHolidayTest.kt @@ -0,0 +1,350 @@ +package com.moa.service.notification + +import com.moa.entity.DailyWorkSchedule +import com.moa.entity.DailyWorkScheduleType +import com.moa.entity.FcmToken +import com.moa.entity.Term +import com.moa.entity.TermAgreement +import com.moa.entity.WorkPolicyVersion +import com.moa.entity.Workday +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationSetting +import com.moa.entity.notification.NotificationStatus +import com.moa.entity.notification.NotificationType +import com.moa.repository.DailyWorkScheduleRepository +import com.moa.repository.FcmTokenRepository +import com.moa.repository.NotificationLogRepository +import com.moa.repository.NotificationSettingRepository +import com.moa.repository.PublicHolidayRepository +import com.moa.repository.TermAgreementRepository +import com.moa.repository.TermRepository +import com.moa.repository.WorkPolicyVersionRepository +import com.moa.service.PublicHolidayService +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalTime + +/** + * 고전파(Classicist) 스타일 단위 테스트. + * + * - 모든 **out-of-process** 의존성(JPA Repository)만 Fake로 대체한다. + * - `PublicHolidayService`, `NotificationEligibilityService` 는 **실물**을 사용한다. + * - 검증은 오직 최종 상태(`notificationLogs` 리스트의 내용)로만 한다 — `verify` 금지. + * + * Fake 저장소(`holidayCalendar`, `workPolicies`, …)가 프로덕션의 MySQL 테이블 역할을 한다. + */ +class NotificationBatchServiceHolidayTest { + + private val holidayCalendar = mutableSetOf() + private val workPolicies = mutableListOf() + private val notificationLogs = mutableListOf() + private val terms = mutableListOf() + private val agreements = mutableListOf() + private val settings = mutableListOf() + private val fcmTokens = mutableListOf() + private val dailySchedules = mutableListOf() + + // ─── Repository = 자료구조의 얇은 어댑터 (Fake 역할) ────────────────────── + private val publicHolidayRepo = mockk().apply { + every { existsByDate(any()) } answers { firstArg() in holidayCalendar } + } + private val workPolicyRepo = mockk().apply { + every { findLatestEffectivePoliciesPerMember(any()) } answers { + val date = firstArg() + workPolicies + .filter { !it.effectiveFrom.isAfter(date) } + .groupBy { it.memberId } + .map { (_, versions) -> versions.maxBy { v -> v.effectiveFrom } } + } + } + private val notificationLogRepo = mockk().apply { + every { saveAll(any>()) } answers { + val added = firstArg>().toList() + notificationLogs += added + added + } + every { + findMemberIdsByScheduledDateAndNotificationTypeAndMemberIdIn(any(), any(), any()) + } answers { + val date = firstArg() + val type = secondArg() + val ids = thirdArg>() + notificationLogs + .filter { it.scheduledDate == date && it.notificationType == type && it.memberId in ids } + .map { it.memberId } + .distinct() + } + } + private val termRepo = mockk().apply { + every { findAll() } answers { terms.toList() } + } + private val agreementRepo = mockk().apply { + every { findAllByMemberIdIn(any()) } answers { + val ids = firstArg>() + agreements.filter { it.memberId in ids } + } + } + private val settingRepo = mockk().apply { + every { findAllByMemberIdIn(any()) } answers { + val ids = firstArg>() + settings.filter { it.memberId in ids } + } + } + private val fcmTokenRepo = mockk().apply { + every { findAllByMemberIdIn(any()) } answers { + val ids = firstArg>() + fcmTokens.filter { it.memberId in ids } + } + } + private val dailyScheduleRepo = mockk().apply { + every { findAllByMemberIdInAndDate(any(), any()) } answers { + val ids = firstArg>() + val date = secondArg() + dailySchedules.filter { it.memberId in ids && it.date == date } + } + } + + // ─── 실물 도메인 서비스 ─────────────────────────────────────────────────── + private val publicHolidayService = PublicHolidayService(publicHolidayRepo) + private val eligibilityService = NotificationEligibilityService( + termRepo, agreementRepo, settingRepo, fcmTokenRepo, dailyScheduleRepo, + ) + private val sut = NotificationBatchService( + workPolicyRepo, notificationLogRepo, eligibilityService, publicHolidayService, + ) + + @BeforeEach + fun `필수 약관 한 건을 공리로 등록해둔다`() { + terms += Term( + code = TOS_CODE, + title = "서비스 이용약관", + required = true, + contentUrl = "https://moa.example/tos", + sortOrder = 1, + ) + } + + // ═════════════════════════════════════════════════════════════════════════ + // 1. 행복 경로 + // ═════════════════════════════════════════════════════════════════════════ + + @Test + fun `공휴일에 세팅·약관·토큰을 모두 갖춘 회원은 09시 PUBLIC_HOLIDAY 로그를 PENDING 상태로 받는다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).hasSize(1) + val log = notificationLogs.first() + assertThat(log.memberId).isEqualTo(1L) + assertThat(log.notificationType).isEqualTo(NotificationType.PUBLIC_HOLIDAY) + assertThat(log.scheduledDate).isEqualTo(신정) + assertThat(log.scheduledTime).isEqualTo(LocalTime.of(9, 0)) + assertThat(log.status).isEqualTo(NotificationStatus.PENDING) + } + + @Test + fun `적격 회원이 여러 명이면 각자 한 건씩 생성된다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + 회원_등록(id = 2L) + 회원_등록(id = 3L) + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs.map { it.memberId }).containsExactlyInAnyOrder(1L, 2L, 3L) + assertThat(notificationLogs).allMatch { it.notificationType == NotificationType.PUBLIC_HOLIDAY } + } + + @Test + fun `비공휴일에는 PUBLIC_HOLIDAY 로그가 생성되지 않는다`() { + val 평일 = LocalDate.of(2026, 3, 10) + 회원_등록(id = 1L, 정책기준일 = 평일) + + sut.generateNotificationsForDate(평일) + + assertThat(notificationLogs.none { it.notificationType == NotificationType.PUBLIC_HOLIDAY }).isTrue() + } + + // ═════════════════════════════════════════════════════════════════════════ + // 2. 제외 조건 (알림을 받지 말아야 하는 회원) + // ═════════════════════════════════════════════════════════════════════════ + + @Test + fun `공휴일이어도 WORK 알림을 꺼둔 회원에게는 로그가 생성되지 않는다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L, 알림_켜짐 = false) + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).isEmpty() + } + + @Test + fun `공휴일이어도 필수 약관에 동의하지 않은 회원에게는 로그가 생성되지 않는다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + agreements.clear() // 약관 동의 철회 + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).isEmpty() + } + + @Test + fun `공휴일이어도 FCM 토큰이 없는 회원에게는 로그가 생성되지 않는다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + fcmTokens.clear() + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).isEmpty() + } + + @Test + fun `공휴일이어도 정책의 effectiveFrom이 미래인 회원은 알림을 받지 않는다`() { + 공휴일로_지정(신정) + workPolicies += WorkPolicyVersion( + memberId = 1L, + effectiveFrom = 신정.plusDays(1), // 정책이 내일부터 유효 + clockInTime = LocalTime.of(9, 0), + clockOutTime = LocalTime.of(18, 0), + workdays = Workday.WEEKDAYS.toMutableSet(), + ) + agreements += TermAgreement(memberId = 1L, termCode = TOS_CODE, agreed = true) + settings += NotificationSetting(memberId = 1L) + fcmTokens += FcmToken(memberId = 1L, token = "t-1") + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).isEmpty() + } + + // ═════════════════════════════════════════════════════════════════════════ + // 3. 멱등성 (배치가 두 번 돌아도 중복 생성되지 않는다) + // ═════════════════════════════════════════════════════════════════════════ + + @Test + fun `같은 공휴일에 두 번 실행해도 회원당 PUBLIC_HOLIDAY 로그는 1건만 존재한다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + + sut.generateNotificationsForDate(신정) + sut.generateNotificationsForDate(신정) + + assertThat( + notificationLogs.count { it.memberId == 1L && it.notificationType == NotificationType.PUBLIC_HOLIDAY }, + ).isEqualTo(1) + } + + @Test + fun `공휴일에 일부 회원만 이미 로그가 있으면 나머지 회원에게만 신규 로그가 생성된다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + 회원_등록(id = 2L) + notificationLogs += NotificationLog( + memberId = 1L, + notificationType = NotificationType.PUBLIC_HOLIDAY, + scheduledDate = 신정, + scheduledTime = LocalTime.of(9, 0), + ) + + sut.generateNotificationsForDate(신정) + + val holidayLogs = notificationLogs.filter { it.notificationType == NotificationType.PUBLIC_HOLIDAY } + assertThat(holidayLogs.map { it.memberId }).containsExactlyInAnyOrder(1L, 2L) + } + + // ═════════════════════════════════════════════════════════════════════════ + // 4. 현재 구현의 의도를 박제하는 "문서화 테스트" + // ═════════════════════════════════════════════════════════════════════════ + + @Test + fun `NotificationSetting row가 아예 없으면 기본 활성화로 간주되어 공휴일 알림이 생성된다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + settings.clear() // isSettingEnabled 의 "null != false → true" 분기 검증 + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).hasSize(1) + } + + @Test + fun `공휴일이 정책상 비근무 요일이어도 PUBLIC_HOLIDAY 로그가 생성된다`() { + // PUBLIC_HOLIDAY 경로는 workdays 를 보지 않는다 (CLOCK_IN 경로와의 의도된 차이) + 공휴일로_지정(신정) + 회원_등록(id = 1L, 근무요일 = setOf(Workday.MON)) + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).hasSize(1) + } + + @Test + fun `공휴일에 휴가가 등록되어 있어도 PUBLIC_HOLIDAY 로그가 생성된다`() { + // PUBLIC_HOLIDAY 경로는 shouldSkipNotification 을 호출하지 않는다 (CLOCK_IN 경로와의 의도된 차이) + 공휴일로_지정(신정) + 회원_등록(id = 1L) + dailySchedules += DailyWorkSchedule( + memberId = 1L, + date = 신정, + type = DailyWorkScheduleType.VACATION, + clockInTime = LocalTime.of(10, 0), + clockOutTime = LocalTime.of(16, 0), + ) + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).hasSize(1) + assertThat(notificationLogs.first().notificationType).isEqualTo(NotificationType.PUBLIC_HOLIDAY) + } + + @Test + fun `FCM 토큰이 여러 개여도 회원당 로그는 1건만 생성된다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + fcmTokens += FcmToken(memberId = 1L, token = "token-1-second-device") + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).hasSize(1) + } + + // ═════════════════════════════════════════════════════════════════════════ + // 도메인 언어 DSL + // ═════════════════════════════════════════════════════════════════════════ + + private fun 공휴일로_지정(date: LocalDate) { + holidayCalendar += date + } + + private fun 회원_등록( + id: Long, + 알림_켜짐: Boolean = true, + 정책기준일: LocalDate = 신정, + 근무요일: Set = Workday.WEEKDAYS, + ) { + workPolicies += WorkPolicyVersion( + memberId = id, + effectiveFrom = 정책기준일.minusDays(30), + clockInTime = LocalTime.of(9, 0), + clockOutTime = LocalTime.of(18, 0), + workdays = 근무요일.toMutableSet(), + ) + agreements += TermAgreement(memberId = id, termCode = TOS_CODE, agreed = true) + settings += NotificationSetting(memberId = id, workNotificationEnabled = 알림_켜짐) + fcmTokens += FcmToken(memberId = id, token = "token-$id") + } + + companion object { + private const val TOS_CODE = "TERMS_OF_SERVICE" + private val 신정: LocalDate = LocalDate.of(2026, 1, 1) + } +} diff --git a/src/test/kotlin/com/moa/service/notification/NotificationDispatchIntegrationTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationDispatchIntegrationTest.kt new file mode 100644 index 0000000..c53c980 --- /dev/null +++ b/src/test/kotlin/com/moa/service/notification/NotificationDispatchIntegrationTest.kt @@ -0,0 +1,89 @@ +package com.moa.service.notification + +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationStatus +import com.moa.entity.notification.NotificationType +import com.moa.repository.NotificationLogRepository +import io.micrometer.core.instrument.MeterRegistry +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.time.LocalDate +import java.time.LocalTime + +/** + * 통합 테스트 + * + * 단위 테스트(`NotificationDispatchServiceMetricsTest`)와 비교: + * - 단위: `SimpleMeterRegistry` + MockK Fake → 빠르고 격리됨, 그러나 Spring 자동 구성을 + * 거치지 않아 common tag(`application/deploy_color/app_version`)가 부착되지 않음. + * - 통합: 실제 Spring 컨텍스트 + 실제 `PrometheusMeterRegistry` + 실제 H2 → 자동 구성된 + * `MeterRegistryCustomizer`까지 포함해 메트릭이 운영에서 보일 모습 그대로 검증. + * + */ +@SpringBootTest +class NotificationDispatchIntegrationTest @Autowired constructor( + private val notificationDispatchService: NotificationDispatchService, + private val notificationLogRepository: NotificationLogRepository, + private val meterRegistry: MeterRegistry, +) { + + @AfterEach + fun cleanup() { + notificationLogRepository.deleteAll() + } + + @Test + fun `통합 - dispatch 호출 후 PrometheusMeterRegistry에 비즈니스 메트릭과 common tag가 함께 기록된다`() { + notificationLogRepository.save( + NotificationLog( + memberId = 1L, + notificationType = NotificationType.CLOCK_IN, + scheduledDate = TODAY, + scheduledTime = NOON.minusHours(1), + status = NotificationStatus.PENDING, + ) + ) + + notificationDispatchService.processNotifications(TODAY, NOON) + + val attempts = meterRegistry.find("moa.notification.dispatch.attempts") + .tag("notification_type", "CLOCK_IN").counter() + val failed = meterRegistry.find("moa.notification.dispatch.failed") + .tag("notification_type", "CLOCK_IN").tag("reason", "no_token").counter() + + assertThat(attempts?.count()).isEqualTo(1.0) + assertThat(failed?.count()).isEqualTo(1.0) + + // common tag 부착 검증 — `MetricsConfig`의 `MeterRegistryCustomizer`가 + // 모든 신규 메트릭에 application/deploy_color/app_version 을 자동으로 붙이는지 확인. + // + // 키 존재만으론 부족하다 — `app_version=""` 처럼 *값이 비어있는* 회귀를 못 잡는다. + // 값까지 검증 + 두 메트릭 간 공통 태그 일관성까지 비교해서 customizer가 *부분만* + // 적용되는 깨진 상태를 잡는다. + val attemptsTags = attempts?.id?.tags.orEmpty().associate { it.key to it.value } + val failedTags = failed?.id?.tags.orEmpty().associate { it.key to it.value } + + // 분기 태그 — 비즈니스 코드가 직접 붙인 태그가 정확히 흘러갔는지 + assertThat(attemptsTags["notification_type"]).isEqualTo("CLOCK_IN") + assertThat(failedTags["notification_type"]).isEqualTo("CLOCK_IN") + assertThat(failedTags["reason"]).isEqualTo("no_token") + + // common tag — 값이 비어있지 않은지 + assertThat(attemptsTags["application"]).isNotNull().isNotBlank() + assertThat(attemptsTags["deploy_color"]).isNotNull().isNotBlank() + assertThat(attemptsTags["app_version"]).isNotNull().isNotBlank() + + // common tag 일관성 — customizer가 두 메트릭에 *같은 값*을 붙였는지 + assertThat(failedTags["application"]).isEqualTo(attemptsTags["application"]) + assertThat(failedTags["deploy_color"]).isEqualTo(attemptsTags["deploy_color"]) + assertThat(failedTags["app_version"]).isEqualTo(attemptsTags["app_version"]) + } + + companion object { + private val TODAY: LocalDate = LocalDate.of(2026, 5, 1) + private val NOON: LocalTime = LocalTime.NOON + } +} diff --git a/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt new file mode 100644 index 0000000..3a8e618 --- /dev/null +++ b/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt @@ -0,0 +1,142 @@ +package com.moa.service.notification + +import com.moa.entity.FcmToken +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationStatus +import com.moa.entity.notification.NotificationType +import com.moa.repository.FcmTokenRepository +import com.moa.repository.NotificationLogRepository +import com.moa.service.FcmService +import com.moa.service.PublicHolidayService +import com.moa.service.dto.FcmRequest +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalTime + +/** + * Step 5 — 비즈니스 메트릭 단위 테스트. + * + * - Classicist 스타일: out-of-process 의존성(Repository, FcmService, MessageBuilder)만 mock, + * `SimpleMeterRegistry`는 실물 사용. + * - 단언은 메트릭 레지스트리의 최종 상태로만. + */ +class NotificationDispatchServiceMetricsTest { + + private val notificationLogs = mutableListOf() + private val fcmTokens = mutableListOf() + + private val notificationLogRepo = mockk().apply { + every { + findAllByScheduledDateAndScheduledTimeLessThanEqualAndStatus(any(), any(), any()) + } answers { + val date = firstArg() + val time = secondArg() + val status = thirdArg() + notificationLogs.filter { + it.scheduledDate == date && !it.scheduledTime.isAfter(time) && it.status == status + } + } + every { saveAll(any>()) } answers { + firstArg>().toList() + } + } + private val fcmTokenRepo = mockk().apply { + every { findAllByMemberIdIn(any()) } answers { + val ids = firstArg>() + fcmTokens.filter { it.memberId in ids } + } + } + private val fcmService = mockk().apply { + every { sendEach(any()) } answers { + val reqs = firstArg>() + List(reqs.size) { true } + } + } + private val publicHolidayService = mockk().apply { + every { getHolidayDatesForMonth(any(), any()) } returns emptySet() + } + private val messageBuilder = mockk().apply { + every { buildMessage(any(), any()) } answers { + val n = firstArg() + NotificationMessage("title", "body", n.notificationType) + } + } + private val registry = SimpleMeterRegistry() + + private val sut = NotificationDispatchService( + notificationLogRepo, + fcmTokenRepo, + fcmService, + messageBuilder, + publicHolidayService, + registry, + ) + + @BeforeEach + fun reset() { + notificationLogs.clear() + fcmTokens.clear() + } + + @Test + fun `토큰 없는 회원의 알림은 failed_no_token 카운터를 증가시킨다`() { + notificationLogs += notificationLog(memberId = 1L, type = NotificationType.CLOCK_IN) + + sut.processNotifications(TODAY, NOON) + + val attempts = registry.find("moa.notification.dispatch.attempts") + .tag("notification_type", "CLOCK_IN").counter() + val failed = registry.find("moa.notification.dispatch.failed") + .tag("notification_type", "CLOCK_IN").tag("reason", "no_token").counter() + + assertThat(attempts?.count()).isEqualTo(1.0) + assertThat(failed?.count()).isEqualTo(1.0) + } + + @Test + fun `메시지 빌드 실패는 failed 카운터를 reason=build 태그로 증가시킨다`() { + notificationLogs += notificationLog(memberId = 1L, type = NotificationType.CLOCK_OUT) + fcmTokens += FcmToken(memberId = 1L, token = "tok-1") + every { messageBuilder.buildMessage(any(), any()) } throws IllegalStateException("boom") + + sut.processNotifications(TODAY, NOON) + + val failed = registry.find("moa.notification.dispatch.failed") + .tag("notification_type", "CLOCK_OUT").tag("reason", "build").counter() + assertThat(failed?.count()).isEqualTo(1.0) + } + + @Test + fun `FCM 결과가 false인 알림은 failed 카운터를 reason=fcm 태그로 증가시킨다`() { + notificationLogs += notificationLog(memberId = 1L, type = NotificationType.PAYDAY) + fcmTokens += FcmToken(memberId = 1L, token = "tok-1") + every { fcmService.sendEach(any()) } answers { + val reqs = firstArg>() + List(reqs.size) { false } + } + + sut.processNotifications(TODAY, NOON) + + val failed = registry.find("moa.notification.dispatch.failed") + .tag("notification_type", "PAYDAY").tag("reason", "fcm").counter() + assertThat(failed?.count()).isEqualTo(1.0) + } + + private fun notificationLog(memberId: Long, type: NotificationType) = NotificationLog( + memberId = memberId, + notificationType = type, + scheduledDate = TODAY, + scheduledTime = NOON.minusHours(1), + status = NotificationStatus.PENDING, + ) + + companion object { + private val TODAY: LocalDate = LocalDate.of(2026, 5, 1) + private val NOON: LocalTime = LocalTime.NOON + } +} diff --git a/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt b/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt new file mode 100644 index 0000000..3ab2893 --- /dev/null +++ b/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt @@ -0,0 +1,149 @@ +package com.moa.service.notification + +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationStatus +import com.moa.entity.notification.NotificationType +import com.moa.repository.NotificationLogRepository +import io.micrometer.core.instrument.MeterRegistry +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.web.client.RestClient +import java.time.LocalDate +import java.time.LocalTime + +/** + * End-to-End 테스트. + * + * NotificationDispatchScheduler 빈을 mock으로 교체한 이유: + * `@SpringBootTest`는 컴포넌트 스캔을 모두 수행해서 Scheduler가 cron으로 살아있다. + * 그 cron이 분 경계에서 실제 `processNotifications`를 호출하면, 본 테스트가 + * 명시 호출을 안 하고도 Timer가 노출되어 false positive로 통과할 수 있다. + * noop mock으로 교체해 *명시 호출만 측정*되도록 격리. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "management.endpoints.web.exposure.include=health,info,metrics,prometheus", + "management.endpoints.access.default=read_only", + "management.prometheus.metrics.export.enabled=true", + "spring.main.allow-bean-definition-overriding=true", + ], +) +@Import(PrometheusEndpointE2eTest.NoopSchedulerConfig::class) +class PrometheusEndpointE2eTest @Autowired constructor( + @LocalServerPort private val port: Int, + private val notificationDispatchService: NotificationDispatchService, + private val notificationLogRepository: NotificationLogRepository, + private val meterRegistry: MeterRegistry, + private val applicationContext: ApplicationContext, + private val dispatchScheduler: NotificationDispatchScheduler, +) { + + private val httpClient = RestClient.create() + + @AfterEach + fun cleanup() { + notificationLogRepository.deleteAll() + meterRegistry.clear() + } + + @Test + fun `Scheduler 빈이 진짜로 mock으로 교체됐는지 — bean override 검증`() { + val schedulerBeans = applicationContext.getBeansOfType(NotificationDispatchScheduler::class.java) + assertThat(schedulerBeans).hasSize(1) + assertThat(schedulerBeans.keys).containsExactly("notificationDispatchScheduler") + + dispatchScheduler.dispatchPendingNotifications() + assertThat(meterRegistry.find("moa.notification.dispatch").timer()) + .`as`("mock scheduler 호출은 production processNotifications 를 안 부르므로 Timer 등록도 안 됨") + .isNull() + } + + @Test + fun `E2E - dispatch 후 actuator prometheus 엔드포인트가 신규 메트릭을 텍스트로 노출한다`() { + notificationLogRepository.save( + NotificationLog( + memberId = 1L, + notificationType = NotificationType.CLOCK_IN, + scheduledDate = TODAY, + scheduledTime = NOON.minusHours(1), + status = NotificationStatus.PENDING, + ) + ) + notificationDispatchService.processNotifications(TODAY, NOON) + + val body = httpClient.get() + .uri("http://localhost:$port/actuator/prometheus") + .retrieve() + .body(String::class.java) ?: "" + + // 1) Counter 라인 노출 (Prometheus는 점을 underscore로 변환하고 카운터에 _total 접미사 부착) + assertThat(body).contains("moa_notification_dispatch_attempts_total") + assertThat(body).contains("moa_notification_dispatch_failed_total") + + // 2) Timer — histogram 모드라 bucket 시리즈가 노출되어야 한다. + // server-side `histogram_quantile()`로 정확한 multi-instance/multi-color 집계가 가능. + // summary(percentiles)와 달리 deploy_color 합산 시 수학적으로 valid. + assertThat(body).contains("moa_notification_dispatch_seconds_bucket") + + // 3) 분기별 태그가 그대로 직렬화되어 보이는지 (notification_type, reason) + assertThat(body).contains("notification_type=\"CLOCK_IN\"") + assertThat(body).contains("reason=\"no_token\"") + + // 4) common tag — 비즈니스 메트릭 라인에 *실제로* 부착되었는지 확인. + // `body.contains("application=\"moa\"")`만 단언하면 JVM/HTTP 등 무관한 메트릭에도 + // common tag가 붙어있어 가짜 통과 가능. 비즈니스 라인만 추출해 그 라인이 모두 + // common tag를 가지는지(`allSatisfy`) 확인해야 customizer 회귀를 잡을 수 있다. + val notificationMetricLines = metricLines(body, "moa_notification_dispatch_attempts_total") + + metricLines(body, "moa_notification_dispatch_failed_total") + + metricLines(body, "moa_notification_dispatch_seconds") + assertThat(notificationMetricLines).isNotEmpty + assertThat(notificationMetricLines).allSatisfy { line -> + assertThat(line).contains("application=\"moa\"") + assertThat(line).contains("deploy_color=") + assertThat(line).contains("app_version=") + } + } + + private fun metricLines(body: String, metricPrefix: String): List = + body.lineSequence() + .filter { line -> line.isNotBlank() && !line.startsWith("#") } + .filter { line -> line.startsWith(metricPrefix) } + .toList() + + /** + * NotificationDispatchScheduler 빈을 noop mock으로 *교체*(override) 한다. + * + * 중요: `@Bean` 의 *name* 을 component-scanned 빈과 *동일하게* + * (`notificationDispatchScheduler`) 지정해야 진짜 교체가 일어난다. + * + * 다른 이름으로 정의하고 `@Primary` 만 붙이면 두 빈이 *공존* 하고, + * component-scanned 빈이 그대로 살아있어 `@Scheduled` cron이 여전히 + * 등록된다 — Spring 의 `ScheduledAnnotationBeanPostProcessor` 는 + * 컨텍스트 안의 *모든* 빈을 스캔하기 때문이고, `@Primary` 는 + * *주입 우선순위* 만 결정할 뿐 빈 등록 자체에 개입하지 않는다. + * + * `spring.main.allow-bean-definition-overriding=true` 가 properties 에 + * 활성화돼야 같은 이름의 빈 재정의가 허용된다. + */ + @TestConfiguration + class NoopSchedulerConfig { + @Bean("notificationDispatchScheduler") + fun noopDispatchScheduler(): NotificationDispatchScheduler = + mockk(relaxed = true) + } + + companion object { + private val TODAY: LocalDate = LocalDate.of(2026, 5, 1) + private val NOON: LocalTime = LocalTime.NOON + } +}