diff --git a/src/main/java/com/offway/core/leave/controller/LeaveApi.java b/src/main/java/com/offway/core/leave/controller/LeaveApi.java index e0969580..a32fa6df 100644 --- a/src/main/java/com/offway/core/leave/controller/LeaveApi.java +++ b/src/main/java/com/offway/core/leave/controller/LeaveApi.java @@ -13,7 +13,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; import java.time.LocalDate; -/** 연차·가용시간 API 문서 계약. 매핑·검증 어노테이션은 구현체({@link LeaveController})가 소유한다. */ +/** + * 연차·가용시간 API 문서 계약. 매핑·검증 어노테이션은 구현체({@link LeaveController})가 소유한다. + * + *
여기 엔드포인트는 전부 인증 게이트 뒤에 있다({@code anyRequest().authenticated()}, #122) — 그래서 어느
+ * 메서드든 401 이 도달 가능하고, 전수 문서화 대상이다. 한 메서드에만 적으면 나머지가 공개로 읽힌다.
+ */
@Tag(name = "연차", description = "연차 기반 가용시간(LNT)·샌드위치 연휴·내 연차")
public interface LeaveApi {
@@ -22,6 +27,7 @@ public interface LeaveApi {
description = "총 연차·쓴 연차·남은 연차와 사용 내역. 아직 설정한 적이 없으면 총 0·내역 없음으로 답한다(404 아님).")
@ApiResponse(responseCode = "200", description = "조회 성공")
@ApiResponse(responseCode = "400", description = "X-Guest-Id 헤더 누락 · 헤더가 비었거나 64자 초과")
+ @ApiResponse(responseCode = "401", description = "인증 필요")
ApiResponseBody 음수를 허용한다 — 코스를 취소하면 쓴 연차를 되돌려야 하고, 그걸 내역을 지워서 하면 "언제 무엇이
- * 취소됐는지" 가 사라진다. 다만 0 은 막는다: 아무것도 바꾸지 않는 내역은 기록이 아니라 소음이다.
+ * 0 은 아무것도 바꾸지 않아 기록이 아니라 소음이다.
+ *
+ * 음수를 계속 받는 것은 한시적이다(#276 에서 닫는다). 취소는 이제 {@code DELETE /me/usages/{id}}
+ * 로 하는 것이 맞고, 상쇄 등록은 취소가 아니라 새 기록이라 같은 취소가 두 번 들어오면 사용 합이 음수로
+ * 내려간다. 그런데 거절을 지금 켜면 앱이 깨진다 — 앱은 삭제 API 가 배포된 뒤에야 갈아탈 수 있어
+ * 순서가 백엔드 → 프론트로 고정이고, 그 사이 구간에서 취소가 400 을 받아 사용자가 취소를 아예 못 한다.
+ *
+ * 그동안 사용자가 보는 증상은 막혀 있다 — 사용 합이 음수로 내려가도 {@link LeaveSummary} 가 잘라서
+ * 잔여가 총 연차를 넘지 않는다(#265). 틀어진 장부는 삭제 API 로 정리한다.
*/
public static boolean isValidUsage(double days) {
- return isValidUnit(days) && days != 0 && Math.abs(days) <= MAX_TOTAL;
+ return isValidUnit(days) && days != NONE && Math.abs(days) <= MAX_TOTAL;
}
/**
diff --git a/src/main/java/com/offway/core/leave/domain/LeaveErrorCode.java b/src/main/java/com/offway/core/leave/domain/LeaveErrorCode.java
index 5c3b8a91..dbb8f34d 100644
--- a/src/main/java/com/offway/core/leave/domain/LeaveErrorCode.java
+++ b/src/main/java/com/offway/core/leave/domain/LeaveErrorCode.java
@@ -49,7 +49,24 @@ public enum LeaveErrorCode implements ErrorCode {
"LEAVE-010", ErrorCategory.BAD_REQUEST, "연차 증감은 0.5일 단위여야 하고 0일은 기록할 수 없습니다."),
/** 소유 키 헤더가 비었거나 너무 김 — 헤더 자체가 없으면 프레임워크가 먼저 COMMON-400 으로 막는다. */
- INVALID_OWNER_ID("LEAVE-011", ErrorCategory.BAD_REQUEST, "사용자 식별값이 올바르지 않습니다.");
+ INVALID_OWNER_ID("LEAVE-011", ErrorCategory.BAD_REQUEST, "사용자 식별값이 올바르지 않습니다."),
+
+ /** 지우려는 사용 내역이 없거나 남의 것 — 둘을 구분해 답하지 않는다(존재 여부를 흘리지 않는다). */
+ LEAVE_USAGE_NOT_FOUND("LEAVE-012", ErrorCategory.NOT_FOUND, "연차 사용 내역을 찾을 수 없습니다."),
+
+ /**
+ * 사용 내역을 음수로 등록하려 함 — 취소는 상쇄 등록이 아니라 삭제다.
+ *
+ * 아직 아무도 던지지 않는다. 죽은 코드가 아니라 자리를 잡아둔 것이다 — #276 이 이 코드를 그대로
+ * 쓴다. #265 에서 거절까지 함께 넣었다가, 앱이 삭제 API 로 갈아타기 전에 배포되면 그 구간에서 취소가
+ * 끊긴다는 것이 드러나 거절만 떼어냈다. 번호는 append-only 라(재사용·재배치 금지) 되돌리면서 지우지 않았다.
+ */
+ LEAVE_USAGE_REVERSAL_NOT_ALLOWED(
+ "LEAVE-013", ErrorCategory.BAD_REQUEST, "연차 사용은 0.5일 단위의 양수여야 합니다. 되돌리려면 해당 내역을 삭제해 주세요."),
+
+ /** 코스 확정으로 기록된 내역을 연차 화면에서 지우려 함 — 코스 쪽 차감 취소로만 되돌릴 수 있다. */
+ COURSE_LEAVE_USAGE_NOT_DELETABLE(
+ "LEAVE-014", ErrorCategory.CONFLICT, "코스 확정으로 기록된 연차입니다. 코스에서 차감을 취소해 주세요.");
private final String code;
private final ErrorCategory category;
diff --git a/src/main/java/com/offway/core/leave/domain/LeaveException.java b/src/main/java/com/offway/core/leave/domain/LeaveException.java
index cd8d93e7..c2aba809 100644
--- a/src/main/java/com/offway/core/leave/domain/LeaveException.java
+++ b/src/main/java/com/offway/core/leave/domain/LeaveException.java
@@ -64,4 +64,24 @@ public static LeaveException invalidLeaveUsageDays() {
public static LeaveException invalidOwnerId() {
return new LeaveException(LeaveErrorCode.INVALID_OWNER_ID);
}
+
+ /** 지우려는 사용 내역이 없거나 남의 것. */
+ public static LeaveException leaveUsageNotFound() {
+ return new LeaveException(LeaveErrorCode.LEAVE_USAGE_NOT_FOUND);
+ }
+
+ /**
+ * 사용 내역을 음수로 등록하려 함 — 취소는 삭제로 한다.
+ *
+ * 호출부는 #276 이 만든다({@link LeaveErrorCode#LEAVE_USAGE_REVERSAL_NOT_ALLOWED} 와 함께 자리만
+ * 잡아둔 것이다). 거절을 지금 켜면 앱이 삭제 API 로 갈아타기 전 구간에서 취소가 끊긴다.
+ */
+ public static LeaveException leaveUsageReversalNotAllowed() {
+ return new LeaveException(LeaveErrorCode.LEAVE_USAGE_REVERSAL_NOT_ALLOWED);
+ }
+
+ /** 코스 확정으로 기록된 내역을 연차 화면에서 지우려 함. */
+ public static LeaveException courseLeaveUsageNotDeletable() {
+ return new LeaveException(LeaveErrorCode.COURSE_LEAVE_USAGE_NOT_DELETABLE);
+ }
}
diff --git a/src/main/java/com/offway/core/leave/domain/LeaveSummary.java b/src/main/java/com/offway/core/leave/domain/LeaveSummary.java
index 5b28c1e7..2c47eda3 100644
--- a/src/main/java/com/offway/core/leave/domain/LeaveSummary.java
+++ b/src/main/java/com/offway/core/leave/domain/LeaveSummary.java
@@ -6,16 +6,62 @@
* 남은 값을 따로 저장하면 사용 내역과 어긋날 수 있다(차감은 됐는데 남은 값 갱신이 실패하는 식). 내역을 정본으로 두면
* 언제든 다시 계산해 맞출 수 있다.
*
+ * 이 값객체가 "잔여는 총 연차를 넘지 않는다" 를 보장한다(#265). 예전엔 원장 합을 그대로 빼서
+ * 내려줬는데, 합이 음수로 내려가면 잔여가 총보다 커졌다 — 총 15일에 2일 쓴 사람이 취소를 두 번 보내면
+ * 잔여가 17이 됐고, 그건 재시도 한 번으로 없던 연차가 생겼다는 뜻이다. 사용 합을 만들어내는 경로가
+ * 지금은 하나뿐이지만(수동 등록), 여기서 보장하면 어느 경로로 들어오든 잔여가 총을 못 넘는다.
+ *
* @param totalDays 총 연차
- * @param usedDays 사용 내역의 증감 합 (취소가 있으면 음수가 섞여 줄어든다)
+ * @param usedDays 쓴 연차 — 0 아래로 내려가지 않는다. "쓴 연차가 -2일" 은 뜻이 없다
+ * @param ledgerDays 자른 적 없는 원장 합. {@link #usedDays} 와 다르면 데이터가 이미 어긋나 있다는 신호다
*/
-public record LeaveSummary(double totalDays, double usedDays) {
+public record LeaveSummary(double totalDays, double usedDays, double ledgerDays) {
+
+ /** 아무것도 쓰지 않은 상태. 사용 합의 하한이기도 하다. */
+ private static final double NOTHING_USED = 0;
+
+ /**
+ * 불변식 — {@link #of} 가 이미 자르므로 여기 닿는 위반은 버그다(500). 그래도 두는 이유는 누가 만들든
+ * 스스로 유효함을 보장하는 최후의 보루이기 때문이다.
+ */
+ public LeaveSummary {
+ if (usedDays < NOTHING_USED) {
+ throw new IllegalArgumentException("쓴 연차는 음수일 수 없습니다: " + usedDays);
+ }
+ }
+
+ /**
+ * 원장 합에서 현황을 계산한다 — 조립이 아니라 계산이라 팩토리다.
+ *
+ * 음수 원장 합은 0 으로 본다. 그렇게 만든 데이터가 이미 남아 있을 수 있어(삭제 API 가 없던 시절의
+ * 상쇄 등록) 거절하면 조회 자체가 500 이 되는데, 사용자는 화면을 못 여는 것으로 그 사실을 알게 된다.
+ * 자른 사실은 {@link #isLedgerNegative()} 로 드러내 호출자가 로그를 남긴다 — 조용히 넘어가지 않는다.
+ *
+ * @param ledgerDays 사용 내역의 증감 합
+ */
+ public static LeaveSummary of(double totalDays, double ledgerDays) {
+ return new LeaveSummary(totalDays, Math.max(NOTHING_USED, ledgerDays), ledgerDays);
+ }
/**
- * 남은 연차. 음수가 될 수 있다. 남은 연차가 부족해도 서버는 막지 않기 때문이다(결정 #38) — 프론트가 경고하고
+ * 남은 연차. 총 연차를 넘지 않고(#265), 음수는 될 수 있다.
+ *
+ * 음수를 허용하는 이유는 남은 연차가 부족해도 서버가 막지 않기 때문이다(결정 #38) — 프론트가 경고하고
* 사용자가 확인하면 진행한다. 여기서 0 으로 깎으면 "얼마나 초과했는지" 를 화면이 알 수 없다.
+ *
+ * 위쪽을 막고 아래쪽을 여는 것이 비대칭으로 보이지만, 두 방향은 뜻이 다르다. 초과 사용은 사용자가
+ * 확인하고 만든 사실이고, 총을 넘는 잔여는 있을 수 없는 값이다.
*/
public double remainingDays() {
return totalDays - usedDays;
}
+
+ /**
+ * 원장 합이 음수라 잘렸는가 — 상쇄 등록이 남긴 데이터의 흔적이다.
+ *
+ * 참이면 그 소유자의 내역에 음수 행이 섞여 있다. 사용자는 이제 그 행을 삭제해 정리할 수 있다.
+ */
+ public boolean isLedgerNegative() {
+ return ledgerDays < NOTHING_USED;
+ }
}
diff --git a/src/main/java/com/offway/core/leave/domain/LeaveUsage.java b/src/main/java/com/offway/core/leave/domain/LeaveUsage.java
index f7b51a40..59470f8a 100644
--- a/src/main/java/com/offway/core/leave/domain/LeaveUsage.java
+++ b/src/main/java/com/offway/core/leave/domain/LeaveUsage.java
@@ -15,8 +15,12 @@
/**
* 연차 사용 내역 한 건. 이 내역의 합이 "쓴 연차" 이고, 총 연차에서 빼면 남은 연차가 된다.
*
- * 증감이다 — 코스를 취소하면 음수 내역을 하나 더 쌓아 되돌린다. 기존 행을 지우면 "언제 무엇이 취소됐는지" 가
- * 사라진다.
+ * 되돌리는 길은 행 삭제다(#265·#113). 예전엔 음수 내역을 하나 더 쌓아 상쇄했는데, 같은 취소가 두 번
+ * 들어오면 합이 음수로 내려가 잔여가 총 연차를 넘었다. 수동 내역은 {@code DELETE /me/usages/{id}} 로,
+ * 코스 차감은 코스의 차감 취소로 지운다.
+ *
+ * 다만 음수 등록을 막는 것은 #276 으로 미뤘다 — 앱이 삭제 API 로 갈아탄 뒤에 닫아야 그 사이 구간에서
+ * 취소가 끊기지 않는다. 그때까지는 상쇄 등록이 계속 들어올 수 있고, 잔여가 총을 넘는 증상만 clamp 가 막는다.
*
* {@code courseId} 는 raw ID 다(도메인 경계를 넘으므로 연관관계를 두지 않는다 — persistence-convention).
* 코스 확정 차감(#91)이 이 값으로 중복 차감을 막는다 — 같은 코스로 이미 쌓인 내역이 있으면 건너뛴다.
@@ -37,11 +41,16 @@ public class LeaveUsage {
@Column(name = "guest_id", nullable = false, length = LeaveBalance.MAX_OWNER_ID_LENGTH)
private String guestId;
- /** 연차를 쓴(또는 되돌린) 날. */
+ /** 연차를 쓴 날. */
@Column(name = "used_on", nullable = false)
private LocalDate usedOn;
- /** 증감(0.5 단위). 사용은 양수, 취소는 음수. */
+ /**
+ * 증감(0.5 단위). 사용은 양수, 취소는 음수. 코스 차감만 0 을 허용한다(#212).
+ *
+ * 음수 행은 상쇄 등록이다 — 취소를 표현할 길이 그것뿐이던 시절의 것이 쌓여 있고, 앱이 삭제
+ * API 로 갈아탈 때까지 계속 들어올 수 있다(#276 에서 닫는다). 사용자는 그 행을 삭제로 정리할 수 있다.
+ */
@Column(name = "days", nullable = false)
private double days;
@@ -96,6 +105,27 @@ public boolean isHalfDayStart() {
return Boolean.TRUE.equals(halfDayStart);
}
+ /** 사용자가 직접 남긴 내역인가 — 코스 확정으로 생긴 행과 규칙이 다르다. */
+ public boolean isManual() {
+ return courseId == null;
+ }
+
+ /**
+ * 사용자가 손으로 지울 수 있는 내역인지 확인한다(#265) — 코스 확정 내역은 거절한다(409).
+ *
+ * 그 행은 차감량이자 확정 표식이다. 연차 화면에서 지우면 코스는 확정인데 연차는 안 깎인 상태가
+ * 남고, 코스 삭제·날짜 변경이 그 행을 전제로 도는 것도 함께 어긋난다. 되돌리는 길은 이미 있다 —
+ * 코스의 차감 취소가 코스와 연차를 한 덩어리로 되돌린다(#113).
+ *
+ * 404 로 감추지 않는다. 자기 내역이 화면에 보이는데 "없다" 고 답하면 사용자는 버그로 읽는다.
+ * 지울 수 없는 이유를 알려줘야 코스 화면으로 갈 수 있다.
+ */
+ public void requireManuallyDeletable() {
+ if (!isManual()) {
+ throw LeaveException.courseLeaveUsageNotDeletable();
+ }
+ }
+
/**
* 코스의 여행 날짜가 바뀌어 차감을 다시 잡는다(#170) — 쓴 날과 일수를 함께 옮긴다.
*
diff --git a/src/main/java/com/offway/core/leave/repository/LeaveUsageJpaRepository.java b/src/main/java/com/offway/core/leave/repository/LeaveUsageJpaRepository.java
index f98adb14..9a1f8964 100644
--- a/src/main/java/com/offway/core/leave/repository/LeaveUsageJpaRepository.java
+++ b/src/main/java/com/offway/core/leave/repository/LeaveUsageJpaRepository.java
@@ -21,6 +21,9 @@ public interface LeaveUsageJpaRepository extends JpaRepository 음수 행을 덧붙이지 않는 이유 — {@code uk_leave_usage_guest_course} 가 코스당 한 행을 강제한다(#91).
- * 음수 누적은 수동 내역 전용이다.
+ * 수동 내역도 같은 규칙을 따른다 — 되돌리기는 삭제다(#265).
*
* @return 지운 행 수 (없었으면 0)
*/
int deleteByGuestIdAndCourseId(String guestId, Long courseId);
+ /**
+ * 내 내역 한 건 — 삭제하려고 읽는다(#265).
+ *
+ * 소유자를 조건에 함께 건다. id 로만 읽고 나중에 소유자를 비교하면, 그 비교를 빠뜨린 코드
+ * 한 줄이 곧 남의 내역을 지우는 길이 된다.
+ */
+ Optional 멱등하지 않다. 없는 내역이면 404 로 답한다. 취소 액션(코스 차감 취소)과 달리 사용자가 목록에서
+ * 고른 한 행을 가리키므로, 조용히 200 을 주면 화면이 지운 줄 알고 다시 그린다.
+ *
+ * 없는 id 와 남의 id 를 같은 404 로 답한다 — 코스 조회와 같은 규칙이다. 나눠 답하면 id 를 넣어보며
+ * "이 번호는 있다" 를 알아낼 수 있다.
+ *
+ * 코스 확정 내역은 지우지 못한다. 그 판단은 {@link LeaveUsage#requireManuallyDeletable()} 이 소유한다.
+ */
+ @Transactional
+ public MyLeave deleteUsage(String guestId, long usageId) {
+ String owner = requireOwner(guestId);
+ LeaveUsage usage = usageRepository
+ .findByIdAndGuestId(usageId, owner)
+ .orElseThrow(LeaveException::leaveUsageNotFound);
+ usage.requireManuallyDeletable();
+ usageRepository.delete(usage);
+ MyLeave after = myLeave(owner);
+ log.info("연차 사용내역 삭제 usageId={} days={} 남은={}",
+ usageId, usage.getDays(), after.summary().remainingDays());
+ return after;
+ }
+
/** 홈 배지가 쓰는 남은 연차. 설정한 적이 없으면 {@code null} — 0 과 구분해야 화면이 "미설정" 을 보여줄 수 있다. */
@Transactional(readOnly = true)
public Double remainingDaysOrNull(String guestId) {
@@ -107,8 +132,7 @@ public Double remainingDaysOrNull(String guestId) {
return null;
}
return balanceRepository.findByGuestId(guestId)
- .map(balance -> new LeaveSummary(balance.getTotalDays(), usageRepository.sumDaysByGuestId(guestId))
- .remainingDays())
+ .map(balance -> summaryOf(guestId, balance.getTotalDays()).remainingDays())
.orElse(null);
}
@@ -200,7 +224,24 @@ private LeaveSummary summaryOf(String guestId) {
double total = balanceRepository.findByGuestId(guestId)
.map(LeaveBalance::getTotalDays)
.orElse(UNSET_TOTAL_DAYS);
- return new LeaveSummary(total, usageRepository.sumDaysByGuestId(guestId));
+ return summaryOf(guestId, total);
+ }
+
+ /**
+ * 원장 합을 읽어 현황을 만든다 — 자르는 곳이 하나면 알리는 곳도 하나여야 한다.
+ *
+ * 총 연차를 이미 손에 든 호출자(홈 배지)도 여기를 지난다. 그쪽이 {@link LeaveSummary#of} 를 직접 부르면
+ * 같은 clamp 가 로그 없이 일어나, 화면 하나는 경고를 남기고 다른 하나는 조용히 넘어간다 — 그리고 홈 배지가
+ * 더 자주 불린다.
+ */
+ private LeaveSummary summaryOf(String guestId, double totalDays) {
+ LeaveSummary summary = LeaveSummary.of(totalDays, usageRepository.sumDaysByGuestId(guestId));
+ if (summary.isLedgerNegative()) {
+ // 잘라서 내려주고 끝내면 아무도 모른다 — 이 소유자의 내역에 상쇄 등록(음수 days)이 남아 있다는
+ // 뜻이다(#265). 소유 키는 사용자 입력이라 로그에 싣지 않는다.
+ log.warn("사용 내역 합이 음수라 0 으로 봅니다 ledger={} — 옛 상쇄 등록이 남아 있습니다", summary.ledgerDays());
+ }
+ return summary;
}
diff --git a/src/main/java/com/offway/core/leave/service/dto/AddLeaveUsage.java b/src/main/java/com/offway/core/leave/service/dto/AddLeaveUsage.java
index dcf77980..629b311f 100644
--- a/src/main/java/com/offway/core/leave/service/dto/AddLeaveUsage.java
+++ b/src/main/java/com/offway/core/leave/service/dto/AddLeaveUsage.java
@@ -6,7 +6,8 @@
* 연차 사용 내역 추가 커맨드 — 서비스 내부용.
*
* @param usedOn 연차를 쓴(또는 되돌린) 날
- * @param days 증감(0.5 단위). 사용은 양수, 취소는 음수
+ * @param days 증감(0.5 단위). 사용은 양수, 취소는 음수 — 되돌리는 것은 등록이 아니라 삭제가 맞고, 음수 거절은
+ * 앱이 갈아탄 뒤로 미뤘다(#276)
* @param reason 사유 (선택)
* @param courseId 이 내역을 만든 코스 (수동 입력이면 null)
* @param halfDayStart 첫날 반차 여부. 코스 차감에서만 뜻이 있고, 날짜를 고칠 때 차감량을 다시 계산하는 입력이라 남긴다(#170)
diff --git a/src/test/java/com/offway/core/itinerary/controller/CourseLeaveDeductionIntegrationTest.java b/src/test/java/com/offway/core/itinerary/controller/CourseLeaveDeductionIntegrationTest.java
index 85262646..173a6c96 100644
--- a/src/test/java/com/offway/core/itinerary/controller/CourseLeaveDeductionIntegrationTest.java
+++ b/src/test/java/com/offway/core/itinerary/controller/CourseLeaveDeductionIntegrationTest.java
@@ -1,6 +1,7 @@
package com.offway.core.itinerary.controller;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+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.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -313,4 +314,34 @@ private org.springframework.test.web.servlet.ResultActions deduct(String guest,
.andExpect(jsonPath("$.data.usedDays").value(2.0))
.andExpect(jsonPath("$.data.usages.length()").value(1));
}
+
+ /**
+ * 코스 확정으로 생긴 내역은 연차 화면에서 지울 수 없다(#265) — 409 로 끊고 코스 쪽으로 안내한다.
+ *
+ * 지우게 두면 코스는 확정인데 연차는 안 깎인 상태가 남고, 그 행을 전제로 도는 코스 삭제·날짜 변경도
+ * 함께 어긋난다. 404 로 감추지도 않는다 — 자기 내역이 화면에 보이는데 "없다" 고 답하면 버그로 읽힌다.
+ */
+ @Test
+ void 코스_확정_내역은_연차_화면에서_지울_수_없다() throws Exception {
+ holidays(Set.of());
+ String guest = uniqueGuest();
+ setTotalLeave(guest, 15.0);
+ long courseId = saveCourse(guest, TWO_DAY_COURSE);
+ String deducted = deduct(guest, courseId, "{}")
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+ long usageId = ((Number) JsonPath.read(deducted, "$.data.usages[0].id")).longValue();
+
+ mockMvc.perform(delete(LEAVES + "/usages/{id}", usageId).header("X-Guest-Id", guest))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.status").value(409))
+ .andExpect(jsonPath("$.code").value("LEAVE-014"))
+ .andExpect(jsonPath("$.detail").value("코스 확정으로 기록된 연차입니다. 코스에서 차감을 취소해 주세요."))
+ .andExpect(jsonPath("$.data").doesNotExist());
+
+ // 차감은 그대로 남는다.
+ mockMvc.perform(get(LEAVES).header("X-Guest-Id", guest))
+ .andExpect(jsonPath("$.data.usedDays").value(2.0))
+ .andExpect(jsonPath("$.data.usages.length()").value(1));
+ }
}
diff --git a/src/test/java/com/offway/core/leave/controller/MyLeaveIntegrationTest.java b/src/test/java/com/offway/core/leave/controller/MyLeaveIntegrationTest.java
index 5516b0fc..0793f933 100644
--- a/src/test/java/com/offway/core/leave/controller/MyLeaveIntegrationTest.java
+++ b/src/test/java/com/offway/core/leave/controller/MyLeaveIntegrationTest.java
@@ -5,12 +5,14 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+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.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import com.jayway.jsonpath.JsonPath;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -25,6 +27,7 @@
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
/**
* "내 연차" 통합 테스트.
@@ -44,6 +47,17 @@ class MyLeaveIntegrationTest {
@Autowired
private MockMvc mockMvc;
+ private ResultActions addUsage(String guest, String body) throws Exception {
+ return mockMvc.perform(post(USAGES_URL).header(GUEST_HEADER, guest)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body));
+ }
+
+ /** 방금 만든 내역의 ID — 삭제 대상이다. 이 소유자에게 내역이 하나뿐인 시나리오에서만 쓴다. */
+ private static long onlyUsageId(String responseBody) {
+ return ((Number) JsonPath.read(responseBody, "$.data.usages[0].id")).longValue();
+ }
+
@Test
void 설정한_적_없으면_총0_내역없음으로_내려준다() throws Exception {
// 없는 소유자를 404 로 돌려주면 클라이언트가 "처음 쓰는 사람" 을 예외로 다뤄야 한다.
@@ -75,28 +89,160 @@ class MyLeaveIntegrationTest {
}
@Test
- void 사용내역을_쌓으면_남은_연차가_줄고_취소하면_되돌아온다() throws Exception {
+ void 사용내역을_쌓으면_남은_연차가_줄고_지우면_되돌아온다() throws Exception {
String guest = "leave-usage";
mockMvc.perform(patch(URL).header(GUEST_HEADER, guest)
.contentType(MediaType.APPLICATION_JSON).content("{\"totalDays\": 10}"))
.andExpect(status().isOk());
- mockMvc.perform(post(USAGES_URL).header(GUEST_HEADER, guest)
- .contentType(MediaType.APPLICATION_JSON)
- .content("{\"usedOn\": \"2026-05-08\", \"days\": 3, \"reason\": \"제주 여행\"}"))
+ String created = addUsage(guest, "{\"usedOn\": \"2026-05-08\", \"days\": 3, \"reason\": \"제주 여행\"}")
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value(201))
.andExpect(jsonPath("$.data.usedDays").value(3.0))
- .andExpect(jsonPath("$.data.remainingDays").value(7.0));
+ .andExpect(jsonPath("$.data.remainingDays").value(7.0))
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+ long usageId = onlyUsageId(created);
+
+ // 취소는 음수 상쇄가 아니라 그 행을 지우는 것이다(#265) — 응답은 갱신된 내 연차 전체다.
+ mockMvc.perform(delete(USAGES_URL + "/{id}", usageId).header(GUEST_HEADER, guest))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.status").value(200))
+ .andExpect(jsonPath("$.code").value("OK"))
+ .andExpect(jsonPath("$.data.totalDays").value(10.0))
+ .andExpect(jsonPath("$.data.usedDays").value(0.0))
+ .andExpect(jsonPath("$.data.remainingDays").value(10.0))
+ .andExpect(jsonPath("$.data.usages.length()").value(0));
+ }
- // 취소는 행을 지우지 않고 음수 내역을 하나 더 쌓는다 — 언제 무엇이 취소됐는지가 남는다.
- mockMvc.perform(post(USAGES_URL).header(GUEST_HEADER, guest)
+ /**
+ * 이 PR 의 존재 이유 — 같은 취소가 두 번 들어와도 잔여가 총 연차를 넘지 않는다.
+ *
+ * 예전에는 취소를 음수 등록으로 흉내냈고, 재시도·중복 탭으로 두 번 들어오면 총 15일인 사람의 잔여가
+ * 17 이 됐다. 지금은 두 번째 삭제가 404 로 끊기고 잔여는 총 그대로다.
+ */
+ @Test
+ void 취소를_두_번_보내도_잔여가_총_연차를_넘지_않는다() throws Exception {
+ String guest = "leave-double-cancel";
+ mockMvc.perform(patch(URL).header(GUEST_HEADER, guest)
+ .contentType(MediaType.APPLICATION_JSON).content("{\"totalDays\": 15}"))
+ .andExpect(status().isOk());
+ String created = addUsage(guest, "{\"usedOn\": \"2026-05-08\", \"days\": 2}")
+ .andExpect(jsonPath("$.data.remainingDays").value(13.0))
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+ long usageId = onlyUsageId(created);
+
+ mockMvc.perform(delete(USAGES_URL + "/{id}", usageId).header(GUEST_HEADER, guest))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.remainingDays").value(15.0));
+
+ mockMvc.perform(delete(USAGES_URL + "/{id}", usageId).header(GUEST_HEADER, guest))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value("LEAVE-012"));
+
+ mockMvc.perform(get(URL).header(GUEST_HEADER, guest))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.usedDays").value(0.0))
+ .andExpect(jsonPath("$.data.remainingDays").value(15.0));
+ }
+
+ @Test
+ void 음수_등록은_아직_받는다() throws Exception {
+ // 거절(400 LEAVE-013)은 #276 으로 미뤘다 — 앱이 삭제 API 로 갈아타기 전에 켜면 그 구간 동안
+ // 취소가 400 을 받아 사용자가 취소를 아예 못 한다. 지금 앱이 쓰는 경로라 열려 있어야 한다.
+ mockMvc.perform(post(USAGES_URL).header(GUEST_HEADER, "leave-reversal")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"usedOn\": \"2026-05-09\", \"days\": -1, \"reason\": \"하루 취소\"}"))
.andExpect(status().isCreated())
+ .andExpect(jsonPath("$.status").value(201))
+ .andExpect(jsonPath("$.code").value("OK"))
+ .andExpect(jsonPath("$.data.usages[0].days").value(-1.0));
+ }
+
+ @Test
+ void 없는_내역을_지우면_404_LEAVE_012() throws Exception {
+ mockMvc.perform(delete(USAGES_URL + "/{id}", 987654321L).header(GUEST_HEADER, "leave-missing"))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.status").value(404))
+ .andExpect(jsonPath("$.code").value("LEAVE-012"))
+ .andExpect(jsonPath("$.detail").value("연차 사용 내역을 찾을 수 없습니다."))
+ .andExpect(jsonPath("$.data").doesNotExist());
+ }
+
+ @Test
+ void 남의_내역은_지울_수_없고_없는_것과_같은_404다() throws Exception {
+ // 403 으로 나눠 답하면 id 를 넣어보며 "이 번호는 있다" 를 알아낼 수 있다 — 코스 조회와 같은 규칙이다.
+ String created = addUsage("leave-owner-x", "{\"usedOn\": \"2026-05-08\", \"days\": 1}")
+ .andExpect(status().isCreated())
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+ long usageId = onlyUsageId(created);
+
+ mockMvc.perform(delete(USAGES_URL + "/{id}", usageId).header(GUEST_HEADER, "leave-owner-y"))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value("LEAVE-012"));
+
+ // 주인의 내역은 그대로 남아 있다.
+ mockMvc.perform(get(URL).header(GUEST_HEADER, "leave-owner-x"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.usages.length()").value(1));
+ }
+
+ @Test
+ void 삭제도_빈_소유_키는_400_LEAVE_011() throws Exception {
+ mockMvc.perform(delete(USAGES_URL + "/{id}", 1L).header(GUEST_HEADER, " "))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("LEAVE-011"));
+ }
+
+ /**
+ * #265 의 재현 시나리오 그 자체 — 지금 앱이 하는 그대로(상쇄 등록) 취소를 두 번 보낸 장부다.
+ *
+ * 넷을 한 번에 잠근다. ① 상쇄 등록은 아직 통과한다(#276 전이라 앱 경로가 열려 있어야 한다)
+ * ② 원장 합이 -2 로 내려가도 잔여가 17 이 아니라 15 — clamp 가 단위 테스트가 아니라 실응답에서 돈다
+ * ③ 그래도 음수 행은 목록에 그대로 보인다 — 마이그레이션으로 지우지 않기로 한 결정의 실제 모습이다
+ * ④ 사용자가 그 행을 지울 수 있고, 지우면 clamp 가 가리고 있던 값과 장부가 같아진다.
+ *
+ * #276 이 음수 등록을 닫으면 이 시나리오의 준비 단계는 API 로 만들 수 없다 — 그때 남아 있는 옛 행을
+ * 재현하려면 도메인을 우회해 직접 적재해야 한다(하이드레이션은 생성자를 거치지 않는다).
+ */
+ @Test
+ void 상쇄_등록이_쌓여도_잔여가_총을_넘지_않고_지워서_정리할_수_있다() throws Exception {
+ String guest = "leave-reversal-ledger";
+ mockMvc.perform(patch(URL).header(GUEST_HEADER, guest)
+ .contentType(MediaType.APPLICATION_JSON).content("{\"totalDays\": 15}"))
+ .andExpect(status().isOk());
+ addUsage(guest, "{\"usedOn\": \"2026-05-08\", \"days\": 2}").andExpect(status().isCreated());
+ // 같은 취소가 두 번 — 재시도·중복 탭이면 실제로 이렇게 들어온다. 원장 합이 -2 가 된다.
+ addUsage(guest, "{\"usedOn\": \"2026-05-09\", \"days\": -2}").andExpect(status().isCreated());
+ addUsage(guest, "{\"usedOn\": \"2026-05-09\", \"days\": -2}").andExpect(status().isCreated());
+
+ String found = mockMvc.perform(get(URL).header(GUEST_HEADER, guest))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.usedDays").value(0.0))
+ .andExpect(jsonPath("$.data.remainingDays").value(15.0)) // 17 이 아니다
+ .andExpect(jsonPath("$.data.usages.length()").value(3))
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+ List 핵심은 잔여가 총 연차를 넘지 않는다는 것이다(#265). 위쪽만 막고 아래쪽(초과 사용)은 열어두므로
+ * 두 방향의 경계를 함께 본다.
+ */
class LeaveSummaryTest {
@Test
void 남은_연차는_총_연차에서_사용_합을_뺀_값이다() {
- assertEquals(13.0, new LeaveSummary(15.0, 2.0).remainingDays());
+ assertEquals(13.0, LeaveSummary.of(15.0, 2.0).remainingDays());
}
@Test
- void 취소가_섞이면_사용_합이_줄어_남은_연차가_늘어난다() {
- // 3일 쓰고 1일 취소(-1) → 사용 합 2
- assertEquals(13.0, new LeaveSummary(15.0, 3.0 - 1.0).remainingDays());
+ void 반차가_섞여도_0점5_단위로_떨어진다() {
+ assertEquals(12.5, LeaveSummary.of(15.0, 2.5).remainingDays());
}
@Test
void 초과_사용하면_남은_연차가_음수다() {
// 서버가 초과를 막지 않으므로(결정 #38) 0 으로 깎지 않는다 — 얼마나 초과했는지를 화면이 알아야 한다.
- assertEquals(-2.0, new LeaveSummary(3.0, 5.0).remainingDays());
+ assertEquals(-2.0, LeaveSummary.of(3.0, 5.0).remainingDays());
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "15, 0, 15", // 아무것도 안 씀 — 잔여 = 총
+ "15, 15, 0", // 딱 맞게 씀 — 잔여 0
+ "15, 15.5, -0.5", // 반차만큼 초과
+ "0, 0, 0" // 총 연차 미설정
+ })
+ void 사용_합이_0_이상이면_그대로_빼서_잔여를_낸다(double total, double ledger, double expected) {
+ assertEquals(expected, LeaveSummary.of(total, ledger).remainingDays());
+ }
+
+ /**
+ * 이 PR 의 존재 이유 — 상쇄 등록이 겹쳐 원장 합이 음수로 내려가도 잔여가 총을 넘지 않는다.
+ *
+ * 총 15일에 2일 쓴 사람이 취소를 두 번 보내면 원장 합이 -2 가 됐고, 예전에는 잔여 17 이 나갔다.
+ * 재시도·중복 탭만으로 없던 연차가 생긴 것이다.
+ */
+ @ParameterizedTest
+ @ValueSource(doubles = {-0.5, -1, -2, -99})
+ void 사용_합이_음수여도_잔여가_총_연차를_넘지_않는다(double ledger) {
+ LeaveSummary summary = LeaveSummary.of(15.0, ledger);
+
+ assertEquals(15.0, summary.remainingDays(), "잔여는 총 연차가 상한이다");
+ assertEquals(0.0, summary.usedDays(), "쓴 연차가 음수라는 말은 뜻이 없다");
}
@Test
- void 반차가_섞여도_0점5_단위로_떨어진다() {
- assertEquals(12.5, new LeaveSummary(15.0, 2.5).remainingDays());
+ void 취소_두_번이_잔여_17을_만들지_않는다() {
+ // 프론트가 재현한 시나리오 그대로 — 2일 사용(+2) 뒤 같은 취소가 두 번(-2, -2) 들어온 원장.
+ double ledger = 2.0 - 2.0 - 2.0;
+
+ assertEquals(15.0, LeaveSummary.of(15.0, ledger).remainingDays());
+ }
+
+ @Test
+ void 사용_합이_음수면_잘렸다는_신호를_남긴다() {
+ // 조용히 자르고 끝내면 상쇄 등록이 남긴 데이터를 아무도 모른다 — 호출자가 이 값으로 warn 을 남긴다.
+ LeaveSummary clamped = LeaveSummary.of(15.0, -2.0);
+
+ assertTrue(clamped.isLedgerNegative());
+ assertEquals(-2.0, clamped.ledgerDays(), "원장 합 원본은 그대로 들고 있다");
+ }
+
+ @ParameterizedTest
+ @ValueSource(doubles = {0, 0.5, 2})
+ void 사용_합이_0_이상이면_자르지_않는다(double ledger) {
+ assertFalse(LeaveSummary.of(15.0, ledger).isLedgerNegative());
+ }
+
+ @Test
+ void 쓴_연차가_음수인_현황은_만들_수_없다() {
+ // 팩토리가 이미 자르므로 여기 닿는 값은 버그다 — 계약 예외가 아니라 불변식이다.
+ assertThrows(IllegalArgumentException.class, () -> new LeaveSummary(15.0, -1.0, -1.0));
}
}
diff --git a/src/test/java/com/offway/core/leave/domain/LeaveUsageTest.java b/src/test/java/com/offway/core/leave/domain/LeaveUsageTest.java
index 8ab556b9..91a18b2b 100644
--- a/src/test/java/com/offway/core/leave/domain/LeaveUsageTest.java
+++ b/src/test/java/com/offway/core/leave/domain/LeaveUsageTest.java
@@ -1,5 +1,6 @@
package com.offway.core.leave.domain;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -7,6 +8,8 @@
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
/**
* 연차 사용 내역 한 건. 코스 날짜가 바뀌면 이 행이 따라 옮겨진다(#170).
@@ -105,4 +108,34 @@ class LeaveUsageTest {
// 코스 차감의 취소는 음수 누적이 아니라 행 삭제다(#113).
assertThrows(LeaveException.class, () -> LeaveUsage.forCourse("guest-1", WHEN, -1.0, "코스 확정", 7L, false));
}
+
+ @ParameterizedTest
+ @ValueSource(doubles = {-0.5, -1, -2, -99})
+ void 수동_내역은_아직_음수를_받는다(double days) {
+ // 되돌리기는 삭제가 맞지만, 거절을 지금 켜면 앱이 갈아타기 전 구간에서 취소가 끊긴다 —
+ // #276 으로 미뤘다. 미뤄도 잔여가 총을 넘는 증상은 LeaveSummary 의 clamp 가 막는다.
+ LeaveUsage usage = LeaveUsage.manual("guest-1", WHEN, days, "취소");
+
+ assertEquals(days, usage.getDays());
+ assertTrue(usage.isManual(), "상쇄 등록도 수동 내역이라 사용자가 삭제로 정리할 수 있다");
+ }
+
+ @Test
+ void 수동_내역은_손으로_지울_수_있다() {
+ LeaveUsage usage = LeaveUsage.manual("guest-1", WHEN, 1.0, "개인 사유");
+
+ assertTrue(usage.isManual());
+ assertDoesNotThrow(usage::requireManuallyDeletable);
+ }
+
+ @Test
+ void 코스_확정_내역은_연차_화면에서_지울_수_없다() {
+ // 그 행은 차감량이자 확정 표식이다 — 지우면 코스는 확정인데 연차는 안 깎인 상태가 남는다.
+ LeaveUsage usage = LeaveUsage.forCourse("guest-1", WHEN, 2.0, "코스 확정", 7L, false);
+
+ LeaveException thrown = assertThrows(LeaveException.class, usage::requireManuallyDeletable);
+
+ assertEquals(LeaveErrorCode.COURSE_LEAVE_USAGE_NOT_DELETABLE, thrown.errorCode());
+ assertFalse(usage.isManual());
+ }
}