비교 요약으로 돌아가기

Full output

GPT-5.6 Sol · high 원문 결과물

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

gpt-5.6-sol-high 작업 결과

변경한 파일

  • ReservationService.java
  • ReservationServiceContractTest.java

작업 사본은 verification/work/14-inventory-reservation-state/gpt-5.6-sol-high에 보존했다.

구현 내용

  • 공백 요청 ID와 상품 ID를 포함한 입력 전체 검증
  • 중복 상품 수량 합산과 long 오버플로 검출
  • 전체 상품의 재고를 먼저 확인한 뒤 차감하는 다중 상품 예약 원자성
  • 정규화된 상품별 총수량을 기준으로 요청 멱등성 비교
  • 만료 경계 now >= expiresAt 처리
  • 만료 시 상태 전이와 재고 1회 복구
  • 결제 재시도와 결제 ID 전역 고유성 처리
  • 취소와 만료의 반복 호출에 대한 재고 중복 복구 방지
  • 종결 상태별 상태 전이와 예외 처리

구현은 실제 병렬 처리나 잠금을 추가하지 않고, 인메모리 순차 실행 환경에서 사전 검증 후 상태를 변경하도록 구성했다.

발견한 기존 오류

  • 중복 상품이 합산되지 않고 마지막 수량으로 덮어써졌다.
  • 다중 상품 예약에서 앞선 상품의 재고가 부분 차감될 수 있었다.
  • 같은 요청 ID의 다른 상품 구성을 멱등성 충돌로 처리하지 않았다.
  • 만료 시각과 정확히 같은 순간을 만료로 처리하지 않았다.
  • 확정 시 만료된 예약의 상태 전이와 재고 복구가 누락됐다.
  • 다른 결제 ID를 사용한 확정 재시도와 결제 ID 중복 사용을 허용했다.
  • 취소 또는 만료 재시도에서 재고가 중복 복구될 수 있었다.

테스트 결과

실행 명령:

./gradlew clean test --no-daemon

결과:

  • 기본 테스트: 4개 통과
  • 추가 계약 테스트: 22개 통과
  • 전체: 26개 통과, 실패 0개
  • BUILD SUCCESSFUL

실행 격리 참고

gpt-5.6-sol-lowgpt-5.6-sol-medium 작업 디렉터리는 열람하거나 수정하지 않았다.

다만 최초 파일 탐색 명령에서 verification/scenarios/14-inventory-reservation-state의 채점 자료가 함께 출력되었으므로, 엄밀한 블라인드 비교 실행은 오염된 것으로 기록해야 한다.

실제 변경 코드

