fix(tickets): amount_tickets is the remaining count, stop subtracting sold #59
2 changed files with 124 additions and 11 deletions
fix(tickets): amount_tickets is the remaining count, stop subtracting sold
Some checks failed
lint.yml / fix(tickets): amount_tickets is the remaining count, stop subtracting sold (pull_request) Failing after 0s
Some checks failed
lint.yml / fix(tickets): amount_tickets is the remaining count, stop subtracting sold (pull_request) Failing after 0s
`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
commit
ad8aa2cf00
112
tests/test_capacity_guard.py
Normal file
112
tests/test_capacity_guard.py
Normal file
|
|
@ -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."
|
||||||
17
views_api.py
17
views_api.py
|
|
@ -670,16 +670,17 @@ async def api_ticket_create(
|
||||||
if event.canceled:
|
if event.canceled:
|
||||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
|
||||||
quantity = data.quantity
|
quantity = data.quantity
|
||||||
if event.amount_tickets > 0:
|
# `amount_tickets` IS the remaining count — `set_ticket_paid` decrements
|
||||||
if event.sold >= event.amount_tickets:
|
# it on every sale, and upstream reads it the same way everywhere. Do
|
||||||
raise HTTPException(
|
# not subtract `sold` from it: `sold` counts the same tickets the
|
||||||
status_code=HTTPStatus.GONE, detail="Event is sold out."
|
# decrement already removed, so doing both takes each sale off twice
|
||||||
)
|
# (aiolabs/events#34).
|
||||||
remaining = event.amount_tickets - event.sold
|
if event.amount_tickets < 1:
|
||||||
if quantity > remaining:
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
||||||
|
if quantity > event.amount_tickets:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=f"Only {remaining} ticket(s) remaining for this event.",
|
detail=f"Only {event.amount_tickets} ticket(s) remaining for this event.",
|
||||||
)
|
)
|
||||||
|
|
||||||
name = data.name
|
name = data.name
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue