diff --git a/src/main/java/me/pinitnotification/application/notification/ScheduleNotificationService.java b/src/main/java/me/pinitnotification/application/notification/ScheduleNotificationService.java index 7e03cdb..82a355b 100644 --- a/src/main/java/me/pinitnotification/application/notification/ScheduleNotificationService.java +++ b/src/main/java/me/pinitnotification/application/notification/ScheduleNotificationService.java @@ -8,11 +8,14 @@ import me.pinitnotification.domain.notification.UpcomingScheduleNotification; import me.pinitnotification.domain.notification.UpcomingScheduleNotificationRepository; import me.pinitnotification.domain.shared.IdGenerator; +import me.pinitnotification.domain.shared.ScheduleStartTimeFormatter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; + @Service public class ScheduleNotificationService { private static final Logger log = LoggerFactory.getLogger(ScheduleNotificationService.class); @@ -39,7 +42,7 @@ public void handleUpcomingUpdated(UpcomingUpdatedCommand command) { resolveScheduleStartTime(command, existing), command.idempotentKey() ), - () -> notificationRepository.save(buildNotification(command.ownerId(), command.scheduleId(), command.idempotentKey(), toStringValue(command.newUpcomingTime()))) + () -> notificationRepository.save(buildNotification(command.ownerId(), command.scheduleId(), command.idempotentKey(), toInstantValue(command.newUpcomingTime()))) ); } @@ -79,9 +82,11 @@ public void handleScheduleCompleted(ScheduleStateChangedCommand command) { } } - private UpcomingScheduleNotification buildNotification(Long ownerId, Long scheduleId, String idempotentKey, String scheduleStartTimeOverride) { + private UpcomingScheduleNotification buildNotification(Long ownerId, Long scheduleId, String idempotentKey, Instant scheduleStartTimeOverride) { ScheduleBasics basics = scheduleQueryPort.getScheduleBasics(scheduleId, ownerId); - String scheduleStartTime = scheduleStartTimeOverride != null ? scheduleStartTimeOverride : basics.designatedStartTime(); + Instant scheduleStartTime = scheduleStartTimeOverride != null + ? scheduleStartTimeOverride + : ScheduleStartTimeFormatter.parse(basics.designatedStartTime()); return new UpcomingScheduleNotification( idGenerator.generate(), @@ -93,12 +98,12 @@ private UpcomingScheduleNotification buildNotification(Long ownerId, Long schedu ); } - private String toStringValue(java.time.OffsetDateTime dateTime) { - return dateTime == null ? null : dateTime.toString(); + private Instant toInstantValue(java.time.OffsetDateTime dateTime) { + return dateTime == null ? null : dateTime.toInstant(); } - private String resolveScheduleStartTime(UpcomingUpdatedCommand command, UpcomingScheduleNotification existing) { - String newStartTime = toStringValue(command.newUpcomingTime()); + private Instant resolveScheduleStartTime(UpcomingUpdatedCommand command, UpcomingScheduleNotification existing) { + Instant newStartTime = toInstantValue(command.newUpcomingTime()); return newStartTime != null ? newStartTime : existing.getScheduleStartTime(); } } diff --git a/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotification.java b/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotification.java index f616766..a12fd0e 100644 --- a/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotification.java +++ b/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotification.java @@ -1,9 +1,9 @@ package me.pinitnotification.domain.notification; import lombok.Getter; +import me.pinitnotification.domain.shared.ScheduleStartTimeFormatter; -import java.time.OffsetDateTime; -import java.time.format.DateTimeParseException; +import java.time.Instant; import java.util.Map; import java.util.UUID; @@ -13,12 +13,12 @@ public class UpcomingScheduleNotification implements Notification { private Long ownerId; private Long scheduleId; private String scheduleTitle; - private String scheduleStartTime; + private Instant scheduleStartTime; private String idempotentKey; protected UpcomingScheduleNotification() {} - public UpcomingScheduleNotification(UUID id, Long ownerId, Long scheduleId, String scheduleTitle, String scheduleStartTime, String idempotentKey) { + public UpcomingScheduleNotification(UUID id, Long ownerId, Long scheduleId, String scheduleTitle, Instant scheduleStartTime, String idempotentKey) { this.id = id; this.ownerId = ownerId; this.scheduleId = scheduleId; @@ -27,7 +27,7 @@ public UpcomingScheduleNotification(UUID id, Long ownerId, Long scheduleId, Stri this.idempotentKey = idempotentKey; } - public UpcomingScheduleNotification(Long ownerId, Long scheduleId, String scheduleTitle, String scheduleStartTime, String idempotentKey) { + public UpcomingScheduleNotification(Long ownerId, Long scheduleId, String scheduleTitle, Instant scheduleStartTime, String idempotentKey) { this.ownerId = ownerId; this.scheduleId = scheduleId; this.scheduleTitle = scheduleTitle; @@ -39,24 +39,16 @@ public UpcomingScheduleNotification(Long ownerId, Long scheduleId, String schedu public Map getData() { return Map.of("scheduleId", String.valueOf(scheduleId), "scheduleTitle", scheduleTitle, - "scheduleStartTime", scheduleStartTime, + "scheduleStartTime", ScheduleStartTimeFormatter.format(scheduleStartTime), "idempotentKey", idempotentKey); } - public void updateScheduleStartTime(String scheduleStartTime, String idempotentKey) { + public void updateScheduleStartTime(Instant scheduleStartTime, String idempotentKey) { this.scheduleStartTime = scheduleStartTime; this.idempotentKey = idempotentKey; } - public boolean isDue(OffsetDateTime now) { - if (scheduleStartTime == null) { - return false; - } - try { - OffsetDateTime startTime = OffsetDateTime.parse(scheduleStartTime); - return !startTime.isAfter(now); - } catch (DateTimeParseException ex) { - return false; - } + public boolean isDue(Instant now) { + return scheduleStartTime != null && !scheduleStartTime.isAfter(now); } } diff --git a/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationRepository.java b/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationRepository.java index 1f929c1..adf9a76 100644 --- a/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationRepository.java +++ b/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationRepository.java @@ -1,5 +1,6 @@ package me.pinitnotification.domain.notification; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -9,7 +10,7 @@ public interface UpcomingScheduleNotificationRepository { boolean existsByScheduleIdAndOwnerId(Long scheduleId, Long ownerId); UpcomingScheduleNotification save(UpcomingScheduleNotification notification); - void updateScheduleStartTimeAndIdempotentKey(Long scheduleId, Long ownerId, String scheduleStartTime, String idempotentKey); + void updateScheduleStartTimeAndIdempotentKey(Long scheduleId, Long ownerId, Instant scheduleStartTime, String idempotentKey); void deleteByScheduleIdAndOwnerId(Long scheduleId, Long ownerId); void deleteAllInBatch(List notifications); } diff --git a/src/main/java/me/pinitnotification/domain/shared/ScheduleStartTimeFormatter.java b/src/main/java/me/pinitnotification/domain/shared/ScheduleStartTimeFormatter.java new file mode 100644 index 0000000..e23f74b --- /dev/null +++ b/src/main/java/me/pinitnotification/domain/shared/ScheduleStartTimeFormatter.java @@ -0,0 +1,41 @@ +package me.pinitnotification.domain.shared; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; + +public final class ScheduleStartTimeFormatter { + private static final DateTimeFormatter UTC_SECONDS_FORMATTER = DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + .withZone(ZoneOffset.UTC); + + private ScheduleStartTimeFormatter() { + } + + public static String format(Instant instant) { + return UTC_SECONDS_FORMATTER.format(instant.truncatedTo(ChronoUnit.SECONDS)); + } + + public static String format(OffsetDateTime offsetDateTime) { + return format(offsetDateTime.toInstant()); + } + + public static String normalize(String rawScheduleStartTime) { + return format(parse(rawScheduleStartTime)); + } + + public static Instant parse(String rawScheduleStartTime) { + if (rawScheduleStartTime == null || rawScheduleStartTime.isBlank()) { + throw new IllegalArgumentException("scheduleStartTime must not be null or blank"); + } + + try { + return OffsetDateTime.parse(rawScheduleStartTime).toInstant(); + } catch (DateTimeParseException ignored) { + return Instant.parse(rawScheduleStartTime); + } + } +} diff --git a/src/main/java/me/pinitnotification/infrastructure/grpc/ScheduleGrpcClient.java b/src/main/java/me/pinitnotification/infrastructure/grpc/ScheduleGrpcClient.java index 94a6bea..d05891c 100644 --- a/src/main/java/me/pinitnotification/infrastructure/grpc/ScheduleGrpcClient.java +++ b/src/main/java/me/pinitnotification/infrastructure/grpc/ScheduleGrpcClient.java @@ -7,6 +7,7 @@ import me.gg.pinit.pinittask.grpc.ScheduleGrpcServiceGrpc; import me.pinitnotification.application.notification.query.ScheduleBasics; import me.pinitnotification.application.notification.query.ScheduleQueryPort; +import me.pinitnotification.domain.shared.ScheduleStartTimeFormatter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @@ -46,6 +47,6 @@ public ScheduleBasics getScheduleBasics(Long scheduleId, Long ownerId) { private String toIsoString(Timestamp timestamp) { Instant instant = Instant.ofEpochSecond(timestamp.getSeconds(), timestamp.getNanos()); - return instant.toString(); + return ScheduleStartTimeFormatter.format(instant); } } diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapter.java b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapter.java index d57ccb2..090e53b 100644 --- a/src/main/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapter.java +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapter.java @@ -6,6 +6,7 @@ import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Repository; +import java.sql.Timestamp; import java.time.Instant; import java.util.*; @@ -34,16 +35,21 @@ public NotificationDispatchQueryRepositoryAdapter(JdbcClient jdbcClient) { @Override public List findAllDueNotificationsWithTokens(Instant now) { List rows = jdbcClient.sql(FIND_DUE_WITH_TOKENS_SQL) - .param(now.toString()) - .query((rs, rowNum) -> new DispatchRow( - UUID.fromString(rs.getString("public_id")), - rs.getLong("owner_id"), - rs.getLong("schedule_id"), - rs.getString("schedule_title"), - rs.getString("schedule_start_time"), - rs.getString("idempotent_key"), - rs.getString("token") - )) + .param(Timestamp.from(now)) + .query((rs, rowNum) -> { + Timestamp scheduleStartAt = rs.getTimestamp("schedule_start_time"); + Instant scheduleStartTime = scheduleStartAt == null ? null : scheduleStartAt.toInstant(); + + return new DispatchRow( + UUID.fromString(rs.getString("public_id")), + rs.getLong("owner_id"), + rs.getLong("schedule_id"), + rs.getString("schedule_title"), + scheduleStartTime, + rs.getString("idempotent_key"), + rs.getString("token") + ); + }) .list(); if (rows.isEmpty()) { @@ -85,7 +91,7 @@ private record DispatchRow( Long ownerId, Long scheduleId, String scheduleTitle, - String scheduleStartTime, + Instant scheduleStartTime, String idempotentKey, String token ) { diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationEntity.java b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationEntity.java index 14e402f..ecc317e 100644 --- a/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationEntity.java +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationEntity.java @@ -7,6 +7,7 @@ import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +import java.time.Instant; import java.util.UUID; @Entity @@ -35,7 +36,7 @@ public class UpcomingScheduleNotificationEntity { @Column(name = "schedule_title", nullable = false) private String scheduleTitle; @Column(name = "schedule_start_time", nullable = false) - private String scheduleStartTime; + private Instant scheduleStartTime; @Column(name = "idempotent_key", nullable = false) private String idempotentKey; diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepository.java b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepository.java index 59c3013..ea973a1 100644 --- a/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepository.java +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.Instant; import java.util.Optional; import java.util.UUID; @@ -23,7 +24,7 @@ public interface UpcomingScheduleNotificationJpaRepository extends JpaRepository int updateScheduleStartTimeAndIdempotentKey( @Param("scheduleId") Long scheduleId, @Param("ownerId") Long ownerId, - @Param("scheduleStartTime") String scheduleStartTime, + @Param("scheduleStartTime") Instant scheduleStartTime, @Param("idempotentKey") String idempotentKey ); diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapter.java b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapter.java index 443ffaa..33a84c1 100644 --- a/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapter.java +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapter.java @@ -6,6 +6,7 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Repository; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -44,8 +45,13 @@ public UpcomingScheduleNotification save(UpcomingScheduleNotification notificati } @Override - public void updateScheduleStartTimeAndIdempotentKey(Long scheduleId, Long ownerId, String scheduleStartTime, String idempotentKey) { - int updatedRows = jpaRepository.updateScheduleStartTimeAndIdempotentKey(scheduleId, ownerId, scheduleStartTime, idempotentKey); + public void updateScheduleStartTimeAndIdempotentKey(Long scheduleId, Long ownerId, Instant scheduleStartTime, String idempotentKey) { + int updatedRows = jpaRepository.updateScheduleStartTimeAndIdempotentKey( + scheduleId, + ownerId, + scheduleStartTime, + idempotentKey + ); if (updatedRows == 0) { log.debug("Skip updating notification. scheduleId={}, ownerId={} not found", scheduleId, ownerId); } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index bebe196..df62dc3 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -18,10 +18,12 @@ spring: ddl-auto: update # dev: update, create-drop / prod: validate 추천 properties: hibernate: + jdbc: + time_zone: UTC format_sql: true show_sql: true open-in-view: false path: key: - firebase: ${HOME}/pinit/keys/pinit-firebase-key.json \ No newline at end of file + firebase: ${HOME}/pinit/keys/pinit-firebase-key.json diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 48a461c..a15c71b 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -7,6 +7,10 @@ spring: jpa: hibernate: ddl-auto: validate # dev: update, create-drop / prod: validate 추천 + properties: + hibernate: + jdbc: + time_zone: UTC open-in-view: false datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/pinit_notification?characterEncoding=UTF-8 @@ -22,4 +26,4 @@ spring: path: key: - firebase: /etc/keys/pinit-firebase-key.json \ No newline at end of file + firebase: /etc/keys/pinit-firebase-key.json diff --git a/src/test/java/me/pinitnotification/application/notification/NotificationDispatchSchedulerTest.java b/src/test/java/me/pinitnotification/application/notification/NotificationDispatchSchedulerTest.java index 4248b8a..3fd03e9 100644 --- a/src/test/java/me/pinitnotification/application/notification/NotificationDispatchSchedulerTest.java +++ b/src/test/java/me/pinitnotification/application/notification/NotificationDispatchSchedulerTest.java @@ -41,7 +41,9 @@ void setUp() { @Test void dispatchDueNotifications_sendsAndDeletesNotificationsFromQuery() { - UpcomingScheduleNotification notification = new UpcomingScheduleNotification(1L, 10L, "title", "2024-06-01T09:50Z", "key-1"); + UpcomingScheduleNotification notification = new UpcomingScheduleNotification( + 1L, 10L, "title", Instant.parse("2024-06-01T09:50:00Z"), "key-1" + ); when(dispatchQueryRepository.findAllDueNotificationsWithTokens(any())) .thenReturn(List.of(new NotificationDispatchItem(notification, List.of("token-1", "token-2")))); @@ -57,7 +59,9 @@ void dispatchDueNotifications_sendsAndDeletesNotificationsFromQuery() { @Test void dispatchDueNotifications_deletesEvenWhenNoTokens() { - UpcomingScheduleNotification past = new UpcomingScheduleNotification(2L, 20L, "title", "2024-06-01T09:00Z", "key-3"); + UpcomingScheduleNotification past = new UpcomingScheduleNotification( + 2L, 20L, "title", Instant.parse("2024-06-01T09:00:00Z"), "key-3" + ); when(dispatchQueryRepository.findAllDueNotificationsWithTokens(any())) .thenReturn(List.of(new NotificationDispatchItem(past, List.of()))); @@ -71,7 +75,9 @@ void dispatchDueNotifications_deletesEvenWhenNoTokens() { @Test void dispatchDueNotifications_collectsInvalidTokensAndDeletesInBatch() { - UpcomingScheduleNotification notification = new UpcomingScheduleNotification(3L, 30L, "title", "2024-06-01T09:30Z", "key-4"); + UpcomingScheduleNotification notification = new UpcomingScheduleNotification( + 3L, 30L, "title", Instant.parse("2024-06-01T09:30:00Z"), "key-4" + ); when(dispatchQueryRepository.findAllDueNotificationsWithTokens(any())) .thenReturn(List.of(new NotificationDispatchItem(notification, List.of("token-1", "token-2", "token-3")))); diff --git a/src/test/java/me/pinitnotification/application/notification/ScheduleNotificationServiceTest.java b/src/test/java/me/pinitnotification/application/notification/ScheduleNotificationServiceTest.java index cefa177..ec7a43e 100644 --- a/src/test/java/me/pinitnotification/application/notification/ScheduleNotificationServiceTest.java +++ b/src/test/java/me/pinitnotification/application/notification/ScheduleNotificationServiceTest.java @@ -17,6 +17,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.Instant; import java.time.OffsetDateTime; import java.util.Optional; @@ -50,7 +51,9 @@ void resetMocks() { @Test void handleUpcomingUpdated_updatesExistingNotification() { - UpcomingScheduleNotification existing = new UpcomingScheduleNotification(ownerId, scheduleId, "title", "2024-01-01T00:00Z", "old-key"); + UpcomingScheduleNotification existing = new UpcomingScheduleNotification( + ownerId, scheduleId, "title", Instant.parse("2024-01-01T00:00:00Z"), "old-key" + ); when(notificationRepository.findByScheduleIdAndOwnerId(scheduleId, ownerId)).thenReturn(Optional.of(existing)); UpcomingUpdatedCommand command = new UpcomingUpdatedCommand( @@ -62,7 +65,7 @@ void handleUpcomingUpdated_updatesExistingNotification() { verify(notificationRepository).updateScheduleStartTimeAndIdempotentKey( scheduleId, ownerId, - "2024-02-01T12:00Z", + Instant.parse("2024-02-01T12:00:00Z"), "new-key" ); verify(notificationRepository, never()).save(any()); @@ -87,7 +90,7 @@ void handleUpcomingUpdated_createsWhenMissing() { assertThat(saved.getOwnerId()).isEqualTo(ownerId); assertThat(saved.getScheduleId()).isEqualTo(scheduleId); assertThat(saved.getScheduleTitle()).isEqualTo("title"); - assertThat(saved.getScheduleStartTime()).isEqualTo("2024-04-01T09:30Z"); + assertThat(saved.getScheduleStartTime()).isEqualTo(Instant.parse("2024-04-01T09:30:00Z")); assertThat(saved.getIdempotentKey()).isEqualTo("key-123"); } @@ -114,10 +117,28 @@ void handleScheduleCanceled_createsWhenMissing() { verify(notificationRepository).save(notificationCaptor.capture()); UpcomingScheduleNotification saved = notificationCaptor.getValue(); assertThat(saved.getScheduleTitle()).isEqualTo("canceled title"); - assertThat(saved.getScheduleStartTime()).isEqualTo("2024-05-10T10:00:00Z"); + assertThat(saved.getScheduleStartTime()).isEqualTo(Instant.parse("2024-05-10T10:00:00Z")); assertThat(saved.getIdempotentKey()).isEqualTo("cancel-key"); } + @Test + void handleUpcomingUpdated_normalizesOffsetToUtcWhenCreating() { + when(notificationRepository.findByScheduleIdAndOwnerId(scheduleId, ownerId)).thenReturn(Optional.empty()); + when(scheduleQueryPort.getScheduleBasics(scheduleId, ownerId)) + .thenReturn(new ScheduleBasics(scheduleId, ownerId, "title", "2024-03-01T00:00:00Z")); + when(idGenerator.generate()).thenReturn(java.util.UUID.randomUUID()); + + UpcomingUpdatedCommand command = new UpcomingUpdatedCommand( + ownerId, scheduleId, OffsetDateTime.parse("2024-04-01T18:30:00+09:00"), "key-utc" + ); + + scheduleNotificationService.handleUpcomingUpdated(command); + + verify(notificationRepository).save(notificationCaptor.capture()); + UpcomingScheduleNotification saved = notificationCaptor.getValue(); + assertThat(saved.getScheduleStartTime()).isEqualTo(Instant.parse("2024-04-01T09:30:00Z")); + } + @Test void handleScheduleCanceled_skipsWhenExists() { when(notificationRepository.existsByScheduleIdAndOwnerId(scheduleId, ownerId)).thenReturn(true); diff --git a/src/test/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationTest.kt b/src/test/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationTest.kt index b84fa4a..74090ff 100644 --- a/src/test/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationTest.kt +++ b/src/test/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationTest.kt @@ -3,8 +3,8 @@ package me.pinitnotification.domain.notification import org.assertj.core.api.Assertions import org.junit.jupiter.api.Test import java.time.Clock +import java.time.Instant import java.time.LocalDateTime -import java.time.OffsetDateTime import java.time.ZoneId import java.time.ZoneOffset.UTC @@ -26,10 +26,10 @@ internal class UpcomingScheduleNotificationTest { val notification = getSampleUpcomingScheduleNotification() //when - notification.updateScheduleStartTime("2024-06-01T10:00:00Z", "123-idempotency-key") + notification.updateScheduleStartTime(Instant.parse("2024-06-01T10:00:00Z"), "123-idempotency-key") //then - Assertions.assertThat(notification.scheduleStartTime).isEqualTo("2024-06-01T10:00:00Z") + Assertions.assertThat(notification.scheduleStartTime).isEqualTo(Instant.parse("2024-06-01T10:00:00Z")) Assertions.assertThat(notification.idempotentKey).isEqualTo("123-idempotency-key") } @@ -37,13 +37,13 @@ internal class UpcomingScheduleNotificationTest { fun isDue() { //given val notification = getSampleUpcomingScheduleNotification( - scheduleStartTime = "2024-06-01T10:00:00Z" + scheduleStartTime = Instant.parse("2024-06-01T10:00:00Z") ) //when - val isDue = notification.isDue(OffsetDateTime.now(clock)) + val isDue = notification.isDue(Instant.now(clock)) //then Assertions.assertThat(isDue).isTrue() } -} \ No newline at end of file +} diff --git a/src/test/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationUtils.kt b/src/test/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationUtils.kt index 3ae6d0f..ad87f4a 100644 --- a/src/test/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationUtils.kt +++ b/src/test/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationUtils.kt @@ -2,13 +2,14 @@ package me.pinitnotification.domain.notification import me.pinitnotification.infrastructure.persistence.UuidV7Generator import org.junit.platform.commons.util.ReflectionUtils +import java.time.Instant fun getSampleUpcomingScheduleNotification( id: Long = 1L, ownerId: Long = 1L, scheduleId: Long = 1L, scheduleTitle: String = "sample", - scheduleStartTime: String = "2024-06-01T10:00:00Z", + scheduleStartTime: Instant = Instant.parse("2024-06-01T10:00:00Z"), idempotencyKey: String = "", ): UpcomingScheduleNotification { val sample = UpcomingScheduleNotification( @@ -28,4 +29,4 @@ fun getSampleUpcomingScheduleNotification( field.set(sample, id) } return sample -} \ No newline at end of file +} diff --git a/src/test/java/me/pinitnotification/domain/shared/ScheduleStartTimeFormatterTest.java b/src/test/java/me/pinitnotification/domain/shared/ScheduleStartTimeFormatterTest.java new file mode 100644 index 0000000..8cb96b5 --- /dev/null +++ b/src/test/java/me/pinitnotification/domain/shared/ScheduleStartTimeFormatterTest.java @@ -0,0 +1,53 @@ +package me.pinitnotification.domain.shared; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.OffsetDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScheduleStartTimeFormatterTest { + + @Test + void normalize_addsSecondsWhenInputHasNoSeconds() { + String normalized = ScheduleStartTimeFormatter.normalize("2024-06-01T10:00Z"); + + assertThat(normalized).isEqualTo("2024-06-01T10:00:00Z"); + } + + @Test + void normalize_convertsOffsetTimeToUtc() { + String normalized = ScheduleStartTimeFormatter.normalize("2024-06-01T19:00:00+09:00"); + + assertThat(normalized).isEqualTo("2024-06-01T10:00:00Z"); + } + + @Test + void format_formatsInstantWithUtcSeconds() { + String formatted = ScheduleStartTimeFormatter.format(Instant.parse("2024-06-01T10:00:00.123Z")); + + assertThat(formatted).isEqualTo("2024-06-01T10:00:00Z"); + } + + @Test + void format_formatsOffsetDateTimeWithUtcSeconds() { + String formatted = ScheduleStartTimeFormatter.format(OffsetDateTime.parse("2024-06-01T19:00:00+09:00")); + + assertThat(formatted).isEqualTo("2024-06-01T10:00:00Z"); + } + + @Test + void parse_parsesMinutePrecisionIso() { + Instant parsed = ScheduleStartTimeFormatter.parse("2024-06-01T10:00Z"); + + assertThat(parsed).isEqualTo(Instant.parse("2024-06-01T10:00:00Z")); + } + + @Test + void parse_parsesOffsetIso() { + Instant parsed = ScheduleStartTimeFormatter.parse("2024-06-01T19:00:00+09:00"); + + assertThat(parsed).isEqualTo(Instant.parse("2024-06-01T10:00:00Z")); + } +} diff --git a/src/test/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapterTest.java b/src/test/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapterTest.java index d1f75f0..bb03d33 100644 --- a/src/test/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapterTest.java +++ b/src/test/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapterTest.java @@ -65,13 +65,27 @@ void returnsDueNotificationsWithAggregatedTokens() { assertThat(withoutTokens.tokens()).isEmpty(); } + @Test + void returnsNotificationWhenStartTimeEqualsNow() { + notificationJpaRepository.save(notification(1L, 99L, "2024-06-01T10:00:00Z")); + + entityManager.flush(); + entityManager.clear(); + + List results = repository.findAllDueNotificationsWithTokens(Instant.parse("2024-06-01T10:00:00Z")); + + assertThat(results) + .extracting(item -> item.notification().getScheduleId()) + .contains(99L); + } + private UpcomingScheduleNotificationEntity notification(Long ownerId, Long scheduleId, String startTimeIso) { UpcomingScheduleNotificationEntity entity = new UpcomingScheduleNotificationEntity(); entity.setPublicId(UUID.randomUUID()); entity.setOwnerId(ownerId); entity.setScheduleId(scheduleId); entity.setScheduleTitle("title-" + scheduleId); - entity.setScheduleStartTime(startTimeIso); + entity.setScheduleStartTime(Instant.parse(startTimeIso)); entity.setIdempotentKey("key-" + scheduleId); return entity; } diff --git a/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepositoryTest.java b/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepositoryTest.java index afacd3a..d3fc3d7 100644 --- a/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepositoryTest.java +++ b/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepositoryTest.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import java.time.Instant; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -19,7 +20,7 @@ void savesAndFindsByScheduleAndOwner() { entity.setOwnerId(10L); entity.setScheduleId(20L); entity.setScheduleTitle("title"); - entity.setScheduleStartTime("2025-01-01T00:00:00Z"); + entity.setScheduleStartTime(Instant.parse("2025-01-01T00:00:00Z")); entity.setIdempotentKey("key-1"); UpcomingScheduleNotificationEntity saved = repository.save(entity); @@ -39,14 +40,14 @@ void updatesScheduleStartTimeAndIdempotentKey() { entity.setOwnerId(10L); entity.setScheduleId(20L); entity.setScheduleTitle("title"); - entity.setScheduleStartTime("2025-01-01T00:00:00Z"); + entity.setScheduleStartTime(Instant.parse("2025-01-01T00:00:00Z")); entity.setIdempotentKey("key-1"); repository.save(entity); int updatedRows = repository.updateScheduleStartTimeAndIdempotentKey( 20L, 10L, - "2025-01-03T10:30:00Z", + Instant.parse("2025-01-03T10:30:00Z"), "key-2" ); @@ -56,7 +57,7 @@ void updatesScheduleStartTimeAndIdempotentKey() { repository.findByScheduleIdAndOwnerId(20L, 10L); assertThat(loaded).isPresent(); - assertThat(loaded.get().getScheduleStartTime()).isEqualTo("2025-01-03T10:30:00Z"); + assertThat(loaded.get().getScheduleStartTime()).isEqualTo(Instant.parse("2025-01-03T10:30:00Z")); assertThat(loaded.get().getIdempotentKey()).isEqualTo("key-2"); } } diff --git a/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapterTest.java b/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapterTest.java index f82d980..8bf761a 100644 --- a/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapterTest.java +++ b/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapterTest.java @@ -7,6 +7,7 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; +import java.time.Instant; import java.util.Optional; import java.util.UUID; @@ -26,7 +27,7 @@ void savesAndLoadsDomainWithPublicId() { 1L, 2L, "title", - "2025-01-01T00:00:00Z", + Instant.parse("2025-01-01T00:00:00Z"), "key-1" ); @@ -49,17 +50,17 @@ void updatesScheduleStartTimeAndIdempotentKey() { 1L, 2L, "title", - "2025-01-01T00:00:00Z", + Instant.parse("2025-01-01T00:00:00Z"), "key-1" )); - repository.updateScheduleStartTimeAndIdempotentKey(2L, 1L, "2025-01-02T01:00:00Z", "key-2"); + repository.updateScheduleStartTimeAndIdempotentKey(2L, 1L, Instant.parse("2025-01-02T01:00:00Z"), "key-2"); Optional loaded = repository.findByScheduleIdAndOwnerId(2L, 1L); assertThat(loaded).isPresent(); - assertThat(loaded.get().getScheduleStartTime()).isEqualTo("2025-01-02T01:00:00Z"); + assertThat(loaded.get().getScheduleStartTime()).isEqualTo(Instant.parse("2025-01-02T01:00:00Z")); assertThat(loaded.get().getIdempotentKey()).isEqualTo("key-2"); } } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 20d8b4d..8b8d32c 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -3,8 +3,15 @@ task: host: localhost port: 9090 +spring: + jpa: + properties: + hibernate: + jdbc: + time_zone: UTC + path: key: firebase: ${HOME}/pinit/keys/pinit-firebase-key.json jwt: - public: ${HOME}/pinit/keys/jwt-public-key.pem \ No newline at end of file + public: ${HOME}/pinit/keys/jwt-public-key.pem