Some checks failed
lint.yml / fix: require a capacity on every ticket wave (pull_request) Failing after 0s
"Capacity is always required, there is no unlimited" was settled on #34 and enforced in #62 — but as a single number on the event. Since v1.6.8 capacity is per-wave and `event.amount_tickets` is a derived roll-up (`sync_event_ticket_waves`), so the rule had nothing holding it up at the level organisers actually set: the wave form's capacity input had no minimum, and nothing on the backend checked one at all. A zero-capacity wave can never be active — `get_active_ticket_waves` requires `amount_tickets > 0` — so it is the wave-level form of exactly what #62 removed: an event that looks on sale but refuses every purchase, on the card and at checkout both. `_validate_wave_capacity` now runs on create and update. Two details that matter: - it checks `ensure_ticket_waves(data)` rather than the raw list, so an event submitted with no waves is checked through the primary wave it is about to be given, not vacuously passed - on an edit it only checks waves NEW to the event. Selling out is the legitimate route to zero, and rejecting it would make a sold-out event uneditable — including the legacy zero-capacity rows this rule exists to let organisers fix Frontend: the wave dialog's capacity input gains the `min="1"` and hint the event-level field already had, and a new wave opens at 1 rather than 0. That default uses `??`, not `||` — a sold-out wave holds 0 and must keep showing it instead of silently regaining stock when saved. Also drops the stale `0 = unlimited / not ticketed` contract still documented on `CreateEvent.amount_tickets`, which has not been true since #62. 6 new tests; 120 pass. ruff, black, prettier clean; mypy error set unchanged from baseline.
118 lines
3.7 KiB
Python
118 lines
3.7 KiB
Python
"""Capacity is required per ticket wave (#34, #62).
|
|
|
|
"Capacity is always required, there is no unlimited" was settled when
|
|
capacity was one number on the event. Since v1.6.8 it is per-wave and
|
|
`event.amount_tickets` is a derived roll-up, so the rule has to bite where
|
|
the organiser sets it. A zero-capacity wave can never be active
|
|
(`get_active_ticket_waves` requires `> 0`), so it is the wave-level form of
|
|
the trap #62 removed: an event that looks on sale but refuses every
|
|
purchase.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from http import HTTPStatus
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from ..models import CreateEvent, Event, EventExtra, TicketWave
|
|
from ..views_api import _validate_wave_capacity
|
|
|
|
TODAY = datetime.now(timezone.utc).date()
|
|
|
|
|
|
def _day(offset: int) -> str:
|
|
return (TODAY + timedelta(days=offset)).isoformat()
|
|
|
|
|
|
def _wave(wave_id: str, stock: int) -> TicketWave:
|
|
return TicketWave(
|
|
id=wave_id,
|
|
title=wave_id,
|
|
opening_date=_day(0),
|
|
closing_date=_day(20),
|
|
currency="sat",
|
|
price_per_ticket=10,
|
|
amount_tickets=stock,
|
|
)
|
|
|
|
|
|
def _create(waves=None, amount_tickets=10) -> CreateEvent:
|
|
return CreateEvent(
|
|
wallet="w",
|
|
name="Fete",
|
|
info="",
|
|
event_start_date=_day(30),
|
|
currency="sat",
|
|
price_per_ticket=10,
|
|
amount_tickets=amount_tickets,
|
|
extra=EventExtra(ticket_waves=waves or []),
|
|
)
|
|
|
|
|
|
def _stored(waves) -> Event:
|
|
return Event(
|
|
id="evt",
|
|
wallet="w",
|
|
name="Fete",
|
|
info="",
|
|
closing_date=_day(30),
|
|
event_start_date=_day(30),
|
|
currency="sat",
|
|
price_per_ticket=10,
|
|
amount_tickets=sum(w.amount_tickets for w in waves),
|
|
time=datetime.now(timezone.utc),
|
|
extra=EventExtra(ticket_waves=waves),
|
|
)
|
|
|
|
|
|
def _detail(exc_info) -> str:
|
|
assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST
|
|
return exc_info.value.detail
|
|
|
|
|
|
def test_wave_with_capacity_is_accepted():
|
|
_validate_wave_capacity(_create([_wave("early", 5), _wave("late", 10)]))
|
|
|
|
|
|
def test_zero_capacity_wave_is_rejected_on_create():
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
_validate_wave_capacity(_create([_wave("early", 5), _wave("late", 0)]))
|
|
assert "late" in _detail(exc_info)
|
|
|
|
|
|
def test_event_submitted_without_waves_is_checked_through_its_primary_wave():
|
|
"""No waves means the event gets a synthesized primary wave seeded from
|
|
`amount_tickets`, so the rule must see that rather than an empty list."""
|
|
_validate_wave_capacity(_create(waves=None, amount_tickets=10))
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
_validate_wave_capacity(_create(waves=None, amount_tickets=0))
|
|
assert "Primary wave" in _detail(exc_info)
|
|
|
|
|
|
def test_selling_a_wave_out_does_not_block_later_edits():
|
|
"""Zero is where a wave legitimately ends up. Rejecting it on edit would
|
|
make a sold-out event uneditable."""
|
|
sold_out = _wave("early", 0)
|
|
existing = _stored([sold_out, _wave("late", 10)])
|
|
|
|
_validate_wave_capacity(_create([sold_out, _wave("late", 10)]), existing)
|
|
|
|
|
|
def test_new_wave_added_by_an_edit_still_needs_capacity():
|
|
existing = _stored([_wave("early", 5)])
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
_validate_wave_capacity(
|
|
_create([_wave("early", 5), _wave("brand-new", 0)]), existing
|
|
)
|
|
assert "brand-new" in _detail(exc_info)
|
|
|
|
|
|
def test_legacy_zero_capacity_event_stays_editable():
|
|
"""An event stored before the rule existed keeps its primary wave id, so
|
|
an edit is not blocked — otherwise the only way to fix it would be
|
|
barred."""
|
|
existing = _stored([_wave("primary", 0)])
|
|
_validate_wave_capacity(_create([_wave("primary", 0)]), existing)
|