Rebase onto upstream v1.6.8 #63
10 changed files with 525 additions and 39 deletions
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.
commit
15c2276e57
12
__init__.py
12
__init__.py
|
|
@ -139,12 +139,22 @@ def events_start():
|
||||||
Quiet by design: on a healthy instance the query returns nothing
|
Quiet by design: on a healthy instance the query returns nothing
|
||||||
and this logs nothing. It only speaks up when there is drift.
|
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
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
|
|
||||||
await asyncio.sleep(REPUBLISH_SWEEP_FIRST_DELAY)
|
await asyncio.sleep(REPUBLISH_SWEEP_FIRST_DELAY)
|
||||||
while True:
|
while True:
|
||||||
try:
|
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()
|
pending = await get_events_pending_republish()
|
||||||
if pending:
|
if pending:
|
||||||
total = len(pending)
|
total = len(pending)
|
||||||
|
|
|
||||||
43
crud.py
43
crud.py
|
|
@ -12,6 +12,7 @@ from .models import (
|
||||||
Ticket,
|
Ticket,
|
||||||
TicketExtra,
|
TicketExtra,
|
||||||
TicketFilters,
|
TicketFilters,
|
||||||
|
advertised_wave_key,
|
||||||
sync_event_ticket_waves,
|
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]
|
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]:
|
async def get_events_pending_republish() -> list[Event]:
|
||||||
"""Events whose relay copy may be behind this row.
|
"""Events whose relay copy may be behind this row.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ Modes C and D were added later in the same rebase, from `services.py`.
|
||||||
|
|
||||||
## The four failure modes
|
## 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
|
### 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
|
> `get_event` and `get_events` in `crud.py`. Both merged cleanly. But the
|
||||||
> fork has four getters upstream never had — `get_all_events`,
|
> fork has four getters upstream never had — `get_all_events`,
|
||||||
> `get_public_events`, `get_pending_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
|
> (`/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
|
> the stale roll-up, so waves would have been wrong on exactly the paths
|
||||||
> that push to relays, and nowhere else.
|
> that push to relays, and nowhere else.
|
||||||
|
|
||||||
### B. Changed meaning
|
### 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
|
it is untouched by the diff and keeps compiling, while now saying
|
||||||
something false.
|
something false.
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ something false.
|
||||||
### C. Misplaced conflict boundary
|
### C. Misplaced conflict boundary
|
||||||
|
|
||||||
Git anchors a conflict on whatever lines happen to match. When both sides
|
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,
|
the anchor — and everything past that line lands **outside** the markers,
|
||||||
where it reads as cleanly merged.
|
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
|
> Ours (220 lines: multipart HTML mail, the QR-card attachment, the
|
||||||
> Date/Message-ID headers that keep SpamAssassin quiet, npub DM support)
|
> Date/Message-ID headers that keep SpamAssassin quiet, npub DM support)
|
||||||
> appeared between the markers; upstream's showed as 4 lines. But both
|
> 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
|
> shared `_send_nostr_ticket_notification` line — so upstream's entire
|
||||||
> parallel stack sat below `>>>>>>>`, looking merged. Taking "ours" and
|
> parallel stack sat below `>>>>>>>`, looking merged. Taking "ours" and
|
||||||
> moving on would have left upstream's definitions last in the file and
|
> moving on would have left upstream's definitions last in the file and
|
||||||
|
|
@ -102,7 +102,7 @@ histories.
|
||||||
|
|
||||||
## The procedure
|
## 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
|
### 1. Enumerate what upstream introduced
|
||||||
|
|
||||||
|
|
@ -117,7 +117,7 @@ git show <tag>:models.py | sed -n '/^def sync_event_ticket_waves/,/return event/
|
||||||
Split the result into **new symbols** (mode A candidates) and
|
Split the result into **new symbols** (mode A candidates) and
|
||||||
**redefined fields** (mode B candidates).
|
**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
|
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
|
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,
|
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:
|
not inferred from the diff. A wrong theory costs more than the check:
|
||||||
during this rebase an inference that `amount_tickets` "goes stale on
|
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.
|
only the call-site list showed.
|
||||||
|
|
||||||
### 5. Write the reason at the site
|
### 5. Write the reason at the site
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Running log of fork features that are shaped so they could be offered to
|
||||||
in an upstream-compatible form; strike it when the PR merges upstream.
|
in an upstream-compatible form; strike it when the PR merges upstream.
|
||||||
|
|
||||||
| Feature | Where | Upstream target | Readiness |
|
| 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 |
|
| `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 |
|
| 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.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 |
|
||||||
|
|
@ -18,3 +18,4 @@ in an upstream-compatible form; strike it when the PR merges upstream.
|
||||||
| `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 |
|
| `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 `<img>` (`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) |
|
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -156,3 +156,32 @@ async def m003_event_nostr_publish_pending(db):
|
||||||
"ALTER TABLE events.events "
|
"ALTER TABLE events.events "
|
||||||
"ADD COLUMN nostr_publish_pending BOOLEAN NOT NULL DEFAULT FALSE",
|
"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",
|
||||||
|
)
|
||||||
|
|
|
||||||
45
models.py
45
models.py
|
|
@ -151,6 +151,12 @@ class Event(BaseModel):
|
||||||
# True means the relay's copy may be behind this row — see
|
# True means the relay's copy may be behind this row — see
|
||||||
# migrations_fork.m003 and the sweep in __init__.events_start.
|
# migrations_fork.m003 and the sweep in __init__.events_start.
|
||||||
nostr_publish_pending: bool = False
|
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)
|
@validator("categories", pre=True)
|
||||||
def parse_categories(cls, v):
|
def parse_categories(cls, v):
|
||||||
|
|
@ -186,18 +192,28 @@ class PublicEvent(BaseModel):
|
||||||
return v or []
|
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`.
|
"""Rails a buyer may pick for `event`.
|
||||||
|
|
||||||
Explicit `extra.payment_methods` wins; an empty list falls back to the
|
Explicit `extra.payment_methods` wins; an empty list falls back to the
|
||||||
pre-#payment-methods rule so events created before the field existed
|
pre-#payment-methods rule so events created before the field existed
|
||||||
keep behaving the same (Lightning always, fiat iff `allow_fiat`).
|
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 [])
|
explicit = list(getattr(event.extra, "payment_methods", []) or [])
|
||||||
if explicit:
|
if explicit:
|
||||||
return explicit
|
return explicit
|
||||||
methods = ["lightning"]
|
methods = ["lightning"]
|
||||||
if event.allow_fiat:
|
if wave.allow_fiat if wave is not None else event.allow_fiat:
|
||||||
methods.append("fiat")
|
methods.append("fiat")
|
||||||
return methods
|
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:
|
def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent:
|
||||||
ticket_waves = ensure_ticket_waves(event)
|
ticket_waves = ensure_ticket_waves(event)
|
||||||
event.extra.ticket_waves = ticket_waves
|
event.extra.ticket_waves = ticket_waves
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import cycle (views_api -> nostr_hooks -> nostr_publisher -> models).
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .crud import update_event
|
from .crud import update_event
|
||||||
from .models import Event
|
from .models import Event, advertised_wave_key
|
||||||
from .nostr_publisher import publish_event_to_nostr
|
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:
|
if not delete:
|
||||||
event.nostr_event_id = nostr_event.id
|
event.nostr_event_id = nostr_event.id
|
||||||
event.nostr_event_created_at = nostr_event.created_at
|
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)
|
await update_event(event)
|
||||||
return True
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,12 @@ from datetime import datetime, timezone
|
||||||
from lnbits.core.signers import NostrSigner
|
from lnbits.core.signers import NostrSigner
|
||||||
from loguru import logger
|
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.event import NostrEvent
|
||||||
from .nostr_timestamp import monotonic_created_at
|
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)
|
start - unix timestamp (31923) or YYYY-MM-DD (31922)
|
||||||
end - same encoding (optional)
|
end - same encoding (optional)
|
||||||
image, location, t (categories) - optional
|
image, location, t (categories) - optional
|
||||||
tickets_available - current remaining capacity (always emitted; 0 = sold out)
|
tickets_available - remaining capacity of the advertised wave
|
||||||
tickets_sold - running paid-count (always emitted; clients can
|
(always emitted; 0 = nothing on sale now)
|
||||||
derive original_capacity = available + sold)
|
tickets_sold - running paid-count across all waves (always
|
||||||
tickets_price - price_per_ticket (always emitted; 0 means free)
|
emitted)
|
||||||
tickets_currency - the currency string
|
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_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
|
||||||
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
|
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
|
||||||
Content: event.info
|
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).
|
# 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
|
# Clients that must still handle an absent tag — a NIP-52 event from
|
||||||
# some other publisher — are unaffected; we simply never omit it.
|
# 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_sold", str(event.sold)])
|
||||||
tags.append(["tickets_price", str(event.price_per_ticket)])
|
tags.append(["tickets_price", str(advertised.price_per_ticket)])
|
||||||
tags.append(["tickets_currency", event.currency])
|
tags.append(["tickets_currency", advertised.currency])
|
||||||
# Fiat-checkout config — only emitted when allow_fiat is on so
|
# Fiat-checkout config — only emitted when allow_fiat is on so
|
||||||
# clients can branch the buy UI without re-reading the schema.
|
# 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"])
|
tags.append(["tickets_allow_fiat", "true"])
|
||||||
if event.fiat_currency:
|
if advertised.fiat_currency:
|
||||||
tags.append(["tickets_fiat_currency", event.fiat_currency])
|
tags.append(["tickets_fiat_currency", advertised.fiat_currency])
|
||||||
# Rails the organizer accepts, resolved through the same helper the
|
# Rails the organizer accepts, resolved through the same helper the
|
||||||
# ticket endpoint enforces with, so a client can render exactly 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
|
# buttons that will be accepted (e.g. a card-only event) without a REST
|
||||||
# round-trip. Comma-separated, lowercase.
|
# 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
|
# NIP-52 calendar events are replaceable: this d-tag is republished
|
||||||
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
|
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
|
||||||
|
|
|
||||||
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