From ad8aa2cf00f6fc5e60f271a1f29fe38728a1bdb3 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 27 Sep 2026 22:25:01 +0200 Subject: [PATCH] fix(tickets): amount_tickets is the remaining count, stop subtracting sold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `set_ticket_paid` decrements `amount_tickets` on every sale and increments `sold`, so the two describe the same tickets. Subtracting one from the other in `api_ticket_create` removed each sale twice: remaining = amount_tickets - sold That under-reported availability to buyers, and because `remaining + sold` is the original capacity, `sold >= amount_tickets` first becomes true at the halfway point — so every event locked itself as sold out once half its seats had gone, with the rest still unsold. Measured on aio-demo before the fix: 16 of 24 live events affected, 3 of them already refusing sales while stock remained. "Tech Meetup" had 9 tickets left and offered -2. The arithmetic was ours, introduced with the multi-quantity purchase feature; upstream has no equivalent because it sells one ticket per request and reads `amount_tickets` as remaining everywhere. So this deletes the divergence and restores upstream's own guard plus the one line `quantity` needs, which should also make the v1.6.8 rebase (#33) a little easier rather than harder. Tests walk a 50-seat event to capacity one sale at a time and pin the boundary: sold=49 passes even unfixed, sold=50 is where it used to lock. Six of the ten fail without the change. Scoped deliberately to the double-subtraction. The rest of #34 — making capacity a required field, dropping the `0 = unlimited` reading, and the organizer form that prefills remaining as though it were capacity — waits for #33, since ticket waves change the shape of that fix. Refs #34 --- tests/test_capacity_guard.py | 112 +++++++++++++++++++++++++++++++++++ views_api.py | 23 +++---- 2 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 tests/test_capacity_guard.py diff --git a/tests/test_capacity_guard.py b/tests/test_capacity_guard.py new file mode 100644 index 0000000..4125278 --- /dev/null +++ b/tests/test_capacity_guard.py @@ -0,0 +1,112 @@ +"""`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 039bc4f..2d596d5 100644 --- a/views_api.py +++ b/views_api.py @@ -670,17 +670,18 @@ async def api_ticket_create( if event.canceled: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") quantity = data.quantity - 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.", - ) + # `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.", + ) name = data.name email = data.email -- 2.55.0