diff --git a/__init__.py b/__init__.py index 6323038..e443ce0 100644 --- a/__init__.py +++ b/__init__.py @@ -139,12 +139,22 @@ def events_start(): Quiet by design: on a healthy instance the query returns nothing and this logs nothing. It only speaks up when there is drift. """ - from .crud import get_events_pending_republish + from .crud import flag_wave_transitions, get_events_pending_republish from .nostr_hooks import publish_or_delete_nostr_event await asyncio.sleep(REPUBLISH_SWEEP_FIRST_DELAY) while True: try: + # Wave boundaries are time-driven, so nothing else flags + # them. Done here rather than on a timer of its own: the + # boundary is day-granular, so one sweep interval of + # staleness is immaterial (aiolabs/events#61). + moved = await flag_wave_transitions() + if moved: + logger.info( + f"[EVENTS] Republish sweep: {moved} event(s) changed " + f"ticket wave" + ) pending = await get_events_pending_republish() if pending: total = len(pending) diff --git a/crud.py b/crud.py index 70a0f5d..52ce233 100644 --- a/crud.py +++ b/crud.py @@ -12,6 +12,7 @@ from .models import ( Ticket, TicketExtra, TicketFilters, + advertised_wave_key, sync_event_ticket_waves, ) @@ -298,6 +299,48 @@ async def get_pending_events() -> list[Event]: return [cast(Event, sync_event_ticket_waves(e)) for e in events] +async def flag_wave_transitions() -> int: + """Flag events whose advertised ticket wave has moved on. + + 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 (aiolabs/events#61). + + Rather than add a scheduler and a second publish path, this compares the + wave a row would advertise now against the one its last successful + publish did (`nostr_published_wave_id`) and sets `nostr_publish_pending` + on a mismatch — handing the work to the existing reconciliation sweep, + which already retries, survives restarts and logs. + + Rows with NULL `nostr_published_wave_id` are skipped: that means "never + published, or published before the column existed", which is no evidence + of drift. Flagging them would republish the whole table on first boot + after the upgrade. + + Returns the number of rows newly flagged. + """ + events = await db.fetchall( + """ + SELECT * FROM events.events + WHERE nostr_published_wave_id IS NOT NULL + AND nostr_publish_pending = FALSE + AND canceled = FALSE + AND status = 'approved' + """, + model=Event, + ) + flagged = 0 + for event in events: + event = cast(Event, sync_event_ticket_waves(event)) + if advertised_wave_key(event) == event.nostr_published_wave_id: + continue + event.nostr_publish_pending = True + await update_event(event) + flagged += 1 + return flagged + + async def get_events_pending_republish() -> list[Event]: """Events whose relay copy may be behind this row. diff --git a/docs/rebase-playbook.md b/docs/rebase-playbook.md index adbf6f3..affbce7 100644 --- a/docs/rebase-playbook.md +++ b/docs/rebase-playbook.md @@ -10,7 +10,7 @@ Modes C and D were added later in the same rebase, from `services.py`. ## The four failure modes -Git resolves *text*. Neither of these produces a conflict marker. +Git resolves _text_. Neither of these produces a conflict marker. ### A. Missed application @@ -23,14 +23,14 @@ so the invariant silently does not hold there. > `get_event` and `get_events` in `crud.py`. Both merged cleanly. But the > fork has four getters upstream never had — `get_all_events`, > `get_public_events`, `get_pending_events`, -> `get_events_pending_republish` — and two of them *publish* +> `get_events_pending_republish` — and two of them _publish_ > (`/republish-all` and the #55 sweep). Without the same call they emit > the stale roll-up, so waves would have been wrong on exactly the paths > that push to relays, and nowhere else. ### B. Changed meaning -Upstream redefines what an existing field *means*. Fork code that reads +Upstream redefines what an existing field _means_. Fork code that reads it is untouched by the diff and keeps compiling, while now saying something false. @@ -44,7 +44,7 @@ something false. ### C. Misplaced conflict boundary Git anchors a conflict on whatever lines happen to match. When both sides -rewrote the same region, an *incidental* shared line inside it can become +rewrote the same region, an _incidental_ shared line inside it can become the anchor — and everything past that line lands **outside** the markers, where it reads as cleanly merged. @@ -57,7 +57,7 @@ is upstream's — the fork's version is shadowed without a single warning. > Ours (220 lines: multipart HTML mail, the QR-card attachment, the > Date/Message-ID headers that keep SpamAssassin quiet, npub DM support) > appeared between the markers; upstream's showed as 4 lines. But both -> sides define the *same nine functions*, and git had anchored on a +> sides define the _same nine functions_, and git had anchored on a > shared `_send_nostr_ticket_notification` line — so upstream's entire > parallel stack sat below `>>>>>>>`, looking merged. Taking "ours" and > moving on would have left upstream's definitions last in the file and @@ -102,7 +102,7 @@ histories. ## The procedure -Run this *after* the merge resolves and *before* the release. +Run this _after_ the merge resolves and _before_ the release. ### 1. Enumerate what upstream introduced @@ -117,7 +117,7 @@ git show :models.py | sed -n '/^def sync_event_ticket_waves/,/return event/ Split the result into **new symbols** (mode A candidates) and **redefined fields** (mode B candidates). -### 2. For each new symbol upstream *calls*, find the fork-only siblings +### 2. For each new symbol upstream _calls_, find the fork-only siblings Ask what category of place the call belongs to — "every function that returns an `Event` from the DB", "every path that prices a ticket" — then @@ -147,7 +147,7 @@ grep -n "price_per_ticket\|amount_tickets" $(comm -23 ...) Both examples above were confirmed by reading the code path end to end, not inferred from the diff. A wrong theory costs more than the check: during this rebase an inference that `amount_tickets` "goes stale on -sale" was wrong — `sync_event_ticket_waves` also runs on *reads*, which +sale" was wrong — `sync_event_ticket_waves` also runs on _reads_, which only the call-site list showed. ### 5. Write the reason at the site diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md index 56e2ee7..f2ceb98 100644 --- a/docs/upstream-candidates.md +++ b/docs/upstream-candidates.md @@ -4,17 +4,18 @@ Running log of fork features that are shaped so they could be offered to `lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here in an upstream-compatible form; strike it when the PR merges upstream. -| Feature | Where | Upstream target | Readiness | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR | -| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above | -| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch | -| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | -| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | -| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | -| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | -| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | -| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | -| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | -| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) | -| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 | +| Feature | Where | Upstream target | Readiness | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR | +| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above | +| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch | +| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | +| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | +| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | +| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | +| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | +| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | +| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | +| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) | +| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 | +| NIP-52 tags describe the **active** ticket wave (cheapest open wave; `tickets_available: 0` when none is open), plus `nostr_published_wave_id` so the reconciliation sweep republishes at wave boundaries | `nostr_publisher.py`, `crud.py` `flag_wave_transitions`, `migrations_fork.py` m004 | lnbits/events #46 (rides with the NIP-52 publishing PR) | **deviation, deliberate** — upstream has waves but publishes no calendar event, so there is nothing upstream to match. Several waves can be open at once and a publisher cannot ask which one the buyer wants (upstream's purchase path errors with "Please select a ticket wave"), so it advertises the cheapest — the price a buyer can actually obtain. Rationale and the rejected alternatives: aiolabs/events#61 | diff --git a/migrations_fork.py b/migrations_fork.py index 238a2d1..cfab24d 100644 --- a/migrations_fork.py +++ b/migrations_fork.py @@ -156,3 +156,32 @@ async def m003_event_nostr_publish_pending(db): "ALTER TABLE events.events " "ADD COLUMN nostr_publish_pending BOOLEAN NOT NULL DEFAULT FALSE", ) + + +async def m004_event_nostr_published_wave(db): + """ + Add `events.nostr_published_wave_id` — which ticket wave the last + successful NIP-52 publish advertised. + + Since upstream v1.6.8 price and inventory live on time-boxed waves, so + what a calendar event should advertise changes at a *date* boundary. + Every republish we have is sale-driven, and no sale happens at + midnight when early bird ends — so without this the relay keeps + serving the closed wave's price until the next ticket sells + (aiolabs/events#61). + + Recording the advertised wave makes that drift detectable with the + machinery already in place: the reconciliation sweep compares this + against the wave that would be advertised now and sets + `nostr_publish_pending`, reusing the existing retry path rather than + adding a scheduler. + + NULL on existing rows means "never published, or published before this + column existed". The sweep treats NULL as "no evidence of drift" and + leaves it alone, so an upgrade does not stampede the signer with a + full-table republish; the first ordinary publish fills it in. + """ + await _alter_add_column_safe( + db, + "ALTER TABLE events.events ADD COLUMN nostr_published_wave_id TEXT", + ) diff --git a/models.py b/models.py index bf7fd32..92dcd84 100644 --- a/models.py +++ b/models.py @@ -151,6 +151,12 @@ class Event(BaseModel): # True means the relay's copy may be behind this row — see # migrations_fork.m003 and the sweep in __init__.events_start. nostr_publish_pending: bool = False + # Which wave the last successful publish advertised (see + # `advertised_wave_key`). NULL = never published; "" = published while + # nothing was on sale. The sweep compares this against the current key + # to catch wave boundaries, which are time-driven and so fire no + # sale-triggered republish (aiolabs/events#61). + nostr_published_wave_id: str | None = None @validator("categories", pre=True) def parse_categories(cls, v): @@ -186,18 +192,28 @@ class PublicEvent(BaseModel): return v or [] -def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> list[str]: +def effective_payment_methods( + event: "Event | PublicEvent | CreateEvent", + wave: "TicketWave | None" = None, +) -> list[str]: """Rails a buyer may pick for `event`. Explicit `extra.payment_methods` wins; an empty list falls back to the pre-#payment-methods rule so events created before the field existed keep behaving the same (Lightning always, fiat iff `allow_fiat`). + + Pass `wave` when the answer is about one specific ticket wave. Fiat is a + per-wave opt-in since v1.6.8 and `event.allow_fiat` is only the PRIMARY + wave's, so without it a publisher can advertise a fiat rail for an + advertised wave that does not accept fiat — which the purchase endpoint + then rejects (aiolabs/events#61). Callers asking the event-level + question ("which rails did the organiser enable at all") leave it unset. """ explicit = list(getattr(event.extra, "payment_methods", []) or []) if explicit: return explicit methods = ["lightning"] - if event.allow_fiat: + if wave.allow_fiat if wave is not None else event.allow_fiat: methods.append("fiat") return methods @@ -430,6 +446,31 @@ def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[Ticket ] +def advertised_ticket_wave(event: "Event | PublicEvent") -> "TicketWave | None": + """The wave a public listing should describe, or None when nothing is + on sale (sold out, between waves, or not yet open). + + Several waves can be open at once. The purchase endpoint refuses to + guess; a publisher has no one to ask, so it advertises the CHEAPEST + open wave — the price a buyer is actually able to obtain. + + Shared by the NIP-52 publisher and the wave-transition detector so the + two cannot disagree about which wave is currently being advertised. + """ + active = get_active_ticket_waves(event) + if not active: + return None + return min(active, key=lambda wave: wave.price_per_ticket) + + +def advertised_wave_key(event: "Event | PublicEvent") -> str: + """Stable key for what a publish advertised. Empty string means "nothing + on sale", which is a real published state and distinct from NULL in + `nostr_published_wave_id` (never published).""" + wave = advertised_ticket_wave(event) + return wave.id if wave else "" + + def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent: ticket_waves = ensure_ticket_waves(event) event.extra.ticket_waves = ticket_waves diff --git a/nostr_hooks.py b/nostr_hooks.py index 74cb8ec..e0b0379 100644 --- a/nostr_hooks.py +++ b/nostr_hooks.py @@ -8,7 +8,7 @@ import cycle (views_api -> nostr_hooks -> nostr_publisher -> models). from loguru import logger from .crud import update_event -from .models import Event +from .models import Event, advertised_wave_key from .nostr_publisher import publish_event_to_nostr @@ -72,6 +72,11 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) - if not delete: event.nostr_event_id = nostr_event.id event.nostr_event_created_at = nostr_event.created_at + # Record which wave this publish advertised so the sweep can + # notice a wave boundary later (aiolabs/events#61). Written in + # the same update as the cleared flag, so the two can never + # disagree about what is on the relay. + event.nostr_published_wave_id = advertised_wave_key(event) await update_event(event) return True except Exception as exc: diff --git a/nostr_publisher.py b/nostr_publisher.py index 0e5b4c4..0bf5ab6 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -17,7 +17,12 @@ from datetime import datetime, timezone from lnbits.core.signers import NostrSigner from loguru import logger -from .models import Event, effective_payment_methods +from .models import ( + Event, + advertised_ticket_wave, + effective_payment_methods, + ensure_ticket_waves, +) from .nostr.event import NostrEvent from .nostr_timestamp import monotonic_created_at @@ -46,11 +51,13 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: start - unix timestamp (31923) or YYYY-MM-DD (31922) end - same encoding (optional) image, location, t (categories) - optional - tickets_available - current remaining capacity (always emitted; 0 = sold out) - tickets_sold - running paid-count (always emitted; clients can - derive original_capacity = available + sold) - tickets_price - price_per_ticket (always emitted; 0 means free) - tickets_currency - the currency string + tickets_available - remaining capacity of the advertised wave + (always emitted; 0 = nothing on sale now) + tickets_sold - running paid-count across all waves (always + emitted) + tickets_price - the advertised wave's price (always emitted; + 0 means free) + tickets_currency - the advertised wave's currency tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise) tickets_fiat_currency - the fiat settle currency (only when allow_fiat) Content: event.info @@ -103,21 +110,52 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: # the detail page and the purchase both returned 410 (aiolabs/events#34). # Clients that must still handle an absent tag — a NIP-52 event from # some other publisher — are unaffected; we simply never omit it. - tags.append(["tickets_available", str(event.amount_tickets)]) + # + # Since v1.6.8 price, currency and inventory belong to a ticket WAVE. + # The event-level fields `sync_event_ticket_waves` derives are the + # PRIMARY wave's price/currency and the SUM of every wave's stock, so + # publishing them would keep advertising the early-bird price after + # early bird closed, and count stock in waves that have not opened yet + # (aiolabs/events#61). Advertise the wave a buyer can actually buy from. + # + # Several waves can be open at once. The purchase endpoint refuses to + # guess ("Please select a ticket wave"); a publisher has no one to ask, + # so it advertises the CHEAPEST open wave — the price a buyer is able to + # obtain right now. Deviation recorded in docs/upstream-candidates.md. + open_wave = advertised_ticket_wave(event) + # Nothing open: sold out, between waves, or not yet on sale. Fall back + # to the primary wave so price/currency still describe the event, + # paired with the zero availability below. + advertised = open_wave or ensure_ticket_waves(event)[0] + + # Always emitted, including zero — see #34 above. With no open wave + # there is nothing to sell regardless of what the waves hold, so this + # is 0 rather than the wave's stock. + available = advertised.amount_tickets if open_wave else 0 + tags.append(["tickets_available", str(available)]) + # Event-level: total paid across every wave, which is what "sold" means + # to a reader of the card. tags.append(["tickets_sold", str(event.sold)]) - tags.append(["tickets_price", str(event.price_per_ticket)]) - tags.append(["tickets_currency", event.currency]) + tags.append(["tickets_price", str(advertised.price_per_ticket)]) + tags.append(["tickets_currency", advertised.currency]) # Fiat-checkout config — only emitted when allow_fiat is on so # clients can branch the buy UI without re-reading the schema. - if event.allow_fiat: + if advertised.allow_fiat: tags.append(["tickets_allow_fiat", "true"]) - if event.fiat_currency: - tags.append(["tickets_fiat_currency", event.fiat_currency]) + if advertised.fiat_currency: + tags.append(["tickets_fiat_currency", advertised.fiat_currency]) # Rails the organizer accepts, resolved through the same helper the # ticket endpoint enforces with, so a client can render exactly the # buttons that will be accepted (e.g. a card-only event) without a REST # round-trip. Comma-separated, lowercase. - tags.append(["tickets_payment_methods", ",".join(effective_payment_methods(event))]) + tags.append( + [ + "tickets_payment_methods", + # Scoped to the advertised wave so this cannot contradict + # `tickets_allow_fiat` above — they are the same fact. + ",".join(effective_payment_methods(event, advertised)), + ] + ) # NIP-52 calendar events are replaceable: this d-tag is republished # whenever inventory changes (a ticket sells). Use a strictly-monotonic diff --git a/tests/test_publish_active_wave.py b/tests/test_publish_active_wave.py new file mode 100644 index 0000000..7c73781 --- /dev/null +++ b/tests/test_publish_active_wave.py @@ -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])) == "" diff --git a/tests/test_wave_transition_sweep.py b/tests/test_wave_transition_sweep.py new file mode 100644 index 0000000..0c57e13 --- /dev/null +++ b/tests/test_wave_transition_sweep.py @@ -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"