From 064795c62a683a7d529bf312c2e749b675db9f12 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 29 Sep 2026 08:12:09 +0200 Subject: [PATCH] fix: keep ticket waves when a client edits an event without them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- tests/test_wave_preservation_on_edit.py | 124 ++++++++++++++++++++++++ views_api.py | 14 +++ 2 files changed, 138 insertions(+) create mode 100644 tests/test_wave_preservation_on_edit.py diff --git a/tests/test_wave_preservation_on_edit.py b/tests/test_wave_preservation_on_edit.py new file mode 100644 index 0000000..1ab1b96 --- /dev/null +++ b/tests/test_wave_preservation_on_edit.py @@ -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" diff --git a/views_api.py b/views_api.py index 91a03f3..94b5bad 100644 --- a/views_api.py +++ b/views_api.py @@ -416,6 +416,20 @@ async def api_event_update( if event.wallet != wallet.wallet.id: 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) from lnbits.settings import settings