feat(nostr): flag events whose NIP-52 publish didn't land

Inventory reaches clients only through the republished calendar event,
and until now a publish that failed or was skipped left no durable
trace — only a log line, if that. Twice the drift was caught by a human
reading a wrong number on a public page (#35 on aio-demo, #51 on cfaun,
where an event's relay copy sat 14 days behind the DB).

Adds `events.nostr_publish_pending`, set before every attempt and
cleared only on a confirmed success. Ordering it that way is what makes
"the attempt was never made" — no signer resolved, no NostrClient, the
process died mid-flight — as discoverable as "the attempt raised". Both
shapes have now been observed in production; only the second one was
ever visible.

`set_ticket_paid` raises the flag inside its own update so the counters
and "the relay doesn't know about them yet" commit atomically, and the
sale path pays no extra write.

`publish_or_delete_nostr_event` now returns a bool so callers can
branch. The flag, not the return value, is the durable record — the
existing call sites stay correct ignoring it.

Publish failures move from WARNING to ERROR: the published ticket count
has stopped tracking reality, which is not routine journal noise.

Refs #35
This commit is contained in:
Padreug 2026-09-26 23:45:47 +02:00
commit 5d52a231d3
6 changed files with 240 additions and 6 deletions

View file

@ -0,0 +1,171 @@
"""The `nostr_publish_pending` marker and its lifecycle.
Inventory reaches clients only through the republished NIP-52 calendar
event. These tests pin the invariant that makes drift recoverable: the
flag goes up before every attempt and comes down only on a confirmed
success, so every shape of failure — raised, skipped, never attempted —
leaves the row queryable by the sweep.
"""
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from .. import nostr_hooks, services
from ..models import Event, Ticket
def _event(**kwargs) -> Event:
defaults = {
"id": "evt",
"wallet": "w",
"name": "Test",
"info": "",
"closing_date": "2030-01-01",
"event_start_date": "2030-01-01",
"event_end_date": "2030-01-02",
"currency": "sat",
"price_per_ticket": 1000,
"amount_tickets": 10,
"time": datetime.now(timezone.utc),
"status": "approved",
}
defaults.update(kwargs)
return Event(**defaults)
@pytest.fixture
def saved(monkeypatch):
"""Capture every update_event write so ordering can be asserted."""
writes: list[bool] = []
async def _update(event):
writes.append(event.nostr_publish_pending)
return event
monkeypatch.setattr(nostr_hooks, "update_event", _update)
return writes
def _signer(monkeypatch, signer):
monkeypatch.setattr(
"lnbits.core.signers.resolve_for_wallet", AsyncMock(return_value=signer)
)
def _publisher(monkeypatch, result):
monkeypatch.setattr(
nostr_hooks, "publish_event_to_nostr", AsyncMock(return_value=result)
)
@pytest.mark.asyncio
async def test_success_raises_then_clears_the_flag(monkeypatch, saved):
event = _event()
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
_publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123))
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
# Flagged before the attempt, cleared after it — in that order.
assert saved == [True, False]
assert event.nostr_publish_pending is False
assert event.nostr_event_id == "nid"
assert event.nostr_event_created_at == 123
@pytest.mark.asyncio
async def test_missing_signer_leaves_the_flag_up(monkeypatch, saved):
event = _event()
_signer(monkeypatch, None)
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
assert saved == [True]
assert event.nostr_publish_pending is True
@pytest.mark.asyncio
async def test_publisher_returning_none_leaves_the_flag_up(monkeypatch, saved):
"""The no-NostrClient shape: nothing raised, nothing published."""
event = _event()
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
_publisher(monkeypatch, None)
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
assert saved == [True]
assert event.nostr_publish_pending is True
@pytest.mark.asyncio
async def test_raised_publish_leaves_the_flag_up(monkeypatch, saved):
event = _event()
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
monkeypatch.setattr(
nostr_hooks,
"publish_event_to_nostr",
AsyncMock(side_effect=RuntimeError("signer timeout")),
)
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
assert saved == [True]
assert event.nostr_publish_pending is True
@pytest.mark.asyncio
async def test_already_pending_row_is_not_re_flagged(monkeypatch, saved):
"""The sweep re-publishing a flagged row writes once, not twice."""
event = _event(nostr_publish_pending=True)
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
_publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123))
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
assert saved == [False]
@pytest.mark.asyncio
async def test_delete_clears_the_flag_without_touching_the_coordinate(
monkeypatch, saved
):
"""A take-down must not overwrite the id/created_at of the event it
just deleted — the kind-5 has its own."""
event = _event(nostr_event_id="old", nostr_event_created_at=100)
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
_publisher(monkeypatch, SimpleNamespace(id="del", created_at=999))
assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True
assert event.nostr_publish_pending is False
assert event.nostr_event_id == "old"
assert event.nostr_event_created_at == 100
@pytest.mark.asyncio
async def test_sale_flags_the_event_in_the_same_write(monkeypatch):
"""`set_ticket_paid` must flag inside its own update, so the counters
and "the relay doesn't know yet" land atomically."""
event = _event(sold=4, amount_tickets=6)
seen: list[tuple[int, int, bool]] = []
async def _update_event(ev):
seen.append((ev.sold, ev.amount_tickets, ev.nostr_publish_pending))
return ev
monkeypatch.setattr(services, "update_ticket", AsyncMock())
monkeypatch.setattr(services, "get_event", AsyncMock(return_value=event))
monkeypatch.setattr(services, "update_event", _update_event)
monkeypatch.setattr(services, "publish_or_delete_nostr_event", AsyncMock())
ticket = Ticket(
id="t1",
wallet="w",
event="evt",
name="A",
email="a@example.com",
registered=False,
paid=False,
time=datetime.now(timezone.utc),
reg_timestamp=datetime.now(timezone.utc),
)
await services.set_ticket_paid(ticket)
assert seen == [(5, 5, True)]