From fd72e75d2d598ae0cfc7c858158e6307d23763e9 Mon Sep 17 00:00:00 2001 From: hod Date: Tue, 26 May 2026 23:00:17 +0900 Subject: [PATCH] Refactor notification scheduling to use NotificationScheduleClock --- .../entity/notification/WorkScheduleTime.kt | 6 +- .../notification/NotificationScheduleClock.kt | 15 +++++ .../notification/NotificationSyncService.kt | 15 +++-- .../NotificationScheduleClockTest.kt | 59 +++++++++++++++++++ 4 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/com/moa/service/notification/NotificationScheduleClock.kt create mode 100644 src/test/kotlin/com/moa/service/notification/NotificationScheduleClockTest.kt diff --git a/src/main/kotlin/com/moa/entity/notification/WorkScheduleTime.kt b/src/main/kotlin/com/moa/entity/notification/WorkScheduleTime.kt index c87f129..a46fe46 100644 --- a/src/main/kotlin/com/moa/entity/notification/WorkScheduleTime.kt +++ b/src/main/kotlin/com/moa/entity/notification/WorkScheduleTime.kt @@ -14,8 +14,10 @@ class WorkScheduleTime private constructor( companion object { fun of(clockIn: LocalTime, clockOut: LocalTime) = WorkScheduleTime( - clockInTime = LocalTime.of(clockIn.hour, clockIn.minute), - clockOutTime = LocalTime.of(clockOut.hour, clockOut.minute), + clockInTime = normalize(clockIn), + clockOutTime = normalize(clockOut), ) + + fun normalize(time: LocalTime): LocalTime = LocalTime.of(time.hour, time.minute) } } diff --git a/src/main/kotlin/com/moa/service/notification/NotificationScheduleClock.kt b/src/main/kotlin/com/moa/service/notification/NotificationScheduleClock.kt new file mode 100644 index 0000000..2c88fa8 --- /dev/null +++ b/src/main/kotlin/com/moa/service/notification/NotificationScheduleClock.kt @@ -0,0 +1,15 @@ +package com.moa.service.notification + +import org.springframework.stereotype.Component +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId + +@Component +class NotificationScheduleClock { + fun now(): LocalDateTime = LocalDateTime.now(ZoneId.of("Asia/Seoul")) + + fun isFutureSchedule(date: LocalDate, time: LocalTime, now: LocalDateTime = now()): Boolean = + date.atTime(time).isAfter(now) +} diff --git a/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt b/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt index 2f4e8e8..538d8c2 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt @@ -10,13 +10,12 @@ import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate -import java.time.LocalDateTime import java.time.LocalTime -import java.time.ZoneId @Service class NotificationSyncService( private val notificationLogRepository: NotificationLogRepository, + private val clock: NotificationScheduleClock, ) { private val log = LoggerFactory.getLogger(javaClass) private val workNotificationTypes = listOf(NotificationType.CLOCK_IN, NotificationType.CLOCK_OUT) @@ -49,7 +48,7 @@ class NotificationSyncService( when (pendingLog.notificationType) { NotificationType.CLOCK_IN -> { clockInTime?.let { - pendingLog.scheduledTime = LocalTime.of(it.hour, it.minute) + pendingLog.scheduledTime = WorkScheduleTime.normalize(it) } pendingLog.scheduledDate = date } @@ -60,7 +59,7 @@ class NotificationSyncService( pendingLog.scheduledDate = schedule.clockOutDate(date) pendingLog.scheduledTime = schedule.clockOutTime } else if (clockOutTime != null) { - pendingLog.scheduledTime = LocalTime.of(clockOutTime.hour, clockOutTime.minute) + pendingLog.scheduledTime = WorkScheduleTime.normalize(clockOutTime) pendingLog.scheduledDate = date } } @@ -75,7 +74,7 @@ class NotificationSyncService( private fun findPendingWorkNotifications(memberId: Long, date: LocalDate): List { val sameDayLogs = notificationLogRepository .findAllByMemberIdAndScheduledDateInAndStatus(memberId, listOf(date), NotificationStatus.PENDING) - .filter { it.notificationType != NotificationType.PAYDAY && it.notificationType != NotificationType.PUBLIC_HOLIDAY } + .filter { it.notificationType in workNotificationTypes } val nextDayClockOutLogs = notificationLogRepository .findAllByMemberIdAndScheduledDateAndNotificationTypeAndStatus( memberId, @@ -98,7 +97,7 @@ class NotificationSyncService( if (type != DailyWorkScheduleType.WORK) return if (clockInTime == null || clockOutTime == null) return - val now = LocalDateTime.now(ZoneId.of("Asia/Seoul")) + val now = clock.now() val schedule = WorkScheduleTime.of(clockInTime, clockOutTime) val clockOutDate = schedule.clockOutDate(date) val relatedDates = listOf(date, date.plusDays(1)) @@ -111,7 +110,7 @@ class NotificationSyncService( val logs = mutableListOf() - if (date.atTime(schedule.clockInTime).isAfter(now) && existingLogs.none { + if (clock.isFutureSchedule(date, schedule.clockInTime, now) && existingLogs.none { it.notificationType == NotificationType.CLOCK_IN && it.scheduledDate == date && it.status != NotificationStatus.CANCELLED @@ -120,7 +119,7 @@ class NotificationSyncService( logs.add(NotificationLog(memberId, NotificationType.CLOCK_IN, date, schedule.clockInTime)) } - if (clockOutDate.atTime(schedule.clockOutTime).isAfter(now) && existingLogs.none { + if (clock.isFutureSchedule(clockOutDate, schedule.clockOutTime, now) && existingLogs.none { it.notificationType == NotificationType.CLOCK_OUT && it.scheduledDate == clockOutDate && it.status != NotificationStatus.CANCELLED diff --git a/src/test/kotlin/com/moa/service/notification/NotificationScheduleClockTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationScheduleClockTest.kt new file mode 100644 index 0000000..56d9cfc --- /dev/null +++ b/src/test/kotlin/com/moa/service/notification/NotificationScheduleClockTest.kt @@ -0,0 +1,59 @@ +package com.moa.service.notification + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId + +class NotificationScheduleClockTest { + + private val sut = NotificationScheduleClock() + + @Test + fun `now 는 Asia_Seoul timezone 기준 현재 시각을 반환한다`() { + val before = LocalDateTime.now(ZoneId.of("Asia/Seoul")) + val now = sut.now() + val after = LocalDateTime.now(ZoneId.of("Asia/Seoul")) + + assertThat(now).isBetween(before, after) + } + + @Test + fun `명시한 now 보다 1분 미래인 스케줄은 future 로 판정한다`() { + val now = LocalDateTime.of(2026, 5, 22, 13, 30) + val date = LocalDate.of(2026, 5, 22) + val time = LocalTime.of(13, 31) + + assertThat(sut.isFutureSchedule(date, time, now)).isTrue() + } + + @Test + fun `명시한 now 보다 1분 과거인 스케줄은 future 가 아니다`() { + val now = LocalDateTime.of(2026, 5, 22, 13, 30) + val date = LocalDate.of(2026, 5, 22) + val time = LocalTime.of(13, 29) + + assertThat(sut.isFutureSchedule(date, time, now)).isFalse() + } + + @Test + fun `명시한 now 와 정확히 같은 시각은 future 가 아니다`() { + // isAfter 는 strict 비교라 동일 시각은 false 가 맞다. + val now = LocalDateTime.of(2026, 5, 22, 13, 30) + val date = LocalDate.of(2026, 5, 22) + val time = LocalTime.of(13, 30) + + assertThat(sut.isFutureSchedule(date, time, now)).isFalse() + } + + @Test + fun `자정 직전 now 에서 다음날 00시 00분 스케줄은 future 로 판정한다`() { + val now = LocalDateTime.of(2026, 5, 22, 23, 59) + val nextDay = LocalDate.of(2026, 5, 23) + val midnight = LocalTime.of(0, 0) + + assertThat(sut.isFutureSchedule(nextDay, midnight, now)).isTrue() + } +}