diff --git a/tests/test_capacity_guard.py b/tests/test_capacity_guard.py deleted file mode 100644 index 4125278..0000000 --- a/tests/test_capacity_guard.py +++ /dev/null @@ -1,112 +0,0 @@ -"""`amount_tickets` is the remaining count (aiolabs/events#34). - -`set_ticket_paid` decrements it on every sale and `sold` increments, so -subtracting `sold` from it removes each sale twice. That made the buyer -cap under-report and, once an event passed half its capacity, turned -`sold >= amount_tickets` true and declared it sold out with stock left. -Measured on aio-demo before the fix: 16 of 24 live events affected, -3 already refusing sales while tickets remained. -""" - -from datetime import datetime, timezone -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest -from fastapi import HTTPException - -from .. import views_api -from ..models import CreateTicket, Event - -SENTINEL = object() - - -def _event(amount_tickets: int, sold: int) -> Event: - return Event( - id="evt", - wallet="w", - name="Capacity", - info="", - closing_date="2030-01-01", - event_start_date="2030-01-01", - event_end_date="2030-01-02", - currency="sat", - price_per_ticket=0, # free path, so the guard is all that gates us - amount_tickets=amount_tickets, - sold=sold, - time=datetime.now(timezone.utc), - status="approved", - ) - - -@pytest.fixture -def issued(monkeypatch): - """Past the capacity guard the free path short-circuits to a sentinel.""" - monkeypatch.setattr( - views_api, "_issue_free_tickets", AsyncMock(return_value=SENTINEL) - ) - monkeypatch.setattr( - views_api, "_resolve_frontend_root", lambda data, req: "http://x" - ) - return SENTINEL - - -async def _buy(amount_tickets: int, sold: int, quantity: int, monkeypatch): - monkeypatch.setattr( - views_api, "get_event", AsyncMock(return_value=_event(amount_tickets, sold)) - ) - return await views_api.api_ticket_create( - "evt", CreateTicket(user_id="u1", quantity=quantity), SimpleNamespace() - ) - - -@pytest.mark.asyncio -async def test_last_ticket_still_sells(monkeypatch, issued): - """One left, one wanted. Previously refused once sold >= remaining.""" - assert await _buy(1, 99, 1, monkeypatch) is issued - - -@pytest.mark.asyncio -async def test_sold_out_only_when_actually_empty(monkeypatch, issued): - with pytest.raises(HTTPException) as exc: - await _buy(0, 100, 1, monkeypatch) - assert exc.value.detail == "Event is sold out." - - -@pytest.mark.asyncio -@pytest.mark.parametrize("sold", [0, 49, 50, 51, 500]) -async def test_remaining_alone_decides_availability(monkeypatch, issued, sold): - """The regression proper: with 50 left the event sells, whatever - `sold` says. Because remaining + sold is the original capacity, - `sold >= amount_tickets` first flips true at the halfway point — - sold=50 here — so an event locked itself once half its seats went. - The boundary cases (49/50/51) are the ones that matter.""" - assert await _buy(50, sold, 1, monkeypatch) is issued - - -@pytest.mark.asyncio -async def test_bulk_order_may_take_everything_left(monkeypatch, issued): - assert await _buy(10, 40, 10, monkeypatch) is issued - - -@pytest.mark.asyncio -async def test_bulk_order_over_capacity_reports_the_true_remainder(monkeypatch, issued): - """3 left, 5 wanted. The message must name the real remainder — it - used to say `3 - 40 = -37`. (`CreateTicket.quantity` is capped at 10 - by the model, so the overshoot is tested within that bound.)""" - with pytest.raises(HTTPException) as exc: - await _buy(3, 40, 5, monkeypatch) - assert exc.value.detail == "Only 3 ticket(s) remaining for this event." - - -@pytest.mark.asyncio -async def test_walk_an_event_to_capacity(monkeypatch, issued): - """50-seat event, one sale at a time, mirroring set_ticket_paid.""" - remaining, sold = 50, 0 - for _ in range(50): - assert await _buy(remaining, sold, 1, monkeypatch) is issued - remaining, sold = remaining - 1, sold + 1 - assert (remaining, sold) == (0, 50) - with pytest.raises(HTTPException) as exc: - await _buy(remaining, sold, 1, monkeypatch) - assert exc.value.detail == "Event is sold out." diff --git a/views_api.py b/views_api.py index 2d596d5..039bc4f 100644 --- a/views_api.py +++ b/views_api.py @@ -670,18 +670,17 @@ async def api_ticket_create( if event.canceled: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") quantity = data.quantity - # `amount_tickets` IS the remaining count — `set_ticket_paid` decrements - # it on every sale, and upstream reads it the same way everywhere. Do - # not subtract `sold` from it: `sold` counts the same tickets the - # decrement already removed, so doing both takes each sale off twice - # (aiolabs/events#34). - if event.amount_tickets < 1: - raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") - if quantity > event.amount_tickets: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Only {event.amount_tickets} ticket(s) remaining for this event.", - ) + if event.amount_tickets > 0: + if event.sold >= event.amount_tickets: + raise HTTPException( + status_code=HTTPStatus.GONE, detail="Event is sold out." + ) + remaining = event.amount_tickets - event.sold + if quantity > remaining: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Only {remaining} ticket(s) remaining for this event.", + ) name = data.name email = data.email