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
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:
parent
d90a0f8322
commit
15c2276e57
10 changed files with 525 additions and 39 deletions
160
tests/test_publish_active_wave.py
Normal file
160
tests/test_publish_active_wave.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""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])) == ""
|
||||
159
tests/test_wave_transition_sweep.py
Normal file
159
tests/test_wave_transition_sweep.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue