events/tests/test_publish_confirmation.py
Padreug cc730256ab
Some checks failed
lint.yml / feat(nostr): confirm publishes against the relay's OK (pull_request) Failing after 0s
feat(nostr): confirm publishes against the relay's OK
`publish_nostr_event` returned as soon as the EVENT was on the send
queue, and the publisher logged "Published" on the next line. Queueing
is not delivery: nostrclient drops an EVENT outright when no relay is
connected, answering `OK false "error: no relays connected"`. We threw
that reply away.

On cfaun this cost a completed repair. The #55 sweep republished a
14-day-stale calendar event 21 seconds before nostrclient had finished
connecting to its relay, got `OK false`, logged `Published`, reported
`1/1 recovered` and cleared `nostr_publish_pending` — leaving the count
stale, the row unflagged and the log asserting success. It took a
manual re-arm of the flag to finish the job.

So the flag's contract was never true: it claimed to clear only on a
confirmed success but cleared on a confirmed enqueue.

`publish_nostr_event` now registers a future per event id, awaits the
`OK`, and returns whether it was accepted. `publish_event_to_nostr`
returns None when unconfirmed, which keeps the row flagged so the sweep
retries rather than recording a delivery that never happened.

Correlation lives in `get_event`, the one place relay messages cross
from the websocket thread into the event loop — no cross-thread future
juggling. OK frames are consumed there rather than forwarded; the sync
loop never handled them. A disconnect settles every in-flight publish
immediately instead of making callers wait out the timeout.

On latency: the timeout is not the common cost. A disconnected relay is
rejected by nostrclient's router in milliseconds (230ms measured on
aio-demo), so the 12s budget only applies when relays are connected but
silent, which nostrclient itself bounds at 10s. `set_ticket_paid` runs
on the invoice-listener task, so that narrow case does stall the loop;
if it ever matters, the remedy is to stop awaiting on the sale path
while leaving the flag set — the sweep already guarantees eventual
delivery — not to go back to reporting unverified success.

Closes #56
2026-09-27 22:11:21 +02:00

105 lines
3.7 KiB
Python

"""Publish confirmation against the relay's `OK` (aiolabs/events#56).
Queueing is not delivery. nostrclient drops an EVENT outright when no
relay is connected and answers `OK false`; before this, that reply was
discarded and the publish reported success, which once cleared the
`nostr_publish_pending` flag on a republish that never left the
building.
"""
import asyncio
import json
import pytest
from ..nostr import nostr_client as nc
from ..nostr.event import NostrEvent
def _event(event_id: str = "a" * 64) -> NostrEvent:
e = NostrEvent(pubkey="b" * 64, created_at=0, kind=31923)
e.id = event_id
return e
async def _publish_and_reply(client, event, reply, delay=0.01):
"""Start a publish, then feed `reply` through the receive path."""
task = asyncio.create_task(client.publish_nostr_event(event))
await asyncio.sleep(delay) # let the future register
if reply is not None:
client.receive_event_queue.put_nowait(reply)
consumer = asyncio.create_task(client.get_event())
await asyncio.sleep(delay)
consumer.cancel()
return await task
@pytest.mark.asyncio
async def test_accepted_publish_returns_true():
client = nc.NostrClient()
event = _event()
ok = json.dumps(["OK", event.id, True, ""])
assert await _publish_and_reply(client, event, ok) is True
assert client._pending_oks == {}
@pytest.mark.asyncio
async def test_rejected_publish_returns_false():
"""The shape that bit us: no relay connected, so nostrclient's
router answers `OK false` without the event ever being sent."""
client = nc.NostrClient()
event = _event()
ok = json.dumps(["OK", event.id, False, "error: no relays connected"])
assert await _publish_and_reply(client, event, ok) is False
assert client._pending_oks == {}
@pytest.mark.asyncio
async def test_missing_ok_times_out_as_unconfirmed(monkeypatch):
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05)
client = nc.NostrClient()
assert await _publish_and_reply(client, _event(), None) is False
assert client._pending_oks == {}
@pytest.mark.asyncio
async def test_ok_for_a_different_event_does_not_settle_ours(monkeypatch):
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05)
client = nc.NostrClient()
other = json.dumps(["OK", "c" * 64, True, ""])
assert await _publish_and_reply(client, _event(), other) is False
@pytest.mark.asyncio
async def test_get_event_swallows_ok_and_forwards_everything_else():
client = nc.NostrClient()
client.receive_event_queue.put_nowait(json.dumps(["OK", "d" * 64, True, ""]))
forwarded = json.dumps(["EVENT", "sub", {"id": "e" * 64}])
client.receive_event_queue.put_nowait(forwarded)
assert await client.get_event() == forwarded
@pytest.mark.asyncio
async def test_disconnect_settles_inflight_publishes_immediately(monkeypatch):
"""A dropped socket must not leave the caller waiting the full
timeout for an OK that can no longer arrive."""
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 30)
client = nc.NostrClient()
event = _event()
task = asyncio.create_task(client.publish_nostr_event(event))
await asyncio.sleep(0.01)
client.receive_event_queue.put_nowait(ValueError("WebSocket closed"))
consumer = asyncio.create_task(client.get_event())
await asyncio.sleep(0.01)
consumer.cancel()
assert await asyncio.wait_for(task, 1) is False
def test_settle_ok_ignores_non_ok_frames():
client = nc.NostrClient()
assert client._settle_ok(json.dumps(["EVENT", "sub", {}])) is False
assert client._settle_ok(json.dumps(["EOSE", "sub"])) is False
assert client._settle_ok("not json") is False
assert client._settle_ok(json.dumps(["OK", "f" * 64, True, ""])) is True