feat(nostr): publish the active ticket wave, not the roll-up
Some checks failed
lint.yml / feat(nostr): publish the active ticket wave, not the roll-up (pull_request) Failing after 0s

Since upstream v1.6.8 price, currency and inventory belong to time-boxed
ticket waves, and the event-level fields `sync_event_ticket_waves`
derives are the PRIMARY wave's price/currency and the SUM of every
wave's stock. The NIP-52 publisher read those, so as soon as an
organiser created a second wave the public card would advertise the
early-bird price after early bird closed and count stock in waves that
had not opened. Refs #61.

`build_nip52_event` now describes the wave a buyer can actually buy
from:

- several waves can be open at once, and a publisher has no one to ask
  which one the buyer wants (the purchase endpoint errors with "Please
  select a ticket wave"), so it advertises the CHEAPEST open wave — the
  price a buyer is able to obtain. Deviation recorded in
  docs/upstream-candidates.md.
- with no open wave, `tickets_available` is 0 and never omitted:
  omission used to mean "unlimited", which #34/#62 removed as a concept.
- `tickets_payment_methods` is scoped to the advertised wave too. It was
  derived from `event.allow_fiat` — the primary wave's — so it could
  offer a fiat rail while `tickets_allow_fiat` was absent and the
  purchase endpoint would refuse it. They are the same fact and now come
  from the same place.

Wave boundaries are time-driven, and every republish we have is
sale-driven, so nothing fires when early bird ends at midnight. Rather
than add a scheduler, a publish records which wave it advertised
(`nostr_published_wave_id`, m004) and the reconciliation sweep compares
that against the wave that would be advertised now, setting
`nostr_publish_pending` on a mismatch — reusing the existing retry path.
NULL means "never published", which the sweep leaves alone so an upgrade
does not republish the whole table on first boot.

The selection rule lives in `models.advertised_ticket_wave` so the
publisher and the drift detector cannot disagree about what is on the
relay.

17 new tests; 114 pass. ruff, black, prettier clean; mypy error set
still identical to HEAD's baseline.
This commit is contained in:
Padreug 2026-09-28 22:28:09 +02:00
commit 15c2276e57
10 changed files with 525 additions and 39 deletions

View file

@ -0,0 +1,159 @@
"""Wave boundaries trigger a republish (#61).
Every republish this extension performs is sale-driven. A wave boundary is
a *date* boundary, so when early bird closes at midnight nothing fires and
the relay keeps serving the closed wave's price until the next ticket
happens to sell. `flag_wave_transitions` closes that gap by comparing the
wave a row would advertise now against the one its last successful publish
did, handing the work to the existing reconciliation sweep.
"""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from .. import crud, nostr_hooks
from ..models import Event, EventExtra, 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, opens: int, closes: int):
return TicketWave(
id=wave_id,
title=wave_id,
opening_date=_day(opens),
closing_date=_day(closes),
currency="EUR",
price_per_ticket=price,
amount_tickets=stock,
)
def _event(waves, published_wave_id, pending=False) -> Event:
return 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,
time=datetime.now(timezone.utc),
status="approved",
nostr_publish_pending=pending,
nostr_published_wave_id=published_wave_id,
extra=EventExtra(ticket_waves=waves),
)
@pytest.fixture
def swept(monkeypatch):
"""Capture rows the detector writes back."""
written: list[Event] = []
async def _update(event):
written.append(event)
return event
monkeypatch.setattr(crud, "update_event", _update)
return written
def _rows(monkeypatch, events):
monkeypatch.setattr(crud.db, "fetchall", AsyncMock(return_value=events))
CLOSED = _wave("early", 10.0, 5, -10, -1)
OPEN = _wave("regular", 25.0, 40, 0, 20)
@pytest.mark.asyncio
async def test_moved_wave_is_flagged(monkeypatch, swept):
"""Early bird closed; the relay still advertises it."""
_rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="early")])
assert await crud.flag_wave_transitions() == 1
assert [e.nostr_publish_pending for e in swept] == [True]
@pytest.mark.asyncio
async def test_unchanged_wave_is_left_alone(monkeypatch, swept):
_rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="regular")])
assert await crud.flag_wave_transitions() == 0
assert swept == []
@pytest.mark.asyncio
async def test_selling_out_the_open_wave_is_a_transition(monkeypatch, swept):
"""No wave open is itself an advertisable state, and a different one."""
sold_out = _wave("regular", 25.0, 0, 0, 20)
_rows(monkeypatch, [_event([sold_out], published_wave_id="regular")])
assert await crud.flag_wave_transitions() == 1
@pytest.mark.asyncio
async def test_already_advertising_nothing_is_not_reflagged(monkeypatch, swept):
""" "" means "published while nothing was on sale" — not drift."""
_rows(monkeypatch, [_event([CLOSED], published_wave_id="")])
assert await crud.flag_wave_transitions() == 0
assert swept == []
@pytest.mark.asyncio
async def test_never_published_rows_are_skipped(monkeypatch, swept):
"""NULL is no evidence of drift. Flagging these would republish the
whole table on the first boot after the upgrade."""
_rows(monkeypatch, []) # the SQL filters them out
assert await crud.flag_wave_transitions() == 0
@pytest.mark.asyncio
async def test_publish_records_the_advertised_wave(monkeypatch):
"""The sweep can only detect drift if a success writes the wave down."""
event = _event([CLOSED, OPEN], published_wave_id="early")
monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e))
monkeypatch.setattr(
"lnbits.core.signers.resolve_for_wallet",
AsyncMock(return_value=SimpleNamespace(pubkey="pk")),
)
monkeypatch.setattr(
nostr_hooks,
"publish_event_to_nostr",
AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)),
)
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
assert event.nostr_published_wave_id == "regular"
@pytest.mark.asyncio
async def test_delete_does_not_record_a_wave(monkeypatch):
"""A takedown advertises nothing, so it must not claim a wave."""
event = _event([CLOSED, OPEN], published_wave_id="early")
monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e))
monkeypatch.setattr(
"lnbits.core.signers.resolve_for_wallet",
AsyncMock(return_value=SimpleNamespace(pubkey="pk")),
)
monkeypatch.setattr(
nostr_hooks,
"publish_event_to_nostr",
AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)),
)
assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True
assert event.nostr_published_wave_id == "early"