The six commits that built the live chat sync added no tests. Everything they
touch fails silently by nature — a filter that drops messages, a subscription
that stops being replayed, a group whose id never reaches the `#h` tag — so the
symptom is always "some messages didn't arrive", days later, on someone else's
phone. 46 tests, in four files.
**What is covered**
RelayPoolSubscriptionTest (13) — the pool's half of surviving a dropped socket.
A query is retained and replayed on reconnect; a closed one is forgotten and
stops the socket reconnecting for it; closing one of two leaves the other alone;
a negentropy exchange is never replayed (its rounds are stateful, so resuming
one reconciles against a conversation the relay is no longer having); an update
to a live subscription replaces what gets replayed, including when the send
itself fails; dropping a relay or closing the pool forgets what they carried;
replay is scoped to the relay that reconnected. Plus the semantic the whole
change rests on, asserted in both directions: a live subscription keeps
delivering after EOSE, a one-shot query still ends at it.
LiveSubscriptionReconcileTest (12) — the requirement this all exists for: the
group filter follows group membership with nobody calling a subscribe function.
Joining widens the filter *in place* rather than reopening (a reopen would drop
the live tail of every other group in that chunk); leaving drops one; leaving
everything closes the subscription; churn inside the debounce window collapses
to one update; a NIP-17 room never becomes a group subscription. Then the
collect loop: events stored against the relay they came from, an event after
EOSE still stored, a CLOSED reopened once the back-off elapses and not before,
and a rate-limited CLOSED waiting far longer — but still coming back.
Backgrounding closes and foregrounding rebuilds, reconnects, and queues the
catch-up.
LiveSubscriptionPlanTest (11) — the filter and planning rules, led by the one
most likely to be "tidied up" later: the gift wrap filter carries no `since`,
because NIP-59 randomizes created_at into the past and a `since` near the
present silently drops new messages.
RelayBackPressureTest (4) and ReconnectBackoffTest (6) — the two pure decisions.
Which CLOSED reasons mean "ease off", and the backoff arithmetic including the
exponent clamp: 2.0.pow(4000) is Infinity and Duration * Double throws on it, so
without it a socket failing long enough turned its reconnect loop into a crash
loop, at the point the network was least likely to recover unaided.
**Seams opened to get there**, each a readability win on its own terms:
- NostrSocketClientFactory becomes an interface with DefaultNostrSocketClientFactory
behind it, so the pool can be driven by a fake socket.
- RelayPool takes its CoroutineScope, so the replay a reconnect triggers can be
observed rather than raced.
- LiveSubscriptionManager depends on a new LiveSubscriptionTransport (4
methods) rather than RelaysSocketManager, which observes the active wallet in
its init and cannot be stood up in a test at all.
- Its pure planning helpers move to the companion as `internal`, and its
launches inherit the caller's dispatcher instead of pinning Dispatchers.IO.
SynchronizationViewModel already launches observe() on IO, so nothing moves —
but a coroutine that picks its own dispatcher cannot be driven by a test
scheduler.
- reconnectDelay is extracted to ReconnectBackoff.kt with jitter as a
parameter, so the arithmetic can be pinned without randomness.
- endsLiveSubscription names the live-subscription termination rule next to
isTerminalFor, which is the one-shot rule. Having both named makes the
difference between them reviewable rather than implicit.
kotlinx-coroutines-test is added to commonTest: the pool's bookkeeping is all
suspend functions and there is no runBlocking in a common source set.
The tests were checked by mutation, not just by passing — reintroducing a
`since`, making EOSE terminal, dropping the leftGroupAt filter and removing
retention from query() each produce failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>