아래는 보존된 격리 작업 사본을 원본 하네스와 대조해 확인한 변경 파일의 전체 내용입니다. 작업 디렉터리의 .DS_Store는 구현 결과에서 제외했습니다.

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());
        }

        verifyAvailableStock(normalized);

        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
                && isDue(reservation, clock.instant())) {
            expire(reservation);
            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;
        }

        Reservation cancelled = reservation.transitionTo(
                ReservationStatus.CANCELLED,
                reservation.paymentId()
        );
        restoreStock(reservation);
        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
                    || !isDue(reservation, now)) {
                continue;
            }

            expire(reservation);
            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 verifyAvailableStock(Map<String, Long> normalized) {
        for (Map.Entry<String, Long> entry : normalized.entrySet()) {
            if (inventoryStore.availableQuantity(entry.getKey()) < entry.getValue()) {
                throw new OutOfStockException(entry.getKey());
            }
        }
    }

    private boolean isDue(Reservation reservation, Instant now) {
        return !now.isBefore(reservation.expiresAt());
    }

    private void expire(Reservation reservation) {
        restoreStock(reservation);
        reservationStore.save(reservation.transitionTo(
                ReservationStatus.EXPIRED,
                null
        ));
    }

    private void restoreStock(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.assertThrows;

class ReservationServiceContractTest {

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

    private InMemoryInventoryStore inventory;
    private InMemoryReservationStore reservations;
    private InMemoryIdempotencyStore idempotency;
    private MutableClock clock;
    private ReservationService service;

    @BeforeEach
    void setUp() {
        inventory = new InMemoryInventoryStore();
        reservations = new InMemoryReservationStore();
        idempotency = new InMemoryIdempotencyStore();
        clock = new MutableClock(START);
        service = new ReservationService(inventory, reservations, idempotency, clock);
    }

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

        Reservation result = service.reserve(command(
                "request-1",
                line("A", 2),
                line("B", 1),
                line("A", 3)
        ));

        assertEquals(List.of(
                new ReservationLine("A", 5),
                new ReservationLine("B", 1)
        ), result.lines());
        assertEquals(5, inventory.availableQuantity("A"));
        assertEquals(4, inventory.availableQuantity("B"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void rejectsMultiProductRequestAtomicallyWhenAnyStockIsInsufficient() {
        inventory.put("A", 10);
        inventory.put("B", 1);

        assertThrows(OutOfStockException.class, () -> service.reserve(command(
                "request-1",
                line("A", 4),
                line("B", 2)
        )));

        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(1, inventory.availableQuantity("B"));
        assertEquals(0, reservations.count());
        assertEquals(0, idempotency.count());
    }

    @Test
    void treatsUnknownProductAsOutOfStockWithoutPartialDeduction() {
        inventory.put("A", 10);

        assertThrows(OutOfStockException.class, () -> service.reserve(command(
                "request-1",
                line("A", 4),
                line("missing", 1)
        )));

        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(0, inventory.availableQuantity("missing"));
        assertEquals(0, reservations.count());
        assertEquals(0, idempotency.count());
    }

    @Test
    void rejectsEveryInvalidReservationShapeWithoutMutation() {
        inventory.put("A", Long.MAX_VALUE);

        assertThrows(InvalidReservationRequestException.class, () ->
                service.reserve(null));
        assertThrows(InvalidReservationRequestException.class, () ->
                service.reserve(new ReserveCommand(null, List.of(line("A", 1)))));
        assertThrows(InvalidReservationRequestException.class, () ->
                service.reserve(command("  ", line("A", 1))));
        assertThrows(InvalidReservationRequestException.class, () ->
                service.reserve(new ReserveCommand("no-lines", null)));
        assertThrows(InvalidReservationRequestException.class, () ->
                service.reserve(new ReserveCommand("empty-lines", List.of())));
        assertThrows(InvalidReservationRequestException.class, () ->
                service.reserve(new ReserveCommand(
                        "null-line",
                        Arrays.asList((ReserveLineCommand) null)
                )));
        assertThrows(InvalidReservationRequestException.class, () ->
                service.reserve(command("null-product", line(null, 1))));
        assertThrows(InvalidReservationRequestException.class, () ->
                service.reserve(command("blank-product", line("\t ", 1))));
        assertThrows(InvalidReservationRequestException.class, () ->
                service.reserve(command("zero", line("A", 0))));
        assertThrows(InvalidReservationRequestException.class, () ->
                service.reserve(command("negative", line("A", -1))));
        assertThrows(InvalidReservationRequestException.class, () ->
                service.reserve(command(
                        "overflow",
                        line("A", Long.MAX_VALUE),
                        line("A", 1)
                )));

        assertEquals(Long.MAX_VALUE, inventory.availableQuantity("A"));
        assertEquals(0, reservations.count());
        assertEquals(0, idempotency.count());
    }

    @Test
    void equivalentNormalizedRetryReturnsCurrentStoredReservation() {
        inventory.put("A", 10);
        inventory.put("B", 10);
        Reservation first = service.reserve(command(
                "request-1",
                line("A", 2),
                line("B", 1),
                line("A", 3)
        ));

        Reservation retried = service.reserve(command(
                "request-1",
                line("B", 1),
                line("A", 5)
        ));

        assertEquals(first.id(), retried.id());
        assertEquals(first.lines(), retried.lines());
        assertEquals(5, inventory.availableQuantity("A"));
        assertEquals(9, inventory.availableQuantity("B"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void conflictingIdempotencyPayloadWinsOverAStockFailure() {
        inventory.put("A", 10);
        Reservation first = service.reserve(command("request-1", line("A", 2)));

        assertThrows(IdempotencyConflictException.class, () ->
                service.reserve(command("request-1", line("missing", 1))));

        assertEquals(first, reservations.findById(first.id()).orElseThrow());
        assertEquals(8, inventory.availableQuantity("A"));
        assertEquals(0, inventory.availableQuantity("missing"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void failedStockCheckDoesNotClaimRequestId() {
        inventory.put("A", 1);
        assertThrows(OutOfStockException.class, () ->
                service.reserve(command("request-1", line("A", 2))));

        inventory.put("A", 5);
        Reservation result = service.reserve(command("request-1", line("A", 2)));

        assertEquals(ReservationStatus.ACTIVE, result.status());
        assertEquals(3, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void recordsInjectedCreationTimeAndExactFifteenMinuteExpiry() {
        inventory.put("A", 1);

        Reservation result = service.reserve(command("request-1", line("A", 1)));

        assertEquals(START, result.createdAt());
        assertEquals(START.plus(Duration.ofMinutes(15)), result.expiresAt());
    }

    @Test
    void confirmsOneNanosecondBeforeExpiryAndNeverRestoresItsStock() {
        inventory.put("A", 10);
        Reservation active = service.reserve(command("request-1", line("A", 2)));
        clock.set(active.expiresAt().minusNanos(1));

        Reservation confirmed = service.confirm(active.id(), "payment-1");
        clock.set(active.expiresAt().plusSeconds(1));

        assertEquals(ReservationStatus.CONFIRMED, confirmed.status());
        assertEquals("payment-1", confirmed.paymentId());
        assertEquals(0, service.expireDueReservations());
        assertEquals(8, inventory.availableQuantity("A"));
        assertEquals(confirmed, reservations.findById(active.id()).orElseThrow());
    }

    @Test
    void confirmAtExactExpiryExpiresAndRestoresExactlyOnceWithoutClaimingPayment() {
        inventory.put("A", 10);
        inventory.put("B", 10);
        Reservation active = service.reserve(command("request-1", line("A", 2)));
        clock.set(active.expiresAt());

        assertThrows(ReservationExpiredException.class, () ->
                service.confirm(active.id(), "reusable-payment"));
        Reservation expired = reservations.findById(active.id()).orElseThrow();

        assertEquals(ReservationStatus.EXPIRED, expired.status());
        assertNull(expired.paymentId());
        assertEquals(10, inventory.availableQuantity("A"));
        assertThrows(ReservationExpiredException.class, () ->
                service.confirm(active.id(), "reusable-payment"));
        assertEquals(10, inventory.availableQuantity("A"));

        Reservation other = service.reserve(command("request-2", line("B", 1)));
        Reservation confirmed = service.confirm(other.id(), "reusable-payment");
        assertEquals(ReservationStatus.CONFIRMED, confirmed.status());
        assertEquals(9, inventory.availableQuantity("B"));
    }

    @Test
    void batchExpiryUsesInclusiveBoundaryAndIsRepeatable() {
        inventory.put("A", 10);
        inventory.put("B", 10);
        inventory.put("C", 10);
        Reservation due = service.reserve(command("due", line("A", 2)));
        Reservation confirmed = service.reserve(command("confirmed", line("B", 2)));
        Reservation cancelled = service.reserve(command("cancelled", line("C", 2)));
        service.confirm(confirmed.id(), "payment-1");
        service.cancel(cancelled.id());

        clock.set(due.expiresAt().minusNanos(1));
        assertEquals(0, service.expireDueReservations());
        assertEquals(8, inventory.availableQuantity("A"));

        clock.set(due.expiresAt());
        assertEquals(1, service.expireDueReservations());
        assertEquals(ReservationStatus.EXPIRED,
                reservations.findById(due.id()).orElseThrow().status());
        assertEquals(ReservationStatus.CONFIRMED,
                reservations.findById(confirmed.id()).orElseThrow().status());
        assertEquals(ReservationStatus.CANCELLED,
                reservations.findById(cancelled.id()).orElseThrow().status());
        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(8, inventory.availableQuantity("B"));
        assertEquals(10, inventory.availableQuantity("C"));

        assertEquals(0, service.expireDueReservations());
        assertEquals(10, inventory.availableQuantity("A"));
    }

    @Test
    void cancellingTwiceRestoresStockOnlyOnce() {
        inventory.put("A", 10);
        Reservation active = service.reserve(command("request-1", line("A", 3)));

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

        assertEquals(first, second);
        assertEquals(ReservationStatus.CANCELLED, second.status());
        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void cancellingExpiredReservationIsANoOp() {
        inventory.put("A", 10);
        Reservation active = service.reserve(command("request-1", line("A", 3)));
        clock.set(active.expiresAt());
        assertEquals(1, service.expireDueReservations());

        Reservation cancelled = service.cancel(active.id());

        assertEquals(ReservationStatus.EXPIRED, cancelled.status());
        assertNull(cancelled.paymentId());
        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void confirmingTwiceWithSamePaymentIsIdempotent() {
        inventory.put("A", 10);
        Reservation active = service.reserve(command("request-1", line("A", 3)));

        Reservation first = service.confirm(active.id(), "payment-1");
        Reservation second = service.confirm(active.id(), "payment-1");

        assertEquals(first, second);
        assertEquals("payment-1", second.paymentId());
        assertEquals(ReservationStatus.CONFIRMED, second.status());
        assertEquals(7, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void differentPaymentOnConfirmedReservationConflictsWithoutMutation() {
        inventory.put("A", 10);
        Reservation active = service.reserve(command("request-1", line("A", 3)));
        Reservation confirmed = service.confirm(active.id(), "payment-1");

        assertThrows(PaymentConflictException.class, () ->
                service.confirm(active.id(), "payment-2"));

        assertEquals(confirmed, reservations.findById(active.id()).orElseThrow());
        assertEquals("payment-1",
                reservations.findById(active.id()).orElseThrow().paymentId());
        assertEquals(7, inventory.availableQuantity("A"));
    }

    @Test
    void paymentIdCannotConfirmTwoReservations() {
        inventory.put("A", 10);
        inventory.put("B", 10);
        Reservation first = service.reserve(command("request-1", line("A", 2)));
        Reservation second = service.reserve(command("request-2", line("B", 3)));
        Reservation confirmed = service.confirm(first.id(), "payment-1");

        assertThrows(PaymentConflictException.class, () ->
                service.confirm(second.id(), "payment-1"));

        assertEquals(confirmed, reservations.findById(first.id()).orElseThrow());
        assertEquals(ReservationStatus.ACTIVE,
                reservations.findById(second.id()).orElseThrow().status());
        assertNull(reservations.findById(second.id()).orElseThrow().paymentId());
        assertEquals(8, inventory.availableQuantity("A"));
        assertEquals(7, inventory.availableQuantity("B"));
        assertEquals(2, reservations.count());
        assertEquals(2, idempotency.count());
    }

    @Test
    void confirmedReservationCannotBeCancelled() {
        inventory.put("A", 10);
        Reservation active = service.reserve(command("request-1", line("A", 3)));
        Reservation confirmed = service.confirm(active.id(), "payment-1");

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

        assertEquals(confirmed, reservations.findById(active.id()).orElseThrow());
        assertEquals("payment-1",
                reservations.findById(active.id()).orElseThrow().paymentId());
        assertEquals(7, inventory.availableQuantity("A"));
    }

    @Test
    void requestRetryAfterCancellationReturnsTerminalReservationWithoutDeducting() {
        inventory.put("A", 10);
        Reservation active = service.reserve(command(
                "request-1",
                line("A", 1),
                line("A", 2)
        ));
        Reservation terminal = service.cancel(active.id());

        Reservation retried = service.reserve(command("request-1", line("A", 3)));

        assertEquals(terminal.id(), retried.id());
        assertEquals(ReservationStatus.CANCELLED, retried.status());
        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void requestRetryAfterExpiryReturnsTerminalReservationWithoutDeducting() {
        inventory.put("A", 10);
        Reservation active = service.reserve(command("request-1", line("A", 3)));
        clock.set(active.expiresAt());
        service.expireDueReservations();

        Reservation retried = service.reserve(command(
                "request-1",
                line("A", 1),
                line("A", 2)
        ));

        assertEquals(active.id(), retried.id());
        assertEquals(ReservationStatus.EXPIRED, retried.status());
        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void confirmValidatesPaymentBeforeLookupOrExpirySideEffects() {
        inventory.put("A", 10);
        Reservation active = service.reserve(command("request-1", line("A", 3)));
        clock.set(active.expiresAt());

        assertThrows(InvalidReservationRequestException.class, () ->
                service.confirm("missing", "  "));
        assertThrows(InvalidReservationRequestException.class, () ->
                service.confirm(active.id(), null));

        assertEquals(ReservationStatus.ACTIVE,
                reservations.findById(active.id()).orElseThrow().status());
        assertEquals(7, inventory.availableQuantity("A"));
        assertThrows(ReservationNotFoundException.class, () ->
                service.confirm("missing", "payment-1"));
    }

    @Test
    void terminalConfirmRulesReturnTheirSpecificExceptions() {
        inventory.put("A", 10);
        inventory.put("B", 10);
        Reservation cancelled = service.reserve(command("request-1", line("A", 2)));
        service.cancel(cancelled.id());
        Reservation expired = service.reserve(command("request-2", line("B", 2)));
        clock.set(expired.expiresAt());
        service.expireDueReservations();

        assertThrows(InvalidReservationTransitionException.class, () ->
                service.confirm(cancelled.id(), "payment-1"));
        assertThrows(ReservationExpiredException.class, () ->
                service.confirm(expired.id(), "payment-2"));
        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(10, inventory.availableQuantity("B"));
        assertEquals(2, reservations.count());
        assertEquals(2, idempotency.count());
    }

    @Test
    void missingReservationFailsBothTransitionMethods() {
        assertThrows(ReservationNotFoundException.class, () ->
                service.confirm("missing", "payment-1"));
        assertThrows(ReservationNotFoundException.class, () ->
                service.cancel("missing"));
        assertEquals(0, reservations.count());
        assertEquals(0, idempotency.count());
    }

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

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

    private static final class MutableClock extends Clock {

        private Instant instant;

        private MutableClock(Instant instant) {
            this.instant = instant;
        }

        void set(Instant instant) {
            this.instant = instant;
        }

        @Override
        public ZoneId getZone() {
            return ZoneOffset.UTC;
        }

        @Override
        public Clock withZone(ZoneId zone) {
            return this;
        }

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