비교 요약으로 돌아가기

Full output

GPT-5.6 Sol · xhigh 원문 결과물

재고 예약 상태 전이·멱등성 보수 / OpenAI / GPT-5.6 Sol / 실행일 2026-07-30

gpt-5.6-sol-xhigh

verification/work/14-inventory-reservation-state/gpt-5.6-sol-xhigh의 독립 작업 사본에서 구현과 테스트를 완료했습니다.

변경한 파일

  • ReservationService.java
  • ReservationServiceContractTest.java

구현 내용

  • 중복 상품 수량을 long 오버플로 없이 합산하고 최초 등장 순서를 유지하도록 정규화했습니다.
  • 모든 상품의 재고를 먼저 확인한 뒤 차감해 다중 상품 예약의 원자성을 보장했습니다.
  • 정규화된 상품별 총수량으로 요청 멱등성을 비교하고, 다른 요청에는 IdempotencyConflictException을 적용했습니다.
  • 결제 ID의 예약 내 재시도와 전체 예약 간 유일성을 구분해 처리했습니다.
  • ACTIVE 상태에서만 확정, 취소 또는 만료로 전이하도록 종결 상태 규칙을 보완했습니다.
  • 만료 시각과 정확히 같은 순간도 만료로 처리하고, 취소·만료 재시도에서는 재고를 다시 복구하지 않도록 했습니다.
  • 공백 식별자, 잘못된 수량과 중복 수량 합산 오버플로를 상태 변경 전에 거부하도록 입력 검증을 강화했습니다.

발견한 기존 오류

  • 중복 상품 행이 합산되지 않고 마지막 수량으로 덮어써졌습니다.
  • 상품별 재고를 즉시 차감해 뒤쪽 상품이 부족하면 앞쪽 상품의 차감이 남았습니다.
  • 같은 requestId의 다른 상품 구성이 기존 예약으로 잘못 처리됐습니다.
  • 결제 ID 충돌과 결제 멱등성 검사가 없었습니다.
  • 정확한 만료 경계가 만료로 판정되지 않았고, confirm 시 만료 전이와 재고 복구가 누락됐습니다.
  • 취소 및 만료 처리를 반복하면 재고가 중복 복구됐습니다.
  • 공백 식별자와 중복 수량 합산 오버플로가 검증되지 않았습니다.

테스트

필수 상태 전이와 경계 조건을 포함한 계약 테스트 20개를 추가했습니다. 기존 테스트 4개를 포함해 아래 명령으로 총 24개 테스트를 실제 재실행했습니다.

./gradlew test --rerun-tasks --no-daemon

BUILD SUCCESSFUL
TOTAL_TESTS=24

해결 방식과 트레이드오프

실제 동시성이나 데이터베이스 트랜잭션을 도입하지 않는 과업 조건에 맞춰, 정규화된 전체 상품의 재고를 사전 검사한 다음 차감하는 방식을 사용했습니다. 단일 스레드 인메모리 실행에서는 부분 차감을 막으면서 구현 복잡도를 낮추지만, 실제 동시 실행 환경으로 확장할 경우 저장소 수준의 원자적 연산이나 트랜잭션이 별도로 필요합니다.

실제 변경 코드

아래는 보존된 격리 작업 사본을 원본 하네스와 대조해 확인한 변경 파일의 전체 내용입니다.

src/main/java/com/samebrief/reservation/service/ReservationService.java

package com.samebrief.reservation.service;

import com.samebrief.reservation.error.IdempotencyConflictException;
import com.samebrief.reservation.error.InvalidReservationRequestException;
import com.samebrief.reservation.error.InvalidReservationTransitionException;
import com.samebrief.reservation.error.OutOfStockException;
import com.samebrief.reservation.error.PaymentConflictException;
import com.samebrief.reservation.error.ReservationExpiredException;
import com.samebrief.reservation.error.ReservationNotFoundException;
import com.samebrief.reservation.model.Reservation;
import com.samebrief.reservation.model.ReservationLine;
import com.samebrief.reservation.model.ReservationStatus;
import com.samebrief.reservation.service.command.ReserveCommand;
import com.samebrief.reservation.service.command.ReserveLineCommand;
import com.samebrief.reservation.store.IdempotencyRecord;
import com.samebrief.reservation.store.InMemoryIdempotencyStore;
import com.samebrief.reservation.store.InMemoryInventoryStore;
import com.samebrief.reservation.store.InMemoryReservationStore;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

