계정/정산 업무 규칙과 전환 불변식 추출 #3
1 changed files with 344 additions and 0 deletions
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue