From 05badb114aefb83455c2c2a83417f9a1b61ad353 Mon Sep 17 00:00:00 2001 From: Alexander Mejia Date: Tue, 21 Apr 2026 08:08:30 -0500 Subject: [PATCH] Implement ticket reservation confirmation flow --- app/models/ticket.py | 14 +- app/routes/ticket_routes.py | 81 +++++-- app/services/booking_service.py | 163 +++++++++++++- config.py | 1 + docs/architecture/README.md | 8 + .../ticket-reservation-confirmation.md | 204 ++++++++++++++++++ docs/technical-requirements.md | 8 + scripts/api_test_cases.sh | 76 +++++++ tests/test_ticket_reservation_confirmation.py | 123 +++++++++++ 9 files changed, 651 insertions(+), 27 deletions(-) create mode 100644 docs/desing_sessions/ticket-reservation-confirmation.md create mode 100755 scripts/api_test_cases.sh create mode 100644 tests/test_ticket_reservation_confirmation.py diff --git a/app/models/ticket.py b/app/models/ticket.py index 3b45fbc..b571063 100644 --- a/app/models/ticket.py +++ b/app/models/ticket.py @@ -1,6 +1,8 @@ from __future__ import annotations -from sqlalchemy import Integer, String +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String from sqlalchemy.orm import Mapped, mapped_column from app.extensions import db @@ -13,11 +15,19 @@ class Ticket(db.Model): event_name: Mapped[str] = mapped_column(String(255), nullable=False) capacity: Mapped[int] = mapped_column(Integer, nullable=False) reserved_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + reservation_status: Mapped[str | None] = mapped_column(String(64), nullable=True) + reservation_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + reservation_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + reservation_confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - def to_dict(self) -> dict[str, int | str]: + def to_dict(self) -> dict[str, int | str | None]: return { "id": self.id, "event_name": self.event_name, "capacity": self.capacity, "reserved_count": self.reserved_count, + "reservation_status": self.reservation_status, + "reservation_created_at": self.reservation_created_at.isoformat() if self.reservation_created_at else None, + "reservation_expires_at": self.reservation_expires_at.isoformat() if self.reservation_expires_at else None, + "reservation_confirmed_at": self.reservation_confirmed_at.isoformat() if self.reservation_confirmed_at else None, } diff --git a/app/routes/ticket_routes.py b/app/routes/ticket_routes.py index 29959bd..d129980 100644 --- a/app/routes/ticket_routes.py +++ b/app/routes/ticket_routes.py @@ -3,12 +3,21 @@ from http import HTTPStatus from typing import Any -from flask import Blueprint, jsonify, request +from flask import Blueprint, current_app, jsonify, request from flask.typing import ResponseReturnValue from app.extensions import db from app.models.ticket import Ticket -from app.services.booking_service import reserve_ticket +from app.services.booking_service import ( + NoActiveTentativeReservationError, + ReservationError, + TentativeReservationExpiredError, + TicketAlreadyConfirmedError, + TicketAlreadyReservedError, + TicketNotFoundError, + confirm_ticket, + reserve_ticket, +) ticket_bp: Blueprint = Blueprint("tickets", __name__, url_prefix="/api/tickets") @@ -40,20 +49,62 @@ def create_ticket() -> ResponseReturnValue: return jsonify(ticket.to_dict()), HTTPStatus.CREATED -@ticket_bp.post("//reserve") +def _error_response(error: ReservationError, status: HTTPStatus) -> ResponseReturnValue: + return ( + jsonify( + { + "error_code": error.error_code, + "message": error.message, + "details": None, + } + ), + status, + ) + + +@ticket_bp.post("//reservation") def reserve(ticket_id: int) -> ResponseReturnValue: - payload: dict[str, Any] = request.get_json(silent=True) or {} + ttl_seconds: int = int(current_app.config.get("RESERVATION_TTL_SECONDS", 60)) try: - quantity: int = int(payload.get("quantity", 1)) - except (TypeError, ValueError): - return jsonify({"error": "quantity must be an integer"}), HTTPStatus.BAD_REQUEST - + updated_ticket: Ticket = reserve_ticket(ticket_id=ticket_id, ttl_seconds=ttl_seconds) + except TicketNotFoundError as error: + return _error_response(error, HTTPStatus.NOT_FOUND) + except (TicketAlreadyReservedError, TicketAlreadyConfirmedError) as error: + return _error_response(error, HTTPStatus.CONFLICT) + + return ( + jsonify( + { + "ticket_id": updated_ticket.id, + "status": updated_ticket.reservation_status, + "reservation_expires_at": updated_ticket.reservation_expires_at.isoformat() + if updated_ticket.reservation_expires_at + else None, + } + ), + HTTPStatus.CREATED, + ) + + +@ticket_bp.post("//confirm") +def confirm(ticket_id: int) -> ResponseReturnValue: try: - updated_ticket: Ticket = reserve_ticket(ticket_id=ticket_id, quantity=quantity) - except LookupError as error: - return jsonify({"error": str(error)}), HTTPStatus.NOT_FOUND - except ValueError as error: - return jsonify({"error": str(error)}), HTTPStatus.BAD_REQUEST - - return jsonify(updated_ticket.to_dict()), HTTPStatus.OK + updated_ticket: Ticket = confirm_ticket(ticket_id=ticket_id) + except TicketNotFoundError as error: + return _error_response(error, HTTPStatus.NOT_FOUND) + except (NoActiveTentativeReservationError, TentativeReservationExpiredError) as error: + return _error_response(error, HTTPStatus.CONFLICT) + + return ( + jsonify( + { + "ticket_id": updated_ticket.id, + "status": updated_ticket.reservation_status, + "reservation_confirmed_at": updated_ticket.reservation_confirmed_at.isoformat() + if updated_ticket.reservation_confirmed_at + else None, + } + ), + HTTPStatus.OK, + ) diff --git a/app/services/booking_service.py b/app/services/booking_service.py index c3e91d4..e1bbff2 100644 --- a/app/services/booking_service.py +++ b/app/services/booking_service.py @@ -1,22 +1,165 @@ from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from sqlalchemy import and_, or_, update + from app.extensions import db from app.models.ticket import Ticket -def reserve_ticket(ticket_id: int, quantity: int) -> Ticket: - if quantity <= 0: - raise ValueError("quantity must be greater than zero") +@dataclass(slots=True) +class ReservationError(Exception): + error_code: str + message: str + + +class TicketNotFoundError(ReservationError): + def __init__(self) -> None: + super().__init__(error_code="TICKET_NOT_FOUND", message="ticket not found") + + +class TicketAlreadyReservedError(ReservationError): + def __init__(self) -> None: + super().__init__( + error_code="TICKET_ALREADY_RESERVED", + message="ticket already reserved and pending confirmation", + ) + + +class TicketAlreadyConfirmedError(ReservationError): + def __init__(self) -> None: + super().__init__(error_code="TICKET_ALREADY_CONFIRMED", message="ticket already confirmed") + + +class NoActiveTentativeReservationError(ReservationError): + def __init__(self) -> None: + super().__init__( + error_code="NO_ACTIVE_TENTATIVE_RESERVATION", + message="no active tentative reservation to confirm", + ) + + +class TentativeReservationExpiredError(ReservationError): + def __init__(self) -> None: + super().__init__( + error_code="TENTATIVE_RESERVATION_EXPIRED", + message="tentative reservation expired at runtime", + ) + + +AWAITING_CONFIRMATION: str = "AWAITING_CONFIRMATION" +CONFIRMED: str = "CONFIRMED" + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _as_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + +def reserve_ticket(ticket_id: int, ttl_seconds: int) -> Ticket: + now_utc: datetime = _utcnow() ticket: Ticket | None = db.session.get(Ticket, ticket_id) if ticket is None: - raise LookupError("ticket not found") + raise TicketNotFoundError() - remaining_capacity: int = ticket.capacity - ticket.reserved_count - if quantity > remaining_capacity: - raise ValueError("not enough capacity") + if ticket.reservation_status == CONFIRMED: + raise TicketAlreadyConfirmedError() - # Intentionally vulnerable read-check-write flow for workshop race conditions. - ticket.reserved_count = ticket.reserved_count + quantity + ticket_expires_at_utc: datetime | None = _as_utc(ticket.reservation_expires_at) + if ( + ticket.reservation_status == AWAITING_CONFIRMATION + and ticket_expires_at_utc is not None + and ticket_expires_at_utc > now_utc + ): + raise TicketAlreadyReservedError() + + if ttl_seconds <= 0: + ttl_seconds = 60 + + expires_at: datetime = now_utc + timedelta(seconds=ttl_seconds) + + now_naive_utc: datetime = now_utc.replace(tzinfo=None) + update_result = db.session.execute( + update(Ticket) + .where(Ticket.id == ticket_id) + .where( + or_( + Ticket.reservation_status.is_(None), + and_( + Ticket.reservation_status == AWAITING_CONFIRMATION, + Ticket.reservation_expires_at <= now_naive_utc, + ), + ) + ) + .values( + reservation_status=AWAITING_CONFIRMATION, + reservation_created_at=now_utc, + reservation_expires_at=expires_at, + reservation_confirmed_at=None, + ) + ) + if update_result.rowcount == 0: + current_ticket: Ticket | None = db.session.get(Ticket, ticket_id) + if current_ticket is None: + raise TicketNotFoundError() + if current_ticket.reservation_status == CONFIRMED: + raise TicketAlreadyConfirmedError() + current_expires_at_utc: datetime | None = _as_utc(current_ticket.reservation_expires_at) + if ( + current_ticket.reservation_status == AWAITING_CONFIRMATION + and current_expires_at_utc is not None + and current_expires_at_utc > now_utc + ): + raise TicketAlreadyReservedError() + + db.session.commit() + + updated_ticket: Ticket | None = db.session.get(Ticket, ticket_id) + if updated_ticket is None: + raise TicketNotFoundError() + + return updated_ticket + + +def confirm_ticket(ticket_id: int) -> Ticket: + now_utc: datetime = _utcnow() + + ticket: Ticket | None = db.session.get(Ticket, ticket_id) + if ticket is None: + raise TicketNotFoundError() + + if ticket.reservation_status == CONFIRMED: + return ticket + + if ticket.reservation_status != AWAITING_CONFIRMATION: + raise NoActiveTentativeReservationError() + + reservation_expires_at_utc: datetime | None = _as_utc(ticket.reservation_expires_at) + if reservation_expires_at_utc is None or reservation_expires_at_utc <= now_utc: + raise TentativeReservationExpiredError() + + db.session.execute( + update(Ticket) + .where(Ticket.id == ticket_id) + .where(Ticket.reservation_status == AWAITING_CONFIRMATION) + .values( + reservation_status=CONFIRMED, + reservation_confirmed_at=now_utc, + ) + ) db.session.commit() - return ticket + + confirmed_ticket: Ticket | None = db.session.get(Ticket, ticket_id) + if confirmed_ticket is None: + raise TicketNotFoundError() + + return confirmed_ticket diff --git a/config.py b/config.py index 4f21bad..98d6d36 100644 --- a/config.py +++ b/config.py @@ -8,6 +8,7 @@ class Config: SQLALCHEMY_TRACK_MODIFICATIONS: bool = False JSON_SORT_KEYS: bool = False TESTING: bool = False + RESERVATION_TTL_SECONDS: int = 60 class DevelopmentConfig(Config): diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 202575a..436da00 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -188,6 +188,14 @@ Alternative run command: flask --app run.py run ``` +Recommended quick-run shortcuts: +```bash +sh run.sh --local +sh run.sh --docker +sh run.sh --docker-detached +sh run.sh --down +``` + Optional container fallback: ```bash docker build -t gdg-workshop-ai-booking . diff --git a/docs/desing_sessions/ticket-reservation-confirmation.md b/docs/desing_sessions/ticket-reservation-confirmation.md new file mode 100644 index 0000000..cf4bd78 --- /dev/null +++ b/docs/desing_sessions/ticket-reservation-confirmation.md @@ -0,0 +1,204 @@ +# Active Design Specification + +## Feature Name + +ticket-reservation-confirmation + +--- + +## Problem Statement + +A user when using the API and reserving a ticket, shall first do a tentative reservation, that has +a time to live. After a reservation is created, then it has to be confirmed via another API call. +Only once it is confirmed, it can be referenced as a full on reservation. + +While a reservation is wating for confirmation, other users cannot reserve the same ticket, unless +the time to live finishes. If a confirmation is done, no other reservation can be done against the +ticket. + +--- + +## Scope + +This includes the definition of two endpoints one for the tentative reservation and the other for +the reservation confirmation. + +Reservation tracking is stored directly in the `tickets` table. There is only one reservation +context per ticket and no separate reservation entity. + +Status values are only `AWAITING_CONFIRMATION` and `CONFIRMED`. Expiration is evaluated at runtime +using timestamps and does not introduce an `EXPIRED` persisted status. + +There shall not be any complex concurrency control. + +No user management or authentication mechanism need to be included. + +--- + +## Implementation Details + +### Domain Model Updates (Ticket Table Only) + +No `reservations` table is introduced. + +Add reservation-tracking fields to `tickets`: + +- `reservation_status` (nullable text): `AWAITING_CONFIRMATION`, `CONFIRMED` +- `reservation_created_at` (nullable datetime UTC) +- `reservation_expires_at` (nullable datetime UTC) +- `reservation_confirmed_at` (nullable datetime UTC) + +No quantity field is used for this feature. A ticket has at most one active reservation flow. + +### State Machine + +Valid transitions: + +1. `AWAITING_CONFIRMATION` -> `CONFIRMED` +2. Expired tentative reservation is treated as inactive at runtime (no persisted `EXPIRED` state) + +Forbidden transitions: + +- `CONFIRMED` -> any other state +- Any transition that assumes or writes an `EXPIRED` status + +### API Contracts + +1) Tentative reservation + +- Method/Path: `POST /api/tickets//reservation` +- Request body: empty +- Success response: `201 Created` + - `ticket_id` + - `status` (`AWAITING_CONFIRMATION`) + - `reservation_expires_at` +- Error responses: + - `404` ticket not found + - `409` ticket already reserved and pending confirmation (not expired) + - `409` ticket already confirmed + +Decision: + +- Reservation endpoint is not idempotent for active tentative state; it always returns `409` when the ticket is currently in a valid `AWAITING_CONFIRMATION` window. + +2) Reservation confirmation + +- Method/Path: `POST /api/tickets//confirm` +- Request body: empty +- Success response: `200 OK` + - `ticket_id` + - `status` (`CONFIRMED`) + - `reservation_confirmed_at` +- Error responses: + - `404` ticket not found + - `409` no active tentative reservation to confirm + - `409` tentative reservation expired at runtime + +Decision: + +- Confirmation endpoint is idempotent: if the ticket is already `CONFIRMED`, return `200` with the current confirmed payload. + +### Availability Rules + +- Reservation validity is derived at runtime: + - `AWAITING_CONFIRMATION` is active only when `reservation_expires_at > now_utc`. + - If expired, reservation is treated as unavailable for confirmation and free for new reservation. +- No `EXPIRED` status is written to the database. + +### TTL and Configuration + +- Reservation TTL must be configuration-driven (no hard-coded magic value). +- Add config value: `RESERVATION_TTL_SECONDS`. +- Default value: `60` seconds. +- Service layer computes `reservation_expires_at = now_utc + TTL`. + +### Layer Responsibilities (Mandatory) + +- Routes (`app/routes`): payload parsing, status mapping, JSON responses only. +- Services (`app/services`): state transition logic and runtime expiration checks. +- Models (`app/models`): schema + constraints only. + +### Concurrency Approach (Simple, SQLite-Compatible) + +No external locking systems and no database engine replacement. + +Use ticket-row conditional updates only; do not create or manage reservation rows. + +Reservation operation: + +1. Read ticket state. +2. If state is `CONFIRMED`, reject. +3. If state is `AWAITING_CONFIRMATION` and not expired, reject. +4. If no active reservation, set `reservation_status=AWAITING_CONFIRMATION` and new expiration timestamp. +5. Existing expired timestamp values are not explicitly cleared beforehand; they are overwritten by the new reservation attempt. + +Confirmation operation: + +1. Read ticket state. +2. Require `reservation_status=AWAITING_CONFIRMATION`. +3. If expired at runtime, reject. +4. Set `reservation_status=CONFIRMED` and `reservation_confirmed_at`. + +No commit is made to any reservation entity/state because no reservation entity exists. + +### Error Contract + +All errors must return deterministic JSON shape: + +- `error_code` (stable machine-readable string) +- `message` (human-readable) +- `details` (optional structured object) + + +--- + + +## Test Plan + +1) Service unit tests + +- Create tentative reservation on an available ticket. +- Reject reservation when ticket has active, non-expired tentative reservation. +- Allow reservation when previous tentative reservation is expired (runtime check). +- Confirm succeeds before expiration. +- Confirm fails after expiration. +- Confirm returns `200` when already confirmed (idempotent behavior). + +2) Integration tests (Flask test client) + +- `POST /api/tickets//reservation` returns `201` with expected contract. +- `POST /api/tickets//confirm` returns `200` with expected contract. +- Error mapping coverage for `404` and `409`. + +3) Concurrency tests + +- Parallel reservation requests for same ticket must result in at most one successful tentative reservation in active window. +- Parallel confirm requests for same ticket must converge to confirmed state and return successful idempotent outcomes. +- Expiration boundary requests must remain deterministic. + +4) Quality gate + +- `black --check app/ tests/` +- `flake8 app/ tests/` +- `mypy app/ --strict` +- `pytest tests/ -v --cov=app --cov-fail-under=90` + +--- + +## Risks / Edge Cases + +- Clock consistency for expiration checks (must use UTC consistently). +- Expiration boundary race (confirm request arriving exactly at `expires_at`). +- Double-submit confirmation requests from client retries. +- Existing `tickets.reserved_count` behavior must remain consistent with single-ticket reservation lifecycle. + +--- + +## Open Questions + +None. Current decisions are finalized for implementation: + +1. `POST /api/tickets//reservation` returns `409` when ticket is actively tentative. +2. `POST /api/tickets//confirm` is idempotent and returns `200` when already confirmed. +3. `RESERVATION_TTL_SECONDS` default is `60`. +4. Expired timestamps are not cleared; values are overwritten on new reservation attempts. diff --git a/docs/technical-requirements.md b/docs/technical-requirements.md index 88acbfe..8403f05 100644 --- a/docs/technical-requirements.md +++ b/docs/technical-requirements.md @@ -130,6 +130,14 @@ pip install -r requirements.txt python run.py ``` +Simplified startup shortcuts: +```bash +sh run.sh --local +sh run.sh --docker +sh run.sh --docker-detached +sh run.sh --down +``` + Optional container fallback: ```bash docker build -t gdg-workshop-ai-booking . diff --git a/scripts/api_test_cases.sh b/scripts/api_test_cases.sh new file mode 100755 index 0000000..f388c6f --- /dev/null +++ b/scripts/api_test_cases.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env sh +set -eu + +BASE_URL="${1:-http://localhost:5000}" + +request() { + method="$1" + path="$2" + data="${3:-}" + + if [ -n "$data" ]; then + curl -sS -X "$method" "${BASE_URL}${path}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + -w "\n%{http_code}" + else + curl -sS -X "$method" "${BASE_URL}${path}" \ + -w "\n%{http_code}" + fi +} + +assert_status() { + expected="$1" + actual="$2" + step="$3" + + if [ "$expected" != "$actual" ]; then + echo "❌ ${step} failed (expected ${expected}, got ${actual})" + exit 1 + fi + + echo "✅ ${step}" +} + +extract_ticket_id() { + body="$1" + python3 -c 'import json,sys; print(json.loads(sys.argv[1])["id"])' "$body" +} + +echo "Running API test cases against ${BASE_URL}" + +health_response="$(request GET /api/health)" +health_status="$(printf '%s' "$health_response" | tail -n 1)" +health_body="$(printf '%s' "$health_response" | sed '$d')" +assert_status "200" "$health_status" "Health check" +echo " Response: $health_body" + +create_response="$(request POST /api/tickets '{"event_name":"Workshop Ticket","capacity":1}')" +create_status="$(printf '%s' "$create_response" | tail -n 1)" +create_body="$(printf '%s' "$create_response" | sed '$d')" +assert_status "201" "$create_status" "Create ticket" + +ticket_id="$(extract_ticket_id "$create_body")" +echo " Created ticket_id=${ticket_id}" + +list_response="$(request GET /api/tickets)" +list_status="$(printf '%s' "$list_response" | tail -n 1)" +assert_status "200" "$list_status" "List tickets" + +reserve_response="$(request POST "/api/tickets/${ticket_id}/reservation")" +reserve_status="$(printf '%s' "$reserve_response" | tail -n 1)" +assert_status "201" "$reserve_status" "Create tentative reservation" + +reserve_conflict_response="$(request POST "/api/tickets/${ticket_id}/reservation")" +reserve_conflict_status="$(printf '%s' "$reserve_conflict_response" | tail -n 1)" +assert_status "409" "$reserve_conflict_status" "Reject duplicate active reservation" + +confirm_response="$(request POST "/api/tickets/${ticket_id}/confirm")" +confirm_status="$(printf '%s' "$confirm_response" | tail -n 1)" +assert_status "200" "$confirm_status" "Confirm reservation" + +confirm_idempotent_response="$(request POST "/api/tickets/${ticket_id}/confirm")" +confirm_idempotent_status="$(printf '%s' "$confirm_idempotent_response" | tail -n 1)" +assert_status "200" "$confirm_idempotent_status" "Idempotent confirmation" + +echo "✅ All API test cases passed" diff --git a/tests/test_ticket_reservation_confirmation.py b/tests/test_ticket_reservation_confirmation.py new file mode 100644 index 0000000..8c528a6 --- /dev/null +++ b/tests/test_ticket_reservation_confirmation.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from flask.testing import FlaskClient + +from app.extensions import db +from app.models.ticket import Ticket + + +def _create_ticket(client: FlaskClient) -> int: + response = client.post( + "/api/tickets", + json={ + "event_name": "GDG Workshop", + "capacity": 100, + }, + ) + payload = response.get_json() + + assert response.status_code == 201 + assert payload is not None + return int(payload["id"]) + + +def test_tentative_reservation_success(client: FlaskClient) -> None: + ticket_id = _create_ticket(client) + + response = client.post(f"/api/tickets/{ticket_id}/reservation") + payload = response.get_json() + + assert response.status_code == 201 + assert payload is not None + assert payload["ticket_id"] == ticket_id + assert payload["status"] == "AWAITING_CONFIRMATION" + assert payload["reservation_expires_at"] is not None + + +def test_tentative_reservation_returns_409_when_active(client: FlaskClient) -> None: + ticket_id = _create_ticket(client) + + first_response = client.post(f"/api/tickets/{ticket_id}/reservation") + second_response = client.post(f"/api/tickets/{ticket_id}/reservation") + payload = second_response.get_json() + + assert first_response.status_code == 201 + assert second_response.status_code == 409 + assert payload is not None + assert payload["error_code"] == "TICKET_ALREADY_RESERVED" + + +def test_confirmation_is_idempotent(client: FlaskClient) -> None: + ticket_id = _create_ticket(client) + + reservation_response = client.post(f"/api/tickets/{ticket_id}/reservation") + first_confirm_response = client.post(f"/api/tickets/{ticket_id}/confirm") + second_confirm_response = client.post(f"/api/tickets/{ticket_id}/confirm") + + first_payload = first_confirm_response.get_json() + second_payload = second_confirm_response.get_json() + + assert reservation_response.status_code == 201 + assert first_confirm_response.status_code == 200 + assert second_confirm_response.status_code == 200 + + assert first_payload is not None + assert second_payload is not None + assert first_payload["status"] == "CONFIRMED" + assert second_payload["status"] == "CONFIRMED" + assert first_payload["ticket_id"] == ticket_id + assert second_payload["ticket_id"] == ticket_id + + +def test_confirmation_returns_409_when_expired(client: FlaskClient) -> None: + ticket_id = _create_ticket(client) + + reservation_response = client.post(f"/api/tickets/{ticket_id}/reservation") + + ticket = db.session.get(Ticket, ticket_id) + assert ticket is not None + + ticket.reservation_expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + db.session.commit() + + confirm_response = client.post(f"/api/tickets/{ticket_id}/confirm") + payload = confirm_response.get_json() + + assert reservation_response.status_code == 201 + assert confirm_response.status_code == 409 + assert payload is not None + assert payload["error_code"] == "TENTATIVE_RESERVATION_EXPIRED" + + +def test_expired_tentative_can_be_overwritten_by_new_reservation(client: FlaskClient) -> None: + ticket_id = _create_ticket(client) + + first_response = client.post(f"/api/tickets/{ticket_id}/reservation") + first_payload = first_response.get_json() + + ticket = db.session.get(Ticket, ticket_id) + assert ticket is not None + + ticket.reservation_expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + db.session.commit() + + second_response = client.post(f"/api/tickets/{ticket_id}/reservation") + second_payload = second_response.get_json() + + assert first_response.status_code == 201 + assert second_response.status_code == 201 + + assert first_payload is not None + assert second_payload is not None + assert first_payload["reservation_expires_at"] != second_payload["reservation_expires_at"] + + +def test_reservation_not_found_returns_404(client: FlaskClient) -> None: + response = client.post("/api/tickets/999999/reservation") + payload = response.get_json() + + assert response.status_code == 404 + assert payload is not None + assert payload["error_code"] == "TICKET_NOT_FOUND"