public final class ReservationService {

    private static final Duration RESERVATION_TTL = Duration.ofMinutes(15);

    private final InMemoryInventoryStore inventoryStore;
    private final InMemoryReservationStore reservationStore;
    private final InMemoryIdempotencyStore idempotencyStore;
    private final Clock clock;

    public ReservationService(
            InMemoryInventoryStore inventoryStore,
            InMemoryReservationStore reservationStore,
            InMemoryIdempotencyStore idempotencyStore,
            Clock clock
    ) {
        this.inventoryStore = Objects.requireNonNull(inventoryStore);
        this.reservationStore = Objects.requireNonNull(reservationStore);
        this.idempotencyStore = Objects.requireNonNull(idempotencyStore);
        this.clock = Objects.requireNonNull(clock);
    }

    public Reservation reserve(ReserveCommand command) {
        Map<String, Long> normalized = normalize(command);

        IdempotencyRecord existing = idempotencyStore.find(command.requestId())
                .orElse(null);
        if (existing != null) {
            if (!existing.normalizedLines().equals(normalized)) {
                throw new IdempotencyConflictException(command.requestId());
            }
            return getReservation(existing.reservationId());
        }

        /*
         * Validate every item before changing any of them. The stores deliberately
         * have no transaction abstraction, so this preflight is what makes a
         * multi-item reservation atomic in the single-threaded contract.
         */
        for (Map.Entry<String, Long> entry : normalized.entrySet()) {
            if (inventoryStore.availableQuantity(entry.getKey()) < entry.getValue()) {
                throw new OutOfStockException(entry.getKey());
            }
        }

        List<ReservationLine> lines = new ArrayList<>();
        for (Map.Entry<String, Long> entry : normalized.entrySet()) {
            inventoryStore.decrease(entry.getKey(), entry.getValue());
            lines.add(new ReservationLine(entry.getKey(), entry.getValue()));
        }

        Instant createdAt = clock.instant();
        Reservation reservation = new Reservation(
                UUID.randomUUID().toString(),
                command.requestId(),
                lines,
                ReservationStatus.ACTIVE,
                createdAt,
                createdAt.plus(RESERVATION_TTL),
                null
        );
        reservationStore.save(reservation);
        idempotencyStore.save(new IdempotencyRecord(
                command.requestId(),
                normalized,
                reservation.id()
        ));
        return reservation;
    }

    public Reservation confirm(String reservationId, String paymentId) {
        if (isBlank(paymentId)) {
            throw new InvalidReservationRequestException("paymentId is required");
        }

        Reservation reservation = getReservation(reservationId);
        if (reservation.status() == ReservationStatus.ACTIVE
                && !clock.instant().isBefore(reservation.expiresAt())) {
            restoreInventory(reservation);
            reservationStore.save(reservation.transitionTo(
                    ReservationStatus.EXPIRED,
                    null
            ));
            throw new ReservationExpiredException(reservationId);
        }

        if (reservation.status() == ReservationStatus.CONFIRMED) {
            if (paymentId.equals(reservation.paymentId())) {
                return reservation;
            }
            throw new PaymentConflictException(paymentId);
        }
        if (reservation.status() == ReservationStatus.EXPIRED) {
            throw new ReservationExpiredException(reservationId);
        }
        if (reservation.status() != ReservationStatus.ACTIVE) {
            throw new InvalidReservationTransitionException(reservationId);
        }

        if (reservationStore.findByPaymentId(paymentId).isPresent()) {
            throw new PaymentConflictException(paymentId);
        }

        Reservation confirmed = reservation.transitionTo(
                ReservationStatus.CONFIRMED,
                paymentId
        );
        reservationStore.save(confirmed);
        return confirmed;
    }

    public Reservation cancel(String reservationId) {
        Reservation reservation = getReservation(reservationId);
        if (reservation.status() == ReservationStatus.CONFIRMED) {
            throw new InvalidReservationTransitionException(reservationId);
        }
        if (reservation.status() == ReservationStatus.CANCELLED
                || reservation.status() == ReservationStatus.EXPIRED) {
            return reservation;
        }

        restoreInventory(reservation);

        Reservation cancelled = reservation.transitionTo(
                ReservationStatus.CANCELLED,
                reservation.paymentId()
        );
        reservationStore.save(cancelled);
        return cancelled;
    }

    public int expireDueReservations() {
        Instant now = clock.instant();
        int expiredCount = 0;

        for (Reservation reservation : reservationStore.findAll()) {
            if (reservation.status() != ReservationStatus.ACTIVE
                    || now.isBefore(reservation.expiresAt())) {
                continue;
            }

            restoreInventory(reservation);
            reservationStore.save(reservation.transitionTo(
                    ReservationStatus.EXPIRED,
                    null
            ));
            expiredCount++;
        }

        return expiredCount;
    }

    private Map<String, Long> normalize(ReserveCommand command) {
        if (command == null) {
            throw new InvalidReservationRequestException("command is required");
        }
        if (isBlank(command.requestId())) {
            throw new InvalidReservationRequestException("requestId is required");
        }
        if (command.lines() == null || command.lines().isEmpty()) {
            throw new InvalidReservationRequestException("lines are required");
        }

        Map<String, Long> normalized = new LinkedHashMap<>();
        for (ReserveLineCommand line : command.lines()) {
            if (line == null || isBlank(line.productId()) || line.quantity() < 1) {
                throw new InvalidReservationRequestException("invalid reservation line");
            }
            try {
                normalized.merge(line.productId(), line.quantity(), Math::addExact);
            } catch (ArithmeticException exception) {
                throw new InvalidReservationRequestException(
                        "reservation line quantity overflow"
                );
            }
        }
        return normalized;
    }

    private void restoreInventory(Reservation reservation) {
        for (ReservationLine line : reservation.lines()) {
            inventoryStore.increase(line.productId(), line.quantity());
        }
    }

    private boolean isBlank(String value) {
        return value == null || value.isBlank();
    }

    private Reservation getReservation(String reservationId) {
        return reservationStore.findById(reservationId)
                .orElseThrow(() -> new ReservationNotFoundException(reservationId));
    }
}

src/test/java/com/samebrief/reservation/ReservationServiceContractTest.java

package com.samebrief.reservation;

import com.samebrief.reservation.error.IdempotencyConflictException;
import com.samebrief.reservation.error.InvalidReservationRequestException;
import com.samebrief.reservation.error.InvalidReservationTransitionException;
import com.samebrief.reservation.error.OutOfStockException;
import com.samebrief.reservation.error.PaymentConflictException;
import com.samebrief.reservation.error.ReservationExpiredException;
import com.samebrief.reservation.error.ReservationNotFoundException;
import com.samebrief.reservation.model.Reservation;
import com.samebrief.reservation.model.ReservationLine;
import com.samebrief.reservation.model.ReservationStatus;
import com.samebrief.reservation.service.ReservationService;
import com.samebrief.reservation.service.command.ReserveCommand;
import com.samebrief.reservation.service.command.ReserveLineCommand;
import com.samebrief.reservation.store.InMemoryIdempotencyStore;
import com.samebrief.reservation.store.InMemoryInventoryStore;
import com.samebrief.reservation.store.InMemoryReservationStore;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;

class ReservationServiceContractTest {

    private static final Instant INITIAL_TIME =
            Instant.parse("2026-07-30T00:00:00Z");

    private InMemoryInventoryStore inventoryStore;
    private InMemoryReservationStore reservationStore;
    private InMemoryIdempotencyStore idempotencyStore;
    private AdjustableClock clock;
    private ReservationService service;

    @BeforeEach
    void setUp() {
        inventoryStore = new InMemoryInventoryStore();
        reservationStore = new InMemoryReservationStore();
        idempotencyStore = new InMemoryIdempotencyStore();
        clock = new AdjustableClock(INITIAL_TIME, ZoneOffset.UTC);
        service = new ReservationService(
                inventoryStore,
                reservationStore,
                idempotencyStore,
                clock
        );
    }

    @Test
    void aggregatesDuplicateProductsInFirstAppearanceOrder() {
        inventoryStore.put("A", 10);
        inventoryStore.put("B", 5);

        Reservation reservation = reserve(
                "req-1",
                line("A", 2),
                line("B", 1),
                line("A", 3)
        );

        assertEquals(
                List.of(new ReservationLine("A", 5), new ReservationLine("B", 1)),
                reservation.lines()
        );
        assertEquals(5, inventoryStore.availableQuantity("A"));
        assertEquals(4, inventoryStore.availableQuantity("B"));
        assertEquals(1, reservationStore.count());
        assertEquals(1, idempotencyStore.count());
    }

    @Test
    void leavesEveryStoreUnchangedWhenLaterProductIsOutOfStock() {
        inventoryStore.put("A", 10);
        inventoryStore.put("B", 1);

        assertThrows(
                OutOfStockException.class,
                () -> reserve("req-atomic", line("A", 3), line("B", 2))
        );

        assertEquals(10, inventoryStore.availableQuantity("A"));
        assertEquals(1, inventoryStore.availableQuantity("B"));
        assertEquals(0, reservationStore.count());
        assertEquals(0, idempotencyStore.count());
    }

    @Test
    void treatsMissingProductAsOutOfStockWithoutPartialDeduction() {
        inventoryStore.put("A", 10);

        assertThrows(
                OutOfStockException.class,
                () -> reserve("req-missing", line("A", 3), line("missing", 1))
        );

        assertEquals(10, inventoryStore.availableQuantity("A"));
        assertEquals(0, inventoryStore.availableQuantity("missing"));
        assertEquals(0, reservationStore.count());
        assertEquals(0, idempotencyStore.count());
    }

    @Test
    void rejectsMalformedRequestsAndAggregationOverflowWithoutMutation() {
        inventoryStore.put("A", 10);

        List<ReserveCommand> invalidCommands = Arrays.asList(
                null,
                new ReserveCommand(null, List.of(line("A", 1))),
                new ReserveCommand(" \t", List.of(line("A", 1))),
                new ReserveCommand("req-null-lines", null),
                new ReserveCommand("req-empty", List.of()),
                new ReserveCommand("req-null-line", Arrays.asList((ReserveLineCommand) null)),
                new ReserveCommand("req-null-product", List.of(line(null, 1))),
                new ReserveCommand("req-blank-product", List.of(line(" \n", 1))),
                new ReserveCommand("req-zero", List.of(line("A", 0))),
                new ReserveCommand("req-negative", List.of(line("A", -1))),
                new ReserveCommand(
                        "req-overflow",
                        List.of(line("A", Long.MAX_VALUE), line("A", 1))
                )
        );

        for (ReserveCommand command : invalidCommands) {
            assertThrows(
                    InvalidReservationRequestException.class,
                    () -> service.reserve(command)
            );
            assertEquals(10, inventoryStore.availableQuantity("A"));
            assertEquals(0, reservationStore.count());
            assertEquals(0, idempotencyStore.count());
        }
    }

    @Test
    void equivalentNormalizedRetryReturnsExistingReservationOnly() {
        inventoryStore.put("A", 10);

        Reservation first = reserve(
                "req-retry",
                line("A", 2),
                line("A", 3)
        );
        Reservation retried = reserve("req-retry", line("A", 5));

        assertEquals(first, retried);
        assertEquals(first.id(), retried.id());
        assertEquals(5, inventoryStore.availableQuantity("A"));
        assertEquals(1, reservationStore.count());
        assertEquals(1, idempotencyStore.count());
    }

    @Test
    void normalizedRetryComparisonIgnoresInputRowOrder() {
        inventoryStore.put("A", 10);
        inventoryStore.put("B", 10);

        Reservation first = reserve(
                "req-order",
                line("A", 2),
                line("B", 3)
        );
        Reservation retried = reserve(
                "req-order",
                line("B", 1),
                line("A", 2),
                line("B", 2)
        );

        assertEquals(first, retried);
        assertEquals(8, inventoryStore.availableQuantity("A"));
        assertEquals(7, inventoryStore.availableQuantity("B"));
        assertEquals(1, reservationStore.count());
        assertEquals(1, idempotencyStore.count());
    }

    @Test
    void conflictingIdempotencyRetryWinsOverCurrentStockState() {
        inventoryStore.put("A", 5);
        Reservation first = reserve("req-conflict", line("A", 5));

        assertThrows(
                IdempotencyConflictException.class,
                () -> reserve("req-conflict", line("A", 6))
        );

        assertEquals(first, reservationStore.findById(first.id()).orElseThrow());
        assertEquals(0, inventoryStore.availableQuantity("A"));
        assertEquals(1, reservationStore.count());
        assertEquals(1, idempotencyStore.count());
    }

    @Test
    void outOfStockFailureDoesNotClaimRequestId() {
        inventoryStore.put("A", 2);

        assertThrows(
                OutOfStockException.class,
                () -> reserve("req-reusable", line("A", 3))
        );
        assertEquals(0, idempotencyStore.count());

        inventoryStore.put("A", 5);
        Reservation reservation = reserve("req-reusable", line("A", 3));

        assertEquals(ReservationStatus.ACTIVE, reservation.status());
        assertEquals(2, inventoryStore.availableQuantity("A"));
        assertEquals(1, reservationStore.count());
        assertEquals(1, idempotencyStore.count());
    }

    @Test
    void recordsCreationAndExactFifteenMinuteExpiryFromInjectedClock() {
        inventoryStore.put("A", 5);
        Instant creationTime = Instant.parse("2030-02-03T04:05:06.123456789Z");
        clock.setInstant(creationTime);

        Reservation reservation = reserve("req-time", line("A", 1));

        assertEquals(creationTime, reservation.createdAt());
        assertEquals(creationTime.plus(Duration.ofMinutes(15)), reservation.expiresAt());
    }

    @Test
    void confirmsOneNanosecondBeforeExpiryAndNeverRestoresConfirmedStock() {
        inventoryStore.put("A", 10);
        Reservation reservation = reserve("req-before-expiry", line("A", 3));
        clock.setInstant(reservation.expiresAt().minusNanos(1));

        Reservation confirmed = service.confirm(reservation.id(), "pay-before-expiry");

        assertEquals(ReservationStatus.CONFIRMED, confirmed.status());
        assertEquals("pay-before-expiry", confirmed.paymentId());
        assertEquals(7, inventoryStore.availableQuantity("A"));

        clock.setInstant(reservation.expiresAt().plus(Duration.ofDays(1)));
        assertEquals(0, service.expireDueReservations());
        assertEquals(confirmed, reservationStore.findById(reservation.id()).orElseThrow());
        assertEquals(7, inventoryStore.availableQuantity("A"));
    }

    @Test
    void confirmAtExactExpiryExpiresRestoresOnceAndDoesNotClaimPayment() {
        inventoryStore.put("A", 10);
        Reservation expiredCandidate = reserve("req-boundary", line("A", 3));
        clock.setInstant(expiredCandidate.expiresAt());

        assertThrows(
                ReservationExpiredException.class,
                () -> service.confirm(expiredCandidate.id(), "pay-boundary")
        );

        Reservation expired = reservationStore.findById(expiredCandidate.id()).orElseThrow();
        assertEquals(ReservationStatus.EXPIRED, expired.status());
        assertNull(expired.paymentId());
        assertEquals(10, inventoryStore.availableQuantity("A"));

        assertThrows(
                ReservationExpiredException.class,
                () -> service.confirm(expiredCandidate.id(), "pay-boundary")
        );
        assertEquals(10, inventoryStore.availableQuantity("A"));

        Reservation another = reserve("req-after-boundary", line("A", 1));
        Reservation confirmed = service.confirm(another.id(), "pay-boundary");
        assertEquals(ReservationStatus.CONFIRMED, confirmed.status());
        assertEquals(9, inventoryStore.availableQuantity("A"));
    }

    @Test
    void bulkExpiryProcessesOnlyDueActiveReservationsAndIsRepeatable() {
        putFiveEach("A", "B", "C", "D", "E");
        Reservation dueActive = reserve("req-active", line("A", 1));
        Reservation confirmed = reserve("req-confirmed", line("B", 1));
        service.confirm(confirmed.id(), "pay-confirmed");
        Reservation cancelled = reserve("req-cancelled", line("C", 1));
        service.cancel(cancelled.id());
        Reservation expired = reserve("req-expired", line("D", 1));

        clock.setInstant(INITIAL_TIME.plus(Duration.ofMinutes(15)));
        assertThrows(
                ReservationExpiredException.class,
                () -> service.confirm(expired.id(), "unused-payment")
        );
        Reservation notDue = reserve("req-not-due", line("E", 1));

        assertEquals(1, service.expireDueReservations());
        assertEquals(ReservationStatus.EXPIRED, stored(dueActive).status());
        assertEquals(ReservationStatus.CONFIRMED, stored(confirmed).status());
        assertEquals(ReservationStatus.CANCELLED, stored(cancelled).status());
        assertEquals(ReservationStatus.EXPIRED, stored(expired).status());
        assertEquals(ReservationStatus.ACTIVE, stored(notDue).status());
        assertEquals(5, inventoryStore.availableQuantity("A"));
        assertEquals(4, inventoryStore.availableQuantity("B"));
        assertEquals(5, inventoryStore.availableQuantity("C"));
        assertEquals(5, inventoryStore.availableQuantity("D"));
        assertEquals(4, inventoryStore.availableQuantity("E"));

        assertEquals(0, service.expireDueReservations());
        assertEquals(5, inventoryStore.availableQuantity("A"));
        assertEquals(5, inventoryStore.availableQuantity("D"));
        assertEquals(4, inventoryStore.availableQuantity("E"));
    }

    @Test
    void cancellingTwiceReturnsSameCancelledReservationAndRestoresOnce() {
        inventoryStore.put("A", 10);
        Reservation active = reserve("req-cancel-twice", line("A", 4));

        Reservation first = service.cancel(active.id());
        Reservation second = service.cancel(active.id());

        assertEquals(ReservationStatus.CANCELLED, first.status());
        assertSame(first, second);
        assertEquals(first.id(), second.id());
        assertEquals(10, inventoryStore.availableQuantity("A"));
        assertEquals(1, reservationStore.count());
        assertEquals(1, idempotencyStore.count());
    }

    @Test
    void cancellingExpiredReservationIsNoOp() {
        inventoryStore.put("A", 10);
        Reservation active = reserve("req-expired-cancel", line("A", 4));
        clock.setInstant(active.expiresAt());
        assertEquals(1, service.expireDueReservations());

        Reservation beforeCancel = stored(active);
        Reservation afterCancel = service.cancel(active.id());

        assertSame(beforeCancel, afterCancel);
        assertEquals(ReservationStatus.EXPIRED, afterCancel.status());
        assertEquals(10, inventoryStore.availableQuantity("A"));
    }

    @Test
    void confirmingTwiceWithSamePaymentIsIdempotent() {
        inventoryStore.put("A", 10);
        Reservation active = reserve("req-confirm-twice", line("A", 4));

        Reservation first = service.confirm(active.id(), "pay-same");
        Reservation second = service.confirm(active.id(), "pay-same");

        assertSame(first, second);
        assertEquals(ReservationStatus.CONFIRMED, second.status());
        assertEquals("pay-same", second.paymentId());
        assertEquals(6, inventoryStore.availableQuantity("A"));
        assertEquals(1, reservationStore.count());
    }

    @Test
    void differentPaymentOnConfirmedReservationConflictsWithoutMutation() {
        inventoryStore.put("A", 10);
        Reservation active = reserve("req-payment-change", line("A", 4));
        Reservation confirmed = service.confirm(active.id(), "pay-original");

        assertThrows(
                PaymentConflictException.class,
                () -> service.confirm(active.id(), "pay-other")
        );

        assertEquals(confirmed, stored(active));
        assertEquals("pay-original", stored(active).paymentId());
        assertEquals(6, inventoryStore.availableQuantity("A"));
    }

    @Test
    void paymentIdCannotConfirmTwoReservations() {
        inventoryStore.put("A", 10);
        inventoryStore.put("B", 10);
        Reservation first = reserve("req-first-payment", line("A", 3));
        Reservation second = reserve("req-second-payment", line("B", 2));
        Reservation firstConfirmed = service.confirm(first.id(), "pay-unique");

        assertThrows(
                PaymentConflictException.class,
                () -> service.confirm(second.id(), "pay-unique")
        );

        assertEquals(firstConfirmed, stored(first));
        assertEquals(ReservationStatus.ACTIVE, stored(second).status());
        assertNull(stored(second).paymentId());
        assertEquals(7, inventoryStore.availableQuantity("A"));
        assertEquals(8, inventoryStore.availableQuantity("B"));
    }

