From 8ca939c057c0bfb92ade1b27f12f1bf808640b23 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 3 Jul 2026 15:43:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(notify):=20P2=20=E2=80=94=20=EC=B6=9C?= =?UTF-8?q?=EC=9E=85=EC=A6=9D=20=EB=B0=9C=EC=86=A1=20outbox=20+=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=9E=90=EB=8F=99/=EC=88=98=EB=8F=99=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PassDelivery(pass_deliveries) outbox 엔티티: 발송 결과(SENT/FAILED)·채널·수신처·시도횟수·오류 기록. - PassNotifier 계약 변경: 실패 시 예외 throw(+ channel()/recipient()). Hanbank/Email이 실패를 더 이상 삼키지 않고 throw → PassDeliveryService가 결과를 outbox에 기록(승인은 롤백 안 됨). - PassDeliveryRetryScheduler: 매 10분(acs.delivery.retry-cron) FAILED & attempts --- .../acs/controller/DeliveryController.java | 43 +++++++ .../acs/dto/PassDeliveryResponse.java | 31 +++++ .../itcenter/acs/entity/DeliveryStatus.java | 7 ++ .../com/itcenter/acs/entity/PassDelivery.java | 46 +++++++ .../acs/notification/EmailPassNotifier.java | 17 ++- .../HanbankMessagePassNotifier.java | 38 +++--- .../acs/notification/LoggingPassNotifier.java | 11 ++ .../acs/notification/PassNotifier.java | 9 ++ .../repository/PassDeliveryRepository.java | 17 +++ .../itcenter/acs/service/ApprovalService.java | 25 +--- .../service/PassDeliveryRetryScheduler.java | 27 +++++ .../acs/service/PassDeliveryService.java | 114 ++++++++++++++++++ .../db/migration/V3__pass_delivery.sql | 16 +++ .../itcenter/acs/EmailPassNotifierTest.java | 8 +- .../com/itcenter/acs/PassDeliveryTest.java | 76 ++++++++++++ 15 files changed, 441 insertions(+), 44 deletions(-) create mode 100644 backend/src/main/java/com/itcenter/acs/controller/DeliveryController.java create mode 100644 backend/src/main/java/com/itcenter/acs/dto/PassDeliveryResponse.java create mode 100644 backend/src/main/java/com/itcenter/acs/entity/DeliveryStatus.java create mode 100644 backend/src/main/java/com/itcenter/acs/entity/PassDelivery.java create mode 100644 backend/src/main/java/com/itcenter/acs/repository/PassDeliveryRepository.java create mode 100644 backend/src/main/java/com/itcenter/acs/service/PassDeliveryRetryScheduler.java create mode 100644 backend/src/main/java/com/itcenter/acs/service/PassDeliveryService.java create mode 100644 backend/src/main/resources/db/migration/V3__pass_delivery.sql create mode 100644 backend/src/test/java/com/itcenter/acs/PassDeliveryTest.java diff --git a/backend/src/main/java/com/itcenter/acs/controller/DeliveryController.java b/backend/src/main/java/com/itcenter/acs/controller/DeliveryController.java new file mode 100644 index 0000000..22b0b35 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/controller/DeliveryController.java @@ -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>> list( + @RequestParam(value = "status", required = false) DeliveryStatus status) { + List items = passDeliveryService.listByStatus(status).stream() + .map(PassDeliveryResponse::from) + .toList(); + return ResponseEntity.ok(ApiResponse.success(items)); + } + + @PostMapping("/{id}/retry") + public ResponseEntity> retry(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success( + PassDeliveryResponse.from(passDeliveryService.retryOne(id)))); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/PassDeliveryResponse.java b/backend/src/main/java/com/itcenter/acs/dto/PassDeliveryResponse.java new file mode 100644 index 0000000..6c3c69b --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/PassDeliveryResponse.java @@ -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()); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/DeliveryStatus.java b/backend/src/main/java/com/itcenter/acs/entity/DeliveryStatus.java new file mode 100644 index 0000000..d64c467 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/DeliveryStatus.java @@ -0,0 +1,7 @@ +package com.itcenter.acs.entity; + +/** Delivery outcome of a visitor pass notification. */ +public enum DeliveryStatus { + SENT, + FAILED +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/PassDelivery.java b/backend/src/main/java/com/itcenter/acs/entity/PassDelivery.java new file mode 100644 index 0000000..1962969 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/PassDelivery.java @@ -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; +} diff --git a/backend/src/main/java/com/itcenter/acs/notification/EmailPassNotifier.java b/backend/src/main/java/com/itcenter/acs/notification/EmailPassNotifier.java index 762a0a2..848bb87 100644 --- a/backend/src/main/java/com/itcenter/acs/notification/EmailPassNotifier.java +++ b/backend/src/main/java/com/itcenter/acs/notification/EmailPassNotifier.java @@ -50,8 +50,7 @@ public class EmailPassNotifier implements PassNotifier { Visitor visitor = visitRequest.getVisitor(); String to = visitor != null ? visitor.getEmail() : null; if (to == null || to.isBlank()) { - log.warn("[email] 방문자 이메일이 없어 출입증 메일을 발송하지 못했습니다. visitRequestId={}", visitRequest.getId()); - return; + throw new IllegalStateException("방문자 이메일이 없어 출입증 메일을 발송할 수 없습니다."); } try { @@ -66,10 +65,22 @@ public class EmailPassNotifier implements PassNotifier { mailSender.send(message); log.info("[email] 출입증 메일 발송 성공 → {} (from={}, QR {} bytes)", to, fromAddress, qrPng.length); } 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) { Visitor visitor = vr.getVisitor(); String name = visitor != null ? visitor.getName() : "방문자"; diff --git a/backend/src/main/java/com/itcenter/acs/notification/HanbankMessagePassNotifier.java b/backend/src/main/java/com/itcenter/acs/notification/HanbankMessagePassNotifier.java index fd62f79..f4b524a 100644 --- a/backend/src/main/java/com/itcenter/acs/notification/HanbankMessagePassNotifier.java +++ b/backend/src/main/java/com/itcenter/acs/notification/HanbankMessagePassNotifier.java @@ -45,8 +45,7 @@ public class HanbankMessagePassNotifier implements PassNotifier { Visitor visitor = visitRequest.getVisitor(); String phone = visitor != null ? digitsOnly(visitor.getContact()) : ""; if (phone.isBlank()) { - log.warn("[sms] 방문자 연락처가 없어 출입증 문자를 발송하지 못했습니다. visitRequestId={}", visitRequest.getId()); - return; + throw new IllegalStateException("방문자 연락처가 없어 출입증 문자를 발송할 수 없습니다."); } Map body = Map.of( @@ -55,22 +54,29 @@ public class HanbankMessagePassNotifier implements PassNotifier { "msg_type", "LMS", "reserve_time", ""); - try { - SmsResponse res = restClient.post() - .uri("/sens/sms") - .body(body) - .retrieve() - .body(SmsResponse.class); + SmsResponse res = restClient.post() + .uri("/sens/sms") + .body(body) + .retrieve() + .body(SmsResponse.class); - if (res != null && "202".equals(res.statusCode())) { - 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()); + 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()); + } + + @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) { diff --git a/backend/src/main/java/com/itcenter/acs/notification/LoggingPassNotifier.java b/backend/src/main/java/com/itcenter/acs/notification/LoggingPassNotifier.java index 231db5b..240f7e1 100644 --- a/backend/src/main/java/com/itcenter/acs/notification/LoggingPassNotifier.java +++ b/backend/src/main/java/com/itcenter/acs/notification/LoggingPassNotifier.java @@ -50,6 +50,17 @@ public class LoggingPassNotifier implements PassNotifier { 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) { Visitor visitor = vr.getVisitor(); String name = visitor != null ? visitor.getName() : "방문자"; diff --git a/backend/src/main/java/com/itcenter/acs/notification/PassNotifier.java b/backend/src/main/java/com/itcenter/acs/notification/PassNotifier.java index 56a6b0c..73bb769 100644 --- a/backend/src/main/java/com/itcenter/acs/notification/PassNotifier.java +++ b/backend/src/main/java/com/itcenter/acs/notification/PassNotifier.java @@ -18,6 +18,15 @@ public interface PassNotifier { * * @param visitRequest the approved request (carries visitor, phone, window) * @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); + + /** 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); } diff --git a/backend/src/main/java/com/itcenter/acs/repository/PassDeliveryRepository.java b/backend/src/main/java/com/itcenter/acs/repository/PassDeliveryRepository.java new file mode 100644 index 0000000..ffc28ef --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/repository/PassDeliveryRepository.java @@ -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 { + + /** Failed deliveries still under the retry cap, oldest first. */ + List findByStatusAndAttemptsLessThanOrderByCreatedAtAsc(DeliveryStatus status, int maxAttempts); + + List findByStatusOrderByCreatedAtDesc(DeliveryStatus status); + + List findTop200ByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/com/itcenter/acs/service/ApprovalService.java b/backend/src/main/java/com/itcenter/acs/service/ApprovalService.java index abc79e7..fba945e 100644 --- a/backend/src/main/java/com/itcenter/acs/service/ApprovalService.java +++ b/backend/src/main/java/com/itcenter/acs/service/ApprovalService.java @@ -6,7 +6,6 @@ import com.itcenter.acs.entity.User; import com.itcenter.acs.entity.VisitRequest; import com.itcenter.acs.entity.VisitStatus; import com.itcenter.acs.exception.ApiException; -import com.itcenter.acs.notification.PassNotifier; import com.itcenter.acs.repository.ApprovalRepository; import com.itcenter.acs.repository.UserRepository; import com.itcenter.acs.repository.VisitRequestRepository; @@ -24,14 +23,10 @@ import java.util.UUID; @Transactional 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 ApprovalRepository approvalRepository; private final UserRepository userRepository; - private final QrService qrService; - private final PassNotifier passNotifier; + private final PassDeliveryService passDeliveryService; private final AuditService auditService; public VisitRequest approve(Long visitRequestId, Long approverId, String comment) { @@ -77,23 +72,11 @@ public class ApprovalService { "방문자=" + visitorName + (comment != null && !comment.isBlank() ? ", 의견=" + comment : "")); 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; } - - /** - * 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()); - } - } } diff --git a/backend/src/main/java/com/itcenter/acs/service/PassDeliveryRetryScheduler.java b/backend/src/main/java/com/itcenter/acs/service/PassDeliveryRetryScheduler.java new file mode 100644 index 0000000..01c7311 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/service/PassDeliveryRetryScheduler.java @@ -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); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/service/PassDeliveryService.java b/backend/src/main/java/com/itcenter/acs/service/PassDeliveryService.java new file mode 100644 index 0000000..67c01e8 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/service/PassDeliveryService.java @@ -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 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 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); + } +} diff --git a/backend/src/main/resources/db/migration/V3__pass_delivery.sql b/backend/src/main/resources/db/migration/V3__pass_delivery.sql new file mode 100644 index 0000000..cf3881c --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__pass_delivery.sql @@ -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); diff --git a/backend/src/test/java/com/itcenter/acs/EmailPassNotifierTest.java b/backend/src/test/java/com/itcenter/acs/EmailPassNotifierTest.java index a61b160..527c75f 100644 --- a/backend/src/test/java/com/itcenter/acs/EmailPassNotifierTest.java +++ b/backend/src/test/java/com/itcenter/acs/EmailPassNotifierTest.java @@ -64,7 +64,7 @@ class EmailPassNotifierTest { } @Test - void skipsWhenVisitorHasNoEmail() { + void throwsWhenVisitorHasNoEmail() { JavaMailSender sender = mock(JavaMailSender.class); EmailPassNotifier notifier = new EmailPassNotifier(sender, "dept_itcm000@bok.or.kr", "http://localhost:5173"); @@ -77,9 +77,9 @@ class EmailPassNotifierTest { vr.setVisitTo(LocalDateTime.now().plusHours(1)); vr.setQrToken("t"); - notifier.sendPass(vr, new byte[]{1}); - - // no email → never touches the mail sender + // no email → delivery failure is signalled (recorded/retried by PassDeliveryService) + org.assertj.core.api.Assertions.assertThatThrownBy(() -> notifier.sendPass(vr, new byte[]{1})) + .isInstanceOf(RuntimeException.class); verify(sender, org.mockito.Mockito.never()).send(org.mockito.Mockito.any(MimeMessage.class)); } } diff --git a/backend/src/test/java/com/itcenter/acs/PassDeliveryTest.java b/backend/src/test/java/com/itcenter/acs/PassDeliveryTest.java new file mode 100644 index 0000000..8eda558 --- /dev/null +++ b/backend/src/test/java/com/itcenter/acs/PassDeliveryTest.java @@ -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); + } +}