events/tests/test_publish_active_wave.py
Padreug b55d6866d6
Some checks failed
lint.yml / fix: honour per-wave fiat when the organiser set an explicit rail list (pull_request) Failing after 0s
fix: honour per-wave fiat when the organiser set an explicit rail list
Reported from aio-demo: an event with fiat enabled, Card offered at
checkout, and the purchase refused with "Fiat payments are not enabled
for this ticket wave."

`effective_payment_methods` returned the organiser's explicit
`extra.payment_methods` list before ever consulting the wave:

    explicit = list(...)
    if explicit:
        return explicit          # <- the wave never got a look in

So the `wave` argument I added for #61 did nothing in the common case.
The webapp always sets `extra.payment_methods` from its payment-method
checkboxes, which means the explicit path is the normal one, not the
exception — three layers then disagreed:

- the NIP-52 tag advertised `tickets_payment_methods: lightning,fiat`
  while omitting `tickets_allow_fiat`, contradicting itself
- the checkout rendered a Card button
- `api_ticket_create`, the only wave-aware check, refused the purchase

Asking about a specific wave means asking what a buyer can actually use
for it, so a rail that wave cannot honour is now dropped. The
event-level question (no wave) still reports every rail the organiser
enabled — that is what `/republish-all` and the admin views want.

Fiat-only rails on a non-fiat wave now yield an empty list, which is
honest: nothing is purchasable from that wave.

5 tests, including both publisher shapes with an explicit list — the
case that actually bit, and which the #61 tests missed because their
fixtures left `payment_methods` empty. 133 pass.

Does not fix the data on events already created through the webapp:
their waves were saved with `allow_fiat` unset, so those waves genuinely
cannot take fiat until toggled. That is aiolabs/webapp's side.
2026-09-30 19:32:07 +02:00

195 lines
6.1 KiB
Python

"""The NIP-52 tags describe the ACTIVE ticket wave, not the roll-up (#61).
Upstream v1.6.8 moved price, currency and inventory onto time-boxed waves
and made the event-level fields derived: `price_per_ticket` and `currency`
become the PRIMARY (first) wave's, `amount_tickets` the SUM across every
wave. Publishing those would advertise the early-bird price after early
bird closed and count stock in waves that have not opened yet.
"""
from datetime import datetime, timedelta, timezone
from typing import cast
import pytest
from ..models import (
Event,
EventExtra,
TicketWave,
advertised_wave_key,
sync_event_ticket_waves,
)
from ..nostr_publisher import build_nip52_event
PUBKEY = "a" * 64
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,
opens: int,
closes: int,
*,
currency: str = "EUR",
allow_fiat: bool = False,
) -> TicketWave:
return TicketWave(
id=wave_id,
title=wave_id,
opening_date=_day(opens),
closing_date=_day(closes),
currency=currency,
price_per_ticket=price,
amount_tickets=stock,
allow_fiat=allow_fiat,
fiat_currency="GBP",
)
def _event(waves: list[TicketWave], sold: int = 0) -> Event:
event = Event(
id="evt",
wallet="w",
name="Waves",
info="",
closing_date=_day(30),
event_start_date=_day(30),
event_end_date=_day(30),
currency="sat",
price_per_ticket=0,
amount_tickets=0,
sold=sold,
time=datetime.now(timezone.utc),
status="approved",
extra=EventExtra(ticket_waves=waves),
)
# Mirrors the CRUD layer, which syncs on every read and write.
return cast(Event, sync_event_ticket_waves(event))
def _tags(event: Event) -> dict[str, str]:
return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1}
CLOSED_EARLY_BIRD = _wave("early", 10.0, 5, -10, -1)
OPEN_REGULAR = _wave("regular", 25.0, 40, 0, 20)
UNOPENED_VIP = _wave("vip", 50.0, 10, 5, 25)
def test_closed_wave_price_is_not_advertised():
"""The defect this fixes: early bird closed yesterday."""
event = _event([CLOSED_EARLY_BIRD, OPEN_REGULAR, UNOPENED_VIP])
# What the event-level roll-up would have published.
assert event.price_per_ticket == 10.0
assert event.amount_tickets == 55
tags = _tags(event)
assert tags["tickets_price"] == "25.0"
assert tags["tickets_available"] == "40"
def test_unopened_wave_stock_is_not_counted():
event = _event([OPEN_REGULAR, UNOPENED_VIP])
assert _tags(event)["tickets_available"] == "40"
def test_cheapest_open_wave_wins_when_several_are_open():
"""A publisher cannot ask which wave the buyer wants, so it advertises
the price a buyer is actually able to obtain."""
cheaper = _wave("cheap", 15.0, 3, 0, 10)
tags = _tags(_event([OPEN_REGULAR, cheaper]))
assert tags["tickets_price"] == "15.0"
assert tags["tickets_available"] == "3"
@pytest.mark.parametrize(
"waves",
[
pytest.param([CLOSED_EARLY_BIRD], id="all-closed"),
pytest.param([UNOPENED_VIP], id="not-yet-open"),
pytest.param([_wave("only", 25.0, 0, 0, 20)], id="sold-out"),
],
)
def test_no_open_wave_publishes_zero_availability(waves):
"""Never omit the tag: omission meant "unlimited" (#34, #62)."""
tags = _tags(_event(waves))
assert tags["tickets_available"] == "0"
def test_currency_and_fiat_follow_the_advertised_wave():
fiat_primary = _wave("a", 10.0, 0, -10, -1, currency="GBP", allow_fiat=True)
sats_open = _wave("b", 2500.0, 9, 0, 20, currency="sat", allow_fiat=False)
tags = _tags(_event([fiat_primary, sats_open]))
assert tags["tickets_currency"] == "sat"
assert "tickets_allow_fiat" not in tags
# Must agree with tickets_allow_fiat — they are the same fact, and a
# client rendering a fiat button here would hit a purchase-time refusal.
assert tags["tickets_payment_methods"] == "lightning"
def test_payment_methods_offer_fiat_when_the_advertised_wave_accepts_it():
tags = _tags(
_event(
[
_wave("a", 10.0, 0, -10, -1, allow_fiat=False),
_wave("b", 25.0, 9, 0, 20, allow_fiat=True),
]
)
)
assert tags["tickets_allow_fiat"] == "true"
assert tags["tickets_payment_methods"] == "lightning,fiat"
def test_sold_stays_event_level():
"""`tickets_sold` is the total paid across all waves."""
assert _tags(_event([OPEN_REGULAR], sold=7))["tickets_sold"] == "7"
def test_advertised_wave_key_tracks_the_published_wave():
assert advertised_wave_key(_event([CLOSED_EARLY_BIRD, OPEN_REGULAR])) == "regular"
# Published while nothing is on sale — a real state, distinct from the
# NULL that means "never published".
assert advertised_wave_key(_event([CLOSED_EARLY_BIRD])) == ""
def test_published_rails_drop_fiat_when_the_advertised_wave_cannot_take_it():
"""The webapp always sets `extra.payment_methods`, so the explicit-list
path is the normal one — and it used to ignore the wave entirely.
That published `tickets_payment_methods: lightning,fiat` beside an
absent `tickets_allow_fiat`, and a card button the purchase endpoint
then refused ("Fiat payments are not enabled for this ticket wave").
"""
event = _event(
[
_wave("a", 10.0, 0, -10, -1, allow_fiat=True),
_wave("b", 25.0, 9, 0, 20, allow_fiat=False),
]
)
event.extra.payment_methods = ["lightning", "fiat"]
tags = _tags(event)
assert "tickets_allow_fiat" not in tags
assert tags["tickets_payment_methods"] == "lightning"
def test_published_rails_keep_fiat_when_the_advertised_wave_takes_it():
event = _event(
[
_wave("a", 10.0, 0, -10, -1, allow_fiat=False),
_wave("b", 25.0, 9, 0, 20, allow_fiat=True),
]
)
event.extra.payment_methods = ["lightning", "fiat"]
tags = _tags(event)
assert tags["tickets_allow_fiat"] == "true"
assert tags["tickets_payment_methods"] == "lightning,fiat"