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
171 lines
5.5 KiB
Python
171 lines
5.5 KiB
Python
"""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)]
|