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:
@@ -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))));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.itcenter.acs.entity;
|
||||
|
||||
/** Delivery outcome of a visitor pass notification. */
|
||||
public enum DeliveryStatus {
|
||||
SENT,
|
||||
FAILED
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() : "방문자";
|
||||
|
||||
@@ -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<String, String> 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) {
|
||||
|
||||
@@ -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() : "방문자";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
76
backend/src/test/java/com/itcenter/acs/PassDeliveryTest.java
Normal file
76
backend/src/test/java/com/itcenter/acs/PassDeliveryTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user