diff --git a/src/main/java/com/zimdugo/push/application/PushReminderDeleteService.java b/src/main/java/com/zimdugo/push/application/PushReminderDeleteService.java new file mode 100644 index 0000000..6b7b645 --- /dev/null +++ b/src/main/java/com/zimdugo/push/application/PushReminderDeleteService.java @@ -0,0 +1,26 @@ +package com.zimdugo.push.application; + +import com.zimdugo.push.domain.PushDeviceReader; +import com.zimdugo.push.domain.PushReminderStore; +import java.time.Clock; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class PushReminderDeleteService { + + private final PushDeviceReader pushDeviceReader; + private final PushReminderStore pushReminderStore; + private final Clock clock; + + public void delete(String deviceTokenHash, Long reminderId) { + // 기기 소유권이 확인된 요청만 삭제 상태를 바꿔 다른 브라우저의 리마인더를 보호한다. + pushDeviceReader.findIdByTokenHash(deviceTokenHash) + .ifPresent(deviceId -> pushReminderStore.cancelActiveByIdAndDeviceId( + reminderId, deviceId, clock.instant() + )); + } +} diff --git a/src/main/java/com/zimdugo/push/config/PushReminderDispatchProperties.java b/src/main/java/com/zimdugo/push/config/PushReminderDispatchProperties.java index 1e96d17..6723783 100644 --- a/src/main/java/com/zimdugo/push/config/PushReminderDispatchProperties.java +++ b/src/main/java/com/zimdugo/push/config/PushReminderDispatchProperties.java @@ -20,4 +20,7 @@ public class PushReminderDispatchProperties { @Min(1) private int maximumDeliveryAttempts; + + @Min(1) + private int dispatchClaimSeconds; } diff --git a/src/main/java/com/zimdugo/push/domain/PushReminderJobStatus.java b/src/main/java/com/zimdugo/push/domain/PushReminderJobStatus.java new file mode 100644 index 0000000..679682e --- /dev/null +++ b/src/main/java/com/zimdugo/push/domain/PushReminderJobStatus.java @@ -0,0 +1,9 @@ +package com.zimdugo.push.domain; + +public enum PushReminderJobStatus { + PENDING, + DISPATCHING, + SENT, + DISCARDED, + CANCELLED +} diff --git a/src/main/java/com/zimdugo/push/domain/PushReminderStore.java b/src/main/java/com/zimdugo/push/domain/PushReminderStore.java index 549d903..261683d 100644 --- a/src/main/java/com/zimdugo/push/domain/PushReminderStore.java +++ b/src/main/java/com/zimdugo/push/domain/PushReminderStore.java @@ -8,6 +8,8 @@ public interface PushReminderStore { long countActiveByDeviceId(Long deviceId, Instant now); + void cancelActiveByIdAndDeviceId(Long reminderId, Long deviceId, Instant now); + Long save(PushReminderSaveCommand command); void schedule(Long reminderId, PushNotificationType type, Instant fireAt); diff --git a/src/main/java/com/zimdugo/push/entrypoint/PushReminderController.java b/src/main/java/com/zimdugo/push/entrypoint/PushReminderController.java index 0c5b513..09a7ff9 100644 --- a/src/main/java/com/zimdugo/push/entrypoint/PushReminderController.java +++ b/src/main/java/com/zimdugo/push/entrypoint/PushReminderController.java @@ -4,6 +4,7 @@ import com.zimdugo.core.response.SuccessCode; import com.zimdugo.push.application.PushDeviceTokenHasher; import com.zimdugo.push.application.PushReminderCreateService; +import com.zimdugo.push.application.PushReminderDeleteService; import com.zimdugo.push.application.PushReminderQueryService; import com.zimdugo.push.application.PushReminderResult; import com.zimdugo.push.entrypoint.dto.PushReminderCreateRequest; @@ -18,10 +19,11 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/v1") -public class PushReminderController implements PushReminderCreateApi, PushReminderQueryApi { +public class PushReminderController implements PushReminderCreateApi, PushReminderDeleteApi, PushReminderQueryApi { private final PushReminderCreateService pushReminderCreateService; private final PushReminderQueryService pushReminderQueryService; + private final PushReminderDeleteService pushReminderDeleteService; private final PushDeviceTokenHasher pushDeviceTokenHasher; @Override @@ -46,6 +48,15 @@ public ResponseEntity>> getActiveReminde return ResponseEntity.ok(RestResponse.of(SuccessCode.OK, responses)); } + @Override + public ResponseEntity> deleteReminder( + Long reminderId, + @CookieValue(name = "deviceToken", required = false) String deviceToken + ) { + pushReminderDeleteService.delete(hashDeviceToken(deviceToken), reminderId); + return ResponseEntity.ok(RestResponse.ok(SuccessCode.OK)); + } + private String hashDeviceToken(String deviceToken) { // 원문 토큰을 저장소 계층으로 넘기지 않아 기기 식별값의 노출 범위를 제한한다. return pushDeviceTokenHasher.hash(deviceToken == null ? "" : deviceToken); diff --git a/src/main/java/com/zimdugo/push/entrypoint/PushReminderDeleteApi.java b/src/main/java/com/zimdugo/push/entrypoint/PushReminderDeleteApi.java new file mode 100644 index 0000000..23a0139 --- /dev/null +++ b/src/main/java/com/zimdugo/push/entrypoint/PushReminderDeleteApi.java @@ -0,0 +1,24 @@ +package com.zimdugo.push.entrypoint; + +import com.zimdugo.core.response.RestResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@Tag(name = "Push", description = "웹 푸시 API") +public interface PushReminderDeleteApi { + + @Operation(summary = "현재 기기의 리마인더 삭제") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "삭제 성공 또는 삭제할 리마인더 없음") + }) + @SecurityRequirements + @DeleteMapping("/push/reminders/{reminderId}") + ResponseEntity> deleteReminder(@PathVariable @Positive Long reminderId, String deviceToken); +} diff --git a/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderJobEntity.java b/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderJobEntity.java index d35ddb5..63b175f 100644 --- a/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderJobEntity.java +++ b/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderJobEntity.java @@ -1,6 +1,7 @@ package com.zimdugo.push.infrastructure.persistence; import com.zimdugo.push.domain.PushNotificationType; +import com.zimdugo.push.domain.PushReminderJobStatus; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -43,26 +44,30 @@ public class PushReminderJobEntity { @Column(name = "attempt_count", nullable = false) private int attemptCount; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private PushReminderJobStatus status; + public PushReminderJobEntity(Long reminderId, PushNotificationType type, Instant fireAt) { this.reminderId = reminderId; this.type = type; this.fireAt = fireAt; this.nextAttemptAt = fireAt; + this.status = PushReminderJobStatus.PENDING; } - public void recordAttempt() { - this.attemptCount++; - } - - public void markProcessed(Instant processedAt) { + public void markSent(Instant processedAt) { + this.status = PushReminderJobStatus.SENT; this.processedAt = processedAt; } public void discard(Instant processedAt) { + this.status = PushReminderJobStatus.DISCARDED; this.processedAt = processedAt; } public void retryAt(Instant nextAttemptAt) { + this.status = PushReminderJobStatus.PENDING; this.nextAttemptAt = nextAttemptAt; } } diff --git a/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderJobRepository.java b/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderJobRepository.java index db462e1..e49d127 100644 --- a/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderJobRepository.java +++ b/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderJobRepository.java @@ -1,10 +1,70 @@ package com.zimdugo.push.infrastructure.persistence; +import com.zimdugo.push.domain.PushReminderJobStatus; import java.time.Instant; import java.util.List; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Pageable; public interface PushReminderJobRepository extends JpaRepository { - List findTop100ByProcessedAtIsNullAndNextAttemptAtLessThanEqualOrderByFireAtAsc(Instant now); + @Query(""" + select job + from PushReminderJobEntity job + where job.status = :status + and job.nextAttemptAt <= :now + order by job.fireAt asc + """) + List findDueJobs( + @Param("status") PushReminderJobStatus status, + @Param("now") Instant now, + Pageable pageable + ); + + @Modifying + @Query(""" + update PushReminderJobEntity job + set job.status = :dispatchingStatus, + job.nextAttemptAt = :leaseExpiresAt, + job.attemptCount = job.attemptCount + 1 + where job.id = :jobId + and job.status = :pendingStatus + """) + int claimPendingById( + @Param("jobId") Long jobId, + @Param("pendingStatus") PushReminderJobStatus pendingStatus, + @Param("dispatchingStatus") PushReminderJobStatus dispatchingStatus, + @Param("leaseExpiresAt") Instant leaseExpiresAt + ); + + @Modifying + @Query(""" + update PushReminderJobEntity job + set job.status = :pendingStatus + where job.status = :dispatchingStatus + and job.nextAttemptAt <= :now + """) + void requeueExpiredDispatches( + @Param("dispatchingStatus") PushReminderJobStatus dispatchingStatus, + @Param("pendingStatus") PushReminderJobStatus pendingStatus, + @Param("now") Instant now + ); + + @Modifying + @Query(""" + update PushReminderJobEntity job + set job.status = :cancelledStatus, + job.processedAt = :processedAt + where job.reminderId = :reminderId + and job.status = :pendingStatus + """) + void cancelPendingByReminderId( + @Param("reminderId") Long reminderId, + @Param("pendingStatus") PushReminderJobStatus pendingStatus, + @Param("cancelledStatus") PushReminderJobStatus cancelledStatus, + @Param("processedAt") Instant processedAt + ); } diff --git a/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderPersistenceAdapter.java b/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderPersistenceAdapter.java index 91bf7c7..844561c 100644 --- a/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderPersistenceAdapter.java +++ b/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderPersistenceAdapter.java @@ -1,6 +1,7 @@ package com.zimdugo.push.infrastructure.persistence; import com.zimdugo.push.domain.PushNotificationType; +import com.zimdugo.push.domain.PushReminderJobStatus; import com.zimdugo.push.domain.PushReminderReader; import com.zimdugo.push.domain.PushReminderSaveCommand; import com.zimdugo.push.domain.PushReminderSummary; @@ -45,6 +46,19 @@ public long countActiveByDeviceId(Long deviceId, Instant now) { return pushReminderRepository.countActiveByDeviceId(deviceId, now); } + @Override + public void cancelActiveByIdAndDeviceId(Long reminderId, Long deviceId, Instant now) { + int deletedCount = pushReminderRepository.softDeleteActiveByIdAndDeviceId( + reminderId, deviceId, now, PushReminderStatus.ACTIVE, now + ); + if (deletedCount > 0) { + // 선점 전 작업만 취소해 삭제 이후 발송은 막고, 이미 시작된 외부 전송은 되돌리지 않는다. + pushReminderJobRepository.cancelPendingByReminderId( + reminderId, PushReminderJobStatus.PENDING, PushReminderJobStatus.CANCELLED, now + ); + } + } + @Override public Long save(PushReminderSaveCommand command) { return pushReminderRepository.save( diff --git a/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderRepository.java b/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderRepository.java index 4c06131..32bbfe2 100644 --- a/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderRepository.java +++ b/src/main/java/com/zimdugo/push/infrastructure/persistence/PushReminderRepository.java @@ -37,6 +37,24 @@ long countActiveByDeviceId( @Param("now") Instant now ); + @Modifying + @Query(""" + update PushReminderEntity reminder + set reminder.deletedAt = :deletedAt + where reminder.id = :reminderId + and reminder.deviceId = :deviceId + and reminder.status = :activeStatus + and reminder.deletedAt is null + and reminder.endAt > :now + """) + int softDeleteActiveByIdAndDeviceId( + @Param("reminderId") Long reminderId, + @Param("deviceId") Long deviceId, + @Param("deletedAt") Instant deletedAt, + @Param("activeStatus") PushReminderStatus activeStatus, + @Param("now") Instant now + ); + @Modifying @Query(""" update PushReminderEntity reminder diff --git a/src/main/java/com/zimdugo/push/infrastructure/scheduler/PushReminderDeliveryProcessor.java b/src/main/java/com/zimdugo/push/infrastructure/scheduler/PushReminderDeliveryProcessor.java index f7e059b..50ae7ad 100644 --- a/src/main/java/com/zimdugo/push/infrastructure/scheduler/PushReminderDeliveryProcessor.java +++ b/src/main/java/com/zimdugo/push/infrastructure/scheduler/PushReminderDeliveryProcessor.java @@ -3,6 +3,7 @@ import com.zimdugo.push.config.PushReminderDispatchProperties; import com.zimdugo.push.domain.PushLockerNameReader; import com.zimdugo.push.domain.PushNotificationType; +import com.zimdugo.push.domain.PushReminderJobStatus; import com.zimdugo.push.domain.PushReminderStatus; import com.zimdugo.push.domain.PushSubscription; import com.zimdugo.push.domain.WebPushSendResult; @@ -32,11 +33,10 @@ public class PushReminderDeliveryProcessor { @Transactional public DeliveryCandidate prepare(Long jobId) { Instant now = clock.instant(); - PushReminderJobEntity job = pushReminderJobRepository.findById(jobId).orElse(null); - if (job == null || job.getProcessedAt() != null) { + PushReminderJobEntity job = claim(jobId, now); + if (job == null) { return null; } - job.recordAttempt(); PushReminderEntity reminder = pushReminderRepository.findById(job.getReminderId()).orElse(null); if (reminder == null || reminder.getStatus() != PushReminderStatus.ACTIVE || reminder.getDeletedAt() != null) { discard(job, reminder, now); @@ -55,6 +55,19 @@ public DeliveryCandidate prepare(Long jobId) { return candidate(job, reminder, subscription); } + private PushReminderJobEntity claim(Long jobId, Instant now) { + int claimedCount = pushReminderJobRepository.claimPendingById( + jobId, + PushReminderJobStatus.PENDING, + PushReminderJobStatus.DISPATCHING, + now.plusSeconds(properties.getDispatchClaimSeconds()) + ); + if (claimedCount == 0) { + return null; + } + return pushReminderJobRepository.findById(jobId).orElse(null); + } + private DeliveryCandidate candidate( PushReminderJobEntity job, PushReminderEntity reminder, @@ -64,6 +77,7 @@ private DeliveryCandidate candidate( job.getId(), reminder.getId(), job.getType(), + job.getAttemptCount(), reminder.getLockerId(), subscription.getId(), subscription.getEndpoint(), @@ -77,7 +91,9 @@ private DeliveryCandidate candidate( @Transactional public void complete(DeliveryCandidate candidate, WebPushSendResult result) { PushReminderJobEntity job = pushReminderJobRepository.findById(candidate.jobId()).orElse(null); - if (job == null || job.getProcessedAt() != null) { + if (job == null || + job.getStatus() != PushReminderJobStatus.DISPATCHING || + job.getAttemptCount() != candidate.attemptCount()) { return; } Instant now = clock.instant(); @@ -90,8 +106,10 @@ public void complete(DeliveryCandidate candidate, WebPushSendResult result) { pushSubscriptionRepository.findById(candidate.subscriptionId()) .filter(subscription -> subscription.getEndpoint().equals(candidate.endpoint())) .ifPresent(pushSubscriptionRepository::delete); + discard(job, reminder, now); + return; } - job.markProcessed(now); + job.markSent(now); completeReminderAtEnd(job, reminder); } @@ -118,6 +136,7 @@ public record DeliveryCandidate( Long jobId, Long reminderId, PushNotificationType type, + int attemptCount, Long lockerId, Long subscriptionId, String endpoint, diff --git a/src/main/java/com/zimdugo/push/infrastructure/scheduler/PushReminderDeliveryScheduler.java b/src/main/java/com/zimdugo/push/infrastructure/scheduler/PushReminderDeliveryScheduler.java index c4abe0e..99ab89e 100644 --- a/src/main/java/com/zimdugo/push/infrastructure/scheduler/PushReminderDeliveryScheduler.java +++ b/src/main/java/com/zimdugo/push/infrastructure/scheduler/PushReminderDeliveryScheduler.java @@ -1,12 +1,15 @@ package com.zimdugo.push.infrastructure.scheduler; +import com.zimdugo.push.domain.PushReminderJobStatus; import com.zimdugo.push.domain.WebPushSendResult; import com.zimdugo.push.domain.WebPushSender; import com.zimdugo.push.infrastructure.persistence.PushReminderJobEntity; import com.zimdugo.push.infrastructure.persistence.PushReminderJobRepository; import java.time.Clock; +import java.time.Instant; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -14,6 +17,8 @@ @RequiredArgsConstructor public class PushReminderDeliveryScheduler { + private static final int DUE_JOB_BATCH_SIZE = 100; + private final PushReminderJobRepository pushReminderJobRepository; private final PushReminderDeliveryProcessor pushReminderDeliveryProcessor; private final WebPushSender webPushSender; @@ -21,9 +26,13 @@ public class PushReminderDeliveryScheduler { @Scheduled(fixedDelayString = "${push.reminder.dispatch-fixed-delay-millis:1000}") public void dispatchDueJobs() { - // DB가 미처리 작업의 기준이므로 재시작 뒤에도 다음 폴링에서 발송 후보를 복구한다. + // 리스가 만료된 선점은 재시도 대기로 되돌려 서버 중단 뒤에도 발송 후보를 복구한다. + Instant now = clock.instant(); + pushReminderJobRepository.requeueExpiredDispatches( + PushReminderJobStatus.DISPATCHING, PushReminderJobStatus.PENDING, now + ); List jobs = pushReminderJobRepository - .findTop100ByProcessedAtIsNullAndNextAttemptAtLessThanEqualOrderByFireAtAsc(clock.instant()); + .findDueJobs(PushReminderJobStatus.PENDING, now, PageRequest.of(0, DUE_JOB_BATCH_SIZE)); jobs.forEach(job -> dispatch(job.getId())); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e87ed49..a5268d0 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -125,6 +125,7 @@ push: delivery-ttl-seconds: ${PUSH_REMINDER_DELIVERY_TTL_SECONDS:60} delivery-retry-delay-seconds: ${PUSH_REMINDER_DELIVERY_RETRY_DELAY_SECONDS:5} maximum-delivery-attempts: ${PUSH_REMINDER_MAXIMUM_DELIVERY_ATTEMPTS:3} + dispatch-claim-seconds: ${PUSH_REMINDER_DISPATCH_CLAIM_SECONDS:30} rate-limit-cleanup-fixed-delay-millis: ${PUSH_REMINDER_RATE_LIMIT_CLEANUP_FIXED_DELAY_MILLIS:60000} seoul-metro: diff --git a/src/test/java/com/zimdugo/push/application/PushReminderDeleteServiceTest.java b/src/test/java/com/zimdugo/push/application/PushReminderDeleteServiceTest.java new file mode 100644 index 0000000..61f3940 --- /dev/null +++ b/src/test/java/com/zimdugo/push/application/PushReminderDeleteServiceTest.java @@ -0,0 +1,56 @@ +package com.zimdugo.push.application; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.zimdugo.push.domain.PushDeviceReader; +import com.zimdugo.push.domain.PushReminderStore; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PushReminderDeleteServiceTest { + + @Mock + private PushDeviceReader pushDeviceReader; + + @Mock + private PushReminderStore pushReminderStore; + + @Test + void cancelsActiveReminderAndPendingJobsOnlyWhenOwnedByCurrentDevice() { + Instant now = Instant.parse("2026-08-31T12:00:00Z"); + PushReminderDeleteService pushReminderDeleteService = new PushReminderDeleteService( + pushDeviceReader, + pushReminderStore, + Clock.fixed(now, ZoneOffset.UTC) + ); + given(pushDeviceReader.findIdByTokenHash("device-token-hash")).willReturn(Optional.of(7L)); + + pushReminderDeleteService.delete("device-token-hash", 456L); + + verify(pushReminderStore).cancelActiveByIdAndDeviceId(456L, 7L, now); + } + + @Test + void completesWhenDeviceCookieIsUnknown() { + PushReminderDeleteService pushReminderDeleteService = new PushReminderDeleteService( + pushDeviceReader, + pushReminderStore, + Clock.systemUTC() + ); + given(pushDeviceReader.findIdByTokenHash("unknown-token-hash")).willReturn(Optional.empty()); + + pushReminderDeleteService.delete("unknown-token-hash", 456L); + + verifyNoInteractions(pushReminderStore); + } +} diff --git a/src/test/java/com/zimdugo/push/application/PushReminderDeliveryServiceTest.java b/src/test/java/com/zimdugo/push/application/PushReminderDeliveryServiceTest.java index 8ba8473..8b04f6f 100644 --- a/src/test/java/com/zimdugo/push/application/PushReminderDeliveryServiceTest.java +++ b/src/test/java/com/zimdugo/push/application/PushReminderDeliveryServiceTest.java @@ -15,6 +15,7 @@ import com.zimdugo.push.config.PushReminderDispatchProperties; import com.zimdugo.push.domain.WebPushSender; import com.zimdugo.push.domain.PushLockerNameReader; +import com.zimdugo.push.domain.PushReminderJobStatus; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; @@ -62,6 +63,9 @@ void marksJobProcessedWhenReminderWasDeleted() { properties(), Clock.fixed(now, ZoneOffset.UTC) ); + given(pushReminderJobRepository.claimPendingById( + 1L, PushReminderJobStatus.PENDING, PushReminderJobStatus.DISPATCHING, now.plusSeconds(30) + )).willReturn(1); given(pushReminderJobRepository.findById(1L)).willReturn(Optional.of(job)); given(job.getReminderId()).willReturn(2L); given(pushReminderRepository.findById(2L)).willReturn(Optional.of(reminder)); @@ -70,7 +74,6 @@ void marksJobProcessedWhenReminderWasDeleted() { service.prepare(1L); - verify(job).recordAttempt(); verify(job).discard(now); verifyNoInteractions(pushSubscriptionRepository, webPushSender); } @@ -86,6 +89,9 @@ void marksOverdueJobProcessedWithoutLoadingReminder() { properties(), Clock.fixed(now, ZoneOffset.UTC) ); + given(pushReminderJobRepository.claimPendingById( + 1L, PushReminderJobStatus.PENDING, PushReminderJobStatus.DISPATCHING, now.plusSeconds(30) + )).willReturn(1); given(pushReminderJobRepository.findById(1L)).willReturn(Optional.of(job)); given(job.getFireAt()).willReturn(now.minusSeconds(61)); given(job.getReminderId()).willReturn(2L); @@ -94,7 +100,6 @@ void marksOverdueJobProcessedWithoutLoadingReminder() { service.prepare(1L); - verify(job).recordAttempt(); verify(job).discard(now); verifyNoInteractions(pushSubscriptionRepository, webPushSender); } @@ -114,6 +119,7 @@ void preservesRenewedSubscriptionWhenExpiredResponseBelongsToOldEndpoint() { 1L, 2L, com.zimdugo.push.domain.PushNotificationType.END, + 1, 2L, 3L, "https://fcm.googleapis.com/fcm/send/old-endpoint", @@ -123,7 +129,8 @@ void preservesRenewedSubscriptionWhenExpiredResponseBelongsToOldEndpoint() { "서울역 보관함" ); given(pushReminderJobRepository.findById(1L)).willReturn(Optional.of(job)); - given(job.getProcessedAt()).willReturn(null); + given(job.getStatus()).willReturn(PushReminderJobStatus.DISPATCHING); + given(job.getAttemptCount()).willReturn(1); given(pushReminderRepository.findById(2L)).willReturn(Optional.of(reminder)); given(pushSubscriptionRepository.findById(3L)).willReturn(Optional.of(subscription)); given(subscription.getEndpoint()).willReturn("https://fcm.googleapis.com/fcm/send/new-endpoint"); @@ -131,7 +138,7 @@ void preservesRenewedSubscriptionWhenExpiredResponseBelongsToOldEndpoint() { service.complete(candidate, com.zimdugo.push.domain.WebPushSendResult.SUBSCRIPTION_EXPIRED); verify(pushSubscriptionRepository, never()).delete(subscription); - verify(job).markProcessed(now); + verify(job).discard(now); } @Test @@ -147,7 +154,7 @@ void schedulesRetryAfterRetryableFailureBeforeAttemptLimit() { ); PushReminderDeliveryProcessor.DeliveryCandidate candidate = candidate(); given(pushReminderJobRepository.findById(1L)).willReturn(Optional.of(job)); - given(job.getProcessedAt()).willReturn(null); + given(job.getStatus()).willReturn(PushReminderJobStatus.DISPATCHING); given(job.getAttemptCount()).willReturn(1); given(pushReminderRepository.findById(2L)).willReturn(Optional.of(reminder)); @@ -161,6 +168,7 @@ private PushReminderDeliveryProcessor.DeliveryCandidate candidate() { 1L, 2L, com.zimdugo.push.domain.PushNotificationType.END, + 1, 2L, 3L, "https://fcm.googleapis.com/fcm/send/endpoint", @@ -176,6 +184,7 @@ private PushReminderDispatchProperties properties() { properties.setDeliveryTtlSeconds(60); properties.setDeliveryRetryDelaySeconds(5); properties.setMaximumDeliveryAttempts(3); + properties.setDispatchClaimSeconds(30); return properties; } } diff --git a/src/test/java/com/zimdugo/push/entrypoint/PushReminderControllerTest.java b/src/test/java/com/zimdugo/push/entrypoint/PushReminderControllerTest.java index 0e77e09..a2371b6 100644 --- a/src/test/java/com/zimdugo/push/entrypoint/PushReminderControllerTest.java +++ b/src/test/java/com/zimdugo/push/entrypoint/PushReminderControllerTest.java @@ -1,12 +1,15 @@ package com.zimdugo.push.entrypoint; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.zimdugo.push.application.PushDeviceTokenHasher; import com.zimdugo.push.application.PushReminderCreateService; +import com.zimdugo.push.application.PushReminderDeleteService; import com.zimdugo.push.application.PushReminderQueryService; import com.zimdugo.push.application.PushReminderResult; import java.time.Instant; @@ -29,6 +32,9 @@ class PushReminderControllerTest { @Mock private PushReminderQueryService pushReminderQueryService; + @Mock + private PushReminderDeleteService pushReminderDeleteService; + private MockMvc mockMvc; private PushDeviceTokenHasher pushDeviceTokenHasher; @@ -36,7 +42,10 @@ class PushReminderControllerTest { void setUp() { pushDeviceTokenHasher = new PushDeviceTokenHasher(); mockMvc = MockMvcBuilders.standaloneSetup(new PushReminderController( - pushReminderCreateService, pushReminderQueryService, pushDeviceTokenHasher + pushReminderCreateService, + pushReminderQueryService, + pushReminderDeleteService, + pushDeviceTokenHasher )).build(); } @@ -58,4 +67,15 @@ void returnsTheCurrentDeviceActiveReminderWithUsageSnapshot() throws Exception { .andExpect(jsonPath("$.data[0].remainingMinutes").value(8)) .andExpect(jsonPath("$.data[0].remindBeforeMinutes").value(5)); } + + @Test + void deletesOnlyTheReminderOfTheCurrentDevice() throws Exception { + mockMvc.perform(delete("/api/v1/push/reminders/456") + .cookie(new MockCookie("deviceToken", "device-token"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("S200")) + .andExpect(jsonPath("$.data").doesNotExist()); + + verify(pushReminderDeleteService).delete(pushDeviceTokenHasher.hash("device-token"), 456L); + } } diff --git a/src/test/java/com/zimdugo/push/infrastructure/persistence/PushReminderJobRepositoryTest.java b/src/test/java/com/zimdugo/push/infrastructure/persistence/PushReminderJobRepositoryTest.java new file mode 100644 index 0000000..ecc5ede --- /dev/null +++ b/src/test/java/com/zimdugo/push/infrastructure/persistence/PushReminderJobRepositoryTest.java @@ -0,0 +1,78 @@ +package com.zimdugo.push.infrastructure.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.zimdugo.push.domain.PushNotificationType; +import com.zimdugo.push.domain.PushReminderJobStatus; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; + +@DataJpaTest +class PushReminderJobRepositoryTest { + + private static final Instant NOW = Instant.parse("2026-08-31T12:00:00Z"); + + @Autowired + private PushReminderJobRepository pushReminderJobRepository; + + @Autowired + private EntityManager entityManager; + + @Test + void claimsPendingJobOnlyOnce() { + PushReminderJobEntity job = pushReminderJobRepository.save( + new PushReminderJobEntity(1L, PushNotificationType.START, NOW) + ); + flushAndClear(); + + assertThat(pushReminderJobRepository.claimPendingById( + job.getId(), + PushReminderJobStatus.PENDING, + PushReminderJobStatus.DISPATCHING, + NOW.plusSeconds(30) + )).isOne(); + assertThat(pushReminderJobRepository.claimPendingById( + job.getId(), + PushReminderJobStatus.PENDING, + PushReminderJobStatus.DISPATCHING, + NOW.plusSeconds(30) + )).isZero(); + flushAndClear(); + + PushReminderJobEntity claimedJob = pushReminderJobRepository.findById(job.getId()).orElseThrow(); + assertThat(claimedJob.getStatus()).isEqualTo(PushReminderJobStatus.DISPATCHING); + assertThat(claimedJob.getAttemptCount()).isOne(); + assertThat(claimedJob.getNextAttemptAt()).isEqualTo(NOW.plusSeconds(30)); + } + + @Test + void requeuesDispatchingJobAfterItsClaimLeaseExpires() { + PushReminderJobEntity job = pushReminderJobRepository.save( + new PushReminderJobEntity(1L, PushNotificationType.START, NOW) + ); + flushAndClear(); + pushReminderJobRepository.claimPendingById( + job.getId(), + PushReminderJobStatus.PENDING, + PushReminderJobStatus.DISPATCHING, + NOW.minusSeconds(1) + ); + flushAndClear(); + + pushReminderJobRepository.requeueExpiredDispatches( + PushReminderJobStatus.DISPATCHING, PushReminderJobStatus.PENDING, NOW + ); + flushAndClear(); + + assertThat(pushReminderJobRepository.findById(job.getId()).orElseThrow().getStatus()) + .isEqualTo(PushReminderJobStatus.PENDING); + } + + private void flushAndClear() { + entityManager.flush(); + entityManager.clear(); + } +} diff --git a/src/test/java/com/zimdugo/push/infrastructure/persistence/PushReminderRepositoryTest.java b/src/test/java/com/zimdugo/push/infrastructure/persistence/PushReminderRepositoryTest.java index 853baff..8d70b88 100644 --- a/src/test/java/com/zimdugo/push/infrastructure/persistence/PushReminderRepositoryTest.java +++ b/src/test/java/com/zimdugo/push/infrastructure/persistence/PushReminderRepositoryTest.java @@ -2,6 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.zimdugo.push.domain.PushNotificationType; +import com.zimdugo.push.domain.PushReminderJobStatus; import com.zimdugo.push.domain.PushReminderSaveCommand; import com.zimdugo.push.domain.PushReminderStatus; import jakarta.persistence.EntityManager; @@ -19,6 +21,9 @@ class PushReminderRepositoryTest { @Autowired private PushReminderRepository pushReminderRepository; + @Autowired + private PushReminderJobRepository pushReminderJobRepository; + @Autowired private EntityManager entityManager; @@ -57,6 +62,62 @@ void completesOnlyNonDeletedExpiredActiveReminders() { .isEqualTo(PushReminderStatus.ACTIVE); } + @Test + void cancelsOnlyPendingJobsWhenOwnedActiveReminderIsDeleted() { + PushReminderEntity reminder = save(7L, NOW.plusSeconds(600)); + PushReminderJobEntity startJob = saveJob(reminder.getId(), PushNotificationType.START); + PushReminderJobEntity beforeEndJob = saveJob(reminder.getId(), PushNotificationType.BEFORE_END); + PushReminderJobEntity endJob = saveJob(reminder.getId(), PushNotificationType.END); + PushReminderJobEntity dispatchingJob = saveJob(reminder.getId(), PushNotificationType.START); + PushReminderJobEntity processedJob = saveJob(reminder.getId(), PushNotificationType.END); + processedJob.markSent(NOW.minusSeconds(1)); + flushAndClear(); + pushReminderJobRepository.claimPendingById( + dispatchingJob.getId(), + PushReminderJobStatus.PENDING, + PushReminderJobStatus.DISPATCHING, + NOW.plusSeconds(30) + ); + flushAndClear(); + + new PushReminderPersistenceAdapter(pushReminderRepository, pushReminderJobRepository) + .cancelActiveByIdAndDeviceId(reminder.getId(), 7L, NOW); + flushAndClear(); + + assertThat(pushReminderRepository.findById(reminder.getId()).orElseThrow().getDeletedAt()).isEqualTo(NOW); + assertThat(pushReminderJobRepository.findById(startJob.getId()).orElseThrow().getStatus()) + .isEqualTo(PushReminderJobStatus.CANCELLED); + assertThat(pushReminderJobRepository.claimPendingById( + startJob.getId(), + PushReminderJobStatus.PENDING, + PushReminderJobStatus.DISPATCHING, + NOW.plusSeconds(30) + )).isZero(); + assertThat(pushReminderJobRepository.findById(beforeEndJob.getId()).orElseThrow().getStatus()) + .isEqualTo(PushReminderJobStatus.CANCELLED); + assertThat(pushReminderJobRepository.findById(endJob.getId()).orElseThrow().getStatus()) + .isEqualTo(PushReminderJobStatus.CANCELLED); + assertThat(pushReminderJobRepository.findById(dispatchingJob.getId()).orElseThrow().getStatus()) + .isEqualTo(PushReminderJobStatus.DISPATCHING); + assertThat(pushReminderJobRepository.findById(processedJob.getId()).orElseThrow().getProcessedAt()) + .isEqualTo(NOW.minusSeconds(1)); + } + + @Test + void doesNotDeleteOrCancelJobsForExpiredReminder() { + PushReminderEntity reminder = save(7L, NOW.minusSeconds(1)); + PushReminderJobEntity job = saveJob(reminder.getId(), PushNotificationType.END); + flushAndClear(); + + new PushReminderPersistenceAdapter(pushReminderRepository, pushReminderJobRepository) + .cancelActiveByIdAndDeviceId(reminder.getId(), 7L, NOW); + flushAndClear(); + + assertThat(pushReminderRepository.findById(reminder.getId()).orElseThrow().getDeletedAt()).isNull(); + assertThat(pushReminderJobRepository.findById(job.getId()).orElseThrow().getStatus()) + .isEqualTo(PushReminderJobStatus.PENDING); + } + private PushReminderEntity save(Long deviceId, Instant endAt) { return pushReminderRepository.save(new PushReminderEntity(new PushReminderSaveCommand( deviceId, @@ -68,6 +129,10 @@ private PushReminderEntity save(Long deviceId, Instant endAt) { ))); } + private PushReminderJobEntity saveJob(Long reminderId, PushNotificationType type) { + return pushReminderJobRepository.save(new PushReminderJobEntity(reminderId, type, NOW.plusSeconds(30))); + } + private void flushAndClear() { entityManager.flush(); entityManager.clear(); diff --git a/src/test/java/com/zimdugo/push/infrastructure/scheduler/PushReminderDeliverySchedulerTest.java b/src/test/java/com/zimdugo/push/infrastructure/scheduler/PushReminderDeliverySchedulerTest.java index 05735af..6c7c0f5 100644 --- a/src/test/java/com/zimdugo/push/infrastructure/scheduler/PushReminderDeliverySchedulerTest.java +++ b/src/test/java/com/zimdugo/push/infrastructure/scheduler/PushReminderDeliverySchedulerTest.java @@ -2,10 +2,12 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; import com.zimdugo.push.domain.PushLocale; import com.zimdugo.push.domain.PushNotificationType; +import com.zimdugo.push.domain.PushReminderJobStatus; import com.zimdugo.push.domain.PushSubscription; import com.zimdugo.push.domain.WebPushSendResult; import com.zimdugo.push.domain.WebPushSender; @@ -20,6 +22,7 @@ import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; @ExtendWith(MockitoExtension.class) class PushReminderDeliverySchedulerTest { @@ -46,7 +49,9 @@ void preparesSendsAndCompletesDueJobInOrder() { Clock.fixed(now, ZoneOffset.UTC) ); PushReminderDeliveryProcessor.DeliveryCandidate candidate = candidate(); - given(pushReminderJobRepository.findTop100ByProcessedAtIsNullAndNextAttemptAtLessThanEqualOrderByFireAtAsc(now)) + given(pushReminderJobRepository.findDueJobs( + PushReminderJobStatus.PENDING, now, PageRequest.of(0, 100) + )) .willReturn(List.of(job)); given(job.getId()).willReturn(1L); given(pushReminderDeliveryProcessor.prepare(1L)).willReturn(candidate); @@ -66,11 +71,28 @@ void preparesSendsAndCompletesDueJobInOrder() { verifyNoMoreInteractions(pushReminderDeliveryProcessor, webPushSender); } + @Test + void doesNotSendWhenJobWasCancelledBeforeItCouldBeClaimed() { + Instant now = Instant.parse("2026-08-28T12:00:00Z"); + PushReminderDeliveryScheduler scheduler = new PushReminderDeliveryScheduler( + pushReminderJobRepository, + pushReminderDeliveryProcessor, + webPushSender, + Clock.fixed(now, ZoneOffset.UTC) + ); + given(pushReminderDeliveryProcessor.prepare(1L)).willReturn(null); + + scheduler.dispatch(1L); + + verifyNoInteractions(webPushSender); + } + private PushReminderDeliveryProcessor.DeliveryCandidate candidate() { return new PushReminderDeliveryProcessor.DeliveryCandidate( 1L, 2L, PushNotificationType.START, + 1, 2L, 3L, "https://fcm.googleapis.com/fcm/send/example-endpoint",