    @Test
    void confirmedReservationCannotBeCancelled() {
        inventoryStore.put("A", 10);
        Reservation active = reserve("req-confirmed-cancel", line("A", 3));
        Reservation confirmed = service.confirm(active.id(), "pay-keep");

        assertThrows(
                InvalidReservationTransitionException.class,
                () -> service.cancel(active.id())
        );

        assertEquals(confirmed, stored(active));
        assertEquals(ReservationStatus.CONFIRMED, stored(active).status());
        assertEquals("pay-keep", stored(active).paymentId());
        assertEquals(7, inventoryStore.availableQuantity("A"));
    }

    @Test
    void retryAfterCancellationOrExpiryReturnsCurrentTerminalReservation() {
        inventoryStore.put("A", 10);
        inventoryStore.put("B", 10);
        Reservation cancelledSource = reserve(
                "req-terminal-cancel",
                line("A", 1),
                line("A", 2)
        );
        Reservation cancelled = service.cancel(cancelledSource.id());
        Reservation cancelledRetry = reserve(
                "req-terminal-cancel",
                line("A", 3)
        );

        Reservation expiredSource = reserve("req-terminal-expire", line("B", 4));
        clock.setInstant(expiredSource.expiresAt());
        assertEquals(1, service.expireDueReservations());
        Reservation expiredRetry = reserve(
                "req-terminal-expire",
                line("B", 2),
                line("B", 2)
        );

        assertEquals(cancelled.id(), cancelledRetry.id());
        assertEquals(ReservationStatus.CANCELLED, cancelledRetry.status());
        assertEquals(expiredSource.id(), expiredRetry.id());
        assertEquals(ReservationStatus.EXPIRED, expiredRetry.status());
        assertEquals(10, inventoryStore.availableQuantity("A"));
        assertEquals(10, inventoryStore.availableQuantity("B"));
        assertEquals(2, reservationStore.count());
        assertEquals(2, idempotencyStore.count());
    }

    @Test
    void validatesPaymentBeforeLookupAndAppliesTerminalStateRules() {
        assertThrows(
                InvalidReservationRequestException.class,
                () -> service.confirm("missing", " \n")
        );
        assertThrows(
                ReservationNotFoundException.class,
                () -> service.confirm("missing", "pay-valid")
        );
        assertThrows(
                ReservationNotFoundException.class,
                () -> service.cancel("missing")
        );

        inventoryStore.put("A", 10);
        Reservation cancelled = service.cancel(
                reserve("req-cancel-confirm", line("A", 1)).id()
        );
        assertThrows(
                InvalidReservationTransitionException.class,
                () -> service.confirm(cancelled.id(), "pay-cancelled")
        );

        Reservation active = reserve("req-expire-confirm", line("A", 1));
        clock.setInstant(active.expiresAt());
        assertEquals(1, service.expireDueReservations());
        assertThrows(
                ReservationExpiredException.class,
                () -> service.confirm(active.id(), "pay-expired")
        );

        assertEquals(10, inventoryStore.availableQuantity("A"));
        assertNull(stored(cancelled).paymentId());
        assertNull(stored(active).paymentId());
    }

    private Reservation reserve(
            String requestId,
            ReserveLineCommand... lines
    ) {
        return service.reserve(new ReserveCommand(requestId, List.of(lines)));
    }

    private ReserveLineCommand line(String productId, long quantity) {
        return new ReserveLineCommand(productId, quantity);
    }

    private Reservation stored(Reservation reservation) {
        return reservationStore.findById(reservation.id()).orElseThrow();
    }

    private void putFiveEach(String... productIds) {
        for (String productId : productIds) {
            inventoryStore.put(productId, 5);
        }
    }

    private static final class AdjustableClock extends Clock {

        private Instant currentInstant;
        private final ZoneId zone;

        private AdjustableClock(Instant currentInstant, ZoneId zone) {
            this.currentInstant = currentInstant;
            this.zone = zone;
        }

        private void setInstant(Instant currentInstant) {
            this.currentInstant = currentInstant;
        }

        @Override
        public ZoneId getZone() {
            return zone;
        }

        @Override
        public Clock withZone(ZoneId requestedZone) {
            return new AdjustableClock(currentInstant, requestedZone);
        }

        @Override
        public Instant instant() {
            return currentInstant;
        }
    }
}