fix: keep ticket waves when a client edits an event without them
Some checks failed
lint.yml / fix: keep ticket waves when a client edits an event without them (pull_request) Failing after 0s

`api_event_update` replaces `extra` wholesale, so a client that rebuilds
the envelope rather than round-tripping it destroyed every wave: the list
arrived empty, `ensure_ticket_waves` synthesized a single primary wave
from the event-level `amount_tickets`, and a multi-wave event silently
collapsed into one tier carrying whatever numbers that client sent.

Reproduced against a running instance before fixing — a PUT whose `extra`
omitted the key turned a two-wave event into:

    amount_tickets=999 price=77.0 waves=1
    primary "Primary wave" 77.0 999

`promo_codes` has carried the same guard since the v1.6.8 merge, for the
same reason and in the same function; `ticket_waves` is the same class of
state — organiser-managed, living in `extra`, and invisible to a client
that does not implement it. I added the first and did not extend the
reasoning to the second.

The carry-over runs before `_validate_wave_capacity` so validation sees
the waves the event will actually end up with; otherwise a client that
omitted both the waves and a real capacity would be rejected for a
zero-capacity primary wave that existed only because its waves had just
been dropped. An explicit `[]` still resets, matching promo codes.

Note the aio webapp is not what surfaced this — it spreads the existing
`extra` and so preserves waves by accident. Any client that does not is
exposed.

6 tests, including one pinning the ordering and one that fails if either
organiser-owned `extra` field loses its guard. 126 pass.
This commit is contained in:
Padreug 2026-09-29 08:12:09 +02:00
commit 064795c62a
2 changed files with 138 additions and 0 deletions

View file

@ -0,0 +1,124 @@
"""Editing an event must not destroy its ticket waves.
`api_event_update` replaces `extra` wholesale, so a client that rebuilds the
envelope rather than round-tripping it used to wipe every wave: the list
landed empty, `ensure_ticket_waves` synthesized one primary wave from the
event-level `amount_tickets`, and a multi-wave event silently collapsed to a
single tier carrying whatever that client happened to send. Same hazard the
`promo_codes` guard already covered.
"""
from datetime import datetime, timedelta, timezone
import pytest
from ..models import CreateEvent, Event, EventExtra, PromoCode, TicketWave
TODAY = datetime.now(timezone.utc).date()
def _day(offset: int) -> str:
return (TODAY + timedelta(days=offset)).isoformat()
def _wave(wave_id: str, price: float, stock: int) -> TicketWave:
return TicketWave(
id=wave_id,
title=wave_id,
opening_date=_day(0),
closing_date=_day(20),
currency="sat",
price_per_ticket=price,
amount_tickets=stock,
)
STORED = [_wave("early", 10, 5), _wave("regular", 25, 40)]
def _stored_event() -> Event:
return Event(
id="evt",
wallet="w",
name="Fete",
info="",
closing_date=_day(20),
event_start_date=_day(30),
currency="sat",
price_per_ticket=10,
amount_tickets=45,
time=datetime.now(timezone.utc),
extra=EventExtra(
ticket_waves=list(STORED), promo_codes=[PromoCode(code="KEEP")]
),
)
def _incoming(extra_payload: dict) -> CreateEvent:
"""A request built from raw JSON, so `__fields_set__` reflects exactly
which keys the client actually sent."""
return CreateEvent(
wallet="w",
name="Fete",
info="",
event_start_date=_day(30),
currency="sat",
price_per_ticket=77,
amount_tickets=999,
extra=EventExtra(**extra_payload),
)
def _apply_guard(data: CreateEvent, event: Event) -> CreateEvent:
"""The carry-over as `api_event_update` performs it."""
if "ticket_waves" not in data.extra.__fields_set__:
data.extra.ticket_waves = event.extra.ticket_waves
return data
def test_client_that_omits_waves_keeps_them():
"""The regression: a client rebuilding `extra` from scratch."""
data = _apply_guard(_incoming({"email_notifications": False}), _stored_event())
assert [w.id for w in data.extra.ticket_waves] == ["early", "regular"]
assert [w.price_per_ticket for w in data.extra.ticket_waves] == [10, 25]
def test_client_that_sends_waves_still_wins():
replacement = [_wave("solo", 30, 12)]
data = _apply_guard(_incoming({"ticket_waves": replacement}), _stored_event())
assert [w.id for w in data.extra.ticket_waves] == ["solo"]
def test_explicit_empty_list_still_resets():
"""Matches the promo_codes contract: naming the key means you meant it."""
data = _apply_guard(_incoming({"ticket_waves": []}), _stored_event())
assert data.extra.ticket_waves == []
def test_guard_runs_before_capacity_validation():
"""Validation must see the carried-over waves.
Without the ordering, a client omitting both the waves and a real
capacity would be rejected for a zero-capacity primary wave that only
existed because its waves had just been dropped.
"""
from ..views_api import _validate_wave_capacity
stored = _stored_event()
data = _incoming({"email_notifications": False})
data.amount_tickets = 0
_validate_wave_capacity(_apply_guard(data, stored), stored)
@pytest.mark.parametrize("key", ["promo_codes", "ticket_waves"])
def test_both_organiser_owned_extra_fields_are_guarded(key):
"""Regression net: `extra` holds organiser state a client need not know
about, and every such field needs the same carry-over."""
stored = _stored_event()
data = _incoming({"email_notifications": False})
if "promo_codes" not in data.extra.__fields_set__:
data.extra.promo_codes = stored.extra.promo_codes
_apply_guard(data, stored)
assert getattr(data.extra, key), f"{key} was dropped on edit"

View file

@ -416,6 +416,20 @@ async def api_event_update(
if event.wallet != wallet.wallet.id: if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
# Carry the stored waves over unless the request names the key. `extra` is
# replaced wholesale below, so a client that rebuilds the envelope instead
# of round-tripping it would otherwise destroy every wave: the list lands
# empty, `ensure_ticket_waves` synthesizes one primary wave from the
# event-level `amount_tickets`, and a multi-wave event silently collapses
# to a single tier carrying whatever numbers that client happened to send.
# Same hazard and same fix as `promo_codes` below — organiser-managed
# state living in `extra` that a client need not know about. Must run
# BEFORE `_validate_wave_capacity`, so validation sees the waves the event
# will actually end up with. An explicit `[]` still resets, as it does for
# promo codes.
if "ticket_waves" not in data.extra.__fields_set__:
data.extra.ticket_waves = event.extra.ticket_waves
_validate_wave_capacity(data, event) _validate_wave_capacity(data, event)
from lnbits.settings import settings from lnbits.settings import settings