diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs index a5cc3bc06b..e3fdc92bc0 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs @@ -1,668 +1,18 @@ -import assert from "node:assert/strict"; -import test, { mock } from "node:test"; +// Thin wrapper — delegates all invariants to the shared merge-lane suite. +// Lane-specific differences: Manager class, outbox reader, watermark key, +// entry shape (muted field), and typed publish/getPending/fetchRemote methods. -import { relayClient } from "@/shared/api/relayClient"; import { readChannelMutesOutbox } from "./channelMutesStorage.ts"; import { ChannelMuteSyncManager } from "./channelMutesSync.ts"; -import { - installFakeWindow, - installEchoTauri, - makeFakeWindow, -} from "./sidebarSyncTestHelpers.mjs"; +import { runMergeLaneSyncSuite } from "./mergeLaneSync.shared.test.mjs"; -const RELAY = "wss://r.test"; -const RELAY_KEY = encodeURIComponent(RELAY); - -function makeStore(channels = {}) { - return { version: 1, channels }; -} -const E = (muted, updatedAt, rev) => ({ muted, updatedAt, rev }); - -// Multi-slot timer fake keyed by delay, for overlapping-publish tests. Mirrors -// the sections suite convention (channelSectionsSync.test.mjs:407-432). -function makeMultiTimerWindow() { - const storage = new Map(); - const timers = new Map(); - let nextId = 1; - const win = { - localStorage: { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - get length() { - return storage.size; - }, - key: (i) => [...storage.keys()][i] ?? null, - }, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, ms }); - return id; - }, - clearTimeout: (id) => timers.delete(id), - }; - return { - win, - storage, - timers, - fireDelay: async (ms) => { - const entry = [...timers.entries()].find(([, v]) => v.ms === ms); - assert.ok(entry, `expected a timer scheduled at ${ms}ms`); - timers.delete(entry[0]); - entry[1].fn(); - for (let i = 0; i < 50; i++) await Promise.resolve(); - }, - hasDelay: (ms) => [...timers.values()].some((t) => t.ms === ms), - }; -} - -// ─── observe() / high-water ingestion ───────────────────────────────────────── - -test("observe: high-water is per-channel max of rev and updatedAt, monotonic", () => { - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const m = new ChannelMuteSyncManager("pk", RELAY); - m.observe(makeStore({ a: E(true, 100, 3), b: E(false, 50, 1) })); - assert.equal(m.maxRevSeen("a"), 3); - assert.equal(m.maxUpdatedAtSeen("a"), 100); - // A later observation raises each dimension independently; a lower one - // never regresses either. - m.observe(makeStore({ a: E(true, 90, 5) })); - assert.equal(m.maxRevSeen("a"), 5, "rev raised"); - assert.equal(m.maxUpdatedAtSeen("a"), 100, "updatedAt not regressed"); - m.observe(makeStore({ a: E(true, 200, 2) })); - assert.equal(m.maxUpdatedAtSeen("a"), 200, "updatedAt raised"); - assert.equal(m.maxRevSeen("a"), 5, "rev not regressed"); - // Unseen channel reports zero on both dimensions. - assert.equal(m.maxRevSeen("never"), 0); - assert.equal(m.maxUpdatedAtSeen("never"), 0); - } finally { - restore(); - } -}); - -// ─── destroy() must cancel pending publish, not flush ───────────────────────── - -test("destroy: cancels pending publish without flushing to the relay", () => { - const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelMuteSyncManager("pk-test", RELAY); - manager.publishMutes(makeStore({ ch1: E(true, 100, 1) })); - manager.destroy(); - assert.equal(publishCalls.length, 0, "no publish after destroy"); - assert.equal(manager.getPendingMuteStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { - let releaseFetch = null; - const publishCalls = []; - mock.method( - relayClient, - "fetchEvents", - () => - new Promise((res) => { - releaseFetch = () => res([]); - }), - ); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelMuteSyncManager("pk-race", RELAY); - manager.publishMutes(makeStore({ ch1: E(true, 100, 1) })); - fw._fireTimer(); - manager.destroy(); - releaseFetch(); - await new Promise((r) => setTimeout(r, 0)); - assert.equal( - publishCalls.length, - 0, - "publishEvent must not be called after destroy", - ); - } finally { - restore(); - mock.reset(); - } -}); - -test("destroy: is safe to call with no pending publish", () => { - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelMuteSyncManager("pk-no-pending", RELAY); - assert.doesNotThrow(() => manager.destroy()); - } finally { - restore(); - } -}); - -// ─── Generation CAS: A-in-flight → B-click → A-completes (both variants) ────── - -// Finding 2 (A succeeds): an older in-flight publish that completes after a -// newer edit is queued must NOT clear the newer edit's pending store/outbox, -// and B must reach the relay via the completion re-drive. Mutation: dropping the -// generation CAS in discardPending lets A's success null out B's pending+outbox. -test("A-in-flight → B-click → A-succeeds: B stays pending and B publishes", async () => { - let releaseFirst = null; - let publishCount = 0; - let storedHead = []; - mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); - mock.method(relayClient, "publishEvent", (event) => { - publishCount++; - if (publishCount === 1) { - // A reaches the relay; hold its ACK open until the test releases it, then - // record it as the retained head. - return new Promise((res) => { - releaseFirst = () => { - storedHead = [event]; - res(); - }; - }); - } - storedHead = [event]; - return Promise.resolve(); - }); - const t = makeMultiTimerWindow(); - const restore = installFakeWindow(t.win); - const tauri = installEchoTauri("pk-ab"); - try { - const manager = new ChannelMuteSyncManager("pk-ab", RELAY); - const storeA = makeStore({ a: E(true, 100, 1) }); - const storeB = makeStore({ b: E(true, 101, 1) }); - - manager.publishMutes(storeA); - await t.fireDelay(2000); // doPublish(A) awaits publishEvent - while (releaseFirst === null) await Promise.resolve(); - - // B arrives while A is in flight. - manager.publishMutes(storeB); - assert.deepEqual( - Object.keys(manager.getPendingMuteStore().channels), - ["b"], - "B is now pending", - ); - assert.ok(readChannelMutesOutbox("pk-ab", RELAY), "outbox holds B"); - - // A completes — must NOT clear B. - releaseFirst(); - for (let i = 0; i < 50; i++) await Promise.resolve(); - assert.deepEqual( - Object.keys(manager.getPendingMuteStore()?.channels ?? {}), - ["b"], - "older A completion leaves B pending", - ); - assert.ok( - readChannelMutesOutbox("pk-ab", RELAY), - "older A completion leaves B outbox", - ); - - // B's own debounce fires and B reaches the relay with no kick; the - // post-publish retained-head fetch reads B's own write back and confirms - // subsumption, so B's outbox clears. - const capturedBefore = tauri.capturedPlaintext(); - await t.fireDelay(2000); - for (let i = 0; i < 50; i++) await Promise.resolve(); - const captured = tauri.capturedPlaintext(); - assert.ok( - captured && captured !== capturedBefore && captured.includes('"b"'), - "B is published to the relay", - ); - assert.equal( - manager.getPendingMuteStore(), - null, - "B cleared after confirmed publish", - ); - assert.equal( - readChannelMutesOutbox("pk-ab", RELAY), - null, - "B outbox cleared", - ); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// Finding 2 (A fails): A's publish rejects after B is queued. B must remain -// pending and be published by the serialized re-drive / retry — no manual kick. -test("A-in-flight → B-click → A-fails: B remains pending and B publishes", async () => { - let rejectFirst = null; - let publishCount = 0; - let storedHead = []; - mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); - mock.method(relayClient, "publishEvent", (event) => { - publishCount++; - if (publishCount === 1) { - return new Promise((_res, rej) => { - rejectFirst = () => rej(new Error("socket error")); - }); - } - storedHead = [event]; - return Promise.resolve(); - }); - const t = makeMultiTimerWindow(); - const restore = installFakeWindow(t.win); - const tauri = installEchoTauri("pk-abfail"); - try { - const manager = new ChannelMuteSyncManager("pk-abfail", RELAY); - manager.publishMutes(makeStore({ a: E(true, 100, 1) })); - await t.fireDelay(2000); - while (rejectFirst === null) await Promise.resolve(); - - manager.publishMutes(makeStore({ b: E(true, 101, 1) })); - rejectFirst(); // A fails - for (let i = 0; i < 50; i++) await Promise.resolve(); - - assert.deepEqual( - Object.keys(manager.getPendingMuteStore()?.channels ?? {}), - ["b"], - "B still pending after A's failure", - ); - assert.ok( - readChannelMutesOutbox("pk-abfail", RELAY), - "B outbox intact after A's failure", - ); - - // B's debounce fires and B publishes successfully; the retained-head fetch - // confirms B's own write and clears it. - await t.fireDelay(2000); - for (let i = 0; i < 50; i++) await Promise.resolve(); - const captured = tauri.capturedPlaintext(); - assert.ok(captured?.includes('"b"'), "B published"); - assert.equal(manager.getPendingMuteStore(), null, "B cleared"); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// ─── Bounded-backoff retry: failed publish on a healthy socket, no later edit ─ - -// Finding 2: a transient publish failure with the socket open and NO further -// click must self-heal via the bounded-backoff retry — the pending edit is kept -// and a retry timer is scheduled. Mutation: dropping scheduleRetry leaves the -// edit stranded (Will's "make another change to kick it" symptom). -test("failed publish schedules a bounded-backoff retry and keeps the pending edit", async () => { - let publishCount = 0; - let storedHead = []; - mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); - mock.method(relayClient, "publishEvent", (event) => { - publishCount++; - if (publishCount === 1) return Promise.reject(new Error("timeout")); - storedHead = [event]; - return Promise.resolve(); - }); - const t = makeMultiTimerWindow(); - const restore = installFakeWindow(t.win); - const tauri = installEchoTauri("pk-retry"); - try { - const manager = new ChannelMuteSyncManager("pk-retry", RELAY); - manager.publishMutes(makeStore({ a: E(true, 100, 1) })); - await t.fireDelay(2000); // debounce → doPublish → publishEvent rejects - assert.ok( - manager.getPendingMuteStore() !== null, - "pending edit retained after failure", - ); - assert.ok(t.hasDelay(2000), "a retry timer at RETRY_BASE_MS is scheduled"); - - // The retry fires and the second publish succeeds; the retained-head fetch - // confirms the write → pending cleared. - await t.fireDelay(2000); - for (let i = 0; i < 50; i++) await Promise.resolve(); - assert.equal(publishCount, 2, "retry re-published"); - assert.equal( - manager.getPendingMuteStore(), - null, - "pending cleared on retry success", - ); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// ─── Retention confirmation (Carl P1): OK is not proof of retention ─────────── - -// The relay OKs a superseded NIP-33 write as a no-op, so two windows racing -// distinct blobs from the same head can both get OK while only one is retained. -// The loser must NOT clear its durable outbox on OK alone: it fetches the -// authoritative retained head and clears only when that head subsumes its -// write. A retained head carrying a peer's distinct blob does not subsume the -// loser's click, so the outbox is kept and a retry is scheduled. -// Mutation: clearing on OK alone (dropping confirmRetainedHeadSubsumes) would -// null the pending edit here and lose the click. -test("publish OK but a peer blob is retained: loser keeps its outbox and retries", async () => { - let publishCount = 0; - // The retained head is a peer window's distinct blob from the same base — - // it does NOT contain our channel `a`, so it cannot subsume our write. - let storedHead = []; - mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); - const tauri = installEchoTauri("pk-loser"); - mock.method(relayClient, "publishEvent", (event) => { - publishCount++; - if (publishCount === 1) { - // Our write is OK'd by the relay but immediately superseded: the retained - // head is the peer's blob for a different channel, minted through the same - // echo seam so it decrypts. - storedHead = [tauri.mintHead(makeStore({ z: E(true, 200, 5) }), 100)]; - return Promise.resolve(); - } - // The retry (which max-merges the peer head in) is retained. - storedHead = [event]; - return Promise.resolve(); - }); - const t = makeMultiTimerWindow(); - const restore = installFakeWindow(t.win); - try { - const manager = new ChannelMuteSyncManager("pk-loser", RELAY); - manager.publishMutes(makeStore({ a: E(true, 100, 1) })); - await t.fireDelay(2000); // publish OK, but peer blob is what's retained - for (let i = 0; i < 50; i++) await Promise.resolve(); - - assert.ok( - manager.getPendingMuteStore() !== null, - "unconfirmed publish keeps the pending edit", - ); - assert.ok( - readChannelMutesOutbox("pk-loser", RELAY), - "loser keeps its durable outbox — OK is not proof of retention", - ); - assert.ok(t.hasDelay(2000), "a retry is scheduled"); - - // The retry re-publishes the max-merge of our click and the peer head; this - // time it is retained (subsumes our `a`) and the outbox clears. - await t.fireDelay(2000); - for (let i = 0; i < 50; i++) await Promise.resolve(); - assert.equal(publishCount, 2, "loser retried"); - assert.equal( - manager.getPendingMuteStore(), - null, - "pending cleared once the retained head subsumes our click", - ); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── - -test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("relay timeout")), - ); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelMuteSyncManager("pk-fail", RELAY); - const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) })); - assert.equal(result.action, "hold"); - assert.equal(manager.getPendingMuteStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - fw.localStorage.setItem( - `buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`, - "1700000000", - ); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelMuteSyncManager("pk-stale", RELAY); - const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) })); - assert.equal(result.action, "hold"); - assert.equal(manager.getPendingMuteStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelMuteSyncManager("pk-fresh", RELAY); - const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) })); - assert.equal(result.action, "hold"); - assert.ok(manager.getPendingMuteStore() !== null); - } finally { - restore(); - mock.reset(); - } -}); - -test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => { - const relayA = "wss://a.relay.test"; - const relayB = "wss://b.relay.test"; - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - fw.localStorage.setItem( - `buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayA)}`, - "1700000100", - ); - const restore = installFakeWindow(fw); - try { - const managerB = new ChannelMuteSyncManager("pk-iso", relayB); - const result = await managerB.bootstrap(makeStore({ ch1: E(true, 1, 0) })); - assert.equal(result.action, "hold"); - assert.ok( - managerB.getPendingMuteStore() !== null, - "first-sync seed on relay B must not be blocked by relay A watermark", - ); - } finally { - restore(); - mock.reset(); - } -}); - -// ─── Failed pre-publish fetch: retain, never publish (Carl P1) ──────────────── - -// The pre-publish fetch THROWS (timeout / auth / socket) — NOT proof that no -// head exists. Publishing the local store here could erase an unseen newer head -// during a transient outage. Fix: `retain` — keep the durable outbox and retry. -// Mutation: reverting the merge-lane catch to `publish` fires publishEvent over -// the unseen head. -test("failed pre-publish fetch retains the pending edit and retries, never publishing", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("socket timeout")), - ); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installEchoTauri("pk-fetchfail"); - try { - const manager = new ChannelMuteSyncManager("pk-fetchfail", RELAY); - manager.publishMutes(makeStore({ a: E(true, 100, 1) })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - publishCalls.length, - 0, - "must not publish when the pre-publish fetch failed", - ); - assert.ok( - manager.getPendingMuteStore() !== null, - "a failed fetch must retain the pending edit", - ); - assert.ok( - readChannelMutesOutbox("pk-fetchfail", RELAY), - "durable outbox must survive a failed fetch", - ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// ─── Unreadable head: retain, never max-merge over it (Carl P1) ─────────────── - -// A pre-publish head event exists but cannot be decrypted (transient keychain -// fault, future NIP-44 scheme, malformed/unsupported payload). A max-merge is -// only safe once both operands were actually read, so publishing the local -// store over an uninspectable head would drop its unread entries. Fix: `retain` -// — keep the durable outbox and retry, never publish. Mutation: reverting the -// `!remote` branch to `publish` fires publishEvent and clobbers the unread head. -test("unreadable head (decrypt failure) retains the pending edit and retries, never publishing", async () => { - // A head event exists but its ciphertext is not registered in the echo map, - // so decrypt rejects → decryptAndParse returns null → retain. - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-undec", - content: "unregistered-cipher", - created_at: 500, - id: "evt-undec", - }, - ]), - ); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installEchoTauri("pk-undec"); - try { - const manager = new ChannelMuteSyncManager("pk-undec", RELAY); - manager.publishMutes(makeStore({ a: E(true, 100, 1) })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - publishCalls.length, - 0, - "must not publish over a head we could not read", - ); - assert.ok( - manager.getPendingMuteStore() !== null, - "unreadable head must retain the pending edit", - ); - assert.ok( - readChannelMutesOutbox("pk-undec", RELAY), - "durable outbox must survive an unreadable head", - ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// A readable head whose decrypted payload carries an unsupported schema version -// (parseMutePayload rejects it → decryptAndParse returns null). Distinct from a -// decrypt fault: the ciphertext decrypts fine, but we still cannot trust the -// contents, so retain rather than max-merge over it. -test("unsupported head payload schema retains the pending edit, never publishing", async () => { - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installEchoTauri("pk-badver"); - // mintHead stringifies the given object as the head plaintext; a future schema - // version decrypts cleanly but parseMutePayload returns null. - const head = tauri.mintHead({ version: 2, channels: {} }, 500); - mock.method(relayClient, "fetchEvents", () => Promise.resolve([head])); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - try { - const manager = new ChannelMuteSyncManager("pk-badver", RELAY); - manager.publishMutes(makeStore({ a: E(true, 100, 1) })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - publishCalls.length, - 0, - "must not publish over a head whose schema we do not support", - ); - assert.ok( - manager.getPendingMuteStore() !== null, - "unsupported schema must retain the pending edit", - ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// ─── Timestamp clamp (Carl P2): a far-future remote head must not push our -// published createdAt past the relay's ±15min future-drift window. -// Mutation test: removing the clamp lets createdAt = lastRemote+1 (~now+3600), -// which exceeds now + MAX_PUBLISH_FUTURE_SECS. -test("timestamp clamp: published createdAt stays inside the relay future window", async () => { - const nowSecs = Math.floor(Date.now() / 1000); - const farFutureHead = nowSecs + 3_600; // 1h ahead — beyond the ±15min window - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installEchoTauri("pk-clamp"); - // A readable far-future head: primes lastRemoteCreatedAt to farFutureHead on - // the priming fetch and is max-merged on the pre-publish fetch. It must be - // decryptable — an unreadable head now retains rather than publishes, so the - // clamp is only exercised via a head we actually read. - const head = tauri.mintHead(makeStore({}), farFutureHead); - mock.method(relayClient, "fetchEvents", () => Promise.resolve([head])); - let signedCreatedAt = null; - mock.method(relayClient, "publishEvent", (evt) => { - signedCreatedAt = evt.created_at; - return Promise.resolve(); - }); - try { - const manager = new ChannelMuteSyncManager("pk-clamp", RELAY); - await manager.fetchRemoteMutes(); // prime lastRemoteCreatedAt - manager.publishMutes(makeStore({ ch1: E(true, 100, 1) })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.ok(signedCreatedAt !== null, "publish must have been attempted"); - assert.ok( - signedCreatedAt <= Math.floor(Date.now() / 1000) + 840, - `createdAt must be clamped inside the future window — got ${signedCreatedAt}`, - ); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } +runMergeLaneSyncSuite({ + label: "mutes", + Manager: ChannelMuteSyncManager, + readOutbox: readChannelMutesOutbox, + watermarkKind: "channel-mutes", + makeEntry: (muted, updatedAt, rev) => ({ muted, updatedAt, rev }), + publish: (m, s) => m.publishMutes(s), + getPending: (m) => m.getPendingMuteStore(), + fetchRemote: (m) => m.fetchRemoteMutes(), }); diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs index 53ea9881d4..6e30421424 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs @@ -1,668 +1,18 @@ -import assert from "node:assert/strict"; -import test, { mock } from "node:test"; +// Thin wrapper — delegates all invariants to the shared merge-lane suite. +// Lane-specific differences: Manager class, outbox reader, watermark key, +// entry shape (starred field), and typed publish/getPending/fetchRemote methods. -import { relayClient } from "@/shared/api/relayClient"; import { readChannelStarsOutbox } from "./channelStarsStorage.ts"; import { ChannelStarSyncManager } from "./channelStarsSync.ts"; -import { - installFakeWindow, - installEchoTauri, - makeFakeWindow, -} from "./sidebarSyncTestHelpers.mjs"; +import { runMergeLaneSyncSuite } from "./mergeLaneSync.shared.test.mjs"; -const RELAY = "wss://r.test"; -const RELAY_KEY = encodeURIComponent(RELAY); - -function makeStore(channels = {}) { - return { version: 1, channels }; -} -const E = (starred, updatedAt, rev) => ({ starred, updatedAt, rev }); - -// Multi-slot timer fake keyed by delay, for overlapping-publish tests. Mirrors -// the sections suite convention (channelSectionsSync.test.mjs:407-432). -function makeMultiTimerWindow() { - const storage = new Map(); - const timers = new Map(); - let nextId = 1; - const win = { - localStorage: { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - get length() { - return storage.size; - }, - key: (i) => [...storage.keys()][i] ?? null, - }, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, ms }); - return id; - }, - clearTimeout: (id) => timers.delete(id), - }; - return { - win, - storage, - timers, - fireDelay: async (ms) => { - const entry = [...timers.entries()].find(([, v]) => v.ms === ms); - assert.ok(entry, `expected a timer scheduled at ${ms}ms`); - timers.delete(entry[0]); - entry[1].fn(); - for (let i = 0; i < 50; i++) await Promise.resolve(); - }, - hasDelay: (ms) => [...timers.values()].some((t) => t.ms === ms), - }; -} - -// ─── observe() / high-water ingestion ───────────────────────────────────────── - -test("observe: high-water is per-channel max of rev and updatedAt, monotonic", () => { - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const m = new ChannelStarSyncManager("pk", RELAY); - m.observe(makeStore({ a: E(true, 100, 3), b: E(false, 50, 1) })); - assert.equal(m.maxRevSeen("a"), 3); - assert.equal(m.maxUpdatedAtSeen("a"), 100); - // A later observation raises each dimension independently; a lower one - // never regresses either. - m.observe(makeStore({ a: E(true, 90, 5) })); - assert.equal(m.maxRevSeen("a"), 5, "rev raised"); - assert.equal(m.maxUpdatedAtSeen("a"), 100, "updatedAt not regressed"); - m.observe(makeStore({ a: E(true, 200, 2) })); - assert.equal(m.maxUpdatedAtSeen("a"), 200, "updatedAt raised"); - assert.equal(m.maxRevSeen("a"), 5, "rev not regressed"); - // Unseen channel reports zero on both dimensions. - assert.equal(m.maxRevSeen("never"), 0); - assert.equal(m.maxUpdatedAtSeen("never"), 0); - } finally { - restore(); - } -}); - -// ─── destroy() must cancel pending publish, not flush ───────────────────────── - -test("destroy: cancels pending publish without flushing to the relay", () => { - const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelStarSyncManager("pk-test", RELAY); - manager.publishStars(makeStore({ ch1: E(true, 100, 1) })); - manager.destroy(); - assert.equal(publishCalls.length, 0, "no publish after destroy"); - assert.equal(manager.getPendingStarStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { - let releaseFetch = null; - const publishCalls = []; - mock.method( - relayClient, - "fetchEvents", - () => - new Promise((res) => { - releaseFetch = () => res([]); - }), - ); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelStarSyncManager("pk-race", RELAY); - manager.publishStars(makeStore({ ch1: E(true, 100, 1) })); - fw._fireTimer(); - manager.destroy(); - releaseFetch(); - await new Promise((r) => setTimeout(r, 0)); - assert.equal( - publishCalls.length, - 0, - "publishEvent must not be called after destroy", - ); - } finally { - restore(); - mock.reset(); - } -}); - -test("destroy: is safe to call with no pending publish", () => { - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelStarSyncManager("pk-no-pending", RELAY); - assert.doesNotThrow(() => manager.destroy()); - } finally { - restore(); - } -}); - -// ─── Generation CAS: A-in-flight → B-click → A-completes (both variants) ────── - -// Finding 2 (A succeeds): an older in-flight publish that completes after a -// newer edit is queued must NOT clear the newer edit's pending store/outbox, -// and B must reach the relay via the completion re-drive. Mutation: dropping the -// generation CAS in discardPending lets A's success null out B's pending+outbox. -test("A-in-flight → B-click → A-succeeds: B stays pending and B publishes", async () => { - let releaseFirst = null; - let publishCount = 0; - let storedHead = []; - mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); - mock.method(relayClient, "publishEvent", (event) => { - publishCount++; - if (publishCount === 1) { - // A reaches the relay; hold its ACK open until the test releases it, then - // record it as the retained head. - return new Promise((res) => { - releaseFirst = () => { - storedHead = [event]; - res(); - }; - }); - } - storedHead = [event]; - return Promise.resolve(); - }); - const t = makeMultiTimerWindow(); - const restore = installFakeWindow(t.win); - const tauri = installEchoTauri("pk-ab"); - try { - const manager = new ChannelStarSyncManager("pk-ab", RELAY); - const storeA = makeStore({ a: E(true, 100, 1) }); - const storeB = makeStore({ b: E(true, 101, 1) }); - - manager.publishStars(storeA); - await t.fireDelay(2000); // doPublish(A) awaits publishEvent - while (releaseFirst === null) await Promise.resolve(); - - // B arrives while A is in flight. - manager.publishStars(storeB); - assert.deepEqual( - Object.keys(manager.getPendingStarStore().channels), - ["b"], - "B is now pending", - ); - assert.ok(readChannelStarsOutbox("pk-ab", RELAY), "outbox holds B"); - - // A completes — must NOT clear B. - releaseFirst(); - for (let i = 0; i < 50; i++) await Promise.resolve(); - assert.deepEqual( - Object.keys(manager.getPendingStarStore()?.channels ?? {}), - ["b"], - "older A completion leaves B pending", - ); - assert.ok( - readChannelStarsOutbox("pk-ab", RELAY), - "older A completion leaves B outbox", - ); - - // B's own debounce fires and B reaches the relay with no kick; the - // post-publish retained-head fetch reads B's own write back and confirms - // subsumption, so B's outbox clears. - const capturedBefore = tauri.capturedPlaintext(); - await t.fireDelay(2000); - for (let i = 0; i < 50; i++) await Promise.resolve(); - const captured = tauri.capturedPlaintext(); - assert.ok( - captured && captured !== capturedBefore && captured.includes('"b"'), - "B is published to the relay", - ); - assert.equal( - manager.getPendingStarStore(), - null, - "B cleared after confirmed publish", - ); - assert.equal( - readChannelStarsOutbox("pk-ab", RELAY), - null, - "B outbox cleared", - ); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// Finding 2 (A fails): A's publish rejects after B is queued. B must remain -// pending and be published by the serialized re-drive / retry — no manual kick. -test("A-in-flight → B-click → A-fails: B remains pending and B publishes", async () => { - let rejectFirst = null; - let publishCount = 0; - let storedHead = []; - mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); - mock.method(relayClient, "publishEvent", (event) => { - publishCount++; - if (publishCount === 1) { - return new Promise((_res, rej) => { - rejectFirst = () => rej(new Error("socket error")); - }); - } - storedHead = [event]; - return Promise.resolve(); - }); - const t = makeMultiTimerWindow(); - const restore = installFakeWindow(t.win); - const tauri = installEchoTauri("pk-abfail"); - try { - const manager = new ChannelStarSyncManager("pk-abfail", RELAY); - manager.publishStars(makeStore({ a: E(true, 100, 1) })); - await t.fireDelay(2000); - while (rejectFirst === null) await Promise.resolve(); - - manager.publishStars(makeStore({ b: E(true, 101, 1) })); - rejectFirst(); // A fails - for (let i = 0; i < 50; i++) await Promise.resolve(); - - assert.deepEqual( - Object.keys(manager.getPendingStarStore()?.channels ?? {}), - ["b"], - "B still pending after A's failure", - ); - assert.ok( - readChannelStarsOutbox("pk-abfail", RELAY), - "B outbox intact after A's failure", - ); - - // B's debounce fires and B publishes successfully; the retained-head fetch - // confirms B's own write and clears it. - await t.fireDelay(2000); - for (let i = 0; i < 50; i++) await Promise.resolve(); - const captured = tauri.capturedPlaintext(); - assert.ok(captured?.includes('"b"'), "B published"); - assert.equal(manager.getPendingStarStore(), null, "B cleared"); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// ─── Bounded-backoff retry: failed publish on a healthy socket, no later edit ─ - -// Finding 2: a transient publish failure with the socket open and NO further -// click must self-heal via the bounded-backoff retry — the pending edit is kept -// and a retry timer is scheduled. Mutation: dropping scheduleRetry leaves the -// edit stranded (Will's "make another change to kick it" symptom). -test("failed publish schedules a bounded-backoff retry and keeps the pending edit", async () => { - let publishCount = 0; - let storedHead = []; - mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); - mock.method(relayClient, "publishEvent", (event) => { - publishCount++; - if (publishCount === 1) return Promise.reject(new Error("timeout")); - storedHead = [event]; - return Promise.resolve(); - }); - const t = makeMultiTimerWindow(); - const restore = installFakeWindow(t.win); - const tauri = installEchoTauri("pk-retry"); - try { - const manager = new ChannelStarSyncManager("pk-retry", RELAY); - manager.publishStars(makeStore({ a: E(true, 100, 1) })); - await t.fireDelay(2000); // debounce → doPublish → publishEvent rejects - assert.ok( - manager.getPendingStarStore() !== null, - "pending edit retained after failure", - ); - assert.ok(t.hasDelay(2000), "a retry timer at RETRY_BASE_MS is scheduled"); - - // The retry fires and the second publish succeeds; the retained-head fetch - // confirms the write → pending cleared. - await t.fireDelay(2000); - for (let i = 0; i < 50; i++) await Promise.resolve(); - assert.equal(publishCount, 2, "retry re-published"); - assert.equal( - manager.getPendingStarStore(), - null, - "pending cleared on retry success", - ); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// ─── Retention confirmation (Carl P1): OK is not proof of retention ─────────── - -// The relay OKs a superseded NIP-33 write as a no-op, so two windows racing -// distinct blobs from the same head can both get OK while only one is retained. -// The loser must NOT clear its durable outbox on OK alone: it fetches the -// authoritative retained head and clears only when that head subsumes its -// write. A retained head carrying a peer's distinct blob does not subsume the -// loser's click, so the outbox is kept and a retry is scheduled. -// Mutation: clearing on OK alone (dropping confirmRetainedHeadSubsumes) would -// null the pending edit here and lose the click. -test("publish OK but a peer blob is retained: loser keeps its outbox and retries", async () => { - let publishCount = 0; - // The retained head is a peer window's distinct blob from the same base — - // it does NOT contain our channel `a`, so it cannot subsume our write. - let storedHead = []; - mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); - const tauri = installEchoTauri("pk-loser"); - mock.method(relayClient, "publishEvent", (event) => { - publishCount++; - if (publishCount === 1) { - // Our write is OK'd by the relay but immediately superseded: the retained - // head is the peer's blob for a different channel, minted through the same - // echo seam so it decrypts. - storedHead = [tauri.mintHead(makeStore({ z: E(true, 200, 5) }), 100)]; - return Promise.resolve(); - } - // The retry (which max-merges the peer head in) is retained. - storedHead = [event]; - return Promise.resolve(); - }); - const t = makeMultiTimerWindow(); - const restore = installFakeWindow(t.win); - try { - const manager = new ChannelStarSyncManager("pk-loser", RELAY); - manager.publishStars(makeStore({ a: E(true, 100, 1) })); - await t.fireDelay(2000); // publish OK, but peer blob is what's retained - for (let i = 0; i < 50; i++) await Promise.resolve(); - - assert.ok( - manager.getPendingStarStore() !== null, - "unconfirmed publish keeps the pending edit", - ); - assert.ok( - readChannelStarsOutbox("pk-loser", RELAY), - "loser keeps its durable outbox — OK is not proof of retention", - ); - assert.ok(t.hasDelay(2000), "a retry is scheduled"); - - // The retry re-publishes the max-merge of our click and the peer head; this - // time it is retained (subsumes our `a`) and the outbox clears. - await t.fireDelay(2000); - for (let i = 0; i < 50; i++) await Promise.resolve(); - assert.equal(publishCount, 2, "loser retried"); - assert.equal( - manager.getPendingStarStore(), - null, - "pending cleared once the retained head subsumes our click", - ); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── - -test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("relay timeout")), - ); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelStarSyncManager("pk-fail", RELAY); - const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) })); - assert.equal(result.action, "hold"); - assert.equal(manager.getPendingStarStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - fw.localStorage.setItem( - `buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`, - "1700000000", - ); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelStarSyncManager("pk-stale", RELAY); - const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) })); - assert.equal(result.action, "hold"); - assert.equal(manager.getPendingStarStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelStarSyncManager("pk-fresh", RELAY); - const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) })); - assert.equal(result.action, "hold"); - assert.ok(manager.getPendingStarStore() !== null); - } finally { - restore(); - mock.reset(); - } -}); - -test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => { - const relayA = "wss://a.relay.test"; - const relayB = "wss://b.relay.test"; - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - fw.localStorage.setItem( - `buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayA)}`, - "1700000100", - ); - const restore = installFakeWindow(fw); - try { - const managerB = new ChannelStarSyncManager("pk-iso", relayB); - const result = await managerB.bootstrap(makeStore({ ch1: E(true, 1, 0) })); - assert.equal(result.action, "hold"); - assert.ok( - managerB.getPendingStarStore() !== null, - "first-sync seed on relay B must not be blocked by relay A watermark", - ); - } finally { - restore(); - mock.reset(); - } -}); - -// ─── Failed pre-publish fetch: retain, never publish (Carl P1) ──────────────── - -// The pre-publish fetch THROWS (timeout / auth / socket) — NOT proof that no -// head exists. Publishing the local store here could erase an unseen newer head -// during a transient outage. Fix: `retain` — keep the durable outbox and retry. -// Mutation: reverting the merge-lane catch to `publish` fires publishEvent over -// the unseen head. -test("failed pre-publish fetch retains the pending edit and retries, never publishing", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("socket timeout")), - ); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installEchoTauri("pk-fetchfail"); - try { - const manager = new ChannelStarSyncManager("pk-fetchfail", RELAY); - manager.publishStars(makeStore({ a: E(true, 100, 1) })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - publishCalls.length, - 0, - "must not publish when the pre-publish fetch failed", - ); - assert.ok( - manager.getPendingStarStore() !== null, - "a failed fetch must retain the pending edit", - ); - assert.ok( - readChannelStarsOutbox("pk-fetchfail", RELAY), - "durable outbox must survive a failed fetch", - ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// ─── Unreadable head: retain, never max-merge over it (Carl P1) ─────────────── - -// A pre-publish head event exists but cannot be decrypted (transient keychain -// fault, future NIP-44 scheme, malformed/unsupported payload). A max-merge is -// only safe once both operands were actually read, so publishing the local -// store over an uninspectable head would drop its unread entries. Fix: `retain` -// — keep the durable outbox and retry, never publish. Mutation: reverting the -// `!remote` branch to `publish` fires publishEvent and clobbers the unread head. -test("unreadable head (decrypt failure) retains the pending edit and retries, never publishing", async () => { - // A head event exists but its ciphertext is not registered in the echo map, - // so decrypt rejects → decryptAndParse returns null → retain. - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-undec", - content: "unregistered-cipher", - created_at: 500, - id: "evt-undec", - }, - ]), - ); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installEchoTauri("pk-undec"); - try { - const manager = new ChannelStarSyncManager("pk-undec", RELAY); - manager.publishStars(makeStore({ a: E(true, 100, 1) })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - publishCalls.length, - 0, - "must not publish over a head we could not read", - ); - assert.ok( - manager.getPendingStarStore() !== null, - "unreadable head must retain the pending edit", - ); - assert.ok( - readChannelStarsOutbox("pk-undec", RELAY), - "durable outbox must survive an unreadable head", - ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// A readable head whose decrypted payload carries an unsupported schema version -// (parseStarPayload rejects it → decryptAndParse returns null). Distinct from a -// decrypt fault: the ciphertext decrypts fine, but we still cannot trust the -// contents, so retain rather than max-merge over it. -test("unsupported head payload schema retains the pending edit, never publishing", async () => { - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installEchoTauri("pk-badver"); - // mintHead stringifies the given object as the head plaintext; a future schema - // version decrypts cleanly but parseStarPayload returns null. - const head = tauri.mintHead({ version: 2, channels: {} }, 500); - mock.method(relayClient, "fetchEvents", () => Promise.resolve([head])); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - try { - const manager = new ChannelStarSyncManager("pk-badver", RELAY); - manager.publishStars(makeStore({ a: E(true, 100, 1) })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - publishCalls.length, - 0, - "must not publish over a head whose schema we do not support", - ); - assert.ok( - manager.getPendingStarStore() !== null, - "unsupported schema must retain the pending edit", - ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// ─── Timestamp clamp (Carl P2): a far-future remote head must not push our -// published createdAt past the relay's ±15min future-drift window. -// Mutation test: removing the clamp lets createdAt = lastRemote+1 (~now+3600), -// which exceeds now + MAX_PUBLISH_FUTURE_SECS. -test("timestamp clamp: published createdAt stays inside the relay future window", async () => { - const nowSecs = Math.floor(Date.now() / 1000); - const farFutureHead = nowSecs + 3_600; // 1h ahead — beyond the ±15min window - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installEchoTauri("pk-clamp"); - // A readable far-future head: primes lastRemoteCreatedAt to farFutureHead on - // the priming fetch and is max-merged on the pre-publish fetch. It must be - // decryptable — an unreadable head now retains rather than publishes, so the - // clamp is only exercised via a head we actually read. - const head = tauri.mintHead(makeStore({}), farFutureHead); - mock.method(relayClient, "fetchEvents", () => Promise.resolve([head])); - let signedCreatedAt = null; - mock.method(relayClient, "publishEvent", (evt) => { - signedCreatedAt = evt.created_at; - return Promise.resolve(); - }); - try { - const manager = new ChannelStarSyncManager("pk-clamp", RELAY); - await manager.fetchRemoteStars(); // prime lastRemoteCreatedAt - manager.publishStars(makeStore({ ch1: E(true, 100, 1) })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.ok(signedCreatedAt !== null, "publish must have been attempted"); - assert.ok( - signedCreatedAt <= Math.floor(Date.now() / 1000) + 840, - `createdAt must be clamped inside the future window — got ${signedCreatedAt}`, - ); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } +runMergeLaneSyncSuite({ + label: "stars", + Manager: ChannelStarSyncManager, + readOutbox: readChannelStarsOutbox, + watermarkKind: "channel-stars", + makeEntry: (starred, updatedAt, rev) => ({ starred, updatedAt, rev }), + publish: (m, s) => m.publishStars(s), + getPending: (m) => m.getPendingStarStore(), + fetchRemote: (m) => m.fetchRemoteStars(), }); diff --git a/desktop/src/features/sidebar/lib/mergeLaneSync.shared.test.mjs b/desktop/src/features/sidebar/lib/mergeLaneSync.shared.test.mjs new file mode 100644 index 0000000000..882b1ce476 --- /dev/null +++ b/desktop/src/features/sidebar/lib/mergeLaneSync.shared.test.mjs @@ -0,0 +1,717 @@ +// Shared parameterized test suite for MergeLaneSyncManager subclasses +// (ChannelStarSyncManager, ChannelMuteSyncManager, and any future merge-lane). +// +// Usage: +// import { runMergeLaneSyncSuite } from "./mergeLaneSync.shared.test.mjs"; +// runMergeLaneSyncSuite({ +// label: "stars", +// Manager: ChannelStarSyncManager, +// readOutbox: readChannelStarsOutbox, +// watermarkKind: "channel-stars", +// makeEntry: (v, updatedAt, rev) => ({ starred: v, updatedAt, rev }), +// publish: (m, s) => m.publishStars(s), +// getPending: (m) => m.getPendingStarStore(), +// fetchRemote: (m) => m.fetchRemoteStars(), +// }); + +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { + installFakeWindow, + installEchoTauri, + makeFakeWindow, +} from "./sidebarSyncTestHelpers.mjs"; + +function makeStore(channels = {}) { + return { version: 1, channels }; +} + +// Multi-slot timer fake keyed by delay, for overlapping-publish tests. Mirrors +// the sections suite convention (channelSectionsSync.test.mjs). +function makeMultiTimerWindow() { + const storage = new Map(); + const timers = new Map(); + let nextId = 1; + const win = { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + get length() { + return storage.size; + }, + key: (i) => [...storage.keys()][i] ?? null, + }, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + return { + win, + storage, + timers, + fireDelay: async (ms) => { + const entry = [...timers.entries()].find(([, v]) => v.ms === ms); + assert.ok(entry, `expected a timer scheduled at ${ms}ms`); + timers.delete(entry[0]); + entry[1].fn(); + for (let i = 0; i < 50; i++) await Promise.resolve(); + }, + hasDelay: (ms) => [...timers.values()].some((t) => t.ms === ms), + }; +} + +/** + * Run the full merge-lane invariant suite for a single lane. + * + * @param {object} cfg + * @param {string} cfg.label - Human-readable lane name for test titles. + * @param {Function} cfg.Manager - The MergeLaneSyncManager subclass constructor. + * @param {Function} cfg.readOutbox - readChannel{Stars|Mutes}Outbox(pubkey, relay). + * @param {string} cfg.watermarkKind - The d-tag string (e.g. "channel-stars"). + * @param {Function} cfg.makeEntry - (value, updatedAt, rev) => lane entry object. + * @param {Function} cfg.publish - (manager, store) => void — calls the typed publish method. + * @param {Function} cfg.getPending - (manager) => store | null — calls the typed getter. + * @param {Function} cfg.fetchRemote - (manager) => Promise — calls the typed fetch. + */ +export function runMergeLaneSyncSuite({ + label, + Manager, + readOutbox, + watermarkKind, + makeEntry, + publish, + getPending, + fetchRemote, +}) { + const RELAY = "wss://r.test"; + const RELAY_KEY = encodeURIComponent(RELAY); + + // ─── observe() / high-water ingestion ─────────────────────────────────────── + + test(`${label}: observe: high-water is per-channel max of rev and updatedAt, monotonic`, () => { + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const m = new Manager("pk", RELAY); + m.observe( + makeStore({ a: makeEntry(true, 100, 3), b: makeEntry(false, 50, 1) }), + ); + assert.equal(m.maxRevSeen("a"), 3); + assert.equal(m.maxUpdatedAtSeen("a"), 100); + // A later observation raises each dimension independently; a lower one + // never regresses either. + m.observe(makeStore({ a: makeEntry(true, 90, 5) })); + assert.equal(m.maxRevSeen("a"), 5, "rev raised"); + assert.equal(m.maxUpdatedAtSeen("a"), 100, "updatedAt not regressed"); + m.observe(makeStore({ a: makeEntry(true, 200, 2) })); + assert.equal(m.maxUpdatedAtSeen("a"), 200, "updatedAt raised"); + assert.equal(m.maxRevSeen("a"), 5, "rev not regressed"); + // Unseen channel reports zero on both dimensions. + assert.equal(m.maxRevSeen("never"), 0); + assert.equal(m.maxUpdatedAtSeen("never"), 0); + } finally { + restore(); + } + }); + + // ─── destroy() must cancel pending publish, not flush ─────────────────────── + + test(`${label}: destroy: cancels pending publish without flushing to the relay`, () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new Manager("pk-test", RELAY); + publish(manager, makeStore({ ch1: makeEntry(true, 100, 1) })); + manager.destroy(); + assert.equal(publishCalls.length, 0, "no publish after destroy"); + assert.equal(getPending(manager), null); + } finally { + restore(); + mock.reset(); + } + }); + + test(`${label}: destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves`, async () => { + let releaseFetch = null; + const publishCalls = []; + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new Manager("pk-race", RELAY); + publish(manager, makeStore({ ch1: makeEntry(true, 100, 1) })); + fw._fireTimer(); + manager.destroy(); + releaseFetch(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal( + publishCalls.length, + 0, + "publishEvent must not be called after destroy", + ); + } finally { + restore(); + mock.reset(); + } + }); + + test(`${label}: destroy: is safe to call with no pending publish`, () => { + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new Manager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } + }); + + // ─── Generation CAS: A-in-flight → B-click → A-completes (both variants) ──── + + // Finding 2 (A succeeds): an older in-flight publish that completes after a + // newer edit is queued must NOT clear the newer edit's pending store/outbox, + // and B must reach the relay via the completion re-drive. Mutation: dropping + // the generation CAS in discardPending lets A's success null out B's + // pending+outbox. + test(`${label}: A-in-flight → B-click → A-succeeds: B stays pending and B publishes`, async () => { + let releaseFirst = null; + let publishCount = 0; + let storedHead = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); + mock.method(relayClient, "publishEvent", (event) => { + publishCount++; + if (publishCount === 1) { + // A reaches the relay; hold its ACK open until the test releases it, + // then record it as the retained head. + return new Promise((res) => { + releaseFirst = () => { + storedHead = [event]; + res(); + }; + }); + } + storedHead = [event]; + return Promise.resolve(); + }); + const t = makeMultiTimerWindow(); + const restore = installFakeWindow(t.win); + const tauri = installEchoTauri("pk-ab"); + try { + const manager = new Manager("pk-ab", RELAY); + const storeA = makeStore({ a: makeEntry(true, 100, 1) }); + const storeB = makeStore({ b: makeEntry(true, 101, 1) }); + + publish(manager, storeA); + await t.fireDelay(2000); // doPublish(A) awaits publishEvent + while (releaseFirst === null) await Promise.resolve(); + + // B arrives while A is in flight. + publish(manager, storeB); + assert.deepEqual( + Object.keys(getPending(manager).channels), + ["b"], + "B is now pending", + ); + assert.ok(readOutbox("pk-ab", RELAY), "outbox holds B"); + + // A completes — must NOT clear B. + releaseFirst(); + for (let i = 0; i < 50; i++) await Promise.resolve(); + assert.deepEqual( + Object.keys(getPending(manager)?.channels ?? {}), + ["b"], + "older A completion leaves B pending", + ); + assert.ok( + readOutbox("pk-ab", RELAY), + "older A completion leaves B outbox", + ); + + // B's own debounce fires and B reaches the relay with no kick; the + // post-publish retained-head fetch reads B's own write back and confirms + // subsumption, so B's outbox clears. + const capturedBefore = tauri.capturedPlaintext(); + await t.fireDelay(2000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + const captured = tauri.capturedPlaintext(); + assert.ok( + captured && captured !== capturedBefore && captured.includes('"b"'), + "B is published to the relay", + ); + assert.equal( + getPending(manager), + null, + "B cleared after confirmed publish", + ); + assert.equal(readOutbox("pk-ab", RELAY), null, "B outbox cleared"); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } + }); + + // Finding 2 (A fails): A's publish rejects after B is queued. B must remain + // pending and be published by the serialized re-drive / retry — no manual kick. + test(`${label}: A-in-flight → B-click → A-fails: B remains pending and B publishes`, async () => { + let rejectFirst = null; + let publishCount = 0; + let storedHead = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); + mock.method(relayClient, "publishEvent", (event) => { + publishCount++; + if (publishCount === 1) { + return new Promise((_res, rej) => { + rejectFirst = () => rej(new Error("socket error")); + }); + } + storedHead = [event]; + return Promise.resolve(); + }); + const t = makeMultiTimerWindow(); + const restore = installFakeWindow(t.win); + const tauri = installEchoTauri("pk-abfail"); + try { + const manager = new Manager("pk-abfail", RELAY); + publish(manager, makeStore({ a: makeEntry(true, 100, 1) })); + await t.fireDelay(2000); + while (rejectFirst === null) await Promise.resolve(); + + publish(manager, makeStore({ b: makeEntry(true, 101, 1) })); + rejectFirst(); // A fails + for (let i = 0; i < 50; i++) await Promise.resolve(); + + assert.deepEqual( + Object.keys(getPending(manager)?.channels ?? {}), + ["b"], + "B still pending after A's failure", + ); + assert.ok( + readOutbox("pk-abfail", RELAY), + "B outbox intact after A's failure", + ); + + // B's debounce fires and B publishes successfully; the retained-head fetch + // confirms B's own write and clears it. + await t.fireDelay(2000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + const captured = tauri.capturedPlaintext(); + assert.ok(captured?.includes('"b"'), "B published"); + assert.equal(getPending(manager), null, "B cleared"); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } + }); + + // ─── Bounded-backoff retry: failed publish on a healthy socket, no later edit + + // Finding 2: a transient publish failure with the socket open and NO further + // click must self-heal via the bounded-backoff retry — the pending edit is kept + // and a retry timer is scheduled. Mutation: dropping scheduleRetry leaves the + // edit stranded (Will's "make another change to kick it" symptom). + test(`${label}: failed publish schedules a bounded-backoff retry and keeps the pending edit`, async () => { + let publishCount = 0; + let storedHead = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); + mock.method(relayClient, "publishEvent", (event) => { + publishCount++; + if (publishCount === 1) return Promise.reject(new Error("timeout")); + storedHead = [event]; + return Promise.resolve(); + }); + const t = makeMultiTimerWindow(); + const restore = installFakeWindow(t.win); + const tauri = installEchoTauri("pk-retry"); + try { + const manager = new Manager("pk-retry", RELAY); + publish(manager, makeStore({ a: makeEntry(true, 100, 1) })); + await t.fireDelay(2000); // debounce → doPublish → publishEvent rejects + assert.ok( + getPending(manager) !== null, + "pending edit retained after failure", + ); + assert.ok( + t.hasDelay(2000), + "a retry timer at RETRY_BASE_MS is scheduled", + ); + + // The retry fires and the second publish succeeds; the retained-head fetch + // confirms the write → pending cleared. + await t.fireDelay(2000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + assert.equal(publishCount, 2, "retry re-published"); + assert.equal( + getPending(manager), + null, + "pending cleared on retry success", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } + }); + + // ─── Retention confirmation (Carl P1): OK is not proof of retention ────────── + + // The relay OKs a superseded NIP-33 write as a no-op, so two windows racing + // distinct blobs from the same head can both get OK while only one is retained. + // The loser must NOT clear its durable outbox on OK alone: it fetches the + // authoritative retained head and clears only when that head subsumes its + // write. A retained head carrying a peer's distinct blob does not subsume the + // loser's click, so the outbox is kept and a retry is scheduled. + // Mutation: clearing on OK alone (dropping confirmRetainedHeadSubsumes) would + // null the pending edit here and lose the click. + test(`${label}: publish OK but a peer blob is retained: loser keeps its outbox and retries`, async () => { + let publishCount = 0; + // The retained head is a peer window's distinct blob from the same base — + // it does NOT contain our channel `a`, so it cannot subsume our write. + let storedHead = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); + const tauri = installEchoTauri("pk-loser"); + mock.method(relayClient, "publishEvent", (event) => { + publishCount++; + if (publishCount === 1) { + // Our write is OK'd by the relay but immediately superseded: the retained + // head is the peer's blob for a different channel, minted through the + // same echo seam so it decrypts. + storedHead = [ + tauri.mintHead(makeStore({ z: makeEntry(true, 200, 5) }), 100), + ]; + return Promise.resolve(); + } + // The retry (which max-merges the peer head in) is retained. + storedHead = [event]; + return Promise.resolve(); + }); + const t = makeMultiTimerWindow(); + const restore = installFakeWindow(t.win); + try { + const manager = new Manager("pk-loser", RELAY); + publish(manager, makeStore({ a: makeEntry(true, 100, 1) })); + await t.fireDelay(2000); // publish OK, but peer blob is what's retained + for (let i = 0; i < 50; i++) await Promise.resolve(); + + assert.ok( + getPending(manager) !== null, + "unconfirmed publish keeps the pending edit", + ); + assert.ok( + readOutbox("pk-loser", RELAY), + "loser keeps its durable outbox — OK is not proof of retention", + ); + assert.ok(t.hasDelay(2000), "a retry is scheduled"); + + // The retry re-publishes the max-merge of our click and the peer head; this + // time it is retained (subsumes our `a`) and the outbox clears. + await t.fireDelay(2000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + assert.equal(publishCount, 2, "loser retried"); + assert.equal( + getPending(manager), + null, + "pending cleared once the retained head subsumes our click", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } + }); + + // ─── Boot seed-publish guard (the revert-fix regression suite) ─────────────── + + test(`${label}: revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap`, async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new Manager("pk-fail", RELAY); + const result = await manager.bootstrap( + makeStore({ ch1: makeEntry(true, 1, 0) }), + ); + assert.equal(result.action, "hold"); + assert.equal(getPending(manager), null); + } finally { + restore(); + mock.reset(); + } + }); + + test(`${label}: revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap`, async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:${watermarkKind}:pk-stale:${RELAY_KEY}`, + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new Manager("pk-stale", RELAY); + const result = await manager.bootstrap( + makeStore({ ch1: makeEntry(true, 1, 0) }), + ); + assert.equal(result.action, "hold"); + assert.equal(getPending(manager), null); + } finally { + restore(); + mock.reset(); + } + }); + + test(`${label}: revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)`, async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new Manager("pk-fresh", RELAY); + const result = await manager.bootstrap( + makeStore({ ch1: makeEntry(true, 1, 0) }), + ); + assert.equal(result.action, "hold"); + assert.ok(getPending(manager) !== null); + } finally { + restore(); + mock.reset(); + } + }); + + test(`${label}: revert-fix: relay-A watermark does not suppress first-sync seed on relay-B`, async () => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:${watermarkKind}:pk-iso:${encodeURIComponent(relayA)}`, + "1700000100", + ); + const restore = installFakeWindow(fw); + try { + const managerB = new Manager("pk-iso", relayB); + const result = await managerB.bootstrap( + makeStore({ ch1: makeEntry(true, 1, 0) }), + ); + assert.equal(result.action, "hold"); + assert.ok( + getPending(managerB) !== null, + `first-sync seed on relay B must not be blocked by relay A watermark`, + ); + } finally { + restore(); + mock.reset(); + } + }); + + // ─── Failed pre-publish fetch: retain, never publish (Carl P1) ─────────────── + + // The pre-publish fetch THROWS (timeout / auth / socket) — NOT proof that no + // head exists. Publishing the local store here could erase an unseen newer head + // during a transient outage. Fix: `retain` — keep the durable outbox and retry. + // Mutation: reverting the merge-lane catch to `publish` fires publishEvent over + // the unseen head. + test(`${label}: failed pre-publish fetch retains the pending edit and retries, never publishing`, async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("socket timeout")), + ); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installEchoTauri("pk-fetchfail"); + try { + const manager = new Manager("pk-fetchfail", RELAY); + publish(manager, makeStore({ a: makeEntry(true, 100, 1) })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal( + publishCalls.length, + 0, + "must not publish when the pre-publish fetch failed", + ); + assert.ok( + getPending(manager) !== null, + "a failed fetch must retain the pending edit", + ); + assert.ok( + readOutbox("pk-fetchfail", RELAY), + "durable outbox must survive a failed fetch", + ); + assert.ok(fw._hasTimer(), "a retry must be scheduled"); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } + }); + + // ─── Unreadable head: retain, never max-merge over it (Carl P1) ────────────── + + // A pre-publish head event exists but cannot be decrypted (transient keychain + // fault, future NIP-44 scheme, malformed/unsupported payload). A max-merge is + // only safe once both operands were actually read, so publishing the local + // store over an uninspectable head would drop its unread entries. Fix: `retain` + // — keep the durable outbox and retry, never publish. Mutation: reverting the + // `!remote` branch to `publish` fires publishEvent and clobbers the unread head. + test(`${label}: unreadable head (decrypt failure) retains the pending edit and retries, never publishing`, async () => { + // A head event exists but its ciphertext is not registered in the echo map, + // so decrypt rejects → decryptAndParse returns null → retain. + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-undec", + content: "unregistered-cipher", + created_at: 500, + id: "evt-undec", + }, + ]), + ); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installEchoTauri("pk-undec"); + try { + const manager = new Manager("pk-undec", RELAY); + publish(manager, makeStore({ a: makeEntry(true, 100, 1) })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal( + publishCalls.length, + 0, + "must not publish over a head we could not read", + ); + assert.ok( + getPending(manager) !== null, + "unreadable head must retain the pending edit", + ); + assert.ok( + readOutbox("pk-undec", RELAY), + "durable outbox must survive an unreadable head", + ); + assert.ok(fw._hasTimer(), "a retry must be scheduled"); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } + }); + + // A readable head whose decrypted payload carries an unsupported schema version + // (the lane parser rejects it → decryptAndParse returns null). Distinct from a + // decrypt fault: the ciphertext decrypts fine, but we still cannot trust the + // contents, so retain rather than max-merge over it. + test(`${label}: unsupported head payload schema retains the pending edit, never publishing`, async () => { + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installEchoTauri("pk-badver"); + // mintHead stringifies the given object as the head plaintext; a future schema + // version decrypts cleanly but the lane parser returns null. + const head = tauri.mintHead({ version: 2, channels: {} }, 500); + mock.method(relayClient, "fetchEvents", () => Promise.resolve([head])); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + try { + const manager = new Manager("pk-badver", RELAY); + publish(manager, makeStore({ a: makeEntry(true, 100, 1) })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal( + publishCalls.length, + 0, + "must not publish over a head whose schema we do not support", + ); + assert.ok( + getPending(manager) !== null, + "unsupported schema must retain the pending edit", + ); + assert.ok(fw._hasTimer(), "a retry must be scheduled"); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } + }); + + // ─── Timestamp clamp (Carl P2): a far-future remote head must not push our + // published createdAt past the relay's ±15min future-drift window. + // Mutation test: removing the clamp lets createdAt = lastRemote+1 (~now+3600), + // which exceeds now + MAX_PUBLISH_FUTURE_SECS. + test(`${label}: timestamp clamp: published createdAt stays inside the relay future window`, async () => { + const nowSecs = Math.floor(Date.now() / 1000); + const farFutureHead = nowSecs + 3_600; // 1h ahead — beyond the ±15min window + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installEchoTauri("pk-clamp"); + // A readable far-future head: primes lastRemoteCreatedAt to farFutureHead on + // the priming fetch and is max-merged on the pre-publish fetch. It must be + // decryptable — an unreadable head now retains rather than publishes, so the + // clamp is only exercised via a head we actually read. + const head = tauri.mintHead(makeStore({}), farFutureHead); + mock.method(relayClient, "fetchEvents", () => Promise.resolve([head])); + let signedCreatedAt = null; + mock.method(relayClient, "publishEvent", (evt) => { + signedCreatedAt = evt.created_at; + return Promise.resolve(); + }); + try { + const manager = new Manager("pk-clamp", RELAY); + await fetchRemote(manager); // prime lastRemoteCreatedAt + publish(manager, makeStore({ ch1: makeEntry(true, 100, 1) })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.ok(signedCreatedAt !== null, "publish must have been attempted"); + assert.ok( + signedCreatedAt <= Math.floor(Date.now() / 1000) + 840, + `createdAt must be clamped inside the future window — got ${signedCreatedAt}`, + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } + }); +}