diff --git a/src/main/java/me/pinitnotification/application/notification/NotificationDispatchItem.java b/src/main/java/me/pinitnotification/application/notification/NotificationDispatchItem.java new file mode 100644 index 0000000..51901f1 --- /dev/null +++ b/src/main/java/me/pinitnotification/application/notification/NotificationDispatchItem.java @@ -0,0 +1,11 @@ +package me.pinitnotification.application.notification; + +import me.pinitnotification.domain.notification.UpcomingScheduleNotification; + +import java.util.List; + +public record NotificationDispatchItem( + UpcomingScheduleNotification notification, + List tokens +) { +} diff --git a/src/main/java/me/pinitnotification/application/notification/NotificationDispatchQueryRepository.java b/src/main/java/me/pinitnotification/application/notification/NotificationDispatchQueryRepository.java new file mode 100644 index 0000000..2749de9 --- /dev/null +++ b/src/main/java/me/pinitnotification/application/notification/NotificationDispatchQueryRepository.java @@ -0,0 +1,8 @@ +package me.pinitnotification.application.notification; + +import java.time.Instant; +import java.util.List; + +public interface NotificationDispatchQueryRepository { + List findAllDueNotificationsWithTokens(Instant now); +} diff --git a/src/main/java/me/pinitnotification/application/notification/NotificationDispatchScheduler.java b/src/main/java/me/pinitnotification/application/notification/NotificationDispatchScheduler.java index 7e0daa6..ad49d94 100644 --- a/src/main/java/me/pinitnotification/application/notification/NotificationDispatchScheduler.java +++ b/src/main/java/me/pinitnotification/application/notification/NotificationDispatchScheduler.java @@ -1,10 +1,9 @@ package me.pinitnotification.application.notification; +import me.pinitnotification.application.push.PushSendResult; +import me.pinitnotification.application.push.PushService; import me.pinitnotification.domain.notification.UpcomingScheduleNotification; import me.pinitnotification.domain.notification.UpcomingScheduleNotificationRepository; -import me.pinitnotification.domain.push.PushSubscription; -import me.pinitnotification.domain.push.PushSubscriptionRepository; -import me.pinitnotification.application.push.PushService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -12,25 +11,29 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Clock; -import java.time.OffsetDateTime; -import java.time.format.DateTimeParseException; +import java.time.Instant; +import java.util.HashSet; import java.util.List; +import java.util.Set; @Service public class NotificationDispatchScheduler { private static final Logger log = LoggerFactory.getLogger(NotificationDispatchScheduler.class); private final UpcomingScheduleNotificationRepository notificationRepository; - private final PushSubscriptionRepository pushSubscriptionRepository; + private final NotificationDispatchQueryRepository dispatchQueryRepository; + private final PushTokenCleanupService pushTokenCleanupService; private final PushService pushService; private final Clock clock; public NotificationDispatchScheduler(UpcomingScheduleNotificationRepository notificationRepository, - PushSubscriptionRepository pushSubscriptionRepository, + NotificationDispatchQueryRepository dispatchQueryRepository, + PushTokenCleanupService pushTokenCleanupService, PushService pushService, Clock clock) { this.notificationRepository = notificationRepository; - this.pushSubscriptionRepository = pushSubscriptionRepository; + this.dispatchQueryRepository = dispatchQueryRepository; + this.pushTokenCleanupService = pushTokenCleanupService; this.pushService = pushService; this.clock = clock; } @@ -38,27 +41,45 @@ public NotificationDispatchScheduler(UpcomingScheduleNotificationRepository noti @Scheduled(cron = "0 */10 * * * *") @Transactional public void dispatchDueNotifications() { - OffsetDateTime now = OffsetDateTime.now(clock); - List dueNotifications = notificationRepository.findAll().stream() - .filter(notification -> notification.isDue(now)) - .toList(); + Instant now = Instant.now(clock); + List dispatchItems = dispatchQueryRepository.findAllDueNotificationsWithTokens(now); - if (dueNotifications.isEmpty()) { + if (dispatchItems.isEmpty()) { return; } - dueNotifications.forEach(this::sendNotificationToOwner); - notificationRepository.deleteAllInBatch(dueNotifications); + Set tokensToDelete = new HashSet<>(); + dispatchItems.forEach(item -> sendNotificationToOwner(item, tokensToDelete)); + notificationRepository.deleteAllInBatch(dispatchItems.stream().map(NotificationDispatchItem::notification).toList()); + + if (!tokensToDelete.isEmpty()) { + deleteTokensSafely(tokensToDelete); + } } - private void sendNotificationToOwner(UpcomingScheduleNotification notification) { - List subscriptions = pushSubscriptionRepository.findAllByMemberId(notification.getOwnerId()); - if (subscriptions.isEmpty()) { + private void sendNotificationToOwner(NotificationDispatchItem dispatchItem, Set tokensToDelete) { + UpcomingScheduleNotification notification = dispatchItem.notification(); + List tokens = dispatchItem.tokens(); + + if (tokens.isEmpty()) { log.info("No push tokens for owner; skip sending. ownerId={}, scheduleId={}", notification.getOwnerId(), notification.getScheduleId()); return; } - subscriptions.forEach(subscription -> pushService.sendPushMessage(subscription.getToken(), notification)); + tokens.forEach(token -> { + PushSendResult result = pushService.sendPushMessage(token, notification); + if (result.shouldDeleteToken()) { + tokensToDelete.add(token); + } + }); + } + + private void deleteTokensSafely(Set tokensToDelete) { + try { + pushTokenCleanupService.deleteTokensInNewTransaction(tokensToDelete); + } catch (Exception ex) { + log.warn("Failed to delete invalid push tokens; notifications already removed. tokens={}", tokensToDelete.size(), ex); + } } } diff --git a/src/main/java/me/pinitnotification/application/notification/PushTokenCleanupService.java b/src/main/java/me/pinitnotification/application/notification/PushTokenCleanupService.java new file mode 100644 index 0000000..46e53dd --- /dev/null +++ b/src/main/java/me/pinitnotification/application/notification/PushTokenCleanupService.java @@ -0,0 +1,29 @@ +package me.pinitnotification.application.notification; + +import me.pinitnotification.domain.push.PushSubscriptionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Set; + +@Service +public class PushTokenCleanupService { + private static final Logger log = LoggerFactory.getLogger(PushTokenCleanupService.class); + private final PushSubscriptionRepository pushSubscriptionRepository; + + public PushTokenCleanupService(PushSubscriptionRepository pushSubscriptionRepository) { + this.pushSubscriptionRepository = pushSubscriptionRepository; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void deleteTokensInNewTransaction(Set tokens) { + if (tokens == null || tokens.isEmpty()) { + return; + } + pushSubscriptionRepository.deleteByTokens(tokens); + log.info("Deleted {} invalid push tokens", tokens.size()); + } +} diff --git a/src/main/java/me/pinitnotification/application/push/PushSendResult.java b/src/main/java/me/pinitnotification/application/push/PushSendResult.java new file mode 100644 index 0000000..f87d5c2 --- /dev/null +++ b/src/main/java/me/pinitnotification/application/push/PushSendResult.java @@ -0,0 +1,22 @@ +package me.pinitnotification.application.push; + +public record PushSendResult( + boolean success, + boolean invalidToken +) { + public static PushSendResult successResult() { + return new PushSendResult(true, false); + } + + public static PushSendResult invalidTokenResult() { + return new PushSendResult(false, true); + } + + public static PushSendResult failedResult() { + return new PushSendResult(false, false); + } + + public boolean shouldDeleteToken() { + return invalidToken; + } +} diff --git a/src/main/java/me/pinitnotification/application/push/PushService.java b/src/main/java/me/pinitnotification/application/push/PushService.java index 284ea16..e339492 100644 --- a/src/main/java/me/pinitnotification/application/push/PushService.java +++ b/src/main/java/me/pinitnotification/application/push/PushService.java @@ -8,7 +8,8 @@ public interface PushService { void subscribe(Long memberId, String deviceId, String token); void unsubscribe(Long memberId, String deviceId, String token); - void sendPushMessage(String token, Notification notification); + + PushSendResult sendPushMessage(String token, Notification notification); boolean isSubscribed(Long memberId, String deviceId); } diff --git a/src/main/java/me/pinitnotification/domain/push/PushSubscriptionRepository.java b/src/main/java/me/pinitnotification/domain/push/PushSubscriptionRepository.java index be393d4..bc688d6 100644 --- a/src/main/java/me/pinitnotification/domain/push/PushSubscriptionRepository.java +++ b/src/main/java/me/pinitnotification/domain/push/PushSubscriptionRepository.java @@ -1,5 +1,6 @@ package me.pinitnotification.domain.push; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -12,5 +13,7 @@ public interface PushSubscriptionRepository { void deleteByToken(String token); + void deleteByTokens(Collection tokens); + void deleteByMemberIdAndDeviceId(Long memberId, String deviceId); } diff --git a/src/main/java/me/pinitnotification/infrastructure/fcm/FcmService.java b/src/main/java/me/pinitnotification/infrastructure/fcm/FcmService.java index d4dc08b..7c04531 100644 --- a/src/main/java/me/pinitnotification/infrastructure/fcm/FcmService.java +++ b/src/main/java/me/pinitnotification/infrastructure/fcm/FcmService.java @@ -5,6 +5,7 @@ import com.google.firebase.messaging.Message; import com.google.firebase.messaging.MessagingErrorCode; import lombok.extern.slf4j.Slf4j; +import me.pinitnotification.application.push.PushSendResult; import me.pinitnotification.application.push.PushService; import me.pinitnotification.domain.notification.Notification; import me.pinitnotification.domain.push.PushSubscription; @@ -34,7 +35,7 @@ public FcmService(FirebaseMessaging firebaseMessaging, } @Override - public void sendPushMessage(String token, Notification notification) { + public PushSendResult sendPushMessage(String token, Notification notification) { log.info("publish token: {}", token); Message message = Message.builder() .setToken(token) @@ -42,12 +43,13 @@ public void sendPushMessage(String token, Notification notification) { .build(); try { firebaseMessaging.send(message); + return PushSendResult.successResult(); } catch (FirebaseMessagingException e) { log.error(e.getMessage(), e); if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED || e.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT) { - // Todo 토큰 삭제 방식 변경 필요 - pushSubscriptionRepository.deleteByToken(token); + return PushSendResult.invalidTokenResult(); } + return PushSendResult.failedResult(); } } diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapter.java b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapter.java new file mode 100644 index 0000000..d57ccb2 --- /dev/null +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapter.java @@ -0,0 +1,103 @@ +package me.pinitnotification.infrastructure.persistence.notification; + +import me.pinitnotification.application.notification.NotificationDispatchItem; +import me.pinitnotification.application.notification.NotificationDispatchQueryRepository; +import me.pinitnotification.domain.notification.UpcomingScheduleNotification; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.*; + +@Repository +public class NotificationDispatchQueryRepositoryAdapter implements NotificationDispatchQueryRepository { + private static final String FIND_DUE_WITH_TOKENS_SQL = """ + SELECT n.public_id, + n.owner_id, + n.schedule_id, + n.schedule_title, + n.schedule_start_time, + n.idempotent_key, + ps.token + FROM upcoming_schedule_notification n + LEFT JOIN push_subscription ps ON ps.member_id = n.owner_id + WHERE n.schedule_start_time IS NOT NULL + AND n.schedule_start_time <= ? + """; + + private final JdbcClient jdbcClient; + + public NotificationDispatchQueryRepositoryAdapter(JdbcClient jdbcClient) { + this.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") + )) + .list(); + + if (rows.isEmpty()) { + return List.of(); + } + + Map aggregated = new LinkedHashMap<>(); + for (DispatchRow row : rows) { + DispatchAccumulator accumulator = aggregated.computeIfAbsent( + row.notificationId, + id -> new DispatchAccumulator( + toDomain(row), + new ArrayList<>() + ) + ); + if (row.token != null) { + accumulator.tokens.add(row.token); + } + } + + return aggregated.values().stream() + .map(accumulator -> new NotificationDispatchItem(accumulator.notification, List.copyOf(accumulator.tokens))) + .toList(); + } + + private UpcomingScheduleNotification toDomain(DispatchRow row) { + return new UpcomingScheduleNotification( + row.notificationId, + row.ownerId, + row.scheduleId, + row.scheduleTitle, + row.scheduleStartTime, + row.idempotentKey + ); + } + + private record DispatchRow( + UUID notificationId, + Long ownerId, + Long scheduleId, + String scheduleTitle, + String scheduleStartTime, + String idempotentKey, + String token + ) { + } + + private static class DispatchAccumulator { + private final UpcomingScheduleNotification notification; + private final List tokens; + + private DispatchAccumulator(UpcomingScheduleNotification notification, List tokens) { + this.notification = notification; + this.tokens = tokens; + } + } +} 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 6655976..14e402f 100644 --- a/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationEntity.java +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationEntity.java @@ -15,6 +15,9 @@ uniqueConstraints = { @UniqueConstraint(name = "uk_schedule_owner", columnNames = {"schedule_id", "owner_id"}), @UniqueConstraint(name = "uk_idempotent_key", columnNames = {"idempotent_key"}) + }, + indexes = { + @Index(name = "idx_upcoming_start_time", columnList = "schedule_start_time") } ) @Getter diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionJpaRepository.java b/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionJpaRepository.java index 493d40b..811114f 100644 --- a/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionJpaRepository.java +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionJpaRepository.java @@ -2,6 +2,7 @@ import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -12,5 +13,7 @@ public interface PushSubscriptionJpaRepository extends JpaRepository tokens); + void deleteByMemberIdAndDeviceId(Long memberId, String deviceId); } diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapter.java b/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapter.java index 6362285..a1abe25 100644 --- a/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapter.java +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapter.java @@ -4,6 +4,7 @@ import me.pinitnotification.domain.push.PushSubscriptionRepository; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -39,6 +40,14 @@ public void deleteByToken(String token) { jpaRepository.deleteByToken(token); } + @Override + public void deleteByTokens(Collection tokens) { + if (tokens == null || tokens.isEmpty()) { + return; + } + jpaRepository.deleteByTokenIn(tokens); + } + @Override public void deleteByMemberIdAndDeviceId(Long memberId, String deviceId) { jpaRepository.deleteByMemberIdAndDeviceId(memberId, deviceId); diff --git a/src/test/java/me/pinitnotification/application/notification/NotificationDispatchSchedulerTest.java b/src/test/java/me/pinitnotification/application/notification/NotificationDispatchSchedulerTest.java index 278ae35..4248b8a 100644 --- a/src/test/java/me/pinitnotification/application/notification/NotificationDispatchSchedulerTest.java +++ b/src/test/java/me/pinitnotification/application/notification/NotificationDispatchSchedulerTest.java @@ -1,10 +1,9 @@ package me.pinitnotification.application.notification; +import me.pinitnotification.application.push.PushSendResult; import me.pinitnotification.application.push.PushService; import me.pinitnotification.domain.notification.UpcomingScheduleNotification; import me.pinitnotification.domain.notification.UpcomingScheduleNotificationRepository; -import me.pinitnotification.domain.push.PushSubscription; -import me.pinitnotification.domain.push.PushSubscriptionRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -15,6 +14,7 @@ import java.time.Instant; import java.time.ZoneOffset; import java.util.List; +import java.util.Set; import static org.mockito.Mockito.*; @@ -24,7 +24,9 @@ class NotificationDispatchSchedulerTest { @Mock private UpcomingScheduleNotificationRepository notificationRepository; @Mock - private PushSubscriptionRepository pushSubscriptionRepository; + private NotificationDispatchQueryRepository dispatchQueryRepository; + @Mock + private PushTokenCleanupService pushTokenCleanupService; @Mock private PushService pushService; @@ -34,36 +36,52 @@ class NotificationDispatchSchedulerTest { @BeforeEach void setUp() { clock = Clock.fixed(Instant.parse("2024-06-01T10:00:00Z"), ZoneOffset.UTC); - scheduler = new NotificationDispatchScheduler(notificationRepository, pushSubscriptionRepository, pushService, clock); + scheduler = new NotificationDispatchScheduler(notificationRepository, dispatchQueryRepository, pushTokenCleanupService, pushService, clock); } @Test - void dispatchDueNotifications_sendsAndDeletesPastNotifications() { - UpcomingScheduleNotification past = new UpcomingScheduleNotification(1L, 10L, "title", "2024-06-01T09:50Z", "key-1"); - UpcomingScheduleNotification future = new UpcomingScheduleNotification(1L, 11L, "title2", "2024-06-01T10:30Z", "key-2"); + void dispatchDueNotifications_sendsAndDeletesNotificationsFromQuery() { + UpcomingScheduleNotification notification = new UpcomingScheduleNotification(1L, 10L, "title", "2024-06-01T09:50Z", "key-1"); - when(notificationRepository.findAll()).thenReturn(List.of(past, future)); - when(pushSubscriptionRepository.findAllByMemberId(1L)) - .thenReturn(List.of(new PushSubscription(1L, "device-1", "token-1"), new PushSubscription(1L, "device-2", "token-2"))); + when(dispatchQueryRepository.findAllDueNotificationsWithTokens(any())) + .thenReturn(List.of(new NotificationDispatchItem(notification, List.of("token-1", "token-2")))); + when(pushService.sendPushMessage(anyString(), eq(notification))).thenReturn(PushSendResult.successResult()); scheduler.dispatchDueNotifications(); - verify(pushService).sendPushMessage("token-1", past); - verify(pushService).sendPushMessage("token-2", past); - verify(notificationRepository).deleteAllInBatch(List.of(past)); - verify(pushService, never()).sendPushMessage(anyString(), eq(future)); + verify(pushService).sendPushMessage("token-1", notification); + verify(pushService).sendPushMessage("token-2", notification); + verify(notificationRepository).deleteAllInBatch(List.of(notification)); + verify(pushTokenCleanupService, never()).deleteTokensInNewTransaction(any()); } @Test void dispatchDueNotifications_deletesEvenWhenNoTokens() { UpcomingScheduleNotification past = new UpcomingScheduleNotification(2L, 20L, "title", "2024-06-01T09:00Z", "key-3"); - when(notificationRepository.findAll()).thenReturn(List.of(past)); - when(pushSubscriptionRepository.findAllByMemberId(2L)).thenReturn(List.of()); + when(dispatchQueryRepository.findAllDueNotificationsWithTokens(any())) + .thenReturn(List.of(new NotificationDispatchItem(past, List.of()))); scheduler.dispatchDueNotifications(); verify(pushService, never()).sendPushMessage(anyString(), any()); verify(notificationRepository).deleteAllInBatch(List.of(past)); + verify(pushTokenCleanupService, never()).deleteTokensInNewTransaction(any()); + } + + @Test + void dispatchDueNotifications_collectsInvalidTokensAndDeletesInBatch() { + UpcomingScheduleNotification notification = new UpcomingScheduleNotification(3L, 30L, "title", "2024-06-01T09:30Z", "key-4"); + + when(dispatchQueryRepository.findAllDueNotificationsWithTokens(any())) + .thenReturn(List.of(new NotificationDispatchItem(notification, List.of("token-1", "token-2", "token-3")))); + when(pushService.sendPushMessage("token-1", notification)).thenReturn(PushSendResult.invalidTokenResult()); + when(pushService.sendPushMessage("token-2", notification)).thenReturn(PushSendResult.failedResult()); + when(pushService.sendPushMessage("token-3", notification)).thenReturn(PushSendResult.successResult()); + + scheduler.dispatchDueNotifications(); + + verify(pushTokenCleanupService).deleteTokensInNewTransaction(Set.of("token-1")); + verify(notificationRepository).deleteAllInBatch(List.of(notification)); } } diff --git a/src/test/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapterTest.java b/src/test/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapterTest.java new file mode 100644 index 0000000..d1f75f0 --- /dev/null +++ b/src/test/java/me/pinitnotification/infrastructure/persistence/notification/NotificationDispatchQueryRepositoryAdapterTest.java @@ -0,0 +1,82 @@ +package me.pinitnotification.infrastructure.persistence.notification; + +import jakarta.persistence.EntityManager; +import me.pinitnotification.application.notification.NotificationDispatchItem; +import me.pinitnotification.application.notification.NotificationDispatchQueryRepository; +import me.pinitnotification.domain.push.PushSubscription; +import me.pinitnotification.domain.push.PushSubscriptionRepository; +import me.pinitnotification.infrastructure.persistence.push.PushSubscriptionRepositoryAdapter; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import({NotificationDispatchQueryRepositoryAdapter.class, PushSubscriptionRepositoryAdapter.class}) +class NotificationDispatchQueryRepositoryAdapterTest { + + @Autowired + private NotificationDispatchQueryRepository repository; + @Autowired + private UpcomingScheduleNotificationJpaRepository notificationJpaRepository; + @Autowired + private PushSubscriptionRepository pushSubscriptionRepository; + @Autowired + private EntityManager entityManager; + + @Test + void returnsDueNotificationsWithAggregatedTokens() { + // given + UpcomingScheduleNotificationEntity dueWithTokens = notificationJpaRepository.save(notification(1L, 11L, "2024-06-01T09:50:00Z")); + notificationJpaRepository.save(notification(2L, 12L, "2024-06-01T09:55:00Z")); // no tokens + notificationJpaRepository.save(notification(3L, 13L, "2024-06-01T10:30:00Z")); // future, should be excluded + + pushSubscriptionRepository.save(subscription(1L, "device-1", "token-1")); + pushSubscriptionRepository.save(subscription(1L, "device-2", "token-2")); + + entityManager.flush(); + entityManager.clear(); + + // when + List results = repository.findAllDueNotificationsWithTokens(Instant.parse("2024-06-01T10:00:00Z")); + + // then + assertThat(results) + .hasSize(2) + .extracting(item -> item.notification().getScheduleId()) + .containsExactlyInAnyOrder(11L, 12L); + + NotificationDispatchItem withTokens = results.stream() + .filter(item -> item.notification().getScheduleId().equals(11L)) + .findFirst() + .orElseThrow(); + assertThat(withTokens.tokens()).containsExactlyInAnyOrder("token-1", "token-2"); + + NotificationDispatchItem withoutTokens = results.stream() + .filter(item -> item.notification().getScheduleId().equals(12L)) + .findFirst() + .orElseThrow(); + assertThat(withoutTokens.tokens()).isEmpty(); + } + + 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.setIdempotentKey("key-" + scheduleId); + return entity; + } + + private PushSubscription subscription(Long memberId, String deviceId, String token) { + return new PushSubscription(UUID.randomUUID(), memberId, deviceId, token); + } +} diff --git a/src/test/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapterTest.java b/src/test/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapterTest.java index 57990bc..a30b88c 100644 --- a/src/test/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapterTest.java +++ b/src/test/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapterTest.java @@ -17,6 +17,8 @@ class PushSubscriptionRepositoryAdapterTest { @Autowired private PushSubscriptionRepository repository; + @Autowired + private PushSubscriptionJpaRepository jpaRepository; @Test void savesAndLoadsDomainWithPublicId() { @@ -38,4 +40,29 @@ void savesAndLoadsDomainWithPublicId() { assertThat(loaded).isPresent(); assertThat(loaded.get().getId()).isEqualTo(publicId); } + + @Test + void deletesTokensInBatch() { + PushSubscriptionEntity token1 = subscription("token-1", "device-1"); + PushSubscriptionEntity token2 = subscription("token-2", "device-2"); + PushSubscriptionEntity token3 = subscription("token-3", "device-3"); + jpaRepository.save(token1); + jpaRepository.save(token2); + jpaRepository.save(token3); + + repository.deleteByTokens(java.util.List.of("token-1", "token-x")); + + assertThat(jpaRepository.findAll()) + .extracting(PushSubscriptionEntity::getToken) + .containsExactlyInAnyOrder("token-2", "token-3"); + } + + private PushSubscriptionEntity subscription(String token, String deviceId) { + PushSubscriptionEntity entity = new PushSubscriptionEntity(); + entity.setPublicId(UUID.randomUUID()); + entity.setMemberId(201L); + entity.setDeviceId(deviceId); + entity.setToken(token); + return entity; + } }