feat(notify): P2 — 출입증 발송 outbox + 실패 자동/수동 재발송

- PassDelivery(pass_deliveries) outbox 엔티티: 발송 결과(SENT/FAILED)·채널·수신처·시도횟수·오류 기록.
- PassNotifier 계약 변경: 실패 시 예외 throw(+ channel()/recipient()). Hanbank/Email이 실패를
  더 이상 삼키지 않고 throw → PassDeliveryService가 결과를 outbox에 기록(승인은 롤백 안 됨).
- PassDeliveryRetryScheduler: 매 10분(acs.delivery.retry-cron) FAILED & attempts<max(기본5) 재발송.
- 관리 API: GET /api/admin/deliveries?status=FAILED, POST /api/admin/deliveries/{id}/retry (ADMIN).
- ApprovalService는 passDeliveryService.deliver(vr)로 위임(QR생성·발송·기록 일원화).
- Flyway V3__pass_delivery.sql. FlywayValidationTest가 V1+V2+V3를 엔티티와 대조.
- 테스트: PassDeliveryTest(발송 실패→FAILED 기록→재시도→SENT). 전체 8건 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
unknown
2026-07-03 15:43:42 +09:00
parent 6905dcaaff
commit 8ca939c057
15 changed files with 441 additions and 44 deletions

View File

@@ -0,0 +1,43 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.PassDeliveryResponse;
import com.itcenter.acs.entity.DeliveryStatus;
import com.itcenter.acs.service.PassDeliveryService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Pass-delivery outbox admin view + manual resend. Restricted to ADMIN by
* SecurityConfig (/api/admin/**).
*/
@RestController
@RequestMapping("/api/admin/deliveries")
@RequiredArgsConstructor
public class DeliveryController {
private final PassDeliveryService passDeliveryService;
@GetMapping
public ResponseEntity<ApiResponse<List<PassDeliveryResponse>>> list(
@RequestParam(value = "status", required = false) DeliveryStatus status) {
List<PassDeliveryResponse> items = passDeliveryService.listByStatus(status).stream()
.map(PassDeliveryResponse::from)
.toList();
return ResponseEntity.ok(ApiResponse.success(items));
}
@PostMapping("/{id}/retry")
public ResponseEntity<ApiResponse<PassDeliveryResponse>> retry(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(
PassDeliveryResponse.from(passDeliveryService.retryOne(id))));
}
}

View File

@@ -0,0 +1,31 @@
package com.itcenter.acs.dto;
import com.itcenter.acs.entity.PassDelivery;
import java.time.LocalDateTime;
/** Read model for a pass-delivery outbox record. */
public record PassDeliveryResponse(
Long id,
Long visitRequestId,
String channel,
String recipient,
String status,
int attempts,
String lastError,
LocalDateTime createdAt,
LocalDateTime updatedAt) {
public static PassDeliveryResponse from(PassDelivery d) {
return new PassDeliveryResponse(
d.getId(),
d.getVisitRequestId(),
d.getChannel(),
d.getRecipient(),
d.getStatus() != null ? d.getStatus().name() : null,
d.getAttempts(),
d.getLastError(),
d.getCreatedAt(),
d.getUpdatedAt());
}
}

View File

@@ -0,0 +1,7 @@
package com.itcenter.acs.entity;
/** Delivery outcome of a visitor pass notification. */
public enum DeliveryStatus {
SENT,
FAILED
}

View File

@@ -0,0 +1,46 @@
package com.itcenter.acs.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Outbox record of a visitor-pass delivery attempt. A FAILED row is retried by
* {@code PassDeliveryRetryScheduler} until it succeeds or hits the attempt cap.
*/
@Entity
@Table(name = "pass_deliveries", indexes = {
@Index(name = "idx_pd_status", columnList = "status"),
@Index(name = "idx_pd_visit_request", columnList = "visit_request_id")
})
@Getter
@Setter
@NoArgsConstructor
public class PassDelivery extends BaseEntity {
@Column(name = "visit_request_id", nullable = false)
private Long visitRequestId;
/** Delivery channel: "dev" / "hanbank" / "email". */
@Column(length = 20)
private String channel;
@Column(length = 120)
private String recipient;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private DeliveryStatus status;
@Column(nullable = false)
private int attempts;
@Column(name = "last_error", length = 500)
private String lastError;
}

View File

@@ -50,8 +50,7 @@ public class EmailPassNotifier implements PassNotifier {
Visitor visitor = visitRequest.getVisitor(); Visitor visitor = visitRequest.getVisitor();
String to = visitor != null ? visitor.getEmail() : null; String to = visitor != null ? visitor.getEmail() : null;
if (to == null || to.isBlank()) { if (to == null || to.isBlank()) {
log.warn("[email] 방문자 이메일이 없어 출입증 메일을 발송하지 못했습니다. visitRequestId={}", visitRequest.getId()); throw new IllegalStateException("방문자 이메일이 없어 출입증 메일을 발송할 수 없습니다.");
return;
} }
try { try {
@@ -66,10 +65,22 @@ public class EmailPassNotifier implements PassNotifier {
mailSender.send(message); mailSender.send(message);
log.info("[email] 출입증 메일 발송 성공 → {} (from={}, QR {} bytes)", to, fromAddress, qrPng.length); log.info("[email] 출입증 메일 발송 성공 → {} (from={}, QR {} bytes)", to, fromAddress, qrPng.length);
} catch (Exception e) { } catch (Exception e) {
log.warn("[email] 출입증 메일 발송 실패 → {} : {}", to, e.getMessage()); // wrap so the caller records a retryable failure
throw new IllegalStateException("메일 발송 실패 → " + to + " : " + e.getMessage(), e);
} }
} }
@Override
public String channel() {
return "email";
}
@Override
public String recipient(VisitRequest visitRequest) {
Visitor visitor = visitRequest.getVisitor();
return visitor != null ? visitor.getEmail() : null;
}
private String buildBody(VisitRequest vr) { private String buildBody(VisitRequest vr) {
Visitor visitor = vr.getVisitor(); Visitor visitor = vr.getVisitor();
String name = visitor != null ? visitor.getName() : "방문자"; String name = visitor != null ? visitor.getName() : "방문자";

View File

@@ -45,8 +45,7 @@ public class HanbankMessagePassNotifier implements PassNotifier {
Visitor visitor = visitRequest.getVisitor(); Visitor visitor = visitRequest.getVisitor();
String phone = visitor != null ? digitsOnly(visitor.getContact()) : ""; String phone = visitor != null ? digitsOnly(visitor.getContact()) : "";
if (phone.isBlank()) { if (phone.isBlank()) {
log.warn("[sms] 방문자 연락처가 없어 출입증 문자를 발송하지 못했습니다. visitRequestId={}", visitRequest.getId()); throw new IllegalStateException("방문자 연락처가 없어 출입증 문자를 발송할 수 없습니다.");
return;
} }
Map<String, String> body = Map.of( Map<String, String> body = Map.of(
@@ -55,22 +54,29 @@ public class HanbankMessagePassNotifier implements PassNotifier {
"msg_type", "LMS", "msg_type", "LMS",
"reserve_time", ""); "reserve_time", "");
try {
SmsResponse res = restClient.post() SmsResponse res = restClient.post()
.uri("/sens/sms") .uri("/sens/sms")
.body(body) .body(body)
.retrieve() .retrieve()
.body(SmsResponse.class); .body(SmsResponse.class);
if (res != null && "202".equals(res.statusCode())) { if (res == null || !"202".equals(res.statusCode())) {
throw new IllegalStateException("LMS 발송 실패 (statusCode="
+ (res != null ? res.statusCode() : "null")
+ ", statusName=" + (res != null ? res.statusName() : "null") + ")");
}
log.info("[sms] 출입증 LMS 발송 성공 → {} (requestId={})", phone, res.requestId()); log.info("[sms] 출입증 LMS 발송 성공 → {} (requestId={})", phone, res.requestId());
} else {
log.warn("[sms] 출입증 LMS 발송 실패 → {} (statusCode={}, statusName={})",
phone, res != null ? res.statusCode() : "null", res != null ? res.statusName() : "null");
} }
} catch (Exception e) {
log.warn("[sms] 출입증 LMS 발송 호출 오류 → {} : {}", phone, e.getMessage()); @Override
public String channel() {
return "hanbank";
} }
@Override
public String recipient(VisitRequest visitRequest) {
Visitor visitor = visitRequest.getVisitor();
return visitor != null ? visitor.getContact() : null;
} }
private String buildContent(VisitRequest vr) { private String buildContent(VisitRequest vr) {

View File

@@ -50,6 +50,17 @@ public class LoggingPassNotifier implements PassNotifier {
saved != null ? " 이미지=" + saved : " (이미지 저장 실패)", message); saved != null ? " 이미지=" + saved : " (이미지 저장 실패)", message);
} }
@Override
public String channel() {
return "dev";
}
@Override
public String recipient(VisitRequest visitRequest) {
Visitor visitor = visitRequest.getVisitor();
return visitor != null ? visitor.getContact() : null;
}
private String buildMessage(VisitRequest vr) { private String buildMessage(VisitRequest vr) {
Visitor visitor = vr.getVisitor(); Visitor visitor = vr.getVisitor();
String name = visitor != null ? visitor.getName() : "방문자"; String name = visitor != null ? visitor.getName() : "방문자";

View File

@@ -18,6 +18,15 @@ public interface PassNotifier {
* *
* @param visitRequest the approved request (carries visitor, phone, window) * @param visitRequest the approved request (carries visitor, phone, window)
* @param qrPng PNG bytes of the pass QR to attach/send * @param qrPng PNG bytes of the pass QR to attach/send
* @throws RuntimeException if delivery fails — the caller ({@code PassDeliveryService})
* records the failure so it can be retried. Implementations
* must NOT swallow delivery errors.
*/ */
void sendPass(VisitRequest visitRequest, byte[] qrPng); void sendPass(VisitRequest visitRequest, byte[] qrPng);
/** Short channel identifier for the delivery record (e.g. "dev", "hanbank", "email"). */
String channel();
/** The address this channel delivers to for the given request (phone or email); may be null. */
String recipient(VisitRequest visitRequest);
} }

View File

@@ -0,0 +1,17 @@
package com.itcenter.acs.repository;
import com.itcenter.acs.entity.DeliveryStatus;
import com.itcenter.acs.entity.PassDelivery;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PassDeliveryRepository extends JpaRepository<PassDelivery, Long> {
/** Failed deliveries still under the retry cap, oldest first. */
List<PassDelivery> findByStatusAndAttemptsLessThanOrderByCreatedAtAsc(DeliveryStatus status, int maxAttempts);
List<PassDelivery> findByStatusOrderByCreatedAtDesc(DeliveryStatus status);
List<PassDelivery> findTop200ByOrderByCreatedAtDesc();
}

View File

@@ -6,7 +6,6 @@ import com.itcenter.acs.entity.User;
import com.itcenter.acs.entity.VisitRequest; import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus; import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.exception.ApiException; import com.itcenter.acs.exception.ApiException;
import com.itcenter.acs.notification.PassNotifier;
import com.itcenter.acs.repository.ApprovalRepository; import com.itcenter.acs.repository.ApprovalRepository;
import com.itcenter.acs.repository.UserRepository; import com.itcenter.acs.repository.UserRepository;
import com.itcenter.acs.repository.VisitRequestRepository; import com.itcenter.acs.repository.VisitRequestRepository;
@@ -24,14 +23,10 @@ import java.util.UUID;
@Transactional @Transactional
public class ApprovalService { public class ApprovalService {
/** QR pixel size for the pass image sent to the visitor. */
private static final int PASS_QR_SIZE = 240;
private final VisitRequestRepository visitRequestRepository; private final VisitRequestRepository visitRequestRepository;
private final ApprovalRepository approvalRepository; private final ApprovalRepository approvalRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final QrService qrService; private final PassDeliveryService passDeliveryService;
private final PassNotifier passNotifier;
private final AuditService auditService; private final AuditService auditService;
public VisitRequest approve(Long visitRequestId, Long approverId, String comment) { public VisitRequest approve(Long visitRequestId, Long approverId, String comment) {
@@ -77,23 +72,11 @@ public class ApprovalService {
"방문자=" + visitorName + (comment != null && !comment.isBlank() ? ", 의견=" + comment : "")); "방문자=" + visitorName + (comment != null && !comment.isBlank() ? ", 의견=" + comment : ""));
if (decision == ApprovalDecision.APPROVED) { if (decision == ApprovalDecision.APPROVED) {
notifyVisitor(vr); // Delivery records its own outcome (SENT/FAILED) and never throws, so a
// delivery problem cannot roll back the approval; failures are retried later.
passDeliveryService.deliver(vr);
} }
return vr; return vr;
} }
/**
* Sends the freshly issued pass to the visitor. A delivery failure must not
* roll back the approval, so it is caught and logged rather than propagated.
*/
private void notifyVisitor(VisitRequest vr) {
try {
byte[] qrPng = qrService.pngForText(vr.getQrToken(), PASS_QR_SIZE);
passNotifier.sendPass(vr, qrPng);
} catch (Exception e) {
log.warn("[approval] 출입증 발송 실패 (승인은 정상 처리됨). visitRequestId={} err={}",
vr.getId(), e.getMessage());
}
}
} }

View File

@@ -0,0 +1,27 @@
package com.itcenter.acs.service;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
/**
* Periodically retries failed pass deliveries recorded in the outbox, up to
* {@code acs.delivery.max-attempts}. Thin wrapper — the send/record logic lives
* in {@link PassDeliveryService}.
*/
@Service
@RequiredArgsConstructor
public class PassDeliveryRetryScheduler {
private final PassDeliveryService passDeliveryService;
@Value("${acs.delivery.max-attempts:5}")
private int maxAttempts;
/** Every 10 minutes by default; override with acs.delivery.retry-cron. */
@Scheduled(cron = "${acs.delivery.retry-cron:0 */10 * * * *}")
public void retryFailed() {
passDeliveryService.retryFailed(maxAttempts);
}
}

View File

@@ -0,0 +1,114 @@
package com.itcenter.acs.service;
import com.itcenter.acs.entity.DeliveryStatus;
import com.itcenter.acs.entity.PassDelivery;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.exception.ApiException;
import com.itcenter.acs.notification.PassNotifier;
import com.itcenter.acs.repository.PassDeliveryRepository;
import com.itcenter.acs.repository.VisitRequestRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* Sends the visitor pass and records the outcome in the {@code pass_deliveries}
* outbox. A failed send is persisted (status=FAILED) so it can be retried later
* by {@code PassDeliveryRetryScheduler} or an admin, rather than being lost.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PassDeliveryService {
/** QR pixel size for the pass image. */
private static final int PASS_QR_SIZE = 240;
private final PassDeliveryRepository passDeliveryRepository;
private final VisitRequestRepository visitRequestRepository;
private final QrService qrService;
private final PassNotifier notifier;
/** First delivery attempt, invoked right after approval. Never throws. */
@Transactional
public PassDelivery deliver(VisitRequest vr) {
PassDelivery d = new PassDelivery();
d.setVisitRequestId(vr.getId());
d.setChannel(notifier.channel());
d.setRecipient(notifier.recipient(vr));
d.setAttempts(1);
attempt(d, vr);
return passDeliveryRepository.save(d);
}
/** Retries every failed delivery still under the attempt cap. Returns how many now succeeded. */
@Transactional
public int retryFailed(int maxAttempts) {
List<PassDelivery> failed =
passDeliveryRepository.findByStatusAndAttemptsLessThanOrderByCreatedAtAsc(DeliveryStatus.FAILED, maxAttempts);
int recovered = 0;
for (PassDelivery d : failed) {
if (retry(d)) {
recovered++;
}
}
if (!failed.isEmpty()) {
log.info("[delivery] 재발송 시도 {}건 중 {}건 성공", failed.size(), recovered);
}
return recovered;
}
/** Manually retry a single delivery (admin action). */
@Transactional
public PassDelivery retryOne(Long deliveryId) {
PassDelivery d = passDeliveryRepository.findById(deliveryId)
.orElseThrow(() -> ApiException.notFound("발송 기록을 찾을 수 없습니다."));
retry(d);
return d;
}
@Transactional(readOnly = true)
public List<PassDelivery> listByStatus(DeliveryStatus status) {
return status != null
? passDeliveryRepository.findByStatusOrderByCreatedAtDesc(status)
: passDeliveryRepository.findTop200ByOrderByCreatedAtDesc();
}
/** Re-attempts a managed delivery record (dirty-checked). Returns true if it now succeeded. */
private boolean retry(PassDelivery d) {
VisitRequest vr = visitRequestRepository.findById(d.getVisitRequestId()).orElse(null);
d.setAttempts(d.getAttempts() + 1);
if (vr == null) {
d.setStatus(DeliveryStatus.FAILED);
d.setLastError("방문 신청을 찾을 수 없습니다. (id=" + d.getVisitRequestId() + ")");
return false;
}
attempt(d, vr);
return d.getStatus() == DeliveryStatus.SENT;
}
/** Generates the QR and sends via the notifier, setting status/lastError on the record. */
private void attempt(PassDelivery d, VisitRequest vr) {
try {
byte[] qr = qrService.pngForText(vr.getQrToken(), PASS_QR_SIZE);
notifier.sendPass(vr, qr);
d.setStatus(DeliveryStatus.SENT);
d.setLastError(null);
} catch (Exception e) {
d.setStatus(DeliveryStatus.FAILED);
d.setLastError(truncate(e.getMessage()));
log.warn("[delivery] 출입증 발송 실패 (visitRequestId={}, attempts={}): {}",
d.getVisitRequestId(), d.getAttempts(), e.getMessage());
}
}
private static String truncate(String s) {
if (s == null) {
return null;
}
return s.length() <= 500 ? s : s.substring(0, 500);
}
}

View File

@@ -0,0 +1,16 @@
-- 출입증 발송 outbox: 발송 결과 기록 + 실패건 재발송 추적
-- Hibernate ddl-auto=validate 가 검증하므로 PassDelivery 엔티티와 컬럼명·타입이 일치해야 한다.
CREATE TABLE pass_deliveries (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
visit_request_id BIGINT NOT NULL,
channel VARCHAR(20),
recipient VARCHAR(120),
status VARCHAR(20) NOT NULL,
attempts INTEGER NOT NULL,
last_error VARCHAR(500)
);
CREATE INDEX idx_pd_status ON pass_deliveries (status);
CREATE INDEX idx_pd_visit_request ON pass_deliveries (visit_request_id);

View File

@@ -64,7 +64,7 @@ class EmailPassNotifierTest {
} }
@Test @Test
void skipsWhenVisitorHasNoEmail() { void throwsWhenVisitorHasNoEmail() {
JavaMailSender sender = mock(JavaMailSender.class); JavaMailSender sender = mock(JavaMailSender.class);
EmailPassNotifier notifier = EmailPassNotifier notifier =
new EmailPassNotifier(sender, "dept_itcm000@bok.or.kr", "http://localhost:5173"); new EmailPassNotifier(sender, "dept_itcm000@bok.or.kr", "http://localhost:5173");
@@ -77,9 +77,9 @@ class EmailPassNotifierTest {
vr.setVisitTo(LocalDateTime.now().plusHours(1)); vr.setVisitTo(LocalDateTime.now().plusHours(1));
vr.setQrToken("t"); vr.setQrToken("t");
notifier.sendPass(vr, new byte[]{1}); // no email → delivery failure is signalled (recorded/retried by PassDeliveryService)
org.assertj.core.api.Assertions.assertThatThrownBy(() -> notifier.sendPass(vr, new byte[]{1}))
// no email → never touches the mail sender .isInstanceOf(RuntimeException.class);
verify(sender, org.mockito.Mockito.never()).send(org.mockito.Mockito.any(MimeMessage.class)); verify(sender, org.mockito.Mockito.never()).send(org.mockito.Mockito.any(MimeMessage.class));
} }
} }

View File

@@ -0,0 +1,76 @@
package com.itcenter.acs;
import com.itcenter.acs.entity.DeliveryStatus;
import com.itcenter.acs.entity.PassDelivery;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.entity.Visitor;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.notification.PassNotifier;
import com.itcenter.acs.repository.PassDeliveryRepository;
import com.itcenter.acs.repository.UserRepository;
import com.itcenter.acs.repository.VisitRequestRepository;
import com.itcenter.acs.repository.VisitorRepository;
import com.itcenter.acs.service.PassDeliveryService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
/** A failed send is recorded as FAILED and later flipped to SENT by the retry batch. */
@SpringBootTest
class PassDeliveryTest {
@MockitoBean PassNotifier notifier;
@Autowired PassDeliveryService passDeliveryService;
@Autowired VisitRequestRepository visitRequestRepository;
@Autowired VisitorRepository visitorRepository;
@Autowired UserRepository userRepository;
@Autowired PassDeliveryRepository passDeliveryRepository;
@Test
void failedDeliveryIsRecordedThenRetriedToSent() {
when(notifier.channel()).thenReturn("test");
User host = userRepository.findByUsername("host").orElseThrow();
Visitor v = new Visitor();
v.setName("발송테스트-" + System.nanoTime());
v.setEmail("visitor@example.com");
visitorRepository.save(v);
LocalDate today = LocalDate.now();
VisitRequest vr = new VisitRequest();
vr.setVisitor(v);
vr.setHost(host);
vr.setPurpose("delivery");
vr.setVisitFrom(today.atStartOfDay());
vr.setVisitTo(today.atTime(23, 59));
vr.setStatus(VisitStatus.APPROVED);
vr.setQrToken(UUID.randomUUID().toString());
visitRequestRepository.save(vr);
// 1) send fails → recorded FAILED
doThrow(new RuntimeException("relay down")).when(notifier).sendPass(any(), any());
PassDelivery d = passDeliveryService.deliver(vr);
assertThat(d.getStatus()).isEqualTo(DeliveryStatus.FAILED);
assertThat(d.getLastError()).contains("relay down");
Long deliveryId = d.getId();
// 2) relay recovers → retry batch flips it to SENT
doNothing().when(notifier).sendPass(any(), any());
int recovered = passDeliveryService.retryFailed(5);
assertThat(recovered).isGreaterThanOrEqualTo(1);
assertThat(passDeliveryRepository.findById(deliveryId).orElseThrow().getStatus())
.isEqualTo(DeliveryStatus.SENT);
}
}