diff --git a/account-migration/src/test/java/com/payman/account/migration/spec/TransactionSemanticsInvariantTest.java b/account-migration/src/test/java/com/payman/account/migration/spec/TransactionSemanticsInvariantTest.java new file mode 100644 index 0000000..6267ae9 --- /dev/null +++ b/account-migration/src/test/java/com/payman/account/migration/spec/TransactionSemanticsInvariantTest.java @@ -0,0 +1,344 @@ +package com.payman.account.migration.spec; + +import com.payman.account.migration.entity.enums.SettlementStatus; +import com.payman.account.migration.entity.enums.DisputeStatus; +import com.payman.account.migration.entity.enums.PayoutStatus; +import com.payman.account.migration.exception.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Transaction Semantics Invariant Tests") +class TransactionSemanticsInvariantTest { + + private static final BigDecimal MIN_SETTLEMENT_AMOUNT = new BigDecimal("1000"); + private static final BigDecimal MAX_PAYOUT_DAILY = new BigDecimal("10000000"); + private static final BigDecimal MIN_PAYOUT_AMOUNT = new BigDecimal("1000"); + private static final BigDecimal CHARGEBACK_THRESHOLD = new BigDecimal("100000"); + private static final int DISPUTE_WINDOW_DAYS = 90; + private static final int DISPUTE_RESPONSE_DEADLINE_DAYS = 7; + private static final int MAX_PAYOUT_DAILY_COUNT = 5; + private static final int MIN_CANCELLATION_REASON_LENGTH = 10; + private static final int MAX_INQUIRY_PAGE_SIZE = 50; + + @Nested + @DisplayName("SC: Settlement Cancellation Invariants") + class SettlementCancellationTests { + + @Test + @DisplayName("SC-001: CancelableSettlementState - COMPLETED/PENDING 상태는 취소 가능") + void cancellableStatesCanBeCancelled() { + assertTrue(isCancellable(SettlementStatus.COMPLETED)); + assertTrue(isCancellable(SettlementStatus.PENDING)); + } + + @ParameterizedTest + @EnumSource(value = SettlementStatus.class, names = {"CANCELLED", "REFUNDED"}) + @DisplayName("SC-001: CancelableSettlementState - terminal 상태는 취소 불가") + void terminalStatesCannotBeCancelled(SettlementStatus status) { + assertFalse(isCancellable(status)); + } + + @Test + @DisplayName("SC-003: CancellationAmountLimit - 취소 금액 초과 시 예외") + void cancellationAmountExceedsOriginalThrowsException() { + assertThrows(InvalidCancellationAmountException.class, () -> + validateCancellationAmount(new BigDecimal("150000"), new BigDecimal("100000"))); + assertDoesNotThrow(() -> + validateCancellationAmount(new BigDecimal("100000"), new BigDecimal("100000"))); + } + + @Test + @DisplayName("SC-005: CancellationTimestamp - 취소 시각 검증") + void cancellationMustBeAfterSettlementTime() { + LocalDateTime settlementTime = LocalDateTime.now().minusDays(1); + assertThrows(InvalidTimestampException.class, () -> + validateCancellationTimestamp(settlementTime.minusHours(1), settlementTime)); + assertDoesNotThrow(() -> + validateCancellationTimestamp(settlementTime.plusHours(1), settlementTime)); + } + + @Test + @DisplayName("SC-008: CancellationReasonRequired - 사유 10자 이상") + void cancellationReasonMustBeAtLeast10Characters() { + assertThrows(MissingCancellationReasonException.class, () -> + validateCancellationReason("단축")); + assertDoesNotThrow(() -> + validateCancellationReason("고객 요청으로 인한 취소")); + } + + @Test + @DisplayName("SC-007: PartialCancellationLimit - 잔여 금액 최소 금액 이상") + void remainingAmountMustBeAboveMinimum() { + assertThrows(InsufficientRemainingAmountException.class, () -> + validatePartialCancellation(new BigDecimal("500"), MIN_SETTLEMENT_AMOUNT)); + assertDoesNotThrow(() -> + validatePartialCancellation(new BigDecimal("1000"), MIN_SETTLEMENT_AMOUNT)); + } + } + + @Nested + @DisplayName("DC: Dispute/Chargeback Invariants") + class DisputeChargebackTests { + + @Test + @DisplayName("DC-001: DisputeEligibilityWindow - 90일 이내 dispute 가능") + void disputeWithin90DaysIsEligible() { + assertTrue(isWithinDisputeWindow(LocalDateTime.now().minusDays(30), DISPUTE_WINDOW_DAYS)); + assertFalse(isWithinDisputeWindow(LocalDateTime.now().minusDays(91), DISPUTE_WINDOW_DAYS)); + } + + @Test + @DisplayName("DC-003: DisputeAmountConstraint - dispute 금액 ≤ settlement 금액") + void disputeAmountMustNotExceedSettlementAmount() { + assertThrows(DisputeAmountExceedsSettlementException.class, () -> + validateDisputeAmount(new BigDecimal("150000"), new BigDecimal("100000"))); + assertDoesNotThrow(() -> + validateDisputeAmount(new BigDecimal("100000"), new BigDecimal("100000"))); + } + + @Test + @DisplayName("DC-007: DisputeChargebackThreshold - 100000원 초과만 chargeback 가능") + void chargebackRequiresAmountAboveThreshold() { + assertThrows(ChargebackThresholdNotMetException.class, () -> + validateChargebackThreshold(new BigDecimal("50000"), CHARGEBACK_THRESHOLD)); + assertDoesNotThrow(() -> + validateChargebackThreshold(new BigDecimal("500000"), CHARGEBACK_THRESHOLD)); + } + + @Test + @DisplayName("DC-008: DisputeResponseDeadline - 7일 이내 응답 필요") + void merchantMustRespondWithin7Days() { + LocalDateTime createdAt = LocalDateTime.now().minusDays(6); + LocalDateTime deadline = createdAt.plusDays(DISPUTE_RESPONSE_DEADLINE_DAYS); + assertDoesNotThrow(() -> + validateDisputeResponseDeadline(LocalDateTime.now(), deadline)); + } + + @Test + @DisplayName("DC-010: DisputeInquiryPagination - 페이지당 최대 50건") + void inquiryPageSizeMustNotExceed50() { + assertThrows(PaginationLimitExceededException.class, () -> + validateInquiryPageSize(51)); + assertDoesNotThrow(() -> + validateInquiryPageSize(50)); + } + } + + @Nested + @DisplayName("PV: Payout Validation Invariants") + class PayoutValidationTests { + + @Test + @DisplayName("PV-001: PayoutBalanceSufficiency - payout ≤ 가용 잔액") + void payoutMustNotExceedAvailableBalance() { + assertThrows(InsufficientBalanceException.class, () -> + validatePayoutBalance(new BigDecimal("600000"), new BigDecimal("500000"))); + assertDoesNotThrow(() -> + validatePayoutBalance(new BigDecimal("300000"), new BigDecimal("500000"))); + } + + @Test + @DisplayName("PV-002: PayoutMinimumAmount - 최소 1000원") + void payoutMustBeAtLeast1000() { + assertThrows(PayoutAmountBelowMinimumException.class, () -> + validatePayoutMinimum(new BigDecimal("500"), MIN_PAYOUT_AMOUNT)); + assertDoesNotThrow(() -> + validatePayoutMinimum(new BigDecimal("1000"), MIN_PAYOUT_AMOUNT)); + } + + @Test + @DisplayName("PV-003: PayoutMaximumAmount - 일별 최대 10000000원") + void payoutMustNotExceedDailyMaximum() { + assertThrows(PayoutDailyLimitExceededException.class, () -> + validatePayoutDailyLimit(new BigDecimal("9500000"), new BigDecimal("600000"), MAX_PAYOUT_DAILY)); + assertDoesNotThrow(() -> + validatePayoutDailyLimit(new BigDecimal("9500000"), new BigDecimal("400000"), MAX_PAYOUT_DAILY)); + } + + @Test + @DisplayName("PV-004: PayoutFrequencyLimit - 1일 최대 5회") + void payoutFrequencyMustNotExceed5PerDay() { + assertThrows(PayoutFrequencyExceededException.class, () -> + validatePayoutFrequency(5, MAX_PAYOUT_DAILY_COUNT)); + assertDoesNotThrow(() -> + validatePayoutFrequency(4, MAX_PAYOUT_DAILY_COUNT)); + } + + @Test + @DisplayName("PV-009: PayoutProcessingTime - 平日 09:00-17:00") + void payoutMustBeWithinProcessingHours() { + assertThrows(OutsideProcessingHoursException.class, () -> + validateProcessingHours(LocalDateTime.now().withHour(20))); + assertDoesNotThrow(() -> + validateProcessingHours(LocalDateTime.now().withHour(14))); + } + + @Test + @DisplayName("PV-010: PayoutAntiFraudCheck - fraud_score < 0.8") + void payoutMustPassFraudCheck() { + assertThrows(FraudDetectionFailedException.class, () -> + validateFraudScore(0.85)); + assertDoesNotThrow(() -> + validateFraudScore(0.5)); + } + } + + @Nested + @DisplayName("State Transition Tests") + class StateTransitionTests { + + @Test + @DisplayName("Settlement 상태 전이 검증") + void settlementStateTransitions() { + assertTrue(isValidSettlementTransition(SettlementStatus.PENDING, SettlementStatus.COMPLETED)); + assertTrue(isValidSettlementTransition(SettlementStatus.PENDING, SettlementStatus.CANCELLED)); + assertTrue(isValidSettlementTransition(SettlementStatus.COMPLETED, SettlementStatus.CANCELLED)); + assertFalse(isValidSettlementTransition(SettlementStatus.CANCELLED, SettlementStatus.COMPLETED)); + } + + @Test + @DisplayName("Dispute 상태 전이 검증") + void disputeStateTransitions() { + assertTrue(isValidDisputeTransition(DisputeStatus.INITIATED, DisputeStatus.UNDER_REVIEW)); + assertTrue(isValidDisputeTransition(DisputeStatus.UNDER_REVIEW, DisputeStatus.ESCALATED)); + assertFalse(isValidDisputeTransition(DisputeStatus.CLOSED, DisputeStatus.INITIATED)); + } + + @Test + @DisplayName("Payout 상태 전이 검증") + void payoutStateTransitions() { + assertTrue(isValidPayoutTransition(PayoutStatus.PENDING, PayoutStatus.PROCESSING)); + assertTrue(isValidPayoutTransition(PayoutStatus.PROCESSING, PayoutStatus.COMPLETED)); + assertTrue(isValidPayoutTransition(PayoutStatus.FAILED, PayoutStatus.PENDING)); + assertFalse(isValidPayoutTransition(PayoutStatus.COMPLETED, PayoutStatus.PENDING)); + } + } + + // Helper methods + private boolean isCancellable(SettlementStatus status) { + return status == SettlementStatus.COMPLETED || status == SettlementStatus.PENDING; + } + + private void validateCancellationAmount(BigDecimal cancellationAmount, BigDecimal originalAmount) { + if (cancellationAmount.compareTo(originalAmount) > 0) { + throw new InvalidCancellationAmountException("Cancellation amount exceeds original"); + } + } + + private void validateCancellationTimestamp(LocalDateTime cancellationTime, LocalDateTime settlementTime) { + if (!cancellationTime.isAfter(settlementTime)) { + throw new InvalidTimestampException("Cancellation must be after settlement"); + } + } + + private void validateCancellationReason(String reason) { + if (reason == null || reason.length() < MIN_CANCELLATION_REASON_LENGTH) { + throw new MissingCancellationReasonException("Cancellation reason must be at least 10 characters"); + } + } + + private void validatePartialCancellation(BigDecimal remaining, BigDecimal minimum) { + if (remaining.compareTo(minimum) < 0) { + throw new InsufficientRemainingAmountException("Remaining amount below minimum"); + } + } + + private boolean isWithinDisputeWindow(LocalDateTime settlementTime, int windowDays) { + return settlementTime.plusDays(windowDays).isAfter(LocalDateTime.now()); + } + + private void validateDisputeAmount(BigDecimal disputeAmount, BigDecimal settlementAmount) { + if (disputeAmount.compareTo(settlementAmount) > 0) { + throw new DisputeAmountExceedsSettlementException("Dispute amount exceeds settlement"); + } + } + + private void validateChargebackThreshold(BigDecimal amount, BigDecimal threshold) { + if (amount.compareTo(threshold) <= 0) { + throw new ChargebackThresholdNotMetException("Chargeback threshold not met"); + } + } + + private void validateDisputeResponseDeadline(LocalDateTime responseTime, LocalDateTime deadline) { + if (responseTime.isAfter(deadline)) { + throw new DisputeResponseDeadlineExceededException("Dispute response deadline exceeded"); + } + } + + private void validateInquiryPageSize(int pageSize) { + if (pageSize > MAX_INQUIRY_PAGE_SIZE) { + throw new PaginationLimitExceededException("Page size exceeds maximum"); + } + } + + private void validatePayoutBalance(BigDecimal payoutAmount, BigDecimal availableBalance) { + if (payoutAmount.compareTo(availableBalance) > 0) { + throw new InsufficientBalanceException("Insufficient balance"); + } + } + + private void validatePayoutMinimum(BigDecimal amount, BigDecimal minimum) { + if (amount.compareTo(minimum) < 0) { + throw new PayoutAmountBelowMinimumException("Payout amount below minimum"); + } + } + + private void validatePayoutDailyLimit(BigDecimal dailyTotal, BigDecimal payoutAmount, BigDecimal maximum) { + if (dailyTotal.add(payoutAmount).compareTo(maximum) > 0) { + throw new PayoutDailyLimitExceededException("Daily payout limit exceeded"); + } + } + + private void validatePayoutFrequency(int dailyCount, int maxCount) { + if (dailyCount >= maxCount) { + throw new PayoutFrequencyExceededException("Daily payout frequency exceeded"); + } + } + + private void validateProcessingHours(LocalDateTime time) { + int hour = time.getHour(); + boolean isWeekday = time.getDayOfWeek().getValue() < 6; + if (!isWeekday || hour < 9 || hour >= 17) { + throw new OutsideProcessingHoursException("Outside processing hours"); + } + } + + private void validateFraudScore(double fraudScore) { + if (fraudScore >= 0.8) { + throw new FraudDetectionFailedException("Fraud detection failed"); + } + } + + private boolean isValidSettlementTransition(SettlementStatus from, SettlementStatus to) { + return switch (from) { + case PENDING -> to == SettlementStatus.COMPLETED || to == SettlementStatus.CANCELLED; + case COMPLETED -> to == SettlementStatus.CANCELLED || to == SettlementStatus.REFUNDED; + case CANCELLED, REFUNDED -> false; + }; + } + + private boolean isValidDisputeTransition(DisputeStatus from, DisputeStatus to) { + return switch (from) { + case INITIATED -> to == DisputeStatus.UNDER_REVIEW || to == DisputeStatus.CLOSED; + case UNDER_REVIEW -> to == DisputeStatus.ESCALATED || to == DisputeStatus.RESOLVED; + case ESCALATED -> to == DisputeStatus.RESOLVED || to == DisputeStatus.CLOSED; + case RESOLVED, CLOSED -> false; + }; + } + + private boolean isValidPayoutTransition(PayoutStatus from, PayoutStatus to) { + return switch (from) { + case PENDING -> to == PayoutStatus.PROCESSING || to == PayoutStatus.FAILED; + case PROCESSING -> to == PayoutStatus.COMPLETED || to == PayoutStatus.FAILED; + case FAILED -> to == PayoutStatus.PENDING; + case COMPLETED -> false; + }; + } +}