events/tests/test_wave_capacity_required.py
Padreug ecd05b4168
Some checks failed
lint.yml / fix: require a capacity on every ticket wave (pull_request) Failing after 0s
fix: require a capacity on every ticket wave
"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.
2026-09-28 23:11:30 +02:00

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)