From 85ca62c15bd15a40231807794a794ad46574f8e9 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 6 Aug 2026 13:26:44 -0400 Subject: [PATCH 01/13] fix(desktop): prevent sidebar prefs from reverting on stale-localStorage boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four sidebar-preference sync managers (sections, sort, stars, mutes) collapsed five distinct fetch outcomes into a single null, causing the boot effect to seed-publish stale localStorage on any failed or empty response. A dev build with old localStorage but the same key + relay would re-sign those stale prefs as fresh, and the DMG's live subscription applied them. Two invariants now applied to all four managers: 1. Tri-state fetch result (found / absent / failed) — decrypt/parse failure on an existing event reports failed, not absent, and records the event's created_at so seed-publish is blocked even for unreadable blobs. 2. Persisted head watermark (sidebarSyncWatermark.ts, key scoped by pubkey + relay + blob type, stored in localStorage) — hydrated in the manager constructor so a session that has ever seen a remote blob refuses to seed-publish on subsequent boots, even when the fetch returns empty (auth-race, timeout, reconnect). Seed logic unchanged for genuine first-time sync: absent fetch + zero watermark + non-empty local state still seed-publishes. Existing destroy() flush-on-destroy behaviour in channelStarsSync and channelMutesSync aligned with channelSectionsSync/channelSortSync (cancel + destroyed flag, no flush) to prevent cross-relay publish when the community switches mid-flight. Tests: 47 new passing tests across the five affected modules (five required regression scenarios × four managers, plus watermark unit tests). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../sidebar/lib/channelMutesSync.test.mjs | 276 ++++++++++++++++ .../features/sidebar/lib/channelMutesSync.ts | 89 +++-- .../sidebar/lib/channelSectionsSync.test.mjs | 305 ++++++++++++++++++ .../sidebar/lib/channelSectionsSync.ts | 74 +++-- .../sidebar/lib/channelSortSync.test.mjs | 205 ++++++++++++ .../features/sidebar/lib/channelSortSync.ts | 63 ++-- .../sidebar/lib/channelStarsSync.test.mjs | 295 +++++++++++++++++ .../features/sidebar/lib/channelStarsSync.ts | 89 +++-- .../sidebar/lib/sidebarSyncWatermark.test.mjs | 118 +++++++ .../sidebar/lib/sidebarSyncWatermark.ts | 85 +++++ .../features/sidebar/lib/useChannelMutes.ts | 26 +- .../sidebar/lib/useChannelSections.ts | 37 ++- .../sidebar/lib/useChannelSortPreference.ts | 28 +- .../features/sidebar/lib/useChannelStars.ts | 26 +- 14 files changed, 1564 insertions(+), 152 deletions(-) create mode 100644 desktop/src/features/sidebar/lib/channelMutesSync.test.mjs create mode 100644 desktop/src/features/sidebar/lib/channelStarsSync.test.mjs create mode 100644 desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs create mode 100644 desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs new file mode 100644 index 00000000000..3cebd541486 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs @@ -0,0 +1,276 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { ChannelMuteSyncManager } from "./channelMutesSync.ts"; + +function makeStore(channels = {}) { + return { version: 1, channels }; +} + +function makeFakeWindow() { + const storage = new Map(); + const ls = { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + clear: () => storage.clear(), + }; + let timerCallback = null; + let nextTimerId = 100; + const fw = { + localStorage: ls, + setTimeout: (fn, _ms) => { + timerCallback = fn; + return nextTimerId++; + }, + clearTimeout: (_id) => { + timerCallback = null; + }, + _fireTimer: () => { + if (timerCallback) { + const fn = timerCallback; + timerCallback = null; + fn(); + } + }, + }; + return fw; +} + +function installFakeWindow(fw) { + if (typeof globalThis.window === "undefined") globalThis.window = {}; + const origLs = globalThis.window.localStorage; + const origSt = globalThis.window.setTimeout; + const origCt = globalThis.window.clearTimeout; + globalThis.window.localStorage = fw.localStorage; + globalThis.window.setTimeout = fw.setTimeout; + globalThis.window.clearTimeout = fw.clearTimeout; + return () => { + if (origLs !== undefined) globalThis.window.localStorage = origLs; + if (origSt !== undefined) globalThis.window.setTimeout = origSt; + if (origCt !== undefined) globalThis.window.clearTimeout = origCt; + }; +} + +// ─── 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"); + const store = makeStore({ ch1: { muted: true, updatedAt: 100 } }); + manager.publishMutes(store); + manager.destroy(); + assert.equal(publishCalls.length, 0); + 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", () => { + return new Promise((resolve) => { + releaseFetch = () => 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-race"); + const store = makeStore({ ch1: { muted: true, updatedAt: 100 } }); + manager.publishMutes(store); + fw._fireTimer(); + manager.destroy(); + releaseFetch(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(publishCalls.length, 0); + } 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"); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } +}); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── + +// 1. fetch failed → zero publish calls +test("revert-fix: fetch failed (error) does not trigger seed-publish", async () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-fail"); + const result = await manager.fetchRemoteMutes(); + assert.equal(result.status, "failed"); + assert.equal(publishCalls.length, 0); + } finally { + restore(); + mock.reset(); + } +}); + +// 1b. undecryptable event → failed + head recorded +test("revert-fix: undecryptable event yields failed with createdAt and advances watermark", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { pubkey: "pk-dc", content: "!bad!", created_at: 1700000099, id: "e1" }, + ]), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-dc"); + const result = await manager.fetchRemoteMutes(); + assert.equal(result.status, "failed"); + assert.equal(result.createdAt, 1700000099); + assert.ok(manager.getPersistedWatermark() > 0); + } finally { + restore(); + mock.reset(); + } +}); + +// 2. absent + persisted head > 0 → no seed-publish +test("revert-fix: absent fetch with prior watermark blocks seed-publish", async () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + "buzz-sync-watermark.v1:channel-mutes:pk-stale", + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-stale"); + assert.ok(manager.getPersistedWatermark() > 0); + const result = await manager.fetchRemoteMutes(); + assert.equal(result.status, "absent"); + if (result.status === "absent" && manager.getPersistedWatermark() === 0) { + manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 1 } })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 0)); + } + assert.equal(publishCalls.length, 0); + } finally { + restore(); + mock.reset(); + } +}); + +// 3. absent + head 0 → seed allowed +test("revert-fix: absent fetch with zero watermark allows seed-publish", 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"); + assert.equal(manager.getPersistedWatermark(), 0); + const result = await manager.fetchRemoteMutes(); + assert.equal(result.status, "absent"); + assert.ok( + result.status === "absent" && manager.getPersistedWatermark() === 0, + "seed condition must hold for a fresh manager", + ); + } finally { + restore(); + mock.reset(); + } +}); + +// 4. decrypt failure records head +test("revert-fix: decrypt failure records head and blocks future seed", async () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-nd", + content: "!!invalid!!", + created_at: 1700000777, + id: "evt-nd", + }, + ]), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-nd"); + const result = await manager.fetchRemoteMutes(); + assert.equal(result.status, "failed"); + assert.equal(result.createdAt, 1700000777); + assert.ok(manager.getPersistedWatermark() >= 1700000777); + assert.equal(publishCalls.length, 0); + } finally { + restore(); + mock.reset(); + } +}); + +// 5. watermark round-trips across manager instances +test("revert-fix: watermark persists across manager instances (simulated restart)", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-restart", + content: "!bad!", + created_at: 1700001234, + id: "evt-r", + }, + ]), + ); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const managerA = new ChannelMuteSyncManager("pk-restart"); + await managerA.fetchRemoteMutes(); + assert.ok(managerA.getPersistedWatermark() >= 1700001234); + mock.restoreAll(); + const managerB = new ChannelMuteSyncManager("pk-restart"); + assert.ok(managerB.getPersistedWatermark() >= 1700001234); + } finally { + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.ts b/desktop/src/features/sidebar/lib/channelMutesSync.ts index 0a0d2bb9f63..9dc3a598534 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.ts +++ b/desktop/src/features/sidebar/lib/channelMutesSync.ts @@ -11,8 +11,14 @@ import { parseMutePayload, type ChannelMuteStore, } from "./channelMutesStorage"; +import { + advanceWatermark, + readWatermark, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-mutes"; +const BLOB_TYPE = "channel-mutes"; const DEBOUNCE_MS = 2_000; export type RemoteMutes = { @@ -34,16 +40,20 @@ async function decryptAndParse(event: RelayEvent): Promise { export class ChannelMuteSyncManager { private pubkey: string; + private relayUrl: string | undefined; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelMuteStore | null = null; private lastPublishedStore: ChannelMuteStore | null = null; + private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl?: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteMutes(): Promise { + async fetchRemoteMutes(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_MUTES], @@ -51,19 +61,35 @@ export class ChannelMuteSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0) return null; - if (events[0].pubkey !== this.pubkey) return null; - const result = await decryptAndParse(events[0]); - if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; + } + } + + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; } + advanceWatermark(this.pubkey, BLOB_TYPE, createdAt, this.relayUrl); + } + + getPersistedWatermark(): number { + return this.lastRemoteCreatedAt; } cancelPendingMutePublish(): void { @@ -101,10 +127,7 @@ export class ChannelMuteSyncManager { if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; const remote = await decryptAndParse(events[0]); if (!remote) return store; - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - remote.createdAt, - ); + this.recordRemoteHead(remote.createdAt); return mergeStores(store, remote.store); } catch { return store; @@ -132,6 +155,10 @@ export class ChannelMuteSyncManager { private async doPublish(store: ChannelMuteStore): Promise { try { const merged = await this.fetchOwnBlobBeforePublish(store); + // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish + // was awaited (community switch during in-flight fetch). If so, abort + // before touching the relay. + if (this.destroyed) return; if (this.isIdenticalToLastPublished(merged)) { this.pendingStore = null; return; @@ -154,15 +181,13 @@ export class ChannelMuteSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); + if (this.destroyed) return; await relayClient.publishEvent( event, "Timed out publishing channel mutes.", "Failed to publish channel mutes.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -184,10 +209,7 @@ export class ChannelMuteSyncManager { if (event.pubkey !== this.pubkey) return; void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + this.recordRemoteHead(result.createdAt); onUpdate(result); } }); @@ -196,13 +218,14 @@ export class ChannelMuteSyncManager { } destroy(): void { - if (this.debounceTimer !== null && this.pendingStore !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - void this.doPublish(this.pendingStore); - } else if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } + // Cancel any pending publish and mark this manager as destroyed so any + // in-flight doPublish() calls abort before reaching relayClient. The + // scoped localStorage write is already durable; when the user returns to + // this relay the existing seed-publish guard will re-publish from local + // state. Flushing here would race against community switching and could + // publish relay A's mutes to relay B via the shared relayClient singleton. + this.destroyed = true; + this.cancelPendingMutePublish(); + this.pendingStore = null; } } diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 5dad6c86735..b3b519f87b6 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -208,3 +208,308 @@ test("destroy: cancelPendingPublish clears pendingStore", () => { globalThis.window.clearTimeout = origClear; } }); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── + +// Helper: build a minimal fake window with controllable localStorage and timers. +function makeFakeWindow() { + const storage = new Map(); + const ls = { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + clear: () => storage.clear(), + }; + let timerCallback = null; + let nextTimerId = 100; + const fakeWindow = { + localStorage: ls, + setTimeout: (fn, _ms) => { + timerCallback = fn; + return nextTimerId++; + }, + clearTimeout: (_id) => { + timerCallback = null; + }, + _fireTimer: () => { + if (timerCallback) { + const fn = timerCallback; + timerCallback = null; + fn(); + } + }, + }; + return fakeWindow; +} + +function installFakeWindow(fw) { + const orig = {}; + for (const key of ["localStorage", "setTimeout", "clearTimeout"]) { + orig[key] = globalThis.window?.[key]; + } + if (typeof globalThis.window === "undefined") globalThis.window = {}; + globalThis.window.localStorage = fw.localStorage; + globalThis.window.setTimeout = fw.setTimeout; + globalThis.window.clearTimeout = fw.clearTimeout; + return () => { + for (const key of ["localStorage", "setTimeout", "clearTimeout"]) { + if (orig[key] !== undefined) { + globalThis.window[key] = orig[key]; + } + } + }; +} + +function makeSectionsStore(sections = []) { + return { + version: 1, + sections, + assignments: {}, + }; +} + +// 1. fetch failed (error/timeout) + local non-empty → zero publish calls +test("revert-fix: fetch failed (error) does not trigger seed-publish", async () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-fail", "wss://r.test"); + const result = await manager.fetchRemoteSections(); + assert.equal( + result.status, + "failed", + "fetch error must yield status=failed", + ); + assert.equal(publishCalls.length, 0, "no publish after failed fetch"); + } finally { + restore(); + mock.reset(); + } +}); + +// 1b. fetch failed (event exists but won't decrypt) → failed, head recorded +test("revert-fix: undecryptable event yields failed with createdAt set", async () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-decrypt", + content: "bad-cipher", + created_at: 1700000099, + id: "evt-bad", + }, + ]), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-decrypt", "wss://r.test"); + const result = await manager.fetchRemoteSections(); + assert.equal(result.status, "failed"); + assert.equal( + result.createdAt, + 1700000099, + "createdAt must be recorded from the unreadable event", + ); + // Manager must have recorded the head watermark so seed-publish is blocked. + assert.ok( + manager.getPersistedWatermark() > 0, + "watermark must be > 0 after seeing an undecryptable event", + ); + assert.equal(publishCalls.length, 0); + } finally { + restore(); + mock.reset(); + } +}); + +// 2. fetch absent + persisted head > 0 → zero publish calls (the dev-build stale-copy case) +test("revert-fix: absent fetch with prior watermark blocks seed-publish", async () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + + const fw = makeFakeWindow(); + // Pre-seed a watermark (simulates a prior session that had seen a blob). + fw.localStorage.setItem( + "buzz-sync-watermark.v1:channel-sections:pk-stale:wss%3A%2F%2Fr.test", + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-stale", "wss://r.test"); + assert.ok( + manager.getPersistedWatermark() > 0, + "manager must read watermark from localStorage at construction", + ); + const result = await manager.fetchRemoteSections(); + assert.equal(result.status, "absent"); + + // Simulate the hook: absent + watermark > 0 → must NOT publish. + if (result.status === "absent" && manager.getPersistedWatermark() === 0) { + const local = makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]); + manager.publishSections(local); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 0)); + } + + assert.equal( + publishCalls.length, + 0, + "watermark > 0 must block seed-publish even on absent fetch", + ); + } finally { + restore(); + mock.reset(); + } +}); + +// 3. fetch absent + head 0 + local non-empty → seed-publish fires (first-sync preserved) +test("revert-fix: absent fetch with zero watermark allows seed-publish", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + + const fw = makeFakeWindow(); + // No watermark in storage — simulates genuine first-time user. + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-fresh", "wss://r.test"); + assert.equal( + manager.getPersistedWatermark(), + 0, + "watermark must start at 0", + ); + const result = await manager.fetchRemoteSections(); + assert.equal(result.status, "absent"); + + // Simulate the hook's seed logic. + if (result.status === "absent" && manager.getPersistedWatermark() === 0) { + const local = makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]); + manager.publishSections(local); + // pendingStore is set immediately on publishSections — verify before the + // debounce fires so we know the seed path was entered. + assert.ok( + manager.getPendingStore() !== null, + "publishSections must be called when absent + watermark == 0", + ); + } else { + assert.fail("seed condition must hold for a fresh manager"); + } + } finally { + restore(); + mock.reset(); + } +}); + +// 4. existing event that fails decrypt → no seed, head recorded from event.created_at +test("revert-fix: decrypt failure records head and blocks any future seed-publish", async () => { + const publishCalls = []; + // First call: return an event with bad ciphertext. + // Second call (fetchOwnBlobBeforePublish, if seed runs): return empty. + let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { + callCount++; + if (callCount === 1) { + return Promise.resolve([ + { + pubkey: "pk-nodecrypt", + content: "!!invalid-base64!!", + created_at: 1700000777, + id: "evt-nodecrypt", + }, + ]); + } + return Promise.resolve([]); + }); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager( + "pk-nodecrypt", + "wss://r.test", + ); + const result = await manager.fetchRemoteSections(); + assert.equal(result.status, "failed"); + assert.equal( + result.createdAt, + 1700000777, + "head must be captured from the failed event", + ); + assert.ok( + manager.getPersistedWatermark() >= 1700000777, + "watermark must be advanced to event.created_at", + ); + // Simulate the hook: failed → no seed. + // (Absent with watermark > 0 also blocks seeding — belt-and-suspenders.) + assert.equal(publishCalls.length, 0, "no publish after decrypt failure"); + } finally { + restore(); + mock.reset(); + } +}); + +// 5. watermark round-trips across manager instances (simulated restart) +test("revert-fix: watermark persists and is read by a new manager instance", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-restart", + content: "bad-cipher", + created_at: 1700001234, + id: "evt-restart", + }, + ]), + ); + + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + // Session A: manager sees an event → watermark written to localStorage. + const managerA = new ChannelSectionSyncManager( + "pk-restart", + "wss://r.test", + ); + await managerA.fetchRemoteSections(); + assert.ok( + managerA.getPersistedWatermark() >= 1700001234, + "session A watermark must be set", + ); + + // Session B: new manager instance reads the same localStorage. + // fetchEvents is not called again; we only test constructor hydration. + mock.restoreAll(); + const managerB = new ChannelSectionSyncManager( + "pk-restart", + "wss://r.test", + ); + assert.ok( + managerB.getPersistedWatermark() >= 1700001234, + "session B must inherit watermark from localStorage without another fetch", + ); + } finally { + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index 70930c26f6c..34b4684ff6f 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -11,8 +11,14 @@ import { type ChannelSection, type ChannelSectionStore, } from "./channelSectionsStorage"; +import { + advanceWatermark, + readWatermark, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-sections"; +const BLOB_TYPE = "channel-sections"; const DEBOUNCE_MS = 2_000; export type RemoteSections = { @@ -36,17 +42,22 @@ async function decryptAndParse( export class ChannelSectionSyncManager { private pubkey: string; + private relayUrl: string | undefined; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelSectionStore | null = null; private lastPublishedStore: ChannelSectionStore | null = null; private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl?: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + // Hydrate from localStorage so we never seed-publish if a remote blob has + // been seen in a prior session. + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteSections(): Promise { + async fetchRemoteSections(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_SECTIONS], @@ -54,21 +65,46 @@ export class ChannelSectionSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0) return null; - if (events[0].pubkey !== this.pubkey) return null; - const result = await decryptAndParse(events[0]); - if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + // An event exists — record its created_at regardless of whether we can + // decrypt it, so seed-publish is blocked even when the payload is + // unreadable (e.g. wrong key). + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; } } + /** Update in-memory + persisted watermark. */ + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; + } + advanceWatermark(this.pubkey, BLOB_TYPE, createdAt, this.relayUrl); + } + + /** + * Returns the persisted watermark as read at construction time. A non-zero + * value means this manager has seen a remote blob in a prior session, so + * seed-publish must be skipped even when a boot fetch returns `absent`. + */ + getPersistedWatermark(): number { + return this.lastRemoteCreatedAt; + } + cancelPendingPublish(): void { if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); @@ -106,7 +142,7 @@ export class ChannelSectionSyncManager { if (!remote) return store; // Sections use whole-blob LWW: take whichever is newer if (remote.createdAt > this.lastRemoteCreatedAt) { - this.lastRemoteCreatedAt = remote.createdAt; + this.recordRemoteHead(remote.createdAt); return remote.store; } return store; @@ -181,10 +217,7 @@ export class ChannelSectionSyncManager { "Timed out publishing channel sections.", "Failed to publish channel sections.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -206,10 +239,7 @@ export class ChannelSectionSyncManager { if (event.pubkey !== this.pubkey) return; void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + this.recordRemoteHead(result.createdAt); onUpdate(result); } }); diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index 76bf57b6c5e..729ea1bc79e 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -174,3 +174,208 @@ test("destroy: cancelPendingPublish clears pendingStore", () => { globalThis.window.clearTimeout = origClear; } }); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── + +function makeFakeWindow() { + const storage = new Map(); + const ls = { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + clear: () => storage.clear(), + }; + let timerCallback = null; + let nextTimerId = 100; + const fw = { + localStorage: ls, + setTimeout: (fn, _ms) => { + timerCallback = fn; + return nextTimerId++; + }, + clearTimeout: (_id) => { + timerCallback = null; + }, + _fireTimer: () => { + if (timerCallback) { + const fn = timerCallback; + timerCallback = null; + fn(); + } + }, + }; + return fw; +} + +function installFakeWindow(fw) { + if (typeof globalThis.window === "undefined") globalThis.window = {}; + const origLs = globalThis.window.localStorage; + const origSt = globalThis.window.setTimeout; + const origCt = globalThis.window.clearTimeout; + globalThis.window.localStorage = fw.localStorage; + globalThis.window.setTimeout = fw.setTimeout; + globalThis.window.clearTimeout = fw.clearTimeout; + return () => { + if (origLs !== undefined) globalThis.window.localStorage = origLs; + if (origSt !== undefined) globalThis.window.setTimeout = origSt; + if (origCt !== undefined) globalThis.window.clearTimeout = origCt; + }; +} + +// 1. fetch failed → zero publish calls +test("revert-fix: fetch failed (error) does not trigger seed-publish", async () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-fail", "wss://r.test"); + const result = await manager.fetchRemoteSortPrefs(); + assert.equal(result.status, "failed"); + assert.equal(publishCalls.length, 0); + } finally { + restore(); + mock.reset(); + } +}); + +// 1b. undecryptable event → failed + head recorded +test("revert-fix: undecryptable event yields failed with createdAt and advances watermark", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { pubkey: "pk-dc", content: "!bad!", created_at: 1700000099, id: "e1" }, + ]), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-dc", "wss://r.test"); + const result = await manager.fetchRemoteSortPrefs(); + assert.equal(result.status, "failed"); + assert.equal(result.createdAt, 1700000099); + assert.ok(manager.getPersistedWatermark() > 0); + } finally { + restore(); + mock.reset(); + } +}); + +// 2. absent + persisted head > 0 → no seed-publish +test("revert-fix: absent fetch with prior watermark blocks seed-publish", async () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + "buzz-sync-watermark.v1:channel-sort:pk-stale:wss%3A%2F%2Fr.test", + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-stale", "wss://r.test"); + assert.ok(manager.getPersistedWatermark() > 0); + const result = await manager.fetchRemoteSortPrefs(); + assert.equal(result.status, "absent"); + // Gate: absent AND watermark > 0 → no seed. + if (result.status === "absent" && manager.getPersistedWatermark() === 0) { + manager.publishSortPrefs(makeStore({ channels: "recent" })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 0)); + } + assert.equal(publishCalls.length, 0); + } finally { + restore(); + mock.reset(); + } +}); + +// 3. absent + head 0 + local non-empty → seed allowed +test("revert-fix: absent fetch with zero watermark allows seed-publish", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-fresh", "wss://r.test"); + assert.equal(manager.getPersistedWatermark(), 0); + const result = await manager.fetchRemoteSortPrefs(); + assert.equal(result.status, "absent"); + // The gate condition is what matters for mutation-sensitivity. + assert.ok( + result.status === "absent" && manager.getPersistedWatermark() === 0, + "seed condition must hold for a fresh manager", + ); + } finally { + restore(); + mock.reset(); + } +}); + +// 4. decrypt failure records head +test("revert-fix: decrypt failure records head and blocks future seed", async () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-nd", + content: "!!invalid!!", + created_at: 1700000777, + id: "evt-nd", + }, + ]), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-nd", "wss://r.test"); + const result = await manager.fetchRemoteSortPrefs(); + assert.equal(result.status, "failed"); + assert.equal(result.createdAt, 1700000777); + assert.ok(manager.getPersistedWatermark() >= 1700000777); + assert.equal(publishCalls.length, 0); + } finally { + restore(); + mock.reset(); + } +}); + +// 5. watermark round-trips across manager instances +test("revert-fix: watermark persists across manager instances (simulated restart)", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-restart", + content: "!bad!", + created_at: 1700001234, + id: "evt-r", + }, + ]), + ); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const managerA = new ChannelSortSyncManager("pk-restart", "wss://r.test"); + await managerA.fetchRemoteSortPrefs(); + assert.ok(managerA.getPersistedWatermark() >= 1700001234); + mock.restoreAll(); + const managerB = new ChannelSortSyncManager("pk-restart", "wss://r.test"); + assert.ok(managerB.getPersistedWatermark() >= 1700001234); + } finally { + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelSortSync.ts b/desktop/src/features/sidebar/lib/channelSortSync.ts index e23387368d1..f9fbee17a34 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.ts +++ b/desktop/src/features/sidebar/lib/channelSortSync.ts @@ -10,8 +10,14 @@ import { parseChannelSortPayload, type ChannelSortStore, } from "./channelSortPreference"; +import { + advanceWatermark, + readWatermark, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-sort"; +const BLOB_TYPE = "channel-sort"; const DEBOUNCE_MS = 2_000; export type RemoteSortPrefs = { @@ -44,17 +50,20 @@ async function decryptAndParse( */ export class ChannelSortSyncManager { private pubkey: string; + private relayUrl: string | undefined; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelSortStore | null = null; private lastPublishedStore: ChannelSortStore | null = null; private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl?: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteSortPrefs(): Promise { + async fetchRemoteSortPrefs(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_SORT], @@ -62,21 +71,37 @@ export class ChannelSortSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0) return null; - if (events[0].pubkey !== this.pubkey) return null; - const result = await decryptAndParse(events[0]); - if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; } } + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; + } + advanceWatermark(this.pubkey, BLOB_TYPE, createdAt, this.relayUrl); + } + + getPersistedWatermark(): number { + return this.lastRemoteCreatedAt; + } + cancelPendingPublish(): void { if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); @@ -114,7 +139,7 @@ export class ChannelSortSyncManager { if (!remote) return store; // Sort prefs use whole-blob LWW: take whichever is newer if (remote.createdAt > this.lastRemoteCreatedAt) { - this.lastRemoteCreatedAt = remote.createdAt; + this.recordRemoteHead(remote.createdAt); return remote.store; } return store; @@ -174,10 +199,7 @@ export class ChannelSortSyncManager { "Timed out publishing channel sort preferences.", "Failed to publish channel sort preferences.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -199,10 +221,7 @@ export class ChannelSortSyncManager { if (event.pubkey !== this.pubkey) return; void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + this.recordRemoteHead(result.createdAt); onUpdate(result); } }); diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs new file mode 100644 index 00000000000..1927a15b549 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs @@ -0,0 +1,295 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { ChannelStarSyncManager } from "./channelStarsSync.ts"; + +function makeStore(channels = {}) { + return { version: 1, channels }; +} + +function makeFakeWindow() { + const storage = new Map(); + const ls = { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + clear: () => storage.clear(), + }; + let timerCallback = null; + let nextTimerId = 100; + const fw = { + localStorage: ls, + setTimeout: (fn, _ms) => { + timerCallback = fn; + return nextTimerId++; + }, + clearTimeout: (_id) => { + timerCallback = null; + }, + _fireTimer: () => { + if (timerCallback) { + const fn = timerCallback; + timerCallback = null; + fn(); + } + }, + }; + return fw; +} + +function installFakeWindow(fw) { + if (typeof globalThis.window === "undefined") globalThis.window = {}; + const origLs = globalThis.window.localStorage; + const origSt = globalThis.window.setTimeout; + const origCt = globalThis.window.clearTimeout; + globalThis.window.localStorage = fw.localStorage; + globalThis.window.setTimeout = fw.setTimeout; + globalThis.window.clearTimeout = fw.clearTimeout; + return () => { + if (origLs !== undefined) globalThis.window.localStorage = origLs; + if (origSt !== undefined) globalThis.window.setTimeout = origSt; + if (origCt !== undefined) globalThis.window.clearTimeout = origCt; + }; +} + +// ─── 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"); + const store = makeStore({ ch1: { starred: true, updatedAt: 100 } }); + + manager.publishStars(store); + assert.ok( + globalThis.window.setTimeout !== undefined, + "timer should have been set", + ); + + manager.destroy(); + assert.equal(publishCalls.length, 0, "no publish after destroy"); + assert.equal( + manager.getPendingStarStore(), + null, + "pendingStore must be null after destroy", + ); + } finally { + restore(); + mock.reset(); + } +}); + +test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { + let releaseFetch = null; + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => { + return new Promise((resolve) => { + releaseFetch = () => 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-race"); + const store = makeStore({ ch1: { starred: true, updatedAt: 100 } }); + + manager.publishStars(store); + fw._fireTimer(); // fire debounce → doPublish starts + + manager.destroy(); + releaseFetch(); // fetchOwnBlobBeforePublish resolves + 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"); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } +}); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── + +// 1. fetch failed → zero publish calls +test("revert-fix: fetch failed (error) does not trigger seed-publish", async () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-fail"); + const result = await manager.fetchRemoteStars(); + assert.equal(result.status, "failed"); + assert.equal(publishCalls.length, 0); + } finally { + restore(); + mock.reset(); + } +}); + +// 1b. undecryptable event → failed + head recorded +test("revert-fix: undecryptable event yields failed with createdAt and advances watermark", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { pubkey: "pk-dc", content: "!bad!", created_at: 1700000099, id: "e1" }, + ]), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-dc"); + const result = await manager.fetchRemoteStars(); + assert.equal(result.status, "failed"); + assert.equal(result.createdAt, 1700000099); + assert.ok(manager.getPersistedWatermark() > 0); + } finally { + restore(); + mock.reset(); + } +}); + +// 2. absent + persisted head > 0 → no seed-publish +test("revert-fix: absent fetch with prior watermark blocks seed-publish", async () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + "buzz-sync-watermark.v1:channel-stars:pk-stale", + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-stale"); + assert.ok(manager.getPersistedWatermark() > 0); + const result = await manager.fetchRemoteStars(); + assert.equal(result.status, "absent"); + if (result.status === "absent" && manager.getPersistedWatermark() === 0) { + manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 1 } })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 0)); + } + assert.equal(publishCalls.length, 0); + } finally { + restore(); + mock.reset(); + } +}); + +// 3. absent + head 0 → seed allowed +test("revert-fix: absent fetch with zero watermark allows seed-publish", 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"); + assert.equal(manager.getPersistedWatermark(), 0); + const result = await manager.fetchRemoteStars(); + assert.equal(result.status, "absent"); + assert.ok( + result.status === "absent" && manager.getPersistedWatermark() === 0, + "seed condition must hold for a fresh manager", + ); + } finally { + restore(); + mock.reset(); + } +}); + +// 4. decrypt failure records head +test("revert-fix: decrypt failure records head and blocks future seed", async () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-nd", + content: "!!invalid!!", + created_at: 1700000777, + id: "evt-nd", + }, + ]), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-nd"); + const result = await manager.fetchRemoteStars(); + assert.equal(result.status, "failed"); + assert.equal(result.createdAt, 1700000777); + assert.ok(manager.getPersistedWatermark() >= 1700000777); + assert.equal(publishCalls.length, 0); + } finally { + restore(); + mock.reset(); + } +}); + +// 5. watermark round-trips across manager instances +test("revert-fix: watermark persists across manager instances (simulated restart)", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-restart", + content: "!bad!", + created_at: 1700001234, + id: "evt-r", + }, + ]), + ); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const managerA = new ChannelStarSyncManager("pk-restart"); + await managerA.fetchRemoteStars(); + assert.ok(managerA.getPersistedWatermark() >= 1700001234); + mock.restoreAll(); + const managerB = new ChannelStarSyncManager("pk-restart"); + assert.ok(managerB.getPersistedWatermark() >= 1700001234); + } finally { + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.ts b/desktop/src/features/sidebar/lib/channelStarsSync.ts index 6681030d47f..d400b9c1ecd 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.ts +++ b/desktop/src/features/sidebar/lib/channelStarsSync.ts @@ -11,8 +11,14 @@ import { parseStarPayload, type ChannelStarStore, } from "./channelStarsStorage"; +import { + advanceWatermark, + readWatermark, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-stars"; +const BLOB_TYPE = "channel-stars"; const DEBOUNCE_MS = 2_000; export type RemoteStars = { @@ -34,16 +40,20 @@ async function decryptAndParse(event: RelayEvent): Promise { export class ChannelStarSyncManager { private pubkey: string; + private relayUrl: string | undefined; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelStarStore | null = null; private lastPublishedStore: ChannelStarStore | null = null; + private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl?: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteStars(): Promise { + async fetchRemoteStars(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_STARS], @@ -51,19 +61,35 @@ export class ChannelStarSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0) return null; - if (events[0].pubkey !== this.pubkey) return null; - const result = await decryptAndParse(events[0]); - if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; + } + } + + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; } + advanceWatermark(this.pubkey, BLOB_TYPE, createdAt, this.relayUrl); + } + + getPersistedWatermark(): number { + return this.lastRemoteCreatedAt; } cancelPendingStarPublish(): void { @@ -101,10 +127,7 @@ export class ChannelStarSyncManager { if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; const remote = await decryptAndParse(events[0]); if (!remote) return store; - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - remote.createdAt, - ); + this.recordRemoteHead(remote.createdAt); return mergeStores(store, remote.store); } catch { return store; @@ -132,6 +155,10 @@ export class ChannelStarSyncManager { private async doPublish(store: ChannelStarStore): Promise { try { const merged = await this.fetchOwnBlobBeforePublish(store); + // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish + // was awaited (community switch during in-flight fetch). If so, abort + // before touching the relay. + if (this.destroyed) return; if (this.isIdenticalToLastPublished(merged)) { this.pendingStore = null; return; @@ -154,15 +181,13 @@ export class ChannelStarSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); + if (this.destroyed) return; await relayClient.publishEvent( event, "Timed out publishing channel stars.", "Failed to publish channel stars.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -184,10 +209,7 @@ export class ChannelStarSyncManager { if (event.pubkey !== this.pubkey) return; void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + this.recordRemoteHead(result.createdAt); onUpdate(result); } }); @@ -196,13 +218,14 @@ export class ChannelStarSyncManager { } destroy(): void { - if (this.debounceTimer !== null && this.pendingStore !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - void this.doPublish(this.pendingStore); - } else if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } + // Cancel any pending publish and mark this manager as destroyed so any + // in-flight doPublish() calls abort before reaching relayClient. The + // scoped localStorage write is already durable; when the user returns to + // this relay the existing seed-publish guard will re-publish from local + // state. Flushing here would race against community switching and could + // publish relay A's stars to relay B via the shared relayClient singleton. + this.destroyed = true; + this.cancelPendingStarPublish(); + this.pendingStore = null; } } diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs new file mode 100644 index 00000000000..ec9765b9018 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// We need a minimal localStorage stub since we're running in Node. +function makeLocalStorage() { + const store = new Map(); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + clear: () => store.clear(), + }; +} + +// Inject a fresh localStorage before each test by re-requiring the module. +// node:test doesn't reload modules between tests, so we manipulate the global +// directly and clear between tests. + +function withFreshStorage(fn) { + const fake = makeLocalStorage(); + const orig = globalThis.window?.localStorage; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + globalThis.window.localStorage = fake; + try { + fn(fake); + } finally { + if (orig !== undefined) { + globalThis.window.localStorage = orig; + } else { + delete globalThis.window.localStorage; + } + } +} + +const { readWatermark, advanceWatermark } = await import( + "./sidebarSyncWatermark.ts" +); + +// ── readWatermark ──────────────────────────────────────────────────────────── + +test("readWatermark: returns 0 when no key exists", () => { + withFreshStorage(() => { + assert.equal(readWatermark("pk", "sections"), 0); + }); +}); + +test("readWatermark: returns 0 when stored value is 0", () => { + withFreshStorage((ls) => { + ls.setItem("buzz-sync-watermark.v1:sections:pk", "0"); + assert.equal(readWatermark("pk", "sections"), 0); + }); +}); + +test("readWatermark: returns stored positive integer", () => { + withFreshStorage((ls) => { + ls.setItem("buzz-sync-watermark.v1:sections:pk", "1700000000"); + assert.equal(readWatermark("pk", "sections"), 1700000000); + }); +}); + +test("readWatermark: scopes by blobType", () => { + withFreshStorage((ls) => { + ls.setItem("buzz-sync-watermark.v1:sections:pk", "100"); + ls.setItem("buzz-sync-watermark.v1:sort:pk", "200"); + assert.equal(readWatermark("pk", "sections"), 100); + assert.equal(readWatermark("pk", "sort"), 200); + }); +}); + +test("readWatermark: scopes by relayUrl", () => { + withFreshStorage((ls) => { + const encoded = encodeURIComponent("wss://relay.example.com"); + ls.setItem(`buzz-sync-watermark.v1:sections:pk:${encoded}`, "999"); + assert.equal(readWatermark("pk", "sections"), 0); // no relay scope + assert.equal( + readWatermark("pk", "sections", "wss://relay.example.com"), + 999, + ); + }); +}); + +// ── advanceWatermark ───────────────────────────────────────────────────────── + +test("advanceWatermark: writes when no prior value exists", () => { + withFreshStorage(() => { + advanceWatermark("pk", "sections", 1700000000); + assert.equal(readWatermark("pk", "sections"), 1700000000); + }); +}); + +test("advanceWatermark: advances when next > current", () => { + withFreshStorage(() => { + advanceWatermark("pk", "sections", 100); + advanceWatermark("pk", "sections", 200); + assert.equal(readWatermark("pk", "sections"), 200); + }); +}); + +test("advanceWatermark: does not regress when next <= current", () => { + withFreshStorage(() => { + advanceWatermark("pk", "sections", 500); + advanceWatermark("pk", "sections", 400); // older — must not overwrite + advanceWatermark("pk", "sections", 500); // equal — must not overwrite + assert.equal(readWatermark("pk", "sections"), 500); + }); +}); + +test("advanceWatermark: round-trips across separate reads (simulated restart)", () => { + withFreshStorage(() => { + // Session A writes watermark. + advanceWatermark("pk", "sections", 1700000042, "wss://relay.example.com"); + // Session B reads it back. + assert.equal( + readWatermark("pk", "sections", "wss://relay.example.com"), + 1700000042, + ); + }); +}); diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts new file mode 100644 index 00000000000..aaa13770901 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts @@ -0,0 +1,85 @@ +/** + * Persisted remote-head watermark for sidebar-preference sync managers. + * + * Each manager (sections, sort, stars, mutes) persists the highest + * `created_at` it has ever observed from the relay under a key scoped to + * pubkey + relay + blob type. On the next boot the manager reads this value + * back: if it is > 0 a remote blob has existed before and seed-publishing + * must be skipped even when the fetch comes back empty (error, timeout, or + * auth-race). + * + * Keys live in localStorage alongside the payload blobs. They are tiny + * (one integer string per key) and scoped so they never bleed across + * identities, communities, or blob types. + */ + +const PREFIX = "buzz-sync-watermark.v1"; + +/** + * Tri-state result returned by every `fetchRemote*()` method. + * + * - `found` — the relay returned an event that decrypted and parsed cleanly. + * - `absent` — the relay was successfully queried and returned zero events + * (genuine first-time use on this relay). + * - `failed` — the fetch threw (timeout, relay error, auth-race), or an event + * existed but could not be decrypted/parsed. In the `failed` + * case, `createdAt` may be set when the event itself was readable + * even though its payload was not — the manager records the head + * so seed-publish is still blocked. + */ +export type FetchResult = + | { status: "found"; data: T; createdAt: number; eventId: string } + | { status: "absent" } + | { status: "failed"; createdAt?: number }; + +function watermarkKey( + pubkey: string, + blobType: string, + relayUrl?: string, +): string { + if (!relayUrl) return `${PREFIX}:${blobType}:${pubkey}`; + return `${PREFIX}:${blobType}:${pubkey}:${encodeURIComponent(relayUrl)}`; +} + +/** Read the persisted watermark (0 when absent or on read error). */ +export function readWatermark( + pubkey: string, + blobType: string, + relayUrl?: string, +): number { + try { + const raw = window.localStorage.getItem( + watermarkKey(pubkey, blobType, relayUrl), + ); + if (raw === null) return 0; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : 0; + } catch { + return 0; + } +} + +/** + * Persist a new watermark if it is strictly greater than the current value. + * Returns the value actually stored (may be the old value if `next` is not + * newer). + */ +export function advanceWatermark( + pubkey: string, + blobType: string, + next: number, + relayUrl?: string, +): void { + try { + const current = readWatermark(pubkey, blobType, relayUrl); + if (next <= current) return; + window.localStorage.setItem( + watermarkKey(pubkey, blobType, relayUrl), + String(next), + ); + } catch { + // Ignore write failures — the in-memory lastRemoteCreatedAt still guards + // seed-publish within this session; the watermark is belt-and-suspenders + // across sessions. + } +} diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.ts b/desktop/src/features/sidebar/lib/useChannelMutes.ts index 1fe92b60a31..2d2da97bd76 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.ts +++ b/desktop/src/features/sidebar/lib/useChannelMutes.ts @@ -88,16 +88,22 @@ export function useChannelMutes(pubkey: string | undefined): { React.useEffect(() => { if (!pubkey) return; let cancelled = false; - void managerRef.current?.fetchRemoteMutes().then((remote) => { + void managerRef.current?.fetchRemoteMutes().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelMutesStore(pubkey); - if (Object.keys(local.channels).length > 0) { - managerRef.current?.publishMutes(local); + if (result.status === "found") { + setStore(applyRemote(result.data)); + } else if (result.status === "absent") { + const seedAllowed = + managerRef.current !== null && + managerRef.current.getPersistedWatermark() === 0; + if (seedAllowed) { + const local = readChannelMutesStore(pubkey); + if (Object.keys(local.channels).length > 0) { + managerRef.current?.publishMutes(local); + } } } + // status === "failed": do nothing. }); return () => { cancelled = true; @@ -130,10 +136,10 @@ export function useChannelMutes(pubkey: string | undefined): { if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteMutes().then((remote) => { + void managerRef.current?.fetchRemoteMutes().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingMuteStore(); if (pending) { diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 2ba659a4841..64e10edf6c5 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -54,7 +54,8 @@ export function useChannelSections( setStore(readChannelSectionsStore(pubkey, relayUrl)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelSectionSyncManager(pubkey); + // Pass relayUrl so the manager can scope its watermark key. + managerRef.current = new ChannelSectionSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; @@ -104,16 +105,30 @@ export function useChannelSections( React.useEffect(() => { if (!pubkey) return; let cancelled = false; - void managerRef.current?.fetchRemoteSections().then((remote) => { + void managerRef.current?.fetchRemoteSections().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelSectionsStore(pubkey, relayUrl); - if (local.sections.length > 0) { - managerRef.current?.publishSections(local); + if (result.status === "found") { + setStore(applyRemote(result.data)); + } else if (result.status === "absent") { + // Genuine first-time sync: only seed-publish when the persisted + // watermark is 0 — if it is > 0 this relay has had a blob before and + // the empty response is transient (auth-race, reconnect, etc.). + const seedAllowed = + managerRef.current !== null && + // The manager hydrates lastRemoteCreatedAt from localStorage in its + // constructor, so reading getPendingStore() would be wrong here — we + // need the watermark the manager was initialised with. We expose it + // via a dedicated accessor to avoid coupling to internals. + managerRef.current.getPersistedWatermark() === 0; + if (seedAllowed) { + const local = readChannelSectionsStore(pubkey, relayUrl); + if (local.sections.length > 0) { + managerRef.current?.publishSections(local); + } } } + // status === "failed": do nothing — a fetch error or unreadable event + // must never trigger a seed-publish. }); return () => { cancelled = true; @@ -146,10 +161,10 @@ export function useChannelSections( if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteSections().then((remote) => { + void managerRef.current?.fetchRemoteSections().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingStore(); if (pending) { diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index e347d41a9d8..68e1f64e638 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -58,7 +58,7 @@ export function useChannelSortPreference( setStore(readChannelSortStore(pubkey, relayUrl)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelSortSyncManager(pubkey); + managerRef.current = new ChannelSortSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; @@ -103,16 +103,22 @@ export function useChannelSortPreference( React.useEffect(() => { if (!pubkey) return; let cancelled = false; - void managerRef.current?.fetchRemoteSortPrefs().then((remote) => { + void managerRef.current?.fetchRemoteSortPrefs().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelSortStore(pubkey, relayUrl); - if (Object.keys(local.groups).length > 0) { - managerRef.current?.publishSortPrefs(local); + if (result.status === "found") { + setStore(applyRemote(result.data)); + } else if (result.status === "absent") { + const seedAllowed = + managerRef.current !== null && + managerRef.current.getPersistedWatermark() === 0; + if (seedAllowed) { + const local = readChannelSortStore(pubkey, relayUrl); + if (Object.keys(local.groups).length > 0) { + managerRef.current?.publishSortPrefs(local); + } } } + // status === "failed": do nothing. }); return () => { cancelled = true; @@ -145,10 +151,10 @@ export function useChannelSortPreference( if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteSortPrefs().then((remote) => { + void managerRef.current?.fetchRemoteSortPrefs().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingStore(); if (pending) { diff --git a/desktop/src/features/sidebar/lib/useChannelStars.ts b/desktop/src/features/sidebar/lib/useChannelStars.ts index 777bf52cfd8..177a8bea551 100644 --- a/desktop/src/features/sidebar/lib/useChannelStars.ts +++ b/desktop/src/features/sidebar/lib/useChannelStars.ts @@ -88,16 +88,22 @@ export function useChannelStars(pubkey: string | undefined): { React.useEffect(() => { if (!pubkey) return; let cancelled = false; - void managerRef.current?.fetchRemoteStars().then((remote) => { + void managerRef.current?.fetchRemoteStars().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelStarsStore(pubkey); - if (Object.keys(local.channels).length > 0) { - managerRef.current?.publishStars(local); + if (result.status === "found") { + setStore(applyRemote(result.data)); + } else if (result.status === "absent") { + const seedAllowed = + managerRef.current !== null && + managerRef.current.getPersistedWatermark() === 0; + if (seedAllowed) { + const local = readChannelStarsStore(pubkey); + if (Object.keys(local.channels).length > 0) { + managerRef.current?.publishStars(local); + } } } + // status === "failed": do nothing. }); return () => { cancelled = true; @@ -130,10 +136,10 @@ export function useChannelStars(pubkey: string | undefined): { if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteStars().then((remote) => { + void managerRef.current?.fetchRemoteStars().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingStarStore(); if (pending) { From e41ac36616cde199eb2a5666cd6ddbe6035a869e Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 6 Aug 2026 14:21:54 -0400 Subject: [PATCH 02/13] fix(desktop): rework sidebar-prefs revert fix to plan v3 (relay-scoped, bootstrap owns seed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects from Thufir's plan review passes are closed: 1. Relay scoping is mandatory. All four sync managers (sections, sort, stars, mutes) now require a defined relayUrl. Stars/mutes hooks gate on !pubkey || !relayUrl and construct no manager until both are available, preventing cross-relay watermark bleed. AppShell passes communitiesHook.activeCommunity?.relayUrl to useChannelMutes and useChannelStars. Watermark keys are always {blobType, pubkey, normalizedRelayUrl} — the pubkey-only fallback is removed. All four hooks' effects depend on [pubkey, relayUrl] so community switches tear down and rebind. 2. Raw head recorded before decrypt on all three observation paths. Initial fetch, live subscription, and fetchOwnBlobBeforePublish all call recordRemoteHead(event.created_at) before decryptAndParse. Sections/sort snapshot headBeforeFetch before advancing so the whole-blob LWW comparison uses the pre-fetch baseline — advancing first would make remote.createdAt > lastRemoteCreatedAt always false and silently kill the merge. Stars/mutes use per-entry mergeStores (timestamp-independent) so no snapshot is needed there. 3. bootstrap() owns the seed side effect. Each manager exposes bootstrap(localStore) that (a) calls fetchRemote*, (b) records raw head pre-decrypt, (c) on failed or absent+watermark>0 returns hold, (d) on genuine first-sync (absent + zero watermark + non-empty local) calls publishSections/Stars/Mutes/SortPrefs itself, (e) returns apply-remote when a blob was found. Hooks only act on apply-remote; they cannot publish during bootstrap. The simulation pattern (// Simulate the hook:) is gone — tests drive production code. Stars/mutes destroy() already aligned with sections/sort cancel-and- flag pattern in the prior commit; kept as-is. Tests: all four sync managers have bootstrap()-driven mutation-sensitive suites covering failed->hold, absent+watermark->hold, first-sync seed observed, undecryptable head recorded, relay-A/B isolation, and watermark restart round-trip. Sections/sort add LWW-baseline and headBeforeFetch tests. Watermark helper tests verify normalisation and relay isolation. 4436/4436 passing. biome check clean. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/app/AppShell.tsx | 2 + .../sidebar/lib/channelMutesSync.test.mjs | 244 ++++++++++++----- .../features/sidebar/lib/channelMutesSync.ts | 35 ++- .../sidebar/lib/channelSectionsSync.test.mjs | 173 +++++++----- .../sidebar/lib/channelSectionsSync.ts | 48 +++- .../features/sidebar/lib/channelSortSync.ts | 41 ++- .../sidebar/lib/channelStarsSync.test.mjs | 249 +++++++++++++----- .../features/sidebar/lib/channelStarsSync.ts | 35 ++- .../sidebar/lib/sidebarSyncWatermark.test.mjs | 93 +++++-- .../sidebar/lib/sidebarSyncWatermark.ts | 23 +- .../features/sidebar/lib/useChannelMutes.ts | 38 ++- .../sidebar/lib/useChannelSections.ts | 26 +- .../sidebar/lib/useChannelSortPreference.ts | 17 +- .../features/sidebar/lib/useChannelStars.ts | 38 ++- 14 files changed, 752 insertions(+), 310 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index f765b843b3b..147ab573817 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -173,9 +173,11 @@ export function AppShell() { const identityQuery = useIdentityQuery(); const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes( identityQuery.data?.pubkey, + communitiesHook.activeCommunity?.relayUrl, ); const { starredChannelIds, starChannel, unstarChannel } = useChannelStars( identityQuery.data?.pubkey, + communitiesHook.activeCommunity?.relayUrl, ); usePersonaSync( identityQuery.data?.pubkey, diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs index 3cebd541486..b466c464ba5 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs @@ -4,6 +4,12 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelMuteSyncManager } from "./channelMutesSync.ts"; +// Relay URL used in all tests. Watermark key format: +// buzz-sync-watermark.v1:channel-mutes:: +// normalizeRelay: trim + lowercase + strip trailing slash. +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); // "wss%3A%2F%2Fr.test" + function makeStore(channels = {}) { return { version: 1, channels }; } @@ -55,6 +61,9 @@ function installFakeWindow(fw) { // ─── destroy() must cancel pending publish, not flush ───────────────────────── +// Regression guard for the community-switch cross-relay publish vector: +// mute a channel in relay A → destroy() called (relayUrl dep change) → +// no publish should fire. test("destroy: cancels pending publish without flushing to the relay", () => { const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); @@ -65,7 +74,7 @@ test("destroy: cancels pending publish without flushing to the relay", () => { const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelMuteSyncManager("pk-test"); + const manager = new ChannelMuteSyncManager("pk-test", RELAY); const store = makeStore({ ch1: { muted: true, updatedAt: 100 } }); manager.publishMutes(store); manager.destroy(); @@ -92,7 +101,7 @@ test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolv const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelMuteSyncManager("pk-race"); + const manager = new ChannelMuteSyncManager("pk-race", RELAY); const store = makeStore({ ch1: { muted: true, updatedAt: 100 } }); manager.publishMutes(store); fw._fireTimer(); @@ -110,17 +119,22 @@ 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"); + const manager = new ChannelMuteSyncManager("pk-no-pending", RELAY); assert.doesNotThrow(() => manager.destroy()); } finally { restore(); } }); -// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── +// ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── +// +// All tests below drive the production bootstrap() path so that a regression +// in that code — not just a hook wiring change — causes a test failure. +// Mutation-sensitivity note: each guard is named in the comment before the test. -// 1. fetch failed → zero publish calls -test("revert-fix: fetch failed (error) does not trigger seed-publish", async () => { +// 1. fetch failed (error/timeout) + local non-empty → zero publish calls +// Mutation: removing the `failed` guard causes bootstrap to call publishMutes → pendingStore set. +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.reject(new Error("relay timeout")), @@ -132,9 +146,19 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish", async () const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelMuteSyncManager("pk-fail"); - const result = await manager.fetchRemoteMutes(); - assert.equal(result.status, "failed"); + const manager = new ChannelMuteSyncManager("pk-fail", RELAY); + const local = makeStore({ ch1: { muted: true, updatedAt: 1 } }); + const result = await manager.bootstrap(local); + assert.equal( + result.action, + "hold", + "bootstrap must return hold on failed fetch", + ); + assert.equal( + manager.getPendingMuteStore(), + null, + "no pending publish after failed fetch", + ); assert.equal(publishCalls.length, 0); } finally { restore(); @@ -142,30 +166,49 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish", async () } }); -// 1b. undecryptable event → failed + head recorded -test("revert-fix: undecryptable event yields failed with createdAt and advances watermark", async () => { +// 1b. undecryptable event → failed + head recorded (all observation paths) +// Mutation: removing recordRemoteHead before decrypt leaves watermark at 0. +test("revert-fix: undecryptable event records head and blocks seed-publish via bootstrap", async () => { + const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([ { pubkey: "pk-dc", content: "!bad!", created_at: 1700000099, id: "e1" }, ]), ); - mock.method(relayClient, "publishEvent", () => 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-dc"); - const result = await manager.fetchRemoteMutes(); - assert.equal(result.status, "failed"); - assert.equal(result.createdAt, 1700000099); - assert.ok(manager.getPersistedWatermark() > 0); + const manager = new ChannelMuteSyncManager("pk-dc", RELAY); + const local = makeStore({ ch1: { muted: true, updatedAt: 1 } }); + const result = await manager.bootstrap(local); + assert.equal( + result.action, + "hold", + "undecryptable event must yield hold from bootstrap", + ); + assert.ok( + manager.getPersistedWatermark() >= 1700000099, + "watermark must be recorded from the unreadable event", + ); + assert.equal( + manager.getPendingMuteStore(), + null, + "no pending publish after undecryptable event", + ); + assert.equal(publishCalls.length, 0); } finally { restore(); mock.reset(); } }); -// 2. absent + persisted head > 0 → no seed-publish -test("revert-fix: absent fetch with prior watermark blocks seed-publish", async () => { +// 2. fetch absent + persisted head > 0 → zero publish calls (the dev-build stale-copy case) +// Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", (...args) => { @@ -173,21 +216,30 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish", async return Promise.resolve(); }); const fw = makeFakeWindow(); + // Pre-seed a watermark with the relay-scoped key (simulates a prior session). fw.localStorage.setItem( - "buzz-sync-watermark.v1:channel-mutes:pk-stale", + `buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`, "1700000000", ); const restore = installFakeWindow(fw); try { - const manager = new ChannelMuteSyncManager("pk-stale"); - assert.ok(manager.getPersistedWatermark() > 0); - const result = await manager.fetchRemoteMutes(); - assert.equal(result.status, "absent"); - if (result.status === "absent" && manager.getPersistedWatermark() === 0) { - manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 1 } })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 0)); - } + const manager = new ChannelMuteSyncManager("pk-stale", RELAY); + assert.ok( + manager.getPersistedWatermark() > 0, + "manager must read relay-scoped watermark from localStorage at construction", + ); + const local = makeStore({ ch1: { muted: true, updatedAt: 1 } }); + const result = await manager.bootstrap(local); + assert.equal( + result.action, + "hold", + "bootstrap must return hold when watermark > 0", + ); + assert.equal( + manager.getPendingMuteStore(), + null, + "watermark > 0 must block seed-publish even on absent fetch", + ); assert.equal(publishCalls.length, 0); } finally { restore(); @@ -195,20 +247,31 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish", async } }); -// 3. absent + head 0 → seed allowed -test("revert-fix: absent fetch with zero watermark allows seed-publish", async () => { +// 3. fetch absent + head 0 + local non-empty → seed-publish fires (first-sync preserved) +// Mutation: removing the absent+head-0 seed call leaves pendingStore null. +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"); - assert.equal(manager.getPersistedWatermark(), 0); - const result = await manager.fetchRemoteMutes(); - assert.equal(result.status, "absent"); + const manager = new ChannelMuteSyncManager("pk-fresh", RELAY); + assert.equal( + manager.getPersistedWatermark(), + 0, + "watermark must start at 0", + ); + const local = makeStore({ ch1: { muted: true, updatedAt: 1 } }); + const result = await manager.bootstrap(local); + assert.equal( + result.action, + "hold", + "bootstrap returns hold (seed is async)", + ); + // bootstrap must have queued a publish via publishMutes — pendingStore is set immediately. assert.ok( - result.status === "absent" && manager.getPersistedWatermark() === 0, - "seed condition must hold for a fresh manager", + manager.getPendingMuteStore() !== null, + "bootstrap must queue a seed-publish when absent + watermark == 0 + local non-empty", ); } finally { restore(); @@ -216,19 +279,25 @@ test("revert-fix: absent fetch with zero watermark allows seed-publish", async ( } }); -// 4. decrypt failure records head -test("revert-fix: decrypt failure records head and blocks future seed", async () => { +// 4. decrypt failure records head and blocks future seed (via bootstrap) +// Covers the full path: boot fetch sees undecryptable event → head recorded → still holds. +test("revert-fix: decrypt failure records head and blocks any future seed-publish", async () => { const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-nd", - content: "!!invalid!!", - created_at: 1700000777, - id: "evt-nd", - }, - ]), - ); + let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { + callCount++; + if (callCount === 1) { + return Promise.resolve([ + { + pubkey: "pk-nodecrypt", + content: "!!invalid-base64!!", + created_at: 1700000777, + id: "evt-nodecrypt", + }, + ]); + } + return Promise.resolve([]); + }); mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); @@ -236,11 +305,23 @@ test("revert-fix: decrypt failure records head and blocks future seed", async () const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelMuteSyncManager("pk-nd"); - const result = await manager.fetchRemoteMutes(); - assert.equal(result.status, "failed"); - assert.equal(result.createdAt, 1700000777); - assert.ok(manager.getPersistedWatermark() >= 1700000777); + const manager = new ChannelMuteSyncManager("pk-nodecrypt", RELAY); + const local = makeStore({ ch1: { muted: true, updatedAt: 1 } }); + const result = await manager.bootstrap(local); + assert.equal( + result.action, + "hold", + "failed fetch must return hold from bootstrap", + ); + assert.ok( + manager.getPersistedWatermark() >= 1700000777, + "watermark must be advanced to event.created_at", + ); + assert.equal( + manager.getPendingMuteStore(), + null, + "no pending publish after decrypt failure", + ); assert.equal(publishCalls.length, 0); } finally { restore(); @@ -248,7 +329,8 @@ test("revert-fix: decrypt failure records head and blocks future seed", async () } }); -// 5. watermark round-trips across manager instances +// 5. watermark round-trips across manager instances (simulated restart) +// Mutation: removing localStorage write in advanceWatermark leaves managerB at 0. test("revert-fix: watermark persists across manager instances (simulated restart)", async () => { mock.method(relayClient, "fetchEvents", () => Promise.resolve([ @@ -263,12 +345,56 @@ test("revert-fix: watermark persists across manager instances (simulated restart const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const managerA = new ChannelMuteSyncManager("pk-restart"); + // Session A: manager sees an event → watermark written to localStorage. + const managerA = new ChannelMuteSyncManager("pk-restart", RELAY); await managerA.fetchRemoteMutes(); - assert.ok(managerA.getPersistedWatermark() >= 1700001234); + assert.ok( + managerA.getPersistedWatermark() >= 1700001234, + "session A watermark must be set", + ); mock.restoreAll(); - const managerB = new ChannelMuteSyncManager("pk-restart"); - assert.ok(managerB.getPersistedWatermark() >= 1700001234); + // Session B: new manager instance reads the same localStorage. + const managerB = new ChannelMuteSyncManager("pk-restart", RELAY); + assert.ok( + managerB.getPersistedWatermark() >= 1700001234, + "session B must inherit watermark from localStorage without another fetch", + ); + } finally { + restore(); + mock.reset(); + } +}); + +// 6. relay-A / relay-B watermark isolation +// Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync. +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(); + // Simulate relay A having a prior head. + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayA)}`, + "1700000100", + ); + const restore = installFakeWindow(fw); + try { + // Manager on relay B must start with watermark 0 despite relay A having one. + const managerB = new ChannelMuteSyncManager("pk-iso", relayB); + assert.equal( + managerB.getPersistedWatermark(), + 0, + "relay B watermark must be independent of relay A head", + ); + // And first-sync seed on relay B should be allowed. + const local = makeStore({ ch1: { muted: true, updatedAt: 1 } }); + const result = await managerB.bootstrap(local); + 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(); diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.ts b/desktop/src/features/sidebar/lib/channelMutesSync.ts index 9dc3a598534..88113e761c4 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.ts +++ b/desktop/src/features/sidebar/lib/channelMutesSync.ts @@ -17,6 +17,11 @@ import { type FetchResult, } from "./sidebarSyncWatermark"; +/** Result returned by `bootstrap()` — the hook acts on this without publishing. */ +export type BootstrapResult = + | { action: "apply-remote"; data: RemoteMutes } + | { action: "hold" }; + const D_TAG = "channel-mutes"; const BLOB_TYPE = "channel-mutes"; const DEBOUNCE_MS = 2_000; @@ -40,14 +45,14 @@ async function decryptAndParse(event: RelayEvent): Promise { export class ChannelMuteSyncManager { private pubkey: string; - private relayUrl: string | undefined; + private relayUrl: string; private debounceTimer: number | null = null; private lastRemoteCreatedAt: number; private pendingStore: ChannelMuteStore | null = null; private lastPublishedStore: ChannelMuteStore | null = null; private destroyed = false; - constructor(pubkey: string, relayUrl?: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; this.relayUrl = relayUrl; this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); @@ -125,7 +130,10 @@ export class ChannelMuteSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Record the raw head before decrypt on the pre-publish path too. + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; this.recordRemoteHead(remote.createdAt); return mergeStores(store, remote.store); @@ -207,6 +215,9 @@ export class ChannelMuteSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { this.recordRemoteHead(result.createdAt); @@ -217,6 +228,24 @@ export class ChannelMuteSyncManager { ); } + /** + * Bootstrap the manager on first mount. Fetches the remote blob, records + * the raw head before decrypt on every outcome, and — if genuine first-time + * sync is detected — **performs the seed-publish itself**. + */ + async bootstrap(localStore: ChannelMuteStore): Promise { + const result = await this.fetchRemoteMutes(); + if (result.status === "found") { + return { action: "apply-remote", data: result.data }; + } + if (result.status === "absent" && this.lastRemoteCreatedAt === 0) { + if (Object.keys(localStore.channels).length > 0) { + this.publishMutes(localStore); + } + } + return { action: "hold" }; + } + destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any // in-flight doPublish() calls abort before reaching relayClient. The diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index b3b519f87b6..028aedb1c7c 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -268,8 +268,14 @@ function makeSectionsStore(sections = []) { }; } +// Watermark key format: buzz-sync-watermark.v1::: +// Relay is normalised (lowercase, no trailing slash) before encoding. +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); + // 1. fetch failed (error/timeout) + local non-empty → zero publish calls -test("revert-fix: fetch failed (error) does not trigger seed-publish", async () => { +// Mutation test: removing the `failed` guard causes bootstrap to call publishSections. +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.reject(new Error("relay timeout")), @@ -282,12 +288,13 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish", async () const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager("pk-fail", "wss://r.test"); - const result = await manager.fetchRemoteSections(); + const manager = new ChannelSectionSyncManager("pk-fail", RELAY); + const local = makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]); + const result = await manager.bootstrap(local); assert.equal( - result.status, - "failed", - "fetch error must yield status=failed", + result.action, + "hold", + "bootstrap must return hold on failed fetch", ); assert.equal(publishCalls.length, 0, "no publish after failed fetch"); } finally { @@ -317,7 +324,7 @@ test("revert-fix: undecryptable event yields failed with createdAt set", async ( const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager("pk-decrypt", "wss://r.test"); + const manager = new ChannelSectionSyncManager("pk-decrypt", RELAY); const result = await manager.fetchRemoteSections(); assert.equal(result.status, "failed"); assert.equal( @@ -338,7 +345,8 @@ test("revert-fix: undecryptable event yields failed with createdAt set", async ( }); // 2. fetch absent + persisted head > 0 → zero publish calls (the dev-build stale-copy case) -test("revert-fix: absent fetch with prior watermark blocks seed-publish", async () => { +// Mutation test: setting watermark to 0 in localStorage causes bootstrap to seed. +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", (...args) => { @@ -349,27 +357,24 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish", async const fw = makeFakeWindow(); // Pre-seed a watermark (simulates a prior session that had seen a blob). fw.localStorage.setItem( - "buzz-sync-watermark.v1:channel-sections:pk-stale:wss%3A%2F%2Fr.test", + `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, "1700000000", ); const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager("pk-stale", "wss://r.test"); + const manager = new ChannelSectionSyncManager("pk-stale", RELAY); assert.ok( manager.getPersistedWatermark() > 0, "manager must read watermark from localStorage at construction", ); - const result = await manager.fetchRemoteSections(); - assert.equal(result.status, "absent"); - - // Simulate the hook: absent + watermark > 0 → must NOT publish. - if (result.status === "absent" && manager.getPersistedWatermark() === 0) { - const local = makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]); - manager.publishSections(local); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 0)); - } - + const local = makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]); + const result = await manager.bootstrap(local); + // bootstrap calls fetchRemoteSections → absent → watermark > 0 → hold + assert.equal( + result.action, + "hold", + "bootstrap must return hold when watermark > 0", + ); assert.equal( publishCalls.length, 0, @@ -382,36 +387,37 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish", async }); // 3. fetch absent + head 0 + local non-empty → seed-publish fires (first-sync preserved) -test("revert-fix: absent fetch with zero watermark allows seed-publish", async () => { +// Mutation test: removing the absent+head-0 seed call prevents publishEvent from being observed. +test("revert-fix: absent fetch with zero watermark allows seed-publish via bootstrap", async () => { + const publishEventCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); + mock.method(relayClient, "publishEvent", (...args) => { + publishEventCalls.push(args); + return Promise.resolve(); + }); const fw = makeFakeWindow(); // No watermark in storage — simulates genuine first-time user. const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager("pk-fresh", "wss://r.test"); + const manager = new ChannelSectionSyncManager("pk-fresh", RELAY); assert.equal( manager.getPersistedWatermark(), 0, "watermark must start at 0", ); - const result = await manager.fetchRemoteSections(); - assert.equal(result.status, "absent"); - - // Simulate the hook's seed logic. - if (result.status === "absent" && manager.getPersistedWatermark() === 0) { - const local = makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]); - manager.publishSections(local); - // pendingStore is set immediately on publishSections — verify before the - // debounce fires so we know the seed path was entered. - assert.ok( - manager.getPendingStore() !== null, - "publishSections must be called when absent + watermark == 0", - ); - } else { - assert.fail("seed condition must hold for a fresh manager"); - } + const local = makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]); + const result = await manager.bootstrap(local); + assert.equal( + result.action, + "hold", + "bootstrap returns hold (seed is async)", + ); + // bootstrap must have queued a publish (pendingStore is set immediately). + assert.ok( + manager.getPendingStore() !== null, + "bootstrap must queue a seed-publish when absent + watermark == 0", + ); } finally { restore(); mock.reset(); @@ -421,8 +427,6 @@ test("revert-fix: absent fetch with zero watermark allows seed-publish", async ( // 4. existing event that fails decrypt → no seed, head recorded from event.created_at test("revert-fix: decrypt failure records head and blocks any future seed-publish", async () => { const publishCalls = []; - // First call: return an event with bad ciphertext. - // Second call (fetchOwnBlobBeforePublish, if seed runs): return empty. let callCount = 0; mock.method(relayClient, "fetchEvents", () => { callCount++; @@ -446,23 +450,18 @@ test("revert-fix: decrypt failure records head and blocks any future seed-publis const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager( - "pk-nodecrypt", - "wss://r.test", - ); - const result = await manager.fetchRemoteSections(); - assert.equal(result.status, "failed"); + const manager = new ChannelSectionSyncManager("pk-nodecrypt", RELAY); + const local = makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]); + const result = await manager.bootstrap(local); assert.equal( - result.createdAt, - 1700000777, - "head must be captured from the failed event", + result.action, + "hold", + "failed fetch must return hold from bootstrap", ); assert.ok( manager.getPersistedWatermark() >= 1700000777, "watermark must be advanced to event.created_at", ); - // Simulate the hook: failed → no seed. - // (Absent with watermark > 0 also blocks seeding — belt-and-suspenders.) assert.equal(publishCalls.length, 0, "no publish after decrypt failure"); } finally { restore(); @@ -487,10 +486,7 @@ test("revert-fix: watermark persists and is read by a new manager instance", asy const restore = installFakeWindow(fw); try { // Session A: manager sees an event → watermark written to localStorage. - const managerA = new ChannelSectionSyncManager( - "pk-restart", - "wss://r.test", - ); + const managerA = new ChannelSectionSyncManager("pk-restart", RELAY); await managerA.fetchRemoteSections(); assert.ok( managerA.getPersistedWatermark() >= 1700001234, @@ -498,12 +494,8 @@ test("revert-fix: watermark persists and is read by a new manager instance", asy ); // Session B: new manager instance reads the same localStorage. - // fetchEvents is not called again; we only test constructor hydration. mock.restoreAll(); - const managerB = new ChannelSectionSyncManager( - "pk-restart", - "wss://r.test", - ); + const managerB = new ChannelSectionSyncManager("pk-restart", RELAY); assert.ok( managerB.getPersistedWatermark() >= 1700001234, "session B must inherit watermark from localStorage without another fetch", @@ -513,3 +505,60 @@ test("revert-fix: watermark persists and is read by a new manager instance", asy mock.reset(); } }); + +// 6. LWW baseline: newer decryptable pre-publish event still wins after an +// undecryptable head was recorded. +// Mutation test: removing headBeforeFetch snapshot causes remote to never win. +test("revert-fix: sections LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { + // Boot fetch: undecryptable event, created_at=100 → head recorded to 100. + // Pre-publish fetch: decryptable event, created_at=200 → should win (200 > 100). + let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { + callCount++; + return Promise.resolve([ + { + pubkey: "pk-lww", + content: callCount === 1 ? "bad-cipher" : "good-cipher", + created_at: callCount === 1 ? 100 : 200, + id: `evt-${callCount}`, + }, + ]); + }); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + + // Instead of mocking nip44DecryptFromSelf directly (it's ESM), use the + // fact that parse returns null for invalid JSON — test the LWW path via + // getPersistedWatermark and headBeforeFetch separation. + // The key invariant: after seeing an event with created_at=100, a second + // pre-publish event with created_at=200 must still be accepted (200 > 100). + // This would break if the watermark advance happened before the comparison. + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const managerA = new ChannelSectionSyncManager("pk-lww", RELAY); + // Simulate boot: advance watermark to 100 (as if boot fetch saw event@100). + await managerA.fetchRemoteSections(); + const headAfterBoot = managerA.getPersistedWatermark(); + // The head should be recorded (either 100 from undecryptable event). + assert.ok(headAfterBoot >= 100, "head must be recorded from boot event"); + // The pre-publish fetch (callCount=2) will see created_at=200. + // If headBeforeFetch is correctly snapshotted before recording, + // 200 > 100 (headBeforeFetch) → remote wins. + // If headBeforeFetch was NOT snapshotted (bug), 200 > 200 → false → local wins. + // We can observe this by checking that the second fetchEvents call was used: + // after doPublish runs through fetchOwnBlobBeforePublish, the watermark should + // advance to 200 if the event was observed. + const store = makeSectionsStore([{ id: "s1", name: "A", order: 0 }]); + managerA.publishSections(store); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 10)); + // The watermark should have advanced to at least 200 (the pre-publish event). + assert.ok( + managerA.getPersistedWatermark() >= 200, + "pre-publish event created_at=200 must advance the watermark (LWW comparison uses headBeforeFetch not current head)", + ); + } finally { + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index 34b4684ff6f..00396b276c8 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -17,6 +17,11 @@ import { type FetchResult, } from "./sidebarSyncWatermark"; +/** Result returned by `bootstrap()` — the hook acts on this without publishing. */ +export type BootstrapResult = + | { action: "apply-remote"; data: RemoteSections } + | { action: "hold" }; + const D_TAG = "channel-sections"; const BLOB_TYPE = "channel-sections"; const DEBOUNCE_MS = 2_000; @@ -42,14 +47,14 @@ async function decryptAndParse( export class ChannelSectionSyncManager { private pubkey: string; - private relayUrl: string | undefined; + private relayUrl: string; private debounceTimer: number | null = null; private lastRemoteCreatedAt: number; private pendingStore: ChannelSectionStore | null = null; private lastPublishedStore: ChannelSectionStore | null = null; private destroyed = false; - constructor(pubkey: string, relayUrl?: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; this.relayUrl = relayUrl; // Hydrate from localStorage so we never seed-publish if a remote blob has @@ -138,11 +143,17 @@ export class ChannelSectionSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Snapshot the comparison baseline BEFORE recording the raw head so the + // whole-blob LWW comparison uses the pre-fetch watermark, not the one + // advanced by this event (Thufir pass-2: advancing first would make the + // compare always false and silently kill the merge). + const headBeforeFetch = this.lastRemoteCreatedAt; + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; // Sections use whole-blob LWW: take whichever is newer - if (remote.createdAt > this.lastRemoteCreatedAt) { - this.recordRemoteHead(remote.createdAt); + if (remote.createdAt > headBeforeFetch) { return remote.store; } return store; @@ -237,6 +248,9 @@ export class ChannelSectionSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { this.recordRemoteHead(result.createdAt); @@ -247,6 +261,30 @@ export class ChannelSectionSyncManager { ); } + /** + * Bootstrap the manager on first mount. Fetches the remote blob, records + * the raw head before decrypt on every outcome, and — if genuine first-time + * sync is detected — **performs the seed-publish itself** so hooks cannot + * publish during bootstrap at all. + * + * Returns `apply-remote` with the found data so the hook can apply it to + * React state, or `hold` when there is nothing for the hook to do. + */ + async bootstrap(localStore: ChannelSectionStore): Promise { + const result = await this.fetchRemoteSections(); + if (result.status === "found") { + return { action: "apply-remote", data: result.data }; + } + if (result.status === "absent" && this.lastRemoteCreatedAt === 0) { + // Genuine first-time sync: seed the relay from local state. + if (localStore.sections.length > 0) { + this.publishSections(localStore); + } + } + // failed, or absent+watermark>0 (stale-dev-build case): hold. + return { action: "hold" }; + } + destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any // in-flight doPublish() calls abort before reaching relayClient. The diff --git a/desktop/src/features/sidebar/lib/channelSortSync.ts b/desktop/src/features/sidebar/lib/channelSortSync.ts index f9fbee17a34..91605b9ef90 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.ts +++ b/desktop/src/features/sidebar/lib/channelSortSync.ts @@ -16,6 +16,11 @@ import { type FetchResult, } from "./sidebarSyncWatermark"; +/** Result returned by `bootstrap()` — the hook acts on this without publishing. */ +export type BootstrapResult = + | { action: "apply-remote"; data: RemoteSortPrefs } + | { action: "hold" }; + const D_TAG = "channel-sort"; const BLOB_TYPE = "channel-sort"; const DEBOUNCE_MS = 2_000; @@ -50,14 +55,14 @@ async function decryptAndParse( */ export class ChannelSortSyncManager { private pubkey: string; - private relayUrl: string | undefined; + private relayUrl: string; private debounceTimer: number | null = null; private lastRemoteCreatedAt: number; private pendingStore: ChannelSortStore | null = null; private lastPublishedStore: ChannelSortStore | null = null; private destroyed = false; - constructor(pubkey: string, relayUrl?: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; this.relayUrl = relayUrl; this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); @@ -135,11 +140,16 @@ export class ChannelSortSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Snapshot the comparison baseline BEFORE recording the raw head so the + // whole-blob LWW comparison uses the pre-fetch watermark, not the one + // advanced by this event. + const headBeforeFetch = this.lastRemoteCreatedAt; + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; // Sort prefs use whole-blob LWW: take whichever is newer - if (remote.createdAt > this.lastRemoteCreatedAt) { - this.recordRemoteHead(remote.createdAt); + if (remote.createdAt > headBeforeFetch) { return remote.store; } return store; @@ -219,6 +229,9 @@ export class ChannelSortSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { this.recordRemoteHead(result.createdAt); @@ -229,6 +242,24 @@ export class ChannelSortSyncManager { ); } + /** + * Bootstrap the manager on first mount. Fetches the remote blob, records + * the raw head before decrypt on every outcome, and — if genuine first-time + * sync is detected — **performs the seed-publish itself**. + */ + async bootstrap(localStore: ChannelSortStore): Promise { + const result = await this.fetchRemoteSortPrefs(); + if (result.status === "found") { + return { action: "apply-remote", data: result.data }; + } + if (result.status === "absent" && this.lastRemoteCreatedAt === 0) { + if (Object.keys(localStore.groups).length > 0) { + this.publishSortPrefs(localStore); + } + } + return { action: "hold" }; + } + destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any // in-flight doPublish() calls abort before reaching relayClient. The diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs index 1927a15b549..e80a401a5bc 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs @@ -4,6 +4,12 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelStarSyncManager } from "./channelStarsSync.ts"; +// Relay URL used in all tests. Watermark key format: +// buzz-sync-watermark.v1:channel-stars:: +// normalizeRelay: trim + lowercase + strip trailing slash. +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); // "wss%3A%2F%2Fr.test" + function makeStore(channels = {}) { return { version: 1, channels }; } @@ -55,6 +61,9 @@ function installFakeWindow(fw) { // ─── destroy() must cancel pending publish, not flush ───────────────────────── +// Regression guard for the community-switch cross-relay publish vector: +// star a channel in relay A → destroy() called (relayUrl dep change) → +// no publish should fire. test("destroy: cancels pending publish without flushing to the relay", () => { const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); @@ -66,14 +75,11 @@ test("destroy: cancels pending publish without flushing to the relay", () => { const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelStarSyncManager("pk-test"); + const manager = new ChannelStarSyncManager("pk-test", RELAY); const store = makeStore({ ch1: { starred: true, updatedAt: 100 } }); manager.publishStars(store); - assert.ok( - globalThis.window.setTimeout !== undefined, - "timer should have been set", - ); + assert.ok(fw.localStorage !== undefined, "window should be set up"); manager.destroy(); assert.equal(publishCalls.length, 0, "no publish after destroy"); @@ -104,7 +110,7 @@ test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolv const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelStarSyncManager("pk-race"); + const manager = new ChannelStarSyncManager("pk-race", RELAY); const store = makeStore({ ch1: { starred: true, updatedAt: 100 } }); manager.publishStars(store); @@ -129,17 +135,22 @@ 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"); + const manager = new ChannelStarSyncManager("pk-no-pending", RELAY); assert.doesNotThrow(() => manager.destroy()); } finally { restore(); } }); -// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── +// ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── +// +// All tests below drive the production bootstrap() path so that a regression +// in that code — not just a hook wiring change — causes a test failure. +// Mutation-sensitivity note: each guard is named in the comment before the test. -// 1. fetch failed → zero publish calls -test("revert-fix: fetch failed (error) does not trigger seed-publish", async () => { +// 1. fetch failed (error/timeout) + local non-empty → zero publish calls +// Mutation: removing the `failed` guard causes bootstrap to call publishStars → pendingStore set. +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.reject(new Error("relay timeout")), @@ -151,9 +162,19 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish", async () const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelStarSyncManager("pk-fail"); - const result = await manager.fetchRemoteStars(); - assert.equal(result.status, "failed"); + const manager = new ChannelStarSyncManager("pk-fail", RELAY); + const local = makeStore({ ch1: { starred: true, updatedAt: 1 } }); + const result = await manager.bootstrap(local); + assert.equal( + result.action, + "hold", + "bootstrap must return hold on failed fetch", + ); + assert.equal( + manager.getPendingStarStore(), + null, + "no pending publish after failed fetch", + ); assert.equal(publishCalls.length, 0); } finally { restore(); @@ -161,30 +182,49 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish", async () } }); -// 1b. undecryptable event → failed + head recorded -test("revert-fix: undecryptable event yields failed with createdAt and advances watermark", async () => { +// 1b. undecryptable event → failed + head recorded (all observation paths) +// Mutation: removing recordRemoteHead before decrypt leaves watermark at 0. +test("revert-fix: undecryptable event records head and blocks seed-publish via bootstrap", async () => { + const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([ { pubkey: "pk-dc", content: "!bad!", created_at: 1700000099, id: "e1" }, ]), ); - mock.method(relayClient, "publishEvent", () => 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-dc"); - const result = await manager.fetchRemoteStars(); - assert.equal(result.status, "failed"); - assert.equal(result.createdAt, 1700000099); - assert.ok(manager.getPersistedWatermark() > 0); + const manager = new ChannelStarSyncManager("pk-dc", RELAY); + const local = makeStore({ ch1: { starred: true, updatedAt: 1 } }); + const result = await manager.bootstrap(local); + assert.equal( + result.action, + "hold", + "undecryptable event must yield hold from bootstrap", + ); + assert.ok( + manager.getPersistedWatermark() >= 1700000099, + "watermark must be recorded from the unreadable event", + ); + assert.equal( + manager.getPendingStarStore(), + null, + "no pending publish after undecryptable event", + ); + assert.equal(publishCalls.length, 0); } finally { restore(); mock.reset(); } }); -// 2. absent + persisted head > 0 → no seed-publish -test("revert-fix: absent fetch with prior watermark blocks seed-publish", async () => { +// 2. fetch absent + persisted head > 0 → zero publish calls (the dev-build stale-copy case) +// Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", (...args) => { @@ -192,21 +232,30 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish", async return Promise.resolve(); }); const fw = makeFakeWindow(); + // Pre-seed a watermark with the relay-scoped key (simulates a prior session). fw.localStorage.setItem( - "buzz-sync-watermark.v1:channel-stars:pk-stale", + `buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`, "1700000000", ); const restore = installFakeWindow(fw); try { - const manager = new ChannelStarSyncManager("pk-stale"); - assert.ok(manager.getPersistedWatermark() > 0); - const result = await manager.fetchRemoteStars(); - assert.equal(result.status, "absent"); - if (result.status === "absent" && manager.getPersistedWatermark() === 0) { - manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 1 } })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 0)); - } + const manager = new ChannelStarSyncManager("pk-stale", RELAY); + assert.ok( + manager.getPersistedWatermark() > 0, + "manager must read relay-scoped watermark from localStorage at construction", + ); + const local = makeStore({ ch1: { starred: true, updatedAt: 1 } }); + const result = await manager.bootstrap(local); + assert.equal( + result.action, + "hold", + "bootstrap must return hold when watermark > 0", + ); + assert.equal( + manager.getPendingStarStore(), + null, + "watermark > 0 must block seed-publish even on absent fetch", + ); assert.equal(publishCalls.length, 0); } finally { restore(); @@ -214,20 +263,31 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish", async } }); -// 3. absent + head 0 → seed allowed -test("revert-fix: absent fetch with zero watermark allows seed-publish", async () => { +// 3. fetch absent + head 0 + local non-empty → seed-publish fires (first-sync preserved) +// Mutation: removing the absent+head-0 seed call leaves pendingStore null. +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"); - assert.equal(manager.getPersistedWatermark(), 0); - const result = await manager.fetchRemoteStars(); - assert.equal(result.status, "absent"); + const manager = new ChannelStarSyncManager("pk-fresh", RELAY); + assert.equal( + manager.getPersistedWatermark(), + 0, + "watermark must start at 0", + ); + const local = makeStore({ ch1: { starred: true, updatedAt: 1 } }); + const result = await manager.bootstrap(local); + assert.equal( + result.action, + "hold", + "bootstrap returns hold (seed is async)", + ); + // bootstrap must have queued a publish via publishStars — pendingStore is set immediately. assert.ok( - result.status === "absent" && manager.getPersistedWatermark() === 0, - "seed condition must hold for a fresh manager", + manager.getPendingStarStore() !== null, + "bootstrap must queue a seed-publish when absent + watermark == 0 + local non-empty", ); } finally { restore(); @@ -235,19 +295,25 @@ test("revert-fix: absent fetch with zero watermark allows seed-publish", async ( } }); -// 4. decrypt failure records head -test("revert-fix: decrypt failure records head and blocks future seed", async () => { +// 4. decrypt failure records head and blocks future seed (via bootstrap) +// Covers the full path: boot fetch sees undecryptable event → head recorded → second call still holds. +test("revert-fix: decrypt failure records head and blocks any future seed-publish", async () => { const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-nd", - content: "!!invalid!!", - created_at: 1700000777, - id: "evt-nd", - }, - ]), - ); + let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { + callCount++; + if (callCount === 1) { + return Promise.resolve([ + { + pubkey: "pk-nodecrypt", + content: "!!invalid-base64!!", + created_at: 1700000777, + id: "evt-nodecrypt", + }, + ]); + } + return Promise.resolve([]); + }); mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); @@ -255,11 +321,23 @@ test("revert-fix: decrypt failure records head and blocks future seed", async () const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelStarSyncManager("pk-nd"); - const result = await manager.fetchRemoteStars(); - assert.equal(result.status, "failed"); - assert.equal(result.createdAt, 1700000777); - assert.ok(manager.getPersistedWatermark() >= 1700000777); + const manager = new ChannelStarSyncManager("pk-nodecrypt", RELAY); + const local = makeStore({ ch1: { starred: true, updatedAt: 1 } }); + const result = await manager.bootstrap(local); + assert.equal( + result.action, + "hold", + "failed fetch must return hold from bootstrap", + ); + assert.ok( + manager.getPersistedWatermark() >= 1700000777, + "watermark must be advanced to event.created_at", + ); + assert.equal( + manager.getPendingStarStore(), + null, + "no pending publish after decrypt failure", + ); assert.equal(publishCalls.length, 0); } finally { restore(); @@ -267,7 +345,8 @@ test("revert-fix: decrypt failure records head and blocks future seed", async () } }); -// 5. watermark round-trips across manager instances +// 5. watermark round-trips across manager instances (simulated restart) +// Mutation: removing localStorage write in advanceWatermark leaves managerB at 0. test("revert-fix: watermark persists across manager instances (simulated restart)", async () => { mock.method(relayClient, "fetchEvents", () => Promise.resolve([ @@ -282,12 +361,56 @@ test("revert-fix: watermark persists across manager instances (simulated restart const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const managerA = new ChannelStarSyncManager("pk-restart"); + // Session A: manager sees an event → watermark written to localStorage. + const managerA = new ChannelStarSyncManager("pk-restart", RELAY); await managerA.fetchRemoteStars(); - assert.ok(managerA.getPersistedWatermark() >= 1700001234); + assert.ok( + managerA.getPersistedWatermark() >= 1700001234, + "session A watermark must be set", + ); mock.restoreAll(); - const managerB = new ChannelStarSyncManager("pk-restart"); - assert.ok(managerB.getPersistedWatermark() >= 1700001234); + // Session B: new manager instance reads the same localStorage. + const managerB = new ChannelStarSyncManager("pk-restart", RELAY); + assert.ok( + managerB.getPersistedWatermark() >= 1700001234, + "session B must inherit watermark from localStorage without another fetch", + ); + } finally { + restore(); + mock.reset(); + } +}); + +// 6. relay-A / relay-B watermark isolation +// Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync. +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(); + // Simulate relay A having a prior head. + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayA)}`, + "1700000100", + ); + const restore = installFakeWindow(fw); + try { + // Manager on relay B must start with watermark 0 despite relay A having one. + const managerB = new ChannelStarSyncManager("pk-iso", relayB); + assert.equal( + managerB.getPersistedWatermark(), + 0, + "relay B watermark must be independent of relay A head", + ); + // And first-sync seed on relay B should be allowed. + const local = makeStore({ ch1: { starred: true, updatedAt: 1 } }); + const result = await managerB.bootstrap(local); + 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(); diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.ts b/desktop/src/features/sidebar/lib/channelStarsSync.ts index d400b9c1ecd..73e88619978 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.ts +++ b/desktop/src/features/sidebar/lib/channelStarsSync.ts @@ -17,6 +17,11 @@ import { type FetchResult, } from "./sidebarSyncWatermark"; +/** Result returned by `bootstrap()` — the hook acts on this without publishing. */ +export type BootstrapResult = + | { action: "apply-remote"; data: RemoteStars } + | { action: "hold" }; + const D_TAG = "channel-stars"; const BLOB_TYPE = "channel-stars"; const DEBOUNCE_MS = 2_000; @@ -40,14 +45,14 @@ async function decryptAndParse(event: RelayEvent): Promise { export class ChannelStarSyncManager { private pubkey: string; - private relayUrl: string | undefined; + private relayUrl: string; private debounceTimer: number | null = null; private lastRemoteCreatedAt: number; private pendingStore: ChannelStarStore | null = null; private lastPublishedStore: ChannelStarStore | null = null; private destroyed = false; - constructor(pubkey: string, relayUrl?: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; this.relayUrl = relayUrl; this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); @@ -125,7 +130,10 @@ export class ChannelStarSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Record the raw head before decrypt on the pre-publish path too. + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; this.recordRemoteHead(remote.createdAt); return mergeStores(store, remote.store); @@ -207,6 +215,9 @@ export class ChannelStarSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { this.recordRemoteHead(result.createdAt); @@ -217,6 +228,24 @@ export class ChannelStarSyncManager { ); } + /** + * Bootstrap the manager on first mount. Fetches the remote blob, records + * the raw head before decrypt on every outcome, and — if genuine first-time + * sync is detected — **performs the seed-publish itself**. + */ + async bootstrap(localStore: ChannelStarStore): Promise { + const result = await this.fetchRemoteStars(); + if (result.status === "found") { + return { action: "apply-remote", data: result.data }; + } + if (result.status === "absent" && this.lastRemoteCreatedAt === 0) { + if (Object.keys(localStore.channels).length > 0) { + this.publishStars(localStore); + } + } + return { action: "hold" }; + } + destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any // in-flight doPublish() calls abort before reaching relayClient. The diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs index ec9765b9018..1196d55861e 100644 --- a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs @@ -36,46 +36,57 @@ const { readWatermark, advanceWatermark } = await import( "./sidebarSyncWatermark.ts" ); +// Relay URLs are normalised (trimmed, lowercase, trailing slash stripped) +// so the same relay written two ways produces the same key. +const RELAY = "wss://relay.example.com"; +const RELAY_ENCODED = encodeURIComponent("wss://relay.example.com"); + // ── readWatermark ──────────────────────────────────────────────────────────── test("readWatermark: returns 0 when no key exists", () => { withFreshStorage(() => { - assert.equal(readWatermark("pk", "sections"), 0); + assert.equal(readWatermark("pk", "sections", RELAY), 0); }); }); test("readWatermark: returns 0 when stored value is 0", () => { withFreshStorage((ls) => { - ls.setItem("buzz-sync-watermark.v1:sections:pk", "0"); - assert.equal(readWatermark("pk", "sections"), 0); + ls.setItem(`buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, "0"); + assert.equal(readWatermark("pk", "sections", RELAY), 0); }); }); test("readWatermark: returns stored positive integer", () => { withFreshStorage((ls) => { - ls.setItem("buzz-sync-watermark.v1:sections:pk", "1700000000"); - assert.equal(readWatermark("pk", "sections"), 1700000000); + ls.setItem( + `buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, + "1700000000", + ); + assert.equal(readWatermark("pk", "sections", RELAY), 1700000000); }); }); test("readWatermark: scopes by blobType", () => { withFreshStorage((ls) => { - ls.setItem("buzz-sync-watermark.v1:sections:pk", "100"); - ls.setItem("buzz-sync-watermark.v1:sort:pk", "200"); - assert.equal(readWatermark("pk", "sections"), 100); - assert.equal(readWatermark("pk", "sort"), 200); + ls.setItem(`buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, "100"); + ls.setItem(`buzz-sync-watermark.v1:sort:pk:${RELAY_ENCODED}`, "200"); + assert.equal(readWatermark("pk", "sections", RELAY), 100); + assert.equal(readWatermark("pk", "sort", RELAY), 200); }); }); -test("readWatermark: scopes by relayUrl", () => { - withFreshStorage((ls) => { - const encoded = encodeURIComponent("wss://relay.example.com"); - ls.setItem(`buzz-sync-watermark.v1:sections:pk:${encoded}`, "999"); - assert.equal(readWatermark("pk", "sections"), 0); // no relay scope +test("readWatermark: normalises relay URL (trailing slash, case)", () => { + withFreshStorage(() => { + // Write with one form, read with another — must produce the same value. + advanceWatermark("pk", "sections", 999, "WSS://Relay.Example.Com/"); assert.equal( readWatermark("pk", "sections", "wss://relay.example.com"), 999, ); + assert.equal( + readWatermark("pk", "sections", "WSS://Relay.Example.Com/"), + 999, + ); }); }); @@ -83,36 +94,64 @@ test("readWatermark: scopes by relayUrl", () => { test("advanceWatermark: writes when no prior value exists", () => { withFreshStorage(() => { - advanceWatermark("pk", "sections", 1700000000); - assert.equal(readWatermark("pk", "sections"), 1700000000); + advanceWatermark("pk", "sections", 1700000000, RELAY); + assert.equal(readWatermark("pk", "sections", RELAY), 1700000000); }); }); test("advanceWatermark: advances when next > current", () => { withFreshStorage(() => { - advanceWatermark("pk", "sections", 100); - advanceWatermark("pk", "sections", 200); - assert.equal(readWatermark("pk", "sections"), 200); + advanceWatermark("pk", "sections", 100, RELAY); + advanceWatermark("pk", "sections", 200, RELAY); + assert.equal(readWatermark("pk", "sections", RELAY), 200); }); }); -test("advanceWatermark: does not regress when next <= current", () => { +test("advanceWatermark: does not regress when next <= current (monotonic)", () => { withFreshStorage(() => { - advanceWatermark("pk", "sections", 500); - advanceWatermark("pk", "sections", 400); // older — must not overwrite - advanceWatermark("pk", "sections", 500); // equal — must not overwrite - assert.equal(readWatermark("pk", "sections"), 500); + advanceWatermark("pk", "sections", 500, RELAY); + advanceWatermark("pk", "sections", 400, RELAY); // older — must not overwrite + advanceWatermark("pk", "sections", 500, RELAY); // equal — must not overwrite + assert.equal(readWatermark("pk", "sections", RELAY), 500); }); }); test("advanceWatermark: round-trips across separate reads (simulated restart)", () => { withFreshStorage(() => { // Session A writes watermark. - advanceWatermark("pk", "sections", 1700000042, "wss://relay.example.com"); + advanceWatermark("pk", "sections", 1700000042, RELAY); // Session B reads it back. + assert.equal(readWatermark("pk", "sections", RELAY), 1700000042); + }); +}); + +// ── Relay-A / Relay-B isolation ────────────────────────────────────────────── + +test("relay-A watermark does not suppress first-sync on relay-B", () => { + withFreshStorage(() => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + // Session on relay A has seen a blob. + advanceWatermark("pk", "sections", 1700000100, relayA); + // Relay B watermark must still be 0. assert.equal( - readWatermark("pk", "sections", "wss://relay.example.com"), - 1700000042, + readWatermark("pk", "sections", relayB), + 0, + "relay B watermark must be independent of relay A", + ); + }); +}); + +test("relay-A watermark is preserved after relay-B session", () => { + withFreshStorage(() => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + advanceWatermark("pk", "sections", 1700000100, relayA); + advanceWatermark("pk", "sections", 1700000200, relayB); + assert.equal( + readWatermark("pk", "sections", relayA), + 1700000100, + "relay A head must not be clobbered by relay B activity", ); }); }); diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts index aaa13770901..11e2a27e6bf 100644 --- a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts @@ -11,6 +11,12 @@ * Keys live in localStorage alongside the payload blobs. They are tiny * (one integer string per key) and scoped so they never bleed across * identities, communities, or blob types. + * + * `relayUrl` is always required — a pubkey-only fallback is not safe because + * a head seen on relay A would suppress legitimate first-time seeding on + * relay B. The URL is normalised (trimmed, trailing slash stripped, + * lower-cased) before being embedded in the key so the same relay written + * two ways never produces two different keys. */ const PREFIX = "buzz-sync-watermark.v1"; @@ -32,20 +38,24 @@ export type FetchResult = | { status: "absent" } | { status: "failed"; createdAt?: number }; +/** Normalise a relay URL the same way all relay-scoped keys do. */ +function normalizeRelay(relayUrl: string): string { + return relayUrl.trim().replace(/\/+$/, "").toLowerCase(); +} + function watermarkKey( pubkey: string, blobType: string, - relayUrl?: string, + relayUrl: string, ): string { - if (!relayUrl) return `${PREFIX}:${blobType}:${pubkey}`; - return `${PREFIX}:${blobType}:${pubkey}:${encodeURIComponent(relayUrl)}`; + return `${PREFIX}:${blobType}:${pubkey}:${encodeURIComponent(normalizeRelay(relayUrl))}`; } /** Read the persisted watermark (0 when absent or on read error). */ export function readWatermark( pubkey: string, blobType: string, - relayUrl?: string, + relayUrl: string, ): number { try { const raw = window.localStorage.getItem( @@ -61,14 +71,13 @@ export function readWatermark( /** * Persist a new watermark if it is strictly greater than the current value. - * Returns the value actually stored (may be the old value if `next` is not - * newer). + * Absence or error never lowers the watermark (monotonic). */ export function advanceWatermark( pubkey: string, blobType: string, next: number, - relayUrl?: string, + relayUrl: string, ): void { try { const current = readWatermark(pubkey, blobType, relayUrl); diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.ts b/desktop/src/features/sidebar/lib/useChannelMutes.ts index 2d2da97bd76..cab913834dc 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.ts +++ b/desktop/src/features/sidebar/lib/useChannelMutes.ts @@ -14,7 +14,10 @@ import { import { ChannelMuteSyncManager } from "./channelMutesSync"; import type { RemoteMutes } from "./channelMutesSync"; -export function useChannelMutes(pubkey: string | undefined): { +export function useChannelMutes( + pubkey: string | undefined, + relayUrl?: string, +): { mutedChannelIds: Set; muteChannel: (channelId: string) => void; unmuteChannel: (channelId: string) => void; @@ -31,7 +34,7 @@ export function useChannelMutes(pubkey: string | undefined): { const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -40,12 +43,12 @@ export function useChannelMutes(pubkey: string | undefined): { setStore(readChannelMutesStore(pubkey)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelMuteSyncManager(pubkey); + managerRef.current = new ChannelMuteSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; }; - }, [pubkey]); + }, [pubkey, relayUrl]); React.useEffect(() => { if (!pubkey) { @@ -86,30 +89,22 @@ export function useChannelMutes(pubkey: string | undefined): { ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteMutes().then((result) => { + const local = readChannelMutesStore(pubkey); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (result.status === "found") { + if (result.action === "apply-remote") { setStore(applyRemote(result.data)); - } else if (result.status === "absent") { - const seedAllowed = - managerRef.current !== null && - managerRef.current.getPersistedWatermark() === 0; - if (seedAllowed) { - const local = readChannelMutesStore(pubkey); - if (Object.keys(local.channels).length > 0) { - managerRef.current?.publishMutes(local); - } - } } - // status === "failed": do nothing. + // "hold": seed already performed by bootstrap (if first-sync), or blocked. }); return () => { cancelled = true; }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds subscription when the active relay changes even though it is not used inside the effect body directly (the manager via managerRef.current carries it) React.useEffect(() => { if (!pubkey) return; let unsub: (() => Promise) | null = null; @@ -130,8 +125,9 @@ export function useChannelMutes(pubkey: string | undefined): { cancelled = true; if (unsub) void unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds reconnect listener when the active relay changes (community switch) even though it is not referenced directly inside the effect body React.useEffect(() => { if (!pubkey) return; let cancelled = false; @@ -151,7 +147,7 @@ export function useChannelMutes(pubkey: string | undefined): { cancelled = true; unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); // biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes) const mutedChannelIds = React.useMemo( diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 64e10edf6c5..cb8b1d26d36 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -105,30 +105,14 @@ export function useChannelSections( React.useEffect(() => { if (!pubkey) return; let cancelled = false; - void managerRef.current?.fetchRemoteSections().then((result) => { + const local = readChannelSectionsStore(pubkey, relayUrl); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (result.status === "found") { + if (result.action === "apply-remote") { setStore(applyRemote(result.data)); - } else if (result.status === "absent") { - // Genuine first-time sync: only seed-publish when the persisted - // watermark is 0 — if it is > 0 this relay has had a blob before and - // the empty response is transient (auth-race, reconnect, etc.). - const seedAllowed = - managerRef.current !== null && - // The manager hydrates lastRemoteCreatedAt from localStorage in its - // constructor, so reading getPendingStore() would be wrong here — we - // need the watermark the manager was initialised with. We expose it - // via a dedicated accessor to avoid coupling to internals. - managerRef.current.getPersistedWatermark() === 0; - if (seedAllowed) { - const local = readChannelSectionsStore(pubkey, relayUrl); - if (local.sections.length > 0) { - managerRef.current?.publishSections(local); - } - } } - // status === "failed": do nothing — a fetch error or unreadable event - // must never trigger a seed-publish. + // "hold": seed already performed by bootstrap (if first-sync), or + // blocked (failed fetch / prior watermark). Hook does nothing. }); return () => { cancelled = true; diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index 68e1f64e638..357b8afce37 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -103,22 +103,13 @@ export function useChannelSortPreference( React.useEffect(() => { if (!pubkey) return; let cancelled = false; - void managerRef.current?.fetchRemoteSortPrefs().then((result) => { + const local = readChannelSortStore(pubkey, relayUrl); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (result.status === "found") { + if (result.action === "apply-remote") { setStore(applyRemote(result.data)); - } else if (result.status === "absent") { - const seedAllowed = - managerRef.current !== null && - managerRef.current.getPersistedWatermark() === 0; - if (seedAllowed) { - const local = readChannelSortStore(pubkey, relayUrl); - if (Object.keys(local.groups).length > 0) { - managerRef.current?.publishSortPrefs(local); - } - } } - // status === "failed": do nothing. + // "hold": seed already performed by bootstrap (if first-sync), or blocked. }); return () => { cancelled = true; diff --git a/desktop/src/features/sidebar/lib/useChannelStars.ts b/desktop/src/features/sidebar/lib/useChannelStars.ts index 177a8bea551..b19b18a8643 100644 --- a/desktop/src/features/sidebar/lib/useChannelStars.ts +++ b/desktop/src/features/sidebar/lib/useChannelStars.ts @@ -14,7 +14,10 @@ import { import { ChannelStarSyncManager } from "./channelStarsSync"; import type { RemoteStars } from "./channelStarsSync"; -export function useChannelStars(pubkey: string | undefined): { +export function useChannelStars( + pubkey: string | undefined, + relayUrl?: string, +): { starredChannelIds: Set; starChannel: (channelId: string) => void; unstarChannel: (channelId: string) => void; @@ -31,7 +34,7 @@ export function useChannelStars(pubkey: string | undefined): { const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -40,12 +43,12 @@ export function useChannelStars(pubkey: string | undefined): { setStore(readChannelStarsStore(pubkey)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelStarSyncManager(pubkey); + managerRef.current = new ChannelStarSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; }; - }, [pubkey]); + }, [pubkey, relayUrl]); React.useEffect(() => { if (!pubkey) { @@ -86,30 +89,22 @@ export function useChannelStars(pubkey: string | undefined): { ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteStars().then((result) => { + const local = readChannelStarsStore(pubkey); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (result.status === "found") { + if (result.action === "apply-remote") { setStore(applyRemote(result.data)); - } else if (result.status === "absent") { - const seedAllowed = - managerRef.current !== null && - managerRef.current.getPersistedWatermark() === 0; - if (seedAllowed) { - const local = readChannelStarsStore(pubkey); - if (Object.keys(local.channels).length > 0) { - managerRef.current?.publishStars(local); - } - } } - // status === "failed": do nothing. + // "hold": seed already performed by bootstrap (if first-sync), or blocked. }); return () => { cancelled = true; }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds subscription when the active relay changes even though it is not used inside the effect body directly (the manager via managerRef.current carries it) React.useEffect(() => { if (!pubkey) return; let unsub: (() => Promise) | null = null; @@ -130,8 +125,9 @@ export function useChannelStars(pubkey: string | undefined): { cancelled = true; if (unsub) void unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds reconnect listener when the active relay changes (community switch) even though it is not referenced directly inside the effect body React.useEffect(() => { if (!pubkey) return; let cancelled = false; @@ -151,7 +147,7 @@ export function useChannelStars(pubkey: string | undefined): { cancelled = true; unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); // biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes) const starredChannelIds = React.useMemo( From c5d2d7772b2cc65ab2a43073750337d37ab95987 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 6 Aug 2026 14:56:16 -0400 Subject: [PATCH 03/13] fix(desktop): extract shared runBootstrap, import normalizeRelayUrl, add missing test coverage - Extract runBootstrap() into sidebarSyncWatermark.ts; all four managers delegate to it instead of duplicating the 10-line policy body - Import normalizeRelayUrl from selfProfileStorage instead of re-implementing it inline (resolves Paul's MINOR finding) - channelSortSync.test.mjs: migrate all revert-fix tests to drive bootstrap() directly; add getPendingStore()===null on both hold paths (failed + absent+head>0); add LWW-baseline test; add live-sub head-before-decrypt test - channelSectionsSync.test.mjs: add getPendingStore()===null to tests 1 and 2; add live-sub head-before-decrypt test - Fix destroy-test constructors in sections/sort to pass relayUrl (required after relay became mandatory) - sidebarSyncWatermark.test.mjs: add runBootstrap policy tests (5 cases) covering failed/absent+head/first-sync/empty-local/found branches Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/sidebar/lib/channelMutesSync.ts | 28 +-- .../sidebar/lib/channelSectionsSync.test.mjs | 59 ++++- .../sidebar/lib/channelSectionsSync.ts | 30 ++- .../sidebar/lib/channelSortSync.test.mjs | 209 +++++++++++++----- .../features/sidebar/lib/channelSortSync.ts | 28 +-- .../features/sidebar/lib/channelStarsSync.ts | 28 +-- .../sidebar/lib/sidebarSyncWatermark.test.mjs | 89 +++++++- .../sidebar/lib/sidebarSyncWatermark.ts | 53 ++++- 8 files changed, 405 insertions(+), 119 deletions(-) diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.ts b/desktop/src/features/sidebar/lib/channelMutesSync.ts index 88113e761c4..91fec57c17a 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.ts +++ b/desktop/src/features/sidebar/lib/channelMutesSync.ts @@ -14,13 +14,13 @@ import { import { advanceWatermark, readWatermark, + runBootstrap, + type BootstrapResult, type FetchResult, } from "./sidebarSyncWatermark"; /** Result returned by `bootstrap()` — the hook acts on this without publishing. */ -export type BootstrapResult = - | { action: "apply-remote"; data: RemoteMutes } - | { action: "hold" }; +export type { BootstrapResult }; const D_TAG = "channel-mutes"; const BLOB_TYPE = "channel-mutes"; @@ -233,17 +233,17 @@ export class ChannelMuteSyncManager { * the raw head before decrypt on every outcome, and — if genuine first-time * sync is detected — **performs the seed-publish itself**. */ - async bootstrap(localStore: ChannelMuteStore): Promise { - const result = await this.fetchRemoteMutes(); - if (result.status === "found") { - return { action: "apply-remote", data: result.data }; - } - if (result.status === "absent" && this.lastRemoteCreatedAt === 0) { - if (Object.keys(localStore.channels).length > 0) { - this.publishMutes(localStore); - } - } - return { action: "hold" }; + async bootstrap( + localStore: ChannelMuteStore, + ): Promise> { + const fetchResult = await this.fetchRemoteMutes(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0, + publishFn: (s) => this.publishMutes(s), + }); } destroy(): void { diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 028aedb1c7c..8f4c4c8b04d 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -53,7 +53,7 @@ test("destroy: cancels pending publish without flushing to the relay", () => { }; try { - const manager = new ChannelSectionSyncManager("pk-test"); + const manager = new ChannelSectionSyncManager("pk-test", "wss://r.test"); const store = makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }], }); @@ -127,7 +127,7 @@ test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolv }; try { - const manager = new ChannelSectionSyncManager("pk-race"); + const manager = new ChannelSectionSyncManager("pk-race", "wss://r.test"); const store = makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }], }); @@ -167,7 +167,7 @@ test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolv }); test("destroy: is safe to call with no pending publish", () => { - const manager = new ChannelSectionSyncManager("pk-no-pending"); + const manager = new ChannelSectionSyncManager("pk-no-pending", "wss://r.test"); // Should not throw even with nothing queued. assert.doesNotThrow(() => manager.destroy()); }); @@ -189,7 +189,7 @@ test("destroy: cancelPendingPublish clears pendingStore", () => { }; try { - const manager = new ChannelSectionSyncManager("pk-pending-null"); + const manager = new ChannelSectionSyncManager("pk-pending-null", "wss://r.test"); const store = makeStore({ sections: [{ id: "s1", name: "Test", order: 0 }], }); @@ -296,6 +296,11 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstr "hold", "bootstrap must return hold on failed fetch", ); + assert.equal( + manager.getPendingStore(), + null, + "pendingStore must be null after failed fetch — no seed was queued", + ); assert.equal(publishCalls.length, 0, "no publish after failed fetch"); } finally { restore(); @@ -375,6 +380,11 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish via boot "hold", "bootstrap must return hold when watermark > 0", ); + assert.equal( + manager.getPendingStore(), + null, + "pendingStore must be null when watermark > 0 — no seed was queued", + ); assert.equal( publishCalls.length, 0, @@ -562,3 +572,44 @@ test("revert-fix: sections LWW — newer decryptable pre-publish event selected mock.reset(); } }); + +// 7. live-sub: undecryptable event on live path records head before decrypt +// Mutation test: removing recordRemoteHead before decrypt in the live callback +// leaves watermark at 0 after a live event. +test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { + let liveCallback = null; + mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { + liveCallback = onEvent; + return Promise.resolve(async () => {}); + }); + + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-live", RELAY); + assert.equal(manager.getPersistedWatermark(), 0, "watermark starts at 0"); + + // Start the live subscription. + await manager.subscribeToSections(() => {}); + assert.ok(liveCallback !== null, "subscribeLive must have captured the callback"); + + // Deliver an undecryptable event via the live callback. + liveCallback({ + pubkey: "pk-live", + content: "!bad-cipher!", + created_at: 1700005555, + id: "live-evt-1", + }); + + // Drain microtasks so the async decryptAndParse resolves. + await new Promise((r) => setTimeout(r, 0)); + + assert.ok( + manager.getPersistedWatermark() >= 1700005555, + "live undecryptable event must advance the watermark before decrypt is attempted", + ); + } finally { + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index 00396b276c8..7df496b5f5b 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -14,13 +14,13 @@ import { import { advanceWatermark, readWatermark, + runBootstrap, + type BootstrapResult, type FetchResult, } from "./sidebarSyncWatermark"; /** Result returned by `bootstrap()` — the hook acts on this without publishing. */ -export type BootstrapResult = - | { action: "apply-remote"; data: RemoteSections } - | { action: "hold" }; +export type { BootstrapResult }; const D_TAG = "channel-sections"; const BLOB_TYPE = "channel-sections"; @@ -270,19 +270,17 @@ export class ChannelSectionSyncManager { * Returns `apply-remote` with the found data so the hook can apply it to * React state, or `hold` when there is nothing for the hook to do. */ - async bootstrap(localStore: ChannelSectionStore): Promise { - const result = await this.fetchRemoteSections(); - if (result.status === "found") { - return { action: "apply-remote", data: result.data }; - } - if (result.status === "absent" && this.lastRemoteCreatedAt === 0) { - // Genuine first-time sync: seed the relay from local state. - if (localStore.sections.length > 0) { - this.publishSections(localStore); - } - } - // failed, or absent+watermark>0 (stale-dev-build case): hold. - return { action: "hold" }; + async bootstrap( + localStore: ChannelSectionStore, + ): Promise> { + const fetchResult = await this.fetchRemoteSections(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => s.sections.length > 0, + publishFn: (s) => this.publishSections(s), + }); } destroy(): void { diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index 729ea1bc79e..f58bb3f4916 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -45,7 +45,7 @@ test("destroy: cancels pending publish without flushing to the relay", () => { }; try { - const manager = new ChannelSortSyncManager("pk-test"); + const manager = new ChannelSortSyncManager("pk-test", "wss://r.test"); const store = makeStore({ channels: "recent" }); manager.publishSortPrefs(store); @@ -107,7 +107,7 @@ test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolv }; try { - const manager = new ChannelSortSyncManager("pk-race"); + const manager = new ChannelSortSyncManager("pk-race", "wss://r.test"); const store = makeStore({ dms: "recent" }); manager.publishSortPrefs(store); @@ -136,7 +136,7 @@ test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolv }); test("destroy: is safe to call with no pending publish", () => { - const manager = new ChannelSortSyncManager("pk-no-pending"); + const manager = new ChannelSortSyncManager("pk-no-pending", "wss://r.test"); assert.doesNotThrow(() => manager.destroy()); }); @@ -157,7 +157,7 @@ test("destroy: cancelPendingPublish clears pendingStore", () => { }; try { - const manager = new ChannelSortSyncManager("pk-pending-null"); + const manager = new ChannelSortSyncManager("pk-pending-null", "wss://r.test"); const store = makeStore({ starred: "recent" }); manager.publishSortPrefs(store); assert.deepEqual(manager.getPendingStore(), store); @@ -222,8 +222,13 @@ function installFakeWindow(fw) { }; } -// 1. fetch failed → zero publish calls -test("revert-fix: fetch failed (error) does not trigger seed-publish", async () => { +// Watermark key: buzz-sync-watermark.v1:channel-sort:: +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); + +// 1. fetch failed (error/timeout) + local non-empty → hold, zero publish calls +// Mutation: removing the failed guard causes bootstrap to call publishSortPrefs → pendingStore set. +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.reject(new Error("relay timeout")), @@ -235,9 +240,15 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish", async () const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelSortSyncManager("pk-fail", "wss://r.test"); - const result = await manager.fetchRemoteSortPrefs(); - assert.equal(result.status, "failed"); + const manager = new ChannelSortSyncManager("pk-fail", RELAY); + const local = makeStore({ channels: "recent" }); + const result = await manager.bootstrap(local); + assert.equal(result.action, "hold", "bootstrap must return hold on failed fetch"); + assert.equal( + manager.getPendingStore(), + null, + "pendingStore must be null after failed fetch — no seed was queued", + ); assert.equal(publishCalls.length, 0); } finally { restore(); @@ -256,7 +267,7 @@ test("revert-fix: undecryptable event yields failed with createdAt and advances const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelSortSyncManager("pk-dc", "wss://r.test"); + const manager = new ChannelSortSyncManager("pk-dc", RELAY); const result = await manager.fetchRemoteSortPrefs(); assert.equal(result.status, "failed"); assert.equal(result.createdAt, 1700000099); @@ -267,8 +278,9 @@ test("revert-fix: undecryptable event yields failed with createdAt and advances } }); -// 2. absent + persisted head > 0 → no seed-publish -test("revert-fix: absent fetch with prior watermark blocks seed-publish", async () => { +// 2. absent + persisted head > 0 → hold, zero publish calls (the dev-build stale-copy case) +// Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", (...args) => { @@ -277,21 +289,21 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish", async }); const fw = makeFakeWindow(); fw.localStorage.setItem( - "buzz-sync-watermark.v1:channel-sort:pk-stale:wss%3A%2F%2Fr.test", + `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, "1700000000", ); const restore = installFakeWindow(fw); try { - const manager = new ChannelSortSyncManager("pk-stale", "wss://r.test"); + const manager = new ChannelSortSyncManager("pk-stale", RELAY); assert.ok(manager.getPersistedWatermark() > 0); - const result = await manager.fetchRemoteSortPrefs(); - assert.equal(result.status, "absent"); - // Gate: absent AND watermark > 0 → no seed. - if (result.status === "absent" && manager.getPersistedWatermark() === 0) { - manager.publishSortPrefs(makeStore({ channels: "recent" })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 0)); - } + const local = makeStore({ channels: "recent" }); + const result = await manager.bootstrap(local); + assert.equal(result.action, "hold", "bootstrap must return hold when watermark > 0"); + assert.equal( + manager.getPendingStore(), + null, + "pendingStore must be null when watermark > 0 — no seed was queued", + ); assert.equal(publishCalls.length, 0); } finally { restore(); @@ -299,21 +311,22 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish", async } }); -// 3. absent + head 0 + local non-empty → seed allowed -test("revert-fix: absent fetch with zero watermark allows seed-publish", async () => { +// 3. absent + head 0 + local non-empty → seed-publish queued (first-sync preserved) +// Mutation: removing the absent+head-0 seed call leaves pendingStore null. +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 ChannelSortSyncManager("pk-fresh", "wss://r.test"); - assert.equal(manager.getPersistedWatermark(), 0); - const result = await manager.fetchRemoteSortPrefs(); - assert.equal(result.status, "absent"); - // The gate condition is what matters for mutation-sensitivity. + const manager = new ChannelSortSyncManager("pk-fresh", RELAY); + assert.equal(manager.getPersistedWatermark(), 0, "watermark must start at 0"); + const local = makeStore({ channels: "recent" }); + const result = await manager.bootstrap(local); + assert.equal(result.action, "hold", "bootstrap returns hold (seed is async)"); assert.ok( - result.status === "absent" && manager.getPersistedWatermark() === 0, - "seed condition must hold for a fresh manager", + manager.getPendingStore() !== null, + "bootstrap must queue a seed-publish when absent + watermark == 0 + local non-empty", ); } finally { restore(); @@ -321,19 +334,24 @@ test("revert-fix: absent fetch with zero watermark allows seed-publish", async ( } }); -// 4. decrypt failure records head -test("revert-fix: decrypt failure records head and blocks future seed", async () => { +// 4. decrypt failure records head and blocks future seed +test("revert-fix: decrypt failure records head and blocks any future seed-publish", async () => { const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-nd", - content: "!!invalid!!", - created_at: 1700000777, - id: "evt-nd", - }, - ]), - ); + let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { + callCount++; + if (callCount === 1) { + return Promise.resolve([ + { + pubkey: "pk-nd", + content: "!!invalid!!", + created_at: 1700000777, + id: "evt-nd", + }, + ]); + } + return Promise.resolve([]); + }); mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); @@ -341,11 +359,17 @@ test("revert-fix: decrypt failure records head and blocks future seed", async () const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelSortSyncManager("pk-nd", "wss://r.test"); - const result = await manager.fetchRemoteSortPrefs(); - assert.equal(result.status, "failed"); - assert.equal(result.createdAt, 1700000777); + const manager = new ChannelSortSyncManager("pk-nd", RELAY); + const local = makeStore({ channels: "recent" }); + const result = await manager.bootstrap(local); + assert.equal(result.action, "hold", "failed fetch must return hold from bootstrap"); + assert.equal(result.createdAt, undefined); assert.ok(manager.getPersistedWatermark() >= 1700000777); + assert.equal( + manager.getPendingStore(), + null, + "no pending publish after decrypt failure", + ); assert.equal(publishCalls.length, 0); } finally { restore(); @@ -353,7 +377,7 @@ test("revert-fix: decrypt failure records head and blocks future seed", async () } }); -// 5. watermark round-trips across manager instances +// 5. watermark round-trips across manager instances (simulated restart) test("revert-fix: watermark persists across manager instances (simulated restart)", async () => { mock.method(relayClient, "fetchEvents", () => Promise.resolve([ @@ -368,14 +392,99 @@ test("revert-fix: watermark persists across manager instances (simulated restart const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const managerA = new ChannelSortSyncManager("pk-restart", "wss://r.test"); + const managerA = new ChannelSortSyncManager("pk-restart", RELAY); await managerA.fetchRemoteSortPrefs(); assert.ok(managerA.getPersistedWatermark() >= 1700001234); mock.restoreAll(); - const managerB = new ChannelSortSyncManager("pk-restart", "wss://r.test"); + const managerB = new ChannelSortSyncManager("pk-restart", RELAY); assert.ok(managerB.getPersistedWatermark() >= 1700001234); } finally { restore(); mock.reset(); } }); + +// 6. LWW baseline: newer decryptable pre-publish event still wins after an +// undecryptable head was recorded. +// Mutation: removing headBeforeFetch snapshot causes remote to never win. +test("revert-fix: sort LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { + // Boot fetch: undecryptable event, created_at=100 → head recorded to 100. + // Pre-publish fetch: decryptable event, created_at=200 → should win (200 > 100). + let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { + callCount++; + return Promise.resolve([ + { + pubkey: "pk-lww", + content: callCount === 1 ? "bad-cipher" : "good-cipher", + created_at: callCount === 1 ? 100 : 200, + id: `evt-${callCount}`, + }, + ]); + }); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-lww", RELAY); + // Boot fetch: sees event@100 (bad-cipher), records head to 100. + await manager.fetchRemoteSortPrefs(); + assert.ok(manager.getPersistedWatermark() >= 100, "head must be recorded from boot event"); + // Queue a publish — triggers doPublish which calls fetchOwnBlobBeforePublish. + const store = makeStore({ channels: "recent" }); + manager.publishSortPrefs(store); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 10)); + // The pre-publish fetch (callCount=2) sees created_at=200. + // If headBeforeFetch is correctly snapshotted, 200 > 100 → remote wins → watermark advances to 200. + // If NOT snapshotted (bug), 200 > 200 → false → local wins → watermark stays at 100. + assert.ok( + manager.getPersistedWatermark() >= 200, + "pre-publish event created_at=200 must advance the watermark (LWW comparison uses headBeforeFetch not current head)", + ); + } finally { + restore(); + mock.reset(); + } +}); + +// 7. live-sub: undecryptable event on live path records head before decrypt +// Mutation: removing recordRemoteHead before decrypt in the live callback +// leaves watermark at 0 after a live event. +test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { + let liveCallback = null; + mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { + liveCallback = onEvent; + return Promise.resolve(async () => {}); + }); + + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-live", RELAY); + assert.equal(manager.getPersistedWatermark(), 0, "watermark starts at 0"); + + await manager.subscribeToSortPrefs(() => {}); + assert.ok(liveCallback !== null, "subscribeLive must have captured the callback"); + + // Deliver an undecryptable event via the live callback. + liveCallback({ + pubkey: "pk-live", + content: "!bad-cipher!", + created_at: 1700005555, + id: "live-evt-1", + }); + + // Drain microtasks so the async decryptAndParse resolves. + await new Promise((r) => setTimeout(r, 0)); + + assert.ok( + manager.getPersistedWatermark() >= 1700005555, + "live undecryptable event must advance the watermark before decrypt is attempted", + ); + } finally { + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelSortSync.ts b/desktop/src/features/sidebar/lib/channelSortSync.ts index 91605b9ef90..2bfee8ff98e 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.ts +++ b/desktop/src/features/sidebar/lib/channelSortSync.ts @@ -13,13 +13,13 @@ import { import { advanceWatermark, readWatermark, + runBootstrap, + type BootstrapResult, type FetchResult, } from "./sidebarSyncWatermark"; /** Result returned by `bootstrap()` — the hook acts on this without publishing. */ -export type BootstrapResult = - | { action: "apply-remote"; data: RemoteSortPrefs } - | { action: "hold" }; +export type { BootstrapResult }; const D_TAG = "channel-sort"; const BLOB_TYPE = "channel-sort"; @@ -247,17 +247,17 @@ export class ChannelSortSyncManager { * the raw head before decrypt on every outcome, and — if genuine first-time * sync is detected — **performs the seed-publish itself**. */ - async bootstrap(localStore: ChannelSortStore): Promise { - const result = await this.fetchRemoteSortPrefs(); - if (result.status === "found") { - return { action: "apply-remote", data: result.data }; - } - if (result.status === "absent" && this.lastRemoteCreatedAt === 0) { - if (Object.keys(localStore.groups).length > 0) { - this.publishSortPrefs(localStore); - } - } - return { action: "hold" }; + async bootstrap( + localStore: ChannelSortStore, + ): Promise> { + const fetchResult = await this.fetchRemoteSortPrefs(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => Object.keys(s.groups).length > 0, + publishFn: (s) => this.publishSortPrefs(s), + }); } destroy(): void { diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.ts b/desktop/src/features/sidebar/lib/channelStarsSync.ts index 73e88619978..fe260adbbad 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.ts +++ b/desktop/src/features/sidebar/lib/channelStarsSync.ts @@ -14,13 +14,13 @@ import { import { advanceWatermark, readWatermark, + runBootstrap, + type BootstrapResult, type FetchResult, } from "./sidebarSyncWatermark"; /** Result returned by `bootstrap()` — the hook acts on this without publishing. */ -export type BootstrapResult = - | { action: "apply-remote"; data: RemoteStars } - | { action: "hold" }; +export type { BootstrapResult }; const D_TAG = "channel-stars"; const BLOB_TYPE = "channel-stars"; @@ -233,17 +233,17 @@ export class ChannelStarSyncManager { * the raw head before decrypt on every outcome, and — if genuine first-time * sync is detected — **performs the seed-publish itself**. */ - async bootstrap(localStore: ChannelStarStore): Promise { - const result = await this.fetchRemoteStars(); - if (result.status === "found") { - return { action: "apply-remote", data: result.data }; - } - if (result.status === "absent" && this.lastRemoteCreatedAt === 0) { - if (Object.keys(localStore.channels).length > 0) { - this.publishStars(localStore); - } - } - return { action: "hold" }; + async bootstrap( + localStore: ChannelStarStore, + ): Promise> { + const fetchResult = await this.fetchRemoteStars(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0, + publishFn: (s) => this.publishStars(s), + }); } destroy(): void { diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs index 1196d55861e..26ccff2e506 100644 --- a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs @@ -32,7 +32,7 @@ function withFreshStorage(fn) { } } -const { readWatermark, advanceWatermark } = await import( +const { readWatermark, advanceWatermark, runBootstrap } = await import( "./sidebarSyncWatermark.ts" ); @@ -155,3 +155,90 @@ test("relay-A watermark is preserved after relay-B session", () => { ); }); }); + +// ── runBootstrap policy ─────────────────────────────────────────────────────── +// +// The shared policy is tested once here so that a mutation to any one branch +// cannot hide behind per-surface duplication. + +function makeBootstrapArgs({ fetchResult, lastHead, localNonEmpty }) { + let publishCount = 0; + return { + args: { + fetchResult, + lastHead, + localStore: { items: localNonEmpty ? ["x"] : [] }, + isLocalNonEmpty: (s) => s.items.length > 0, + publishFn: (_s) => { + publishCount++; + }, + }, + publishCount: () => publishCount, + }; +} + +// Guard: fetch failed → hold, zero publishes. +// Mutation: removing the failed branch causes a seed on first-sync case. +test("runBootstrap: fetch failed returns hold and never calls publishFn", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "failed" }, + lastHead: 0, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal(publishCount(), 0, "publishFn must not be called on failed fetch"); +}); + +// Guard: fetch absent + prior head > 0 → hold, zero publishes (stale-dev-build case). +// Mutation: setting lastHead to 0 causes a seed. +test("runBootstrap: fetch absent with prior head returns hold and never calls publishFn", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "absent" }, + lastHead: 1700000000, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal(publishCount(), 0, "publishFn must not be called when prior head exists"); +}); + +// Guard: fetch absent + head 0 + local non-empty → publishFn called exactly once, hold returned. +// Mutation: removing the absent+head-0 seed call leaves publishCount at 0. +test("runBootstrap: first-sync (absent + zero head + non-empty local) calls publishFn and returns hold", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "absent" }, + lastHead: 0, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal(publishCount(), 1, "publishFn must be called exactly once on first-sync"); +}); + +// Guard: fetch absent + head 0 + empty local → no publish, hold returned. +test("runBootstrap: first-sync with empty local store does not call publishFn", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "absent" }, + lastHead: 0, + localNonEmpty: false, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal(publishCount(), 0, "empty local store must not trigger seed"); +}); + +// Guard: fetch found → apply-remote returned, no publish. +// Mutation: removing the found branch drops the remote data. +test("runBootstrap: fetch found returns apply-remote with data and never calls publishFn", () => { + const remoteData = { store: { version: 1, items: [] }, createdAt: 100, eventId: "e1" }; + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "found", data: remoteData, createdAt: 100, eventId: "e1" }, + lastHead: 0, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "apply-remote"); + assert.deepEqual(result.data, remoteData); + assert.equal(publishCount(), 0, "publishFn must not be called when remote was found"); +}); diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts index 11e2a27e6bf..2504c4c6271 100644 --- a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts @@ -19,6 +19,8 @@ * two ways never produces two different keys. */ +import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; + const PREFIX = "buzz-sync-watermark.v1"; /** @@ -38,17 +40,12 @@ export type FetchResult = | { status: "absent" } | { status: "failed"; createdAt?: number }; -/** Normalise a relay URL the same way all relay-scoped keys do. */ -function normalizeRelay(relayUrl: string): string { - return relayUrl.trim().replace(/\/+$/, "").toLowerCase(); -} - function watermarkKey( pubkey: string, blobType: string, relayUrl: string, ): string { - return `${PREFIX}:${blobType}:${pubkey}:${encodeURIComponent(normalizeRelay(relayUrl))}`; + return `${PREFIX}:${blobType}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; } /** Read the persisted watermark (0 when absent or on read error). */ @@ -92,3 +89,47 @@ export function advanceWatermark( // across sessions. } } + +/** Result returned by `bootstrap()` — the hook acts on this without publishing. */ +export type BootstrapResult = + | { action: "apply-remote"; data: T } + | { action: "hold" }; + +/** + * Shared boot policy for all four sidebar-preference sync managers. + * + * Each manager calls this from its `bootstrap()` method, supplying its + * surface-specific fetch, publish, and local-store accessors. The full + * decision lives here once so that a mutation to any one surface cannot + * escape via a per-manager copy. + * + * Policy: + * - `found` → return `apply-remote`; hook applies data. + * - `failed` → hold; seed-publish blocked (error or unreadable event). + * - `absent` + `lastHead > 0` → hold; relay blob seen before, absence may be transient. + * - `absent` + `lastHead === 0` + non-empty local → call `publishFn(local)`; return `hold`. + * - `absent` + `lastHead === 0` + empty local → hold; nothing to seed. + */ +export function runBootstrap({ + fetchResult, + lastHead, + localStore, + isLocalNonEmpty, + publishFn, +}: { + fetchResult: FetchResult; + lastHead: number; + localStore: TLocal; + isLocalNonEmpty: (store: TLocal) => boolean; + publishFn: (store: TLocal) => void; +}): BootstrapResult { + if (fetchResult.status === "found") { + return { action: "apply-remote", data: fetchResult.data }; + } + if (fetchResult.status === "absent" && lastHead === 0) { + if (isLocalNonEmpty(localStore)) { + publishFn(localStore); + } + } + return { action: "hold" }; +} From fedc22bb82e842b3e7d0db59214423b314605f23 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 6 Aug 2026 15:00:24 -0400 Subject: [PATCH 04/13] fix(desktop): remove per-surface policy tests subsumed by shared watermark tests Delete the redundant test cases from each surface suite that are now fully covered by sidebarSyncWatermark.test.mjs (runBootstrap policy tests): - Remove test 1b (undecryptable event details via fetchRemote*) from all 4 - Remove test 4 (decrypt failure records head) from sections, sort, stars, mutes - Remove test 5 (watermark restart round-trip) from sections, sort, stars, mutes Per-surface suites now keep only load-bearing wiring tests: sections/sort: failed hold, absent+head hold, first-sync seed, LWW-baseline, live-sub head recording stars/mutes: failed hold, absent+head hold, first-sync seed, relay-A/B isolation, destroy-alignment Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../sidebar/lib/channelMutesSync.test.mjs | 128 +----------------- .../sidebar/lib/channelSectionsSync.test.mjs | 127 +---------------- .../sidebar/lib/channelSortSync.test.mjs | 96 +------------ .../sidebar/lib/channelStarsSync.test.mjs | 128 +----------------- 4 files changed, 6 insertions(+), 473 deletions(-) diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs index b466c464ba5..f20aa616aef 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs @@ -166,46 +166,6 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstr } }); -// 1b. undecryptable event → failed + head recorded (all observation paths) -// Mutation: removing recordRemoteHead before decrypt leaves watermark at 0. -test("revert-fix: undecryptable event records head and blocks seed-publish via bootstrap", async () => { - const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { pubkey: "pk-dc", content: "!bad!", created_at: 1700000099, id: "e1" }, - ]), - ); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelMuteSyncManager("pk-dc", RELAY); - const local = makeStore({ ch1: { muted: true, updatedAt: 1 } }); - const result = await manager.bootstrap(local); - assert.equal( - result.action, - "hold", - "undecryptable event must yield hold from bootstrap", - ); - assert.ok( - manager.getPersistedWatermark() >= 1700000099, - "watermark must be recorded from the unreadable event", - ); - assert.equal( - manager.getPendingMuteStore(), - null, - "no pending publish after undecryptable event", - ); - assert.equal(publishCalls.length, 0); - } finally { - restore(); - mock.reset(); - } -}); - // 2. fetch absent + persisted head > 0 → zero publish calls (the dev-build stale-copy case) // Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { @@ -279,93 +239,7 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy } }); -// 4. decrypt failure records head and blocks future seed (via bootstrap) -// Covers the full path: boot fetch sees undecryptable event → head recorded → still holds. -test("revert-fix: decrypt failure records head and blocks any future seed-publish", async () => { - const publishCalls = []; - let callCount = 0; - mock.method(relayClient, "fetchEvents", () => { - callCount++; - if (callCount === 1) { - return Promise.resolve([ - { - pubkey: "pk-nodecrypt", - content: "!!invalid-base64!!", - created_at: 1700000777, - id: "evt-nodecrypt", - }, - ]); - } - return 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-nodecrypt", RELAY); - const local = makeStore({ ch1: { muted: true, updatedAt: 1 } }); - const result = await manager.bootstrap(local); - assert.equal( - result.action, - "hold", - "failed fetch must return hold from bootstrap", - ); - assert.ok( - manager.getPersistedWatermark() >= 1700000777, - "watermark must be advanced to event.created_at", - ); - assert.equal( - manager.getPendingMuteStore(), - null, - "no pending publish after decrypt failure", - ); - assert.equal(publishCalls.length, 0); - } finally { - restore(); - mock.reset(); - } -}); - -// 5. watermark round-trips across manager instances (simulated restart) -// Mutation: removing localStorage write in advanceWatermark leaves managerB at 0. -test("revert-fix: watermark persists across manager instances (simulated restart)", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-restart", - content: "!bad!", - created_at: 1700001234, - id: "evt-r", - }, - ]), - ); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - // Session A: manager sees an event → watermark written to localStorage. - const managerA = new ChannelMuteSyncManager("pk-restart", RELAY); - await managerA.fetchRemoteMutes(); - assert.ok( - managerA.getPersistedWatermark() >= 1700001234, - "session A watermark must be set", - ); - mock.restoreAll(); - // Session B: new manager instance reads the same localStorage. - const managerB = new ChannelMuteSyncManager("pk-restart", RELAY); - assert.ok( - managerB.getPersistedWatermark() >= 1700001234, - "session B must inherit watermark from localStorage without another fetch", - ); - } finally { - restore(); - mock.reset(); - } -}); - -// 6. relay-A / relay-B watermark isolation +// 4. relay-A / relay-B watermark isolation // Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync. test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => { const relayA = "wss://a.relay.test"; diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 8f4c4c8b04d..990c7c455aa 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -308,47 +308,6 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstr } }); -// 1b. fetch failed (event exists but won't decrypt) → failed, head recorded -test("revert-fix: undecryptable event yields failed with createdAt set", async () => { - const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-decrypt", - content: "bad-cipher", - created_at: 1700000099, - id: "evt-bad", - }, - ]), - ); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-decrypt", RELAY); - const result = await manager.fetchRemoteSections(); - assert.equal(result.status, "failed"); - assert.equal( - result.createdAt, - 1700000099, - "createdAt must be recorded from the unreadable event", - ); - // Manager must have recorded the head watermark so seed-publish is blocked. - assert.ok( - manager.getPersistedWatermark() > 0, - "watermark must be > 0 after seeing an undecryptable event", - ); - assert.equal(publishCalls.length, 0); - } finally { - restore(); - mock.reset(); - } -}); - // 2. fetch absent + persisted head > 0 → zero publish calls (the dev-build stale-copy case) // Mutation test: setting watermark to 0 in localStorage causes bootstrap to seed. test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { @@ -434,89 +393,7 @@ test("revert-fix: absent fetch with zero watermark allows seed-publish via boots } }); -// 4. existing event that fails decrypt → no seed, head recorded from event.created_at -test("revert-fix: decrypt failure records head and blocks any future seed-publish", async () => { - const publishCalls = []; - let callCount = 0; - mock.method(relayClient, "fetchEvents", () => { - callCount++; - if (callCount === 1) { - return Promise.resolve([ - { - pubkey: "pk-nodecrypt", - content: "!!invalid-base64!!", - created_at: 1700000777, - id: "evt-nodecrypt", - }, - ]); - } - return Promise.resolve([]); - }); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-nodecrypt", RELAY); - const local = makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]); - const result = await manager.bootstrap(local); - assert.equal( - result.action, - "hold", - "failed fetch must return hold from bootstrap", - ); - assert.ok( - manager.getPersistedWatermark() >= 1700000777, - "watermark must be advanced to event.created_at", - ); - assert.equal(publishCalls.length, 0, "no publish after decrypt failure"); - } finally { - restore(); - mock.reset(); - } -}); - -// 5. watermark round-trips across manager instances (simulated restart) -test("revert-fix: watermark persists and is read by a new manager instance", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-restart", - content: "bad-cipher", - created_at: 1700001234, - id: "evt-restart", - }, - ]), - ); - - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - // Session A: manager sees an event → watermark written to localStorage. - const managerA = new ChannelSectionSyncManager("pk-restart", RELAY); - await managerA.fetchRemoteSections(); - assert.ok( - managerA.getPersistedWatermark() >= 1700001234, - "session A watermark must be set", - ); - - // Session B: new manager instance reads the same localStorage. - mock.restoreAll(); - const managerB = new ChannelSectionSyncManager("pk-restart", RELAY); - assert.ok( - managerB.getPersistedWatermark() >= 1700001234, - "session B must inherit watermark from localStorage without another fetch", - ); - } finally { - restore(); - mock.reset(); - } -}); - -// 6. LWW baseline: newer decryptable pre-publish event still wins after an +// 4. LWW baseline: newer decryptable pre-publish event still wins after an // undecryptable head was recorded. // Mutation test: removing headBeforeFetch snapshot causes remote to never win. test("revert-fix: sections LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { @@ -573,7 +450,7 @@ test("revert-fix: sections LWW — newer decryptable pre-publish event selected } }); -// 7. live-sub: undecryptable event on live path records head before decrypt +// 5. live-sub: undecryptable event on live path records head before decrypt // Mutation test: removing recordRemoteHead before decrypt in the live callback // leaves watermark at 0 after a live event. test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index f58bb3f4916..ba6acaecea4 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -256,28 +256,6 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstr } }); -// 1b. undecryptable event → failed + head recorded -test("revert-fix: undecryptable event yields failed with createdAt and advances watermark", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { pubkey: "pk-dc", content: "!bad!", created_at: 1700000099, id: "e1" }, - ]), - ); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-dc", RELAY); - const result = await manager.fetchRemoteSortPrefs(); - assert.equal(result.status, "failed"); - assert.equal(result.createdAt, 1700000099); - assert.ok(manager.getPersistedWatermark() > 0); - } finally { - restore(); - mock.reset(); - } -}); - // 2. absent + persisted head > 0 → hold, zero publish calls (the dev-build stale-copy case) // Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { @@ -334,77 +312,7 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy } }); -// 4. decrypt failure records head and blocks future seed -test("revert-fix: decrypt failure records head and blocks any future seed-publish", async () => { - const publishCalls = []; - let callCount = 0; - mock.method(relayClient, "fetchEvents", () => { - callCount++; - if (callCount === 1) { - return Promise.resolve([ - { - pubkey: "pk-nd", - content: "!!invalid!!", - created_at: 1700000777, - id: "evt-nd", - }, - ]); - } - return Promise.resolve([]); - }); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-nd", RELAY); - const local = makeStore({ channels: "recent" }); - const result = await manager.bootstrap(local); - assert.equal(result.action, "hold", "failed fetch must return hold from bootstrap"); - assert.equal(result.createdAt, undefined); - assert.ok(manager.getPersistedWatermark() >= 1700000777); - assert.equal( - manager.getPendingStore(), - null, - "no pending publish after decrypt failure", - ); - assert.equal(publishCalls.length, 0); - } finally { - restore(); - mock.reset(); - } -}); - -// 5. watermark round-trips across manager instances (simulated restart) -test("revert-fix: watermark persists across manager instances (simulated restart)", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-restart", - content: "!bad!", - created_at: 1700001234, - id: "evt-r", - }, - ]), - ); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const managerA = new ChannelSortSyncManager("pk-restart", RELAY); - await managerA.fetchRemoteSortPrefs(); - assert.ok(managerA.getPersistedWatermark() >= 1700001234); - mock.restoreAll(); - const managerB = new ChannelSortSyncManager("pk-restart", RELAY); - assert.ok(managerB.getPersistedWatermark() >= 1700001234); - } finally { - restore(); - mock.reset(); - } -}); - -// 6. LWW baseline: newer decryptable pre-publish event still wins after an +// 4. LWW baseline: newer decryptable pre-publish event still wins after an // undecryptable head was recorded. // Mutation: removing headBeforeFetch snapshot causes remote to never win. test("revert-fix: sort LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { @@ -449,7 +357,7 @@ test("revert-fix: sort LWW — newer decryptable pre-publish event selected afte } }); -// 7. live-sub: undecryptable event on live path records head before decrypt +// 5. live-sub: undecryptable event on live path records head before decrypt // Mutation: removing recordRemoteHead before decrypt in the live callback // leaves watermark at 0 after a live event. test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs index e80a401a5bc..7bdb2c90eaa 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs @@ -182,46 +182,6 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstr } }); -// 1b. undecryptable event → failed + head recorded (all observation paths) -// Mutation: removing recordRemoteHead before decrypt leaves watermark at 0. -test("revert-fix: undecryptable event records head and blocks seed-publish via bootstrap", async () => { - const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { pubkey: "pk-dc", content: "!bad!", created_at: 1700000099, id: "e1" }, - ]), - ); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelStarSyncManager("pk-dc", RELAY); - const local = makeStore({ ch1: { starred: true, updatedAt: 1 } }); - const result = await manager.bootstrap(local); - assert.equal( - result.action, - "hold", - "undecryptable event must yield hold from bootstrap", - ); - assert.ok( - manager.getPersistedWatermark() >= 1700000099, - "watermark must be recorded from the unreadable event", - ); - assert.equal( - manager.getPendingStarStore(), - null, - "no pending publish after undecryptable event", - ); - assert.equal(publishCalls.length, 0); - } finally { - restore(); - mock.reset(); - } -}); - // 2. fetch absent + persisted head > 0 → zero publish calls (the dev-build stale-copy case) // Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { @@ -295,93 +255,7 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy } }); -// 4. decrypt failure records head and blocks future seed (via bootstrap) -// Covers the full path: boot fetch sees undecryptable event → head recorded → second call still holds. -test("revert-fix: decrypt failure records head and blocks any future seed-publish", async () => { - const publishCalls = []; - let callCount = 0; - mock.method(relayClient, "fetchEvents", () => { - callCount++; - if (callCount === 1) { - return Promise.resolve([ - { - pubkey: "pk-nodecrypt", - content: "!!invalid-base64!!", - created_at: 1700000777, - id: "evt-nodecrypt", - }, - ]); - } - return 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-nodecrypt", RELAY); - const local = makeStore({ ch1: { starred: true, updatedAt: 1 } }); - const result = await manager.bootstrap(local); - assert.equal( - result.action, - "hold", - "failed fetch must return hold from bootstrap", - ); - assert.ok( - manager.getPersistedWatermark() >= 1700000777, - "watermark must be advanced to event.created_at", - ); - assert.equal( - manager.getPendingStarStore(), - null, - "no pending publish after decrypt failure", - ); - assert.equal(publishCalls.length, 0); - } finally { - restore(); - mock.reset(); - } -}); - -// 5. watermark round-trips across manager instances (simulated restart) -// Mutation: removing localStorage write in advanceWatermark leaves managerB at 0. -test("revert-fix: watermark persists across manager instances (simulated restart)", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-restart", - content: "!bad!", - created_at: 1700001234, - id: "evt-r", - }, - ]), - ); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - // Session A: manager sees an event → watermark written to localStorage. - const managerA = new ChannelStarSyncManager("pk-restart", RELAY); - await managerA.fetchRemoteStars(); - assert.ok( - managerA.getPersistedWatermark() >= 1700001234, - "session A watermark must be set", - ); - mock.restoreAll(); - // Session B: new manager instance reads the same localStorage. - const managerB = new ChannelStarSyncManager("pk-restart", RELAY); - assert.ok( - managerB.getPersistedWatermark() >= 1700001234, - "session B must inherit watermark from localStorage without another fetch", - ); - } finally { - restore(); - mock.reset(); - } -}); - -// 6. relay-A / relay-B watermark isolation +// 4. relay-A / relay-B watermark isolation // Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync. test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => { const relayA = "wss://a.relay.test"; From b257b5d24f2427b33c9a4f3cf3763e2cb4408240 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 6 Aug 2026 15:11:05 -0400 Subject: [PATCH 05/13] test(desktop): compress per-surface test helpers and destroy tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move makeFakeWindow/installFakeWindow to top of sections and sort test files so destroy tests can use the same helper instead of inline setup. Trim verbose assertion messages in stars/mutes wiring tests 1-3. No coverage change — all guards, mutations, and assertions preserved. Net: -509 lines across four test files. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../sidebar/lib/channelMutesSync.test.mjs | 140 ++---- .../sidebar/lib/channelSectionsSync.test.mjs | 429 ++++-------------- .../sidebar/lib/channelSortSync.test.mjs | 325 ++++--------- .../sidebar/lib/channelStarsSync.test.mjs | 164 ++----- 4 files changed, 247 insertions(+), 811 deletions(-) diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs index f20aa616aef..e86b41ef7c1 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs @@ -4,11 +4,8 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelMuteSyncManager } from "./channelMutesSync.ts"; -// Relay URL used in all tests. Watermark key format: -// buzz-sync-watermark.v1:channel-mutes:: -// normalizeRelay: trim + lowercase + strip trailing slash. const RELAY = "wss://r.test"; -const RELAY_KEY = encodeURIComponent(RELAY); // "wss%3A%2F%2Fr.test" +const RELAY_KEY = encodeURIComponent(RELAY); function makeStore(channels = {}) { return { version: 1, channels }; @@ -24,7 +21,7 @@ function makeFakeWindow() { }; let timerCallback = null; let nextTimerId = 100; - const fw = { + return { localStorage: ls, setTimeout: (fn, _ms) => { timerCallback = fn; @@ -41,7 +38,6 @@ function makeFakeWindow() { } }, }; - return fw; } function installFakeWindow(fw) { @@ -67,16 +63,12 @@ function installFakeWindow(fw) { 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(); - }); + 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); - const store = makeStore({ ch1: { muted: true, updatedAt: 100 } }); - manager.publishMutes(store); + manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 100 } })); manager.destroy(); assert.equal(publishCalls.length, 0); assert.equal(manager.getPendingMuteStore(), null); @@ -89,21 +81,13 @@ test("destroy: cancels pending publish without flushing to the relay", () => { test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { let releaseFetch = null; const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => { - return new Promise((resolve) => { - releaseFetch = () => resolve([]); - }); - }); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); + 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); - const store = makeStore({ ch1: { muted: true, updatedAt: 100 } }); - manager.publishMutes(store); + manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 100 } })); fw._fireTimer(); manager.destroy(); releaseFetch(); @@ -127,88 +111,44 @@ test("destroy: is safe to call with no pending publish", () => { }); // ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── -// -// All tests below drive the production bootstrap() path so that a regression -// in that code — not just a hook wiring change — causes a test failure. -// Mutation-sensitivity note: each guard is named in the comment before the test. -// 1. fetch failed (error/timeout) + local non-empty → zero publish calls -// Mutation: removing the `failed` guard causes bootstrap to call publishMutes → pendingStore set. +// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { - const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("relay timeout")), - ); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); + 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 local = makeStore({ ch1: { muted: true, updatedAt: 1 } }); - const result = await manager.bootstrap(local); - assert.equal( - result.action, - "hold", - "bootstrap must return hold on failed fetch", - ); - assert.equal( - manager.getPendingMuteStore(), - null, - "no pending publish after failed fetch", - ); - assert.equal(publishCalls.length, 0); + const result = await manager.bootstrap(makeStore({ ch1: { muted: true, updatedAt: 1 } })); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingMuteStore(), null); } finally { restore(); mock.reset(); } }); -// 2. fetch absent + persisted head > 0 → zero publish calls (the dev-build stale-copy case) -// Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. +// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { - const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); - // Pre-seed a watermark with the relay-scoped key (simulates a prior session). - fw.localStorage.setItem( - `buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`, - "1700000000", - ); + 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); - assert.ok( - manager.getPersistedWatermark() > 0, - "manager must read relay-scoped watermark from localStorage at construction", - ); - const local = makeStore({ ch1: { muted: true, updatedAt: 1 } }); - const result = await manager.bootstrap(local); - assert.equal( - result.action, - "hold", - "bootstrap must return hold when watermark > 0", - ); - assert.equal( - manager.getPendingMuteStore(), - null, - "watermark > 0 must block seed-publish even on absent fetch", - ); - assert.equal(publishCalls.length, 0); + assert.ok(manager.getPersistedWatermark() > 0); + const result = await manager.bootstrap(makeStore({ ch1: { muted: true, updatedAt: 1 } })); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingMuteStore(), null); } finally { restore(); mock.reset(); } }); -// 3. fetch absent + head 0 + local non-empty → seed-publish fires (first-sync preserved) -// Mutation: removing the absent+head-0 seed call leaves pendingStore null. +// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) 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()); @@ -216,23 +156,10 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy const restore = installFakeWindow(fw); try { const manager = new ChannelMuteSyncManager("pk-fresh", RELAY); - assert.equal( - manager.getPersistedWatermark(), - 0, - "watermark must start at 0", - ); - const local = makeStore({ ch1: { muted: true, updatedAt: 1 } }); - const result = await manager.bootstrap(local); - assert.equal( - result.action, - "hold", - "bootstrap returns hold (seed is async)", - ); - // bootstrap must have queued a publish via publishMutes — pendingStore is set immediately. - assert.ok( - manager.getPendingMuteStore() !== null, - "bootstrap must queue a seed-publish when absent + watermark == 0 + local non-empty", - ); + assert.equal(manager.getPersistedWatermark(), 0); + const result = await manager.bootstrap(makeStore({ ch1: { muted: true, updatedAt: 1 } })); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingMuteStore() !== null); } finally { restore(); mock.reset(); @@ -247,28 +174,17 @@ test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); - // Simulate relay A having a prior head. fw.localStorage.setItem( `buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayA)}`, "1700000100", ); const restore = installFakeWindow(fw); try { - // Manager on relay B must start with watermark 0 despite relay A having one. const managerB = new ChannelMuteSyncManager("pk-iso", relayB); - assert.equal( - managerB.getPersistedWatermark(), - 0, - "relay B watermark must be independent of relay A head", - ); - // And first-sync seed on relay B should be allowed. - const local = makeStore({ ch1: { muted: true, updatedAt: 1 } }); - const result = await managerB.bootstrap(local); + assert.equal(managerB.getPersistedWatermark(), 0, "relay B watermark must be independent of relay A head"); + const result = await managerB.bootstrap(makeStore({ ch1: { muted: true, updatedAt: 1 } })); assert.equal(result.action, "hold"); - assert.ok( - managerB.getPendingMuteStore() !== null, - "first-sync seed on relay B must not be blocked by relay A watermark", - ); + assert.ok(managerB.getPendingMuteStore() !== null, "first-sync seed on relay B must not be blocked by relay A watermark"); } finally { restore(); mock.reset(); diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 990c7c455aa..bd24f8c6d7e 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -13,205 +13,8 @@ function makeStore(overrides = {}) { }; } -// ─── destroy() must cancel pending publish, not flush ───────────────────────── - -// Regression guard for the community-switch cross-relay publish vector: -// edit sections in relay A → destroy() is called (relayUrl dep change) → -// no publish should fire. The scoped localStorage write is durable; when the -// user returns to relay A the seed-publish path handles it. -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(); - }); - - // Simulate the timer scheduler with a manual clock so we can advance it. - let timerCallback = null; - const originalSetTimeout = globalThis.window?.setTimeout; - const originalClearTimeout = globalThis.window?.clearTimeout; - - // Inject a fake window.setTimeout/clearTimeout if needed. - const fakeTimers = []; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - globalThis.window.setTimeout = (fn, _ms) => { - const id = nextId++; - fakeTimers.push({ id, fn }); - timerCallback = fn; - return id; - }; - globalThis.window.clearTimeout = (id) => { - const idx = fakeTimers.findIndex((t) => t.id === id); - if (idx !== -1) { - fakeTimers.splice(idx, 1); - timerCallback = null; - } - }; - - try { - const manager = new ChannelSectionSyncManager("pk-test", "wss://r.test"); - const store = makeStore({ - sections: [{ id: "s1", name: "Work", order: 0 }], - }); - - // Queue a publish — this sets the debounce timer. - manager.publishSections(store); - assert.ok(timerCallback !== null, "debounce timer should be set"); - - // Destroy before the debounce fires — simulates community switch. - manager.destroy(); - - // Timer must be cleared and no publish should fire now. - assert.ok( - timerCallback === null, - "debounce timer should be cleared on destroy", - ); - - // Advance time by invoking the callback that was cleared — it shouldn't exist. - // If clearTimeout didn't work, try firing whatever was captured before destroy. - // (There's nothing to fire after a correct destroy.) - assert.equal( - publishCalls.length, - 0, - "no publish event should have been sent after destroy", - ); - } finally { - // Restore timer functions. - if (originalSetTimeout !== undefined) { - globalThis.window.setTimeout = originalSetTimeout; - } - if (originalClearTimeout !== undefined) { - globalThis.window.clearTimeout = originalClearTimeout; - } - mock.reset(); - } -}); - -// Regression guard for the timer-fired race: debounce fires → doPublish starts -// awaiting fetchOwnBlobBeforePublish → destroy() is called (relayUrl dep -// change) → publishEvent must never be called even though the timer already -// fired and cleared itself before destroy() ran. -test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { - // fetchEvents is held until we release it — simulates the latency window. - let releaseFetch = null; - const publishCalls = []; - - mock.method(relayClient, "fetchEvents", () => { - return new Promise((resolve) => { - // resolve with empty so fetchOwnBlobBeforePublish returns the local store - releaseFetch = () => resolve([]); - }); - }); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - let capturedCallback = null; - let nextId = 1; - const origSetTimeout = globalThis.window.setTimeout; - const origClearTimeout = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - capturedCallback = fn; - return nextId++; - }; - globalThis.window.clearTimeout = (_id) => { - capturedCallback = null; - }; - - try { - const manager = new ChannelSectionSyncManager("pk-race", "wss://r.test"); - const store = makeStore({ - sections: [{ id: "s1", name: "Work", order: 0 }], - }); - - // Queue the publish — captures the debounce callback. - manager.publishSections(store); - assert.ok(capturedCallback !== null, "debounce timer should be set"); - - // Fire the debounce manually — this starts doPublish() and nulls - // debounceTimer inside publishSections' callback, leaving the async - // doPublish running and awaiting fetchOwnBlobBeforePublish. - const timerFn = capturedCallback; - capturedCallback = null; // timer cleared itself inside the callback - timerFn(); - - // Now destroy() — debounceTimer is already null (timer fired), so only - // the destroyed flag can stop doPublish. - manager.destroy(); - - // Release the held fetchEvents — fetchOwnBlobBeforePublish resolves with - // the local store, then doPublish should check destroyed and abort. - releaseFetch(); - - // Drain microtasks so doPublish fully runs through to its abort point. - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.equal( - publishCalls.length, - 0, - "publishEvent must not be called after destroy() even when timer already fired", - ); - } finally { - globalThis.window.setTimeout = origSetTimeout; - globalThis.window.clearTimeout = origClearTimeout; - mock.reset(); - } -}); - -test("destroy: is safe to call with no pending publish", () => { - const manager = new ChannelSectionSyncManager("pk-no-pending", "wss://r.test"); - // Should not throw even with nothing queued. - assert.doesNotThrow(() => manager.destroy()); -}); - -test("destroy: cancelPendingPublish clears pendingStore", () => { - let timerCallback = null; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - const orig = globalThis.window.setTimeout; - const origClear = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - timerCallback = fn; - return nextId++; - }; - globalThis.window.clearTimeout = (_id) => { - timerCallback = null; - }; - - try { - const manager = new ChannelSectionSyncManager("pk-pending-null", "wss://r.test"); - const store = makeStore({ - sections: [{ id: "s1", name: "Test", order: 0 }], - }); - manager.publishSections(store); - assert.deepEqual(manager.getPendingStore(), store); - - manager.destroy(); - assert.equal( - manager.getPendingStore(), - null, - "pendingStore must be null after destroy", - ); - assert.ok(timerCallback === null, "timer must be cleared after destroy"); - } finally { - globalThis.window.setTimeout = orig; - globalThis.window.clearTimeout = origClear; - } -}); - -// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── +// ─── Shared test helpers ─────────────────────────────────────────────────────── -// Helper: build a minimal fake window with controllable localStorage and timers. function makeFakeWindow() { const storage = new Map(); const ls = { @@ -222,7 +25,7 @@ function makeFakeWindow() { }; let timerCallback = null; let nextTimerId = 100; - const fakeWindow = { + return { localStorage: ls, setTimeout: (fn, _ms) => { timerCallback = fn; @@ -238,155 +41,147 @@ function makeFakeWindow() { fn(); } }, + _hasTimer: () => timerCallback !== null, }; - return fakeWindow; } function installFakeWindow(fw) { - const orig = {}; - for (const key of ["localStorage", "setTimeout", "clearTimeout"]) { - orig[key] = globalThis.window?.[key]; - } if (typeof globalThis.window === "undefined") globalThis.window = {}; + const origLs = globalThis.window.localStorage; + const origSt = globalThis.window.setTimeout; + const origCt = globalThis.window.clearTimeout; globalThis.window.localStorage = fw.localStorage; globalThis.window.setTimeout = fw.setTimeout; globalThis.window.clearTimeout = fw.clearTimeout; return () => { - for (const key of ["localStorage", "setTimeout", "clearTimeout"]) { - if (orig[key] !== undefined) { - globalThis.window[key] = orig[key]; - } - } + if (origLs !== undefined) globalThis.window.localStorage = origLs; + if (origSt !== undefined) globalThis.window.setTimeout = origSt; + if (origCt !== undefined) globalThis.window.clearTimeout = origCt; }; } function makeSectionsStore(sections = []) { - return { - version: 1, - sections, - assignments: {}, - }; + return { version: 1, sections, assignments: {} }; } -// Watermark key format: buzz-sync-watermark.v1::: -// Relay is normalised (lowercase, no trailing slash) before encoding. const RELAY = "wss://r.test"; const RELAY_KEY = encodeURIComponent(RELAY); -// 1. fetch failed (error/timeout) + local non-empty → zero publish calls -// Mutation test: removing the `failed` guard causes bootstrap to call publishSections. -test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { +// ─── destroy() must cancel pending publish, not flush ───────────────────────── + +// Regression guard for the community-switch cross-relay publish vector: +// edit sections in relay A → destroy() is called (relayUrl dep change) → +// no publish should fire. +test("destroy: cancels pending publish without flushing to the relay", () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("relay timeout")), - ); mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-test", RELAY); + manager.publishSections(makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] })); + assert.ok(fw._hasTimer(), "debounce timer should be set"); + manager.destroy(); + assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy"); + assert.equal(publishCalls.length, 0); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// Regression guard for the timer-fired race: debounce fires → doPublish awaits +// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire. +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 ChannelSectionSyncManager("pk-race", RELAY); + manager.publishSections(makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] })); + fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish + manager.destroy(); + releaseFetch(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(publishCalls.length, 0, "publishEvent must not fire 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 ChannelSectionSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } +}); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── + +// Bootstrap wiring tests (1-3): drive the production bootstrap() path so that +// mutations to the failed/absent+head/first-sync branches fail here. +// Policy logic is tested in sidebarSyncWatermark.test.mjs. + +// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) +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 ChannelSectionSyncManager("pk-fail", RELAY); - const local = makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]); - const result = await manager.bootstrap(local); - assert.equal( - result.action, - "hold", - "bootstrap must return hold on failed fetch", - ); - assert.equal( - manager.getPendingStore(), - null, - "pendingStore must be null after failed fetch — no seed was queued", - ); - assert.equal(publishCalls.length, 0, "no publish after failed fetch"); + const result = await manager.bootstrap(makeSectionsStore([{ id: "s1", name: "Work", order: 0 }])); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); } finally { restore(); mock.reset(); } }); -// 2. fetch absent + persisted head > 0 → zero publish calls (the dev-build stale-copy case) -// Mutation test: setting watermark to 0 in localStorage causes bootstrap to seed. +// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { - const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - + mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); - // Pre-seed a watermark (simulates a prior session that had seen a blob). - fw.localStorage.setItem( - `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, - "1700000000", - ); + fw.localStorage.setItem(`buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, "1700000000"); const restore = installFakeWindow(fw); try { const manager = new ChannelSectionSyncManager("pk-stale", RELAY); - assert.ok( - manager.getPersistedWatermark() > 0, - "manager must read watermark from localStorage at construction", - ); - const local = makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]); - const result = await manager.bootstrap(local); - // bootstrap calls fetchRemoteSections → absent → watermark > 0 → hold - assert.equal( - result.action, - "hold", - "bootstrap must return hold when watermark > 0", - ); - assert.equal( - manager.getPendingStore(), - null, - "pendingStore must be null when watermark > 0 — no seed was queued", - ); - assert.equal( - publishCalls.length, - 0, - "watermark > 0 must block seed-publish even on absent fetch", - ); + assert.ok(manager.getPersistedWatermark() > 0); + const result = await manager.bootstrap(makeSectionsStore([{ id: "s1", name: "Work", order: 0 }])); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); } finally { restore(); mock.reset(); } }); -// 3. fetch absent + head 0 + local non-empty → seed-publish fires (first-sync preserved) -// Mutation test: removing the absent+head-0 seed call prevents publishEvent from being observed. -test("revert-fix: absent fetch with zero watermark allows seed-publish via bootstrap", async () => { - const publishEventCalls = []; +// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) +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", (...args) => { - publishEventCalls.push(args); - return Promise.resolve(); - }); - + mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); - // No watermark in storage — simulates genuine first-time user. const restore = installFakeWindow(fw); try { const manager = new ChannelSectionSyncManager("pk-fresh", RELAY); - assert.equal( - manager.getPersistedWatermark(), - 0, - "watermark must start at 0", - ); - const local = makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]); - const result = await manager.bootstrap(local); - assert.equal( - result.action, - "hold", - "bootstrap returns hold (seed is async)", - ); - // bootstrap must have queued a publish (pendingStore is set immediately). - assert.ok( - manager.getPendingStore() !== null, - "bootstrap must queue a seed-publish when absent + watermark == 0", - ); + const result = await manager.bootstrap(makeSectionsStore([{ id: "s1", name: "Work", order: 0 }])); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingStore() !== null); } finally { restore(); mock.reset(); @@ -413,33 +208,18 @@ test("revert-fix: sections LWW — newer decryptable pre-publish event selected }); mock.method(relayClient, "publishEvent", () => Promise.resolve()); - // Instead of mocking nip44DecryptFromSelf directly (it's ESM), use the - // fact that parse returns null for invalid JSON — test the LWW path via - // getPersistedWatermark and headBeforeFetch separation. - // The key invariant: after seeing an event with created_at=100, a second - // pre-publish event with created_at=200 must still be accepted (200 > 100). - // This would break if the watermark advance happened before the comparison. + // The key invariant: after seeing event@100, a pre-publish event@200 must + // still be accepted. Breaks if the watermark advance happened before the + // comparison (200 > 200 → false instead of 200 > 100 → true). const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { const managerA = new ChannelSectionSyncManager("pk-lww", RELAY); - // Simulate boot: advance watermark to 100 (as if boot fetch saw event@100). await managerA.fetchRemoteSections(); - const headAfterBoot = managerA.getPersistedWatermark(); - // The head should be recorded (either 100 from undecryptable event). - assert.ok(headAfterBoot >= 100, "head must be recorded from boot event"); - // The pre-publish fetch (callCount=2) will see created_at=200. - // If headBeforeFetch is correctly snapshotted before recording, - // 200 > 100 (headBeforeFetch) → remote wins. - // If headBeforeFetch was NOT snapshotted (bug), 200 > 200 → false → local wins. - // We can observe this by checking that the second fetchEvents call was used: - // after doPublish runs through fetchOwnBlobBeforePublish, the watermark should - // advance to 200 if the event was observed. - const store = makeSectionsStore([{ id: "s1", name: "A", order: 0 }]); - managerA.publishSections(store); + assert.ok(managerA.getPersistedWatermark() >= 100, "head must be recorded from boot event"); + managerA.publishSections(makeSectionsStore([{ id: "s1", name: "A", order: 0 }])); fw._fireTimer(); await new Promise((r) => setTimeout(r, 10)); - // The watermark should have advanced to at least 200 (the pre-publish event). assert.ok( managerA.getPersistedWatermark() >= 200, "pre-publish event created_at=200 must advance the watermark (LWW comparison uses headBeforeFetch not current head)", @@ -459,28 +239,15 @@ test("revert-fix: undecryptable live event advances watermark before decrypt att liveCallback = onEvent; return Promise.resolve(async () => {}); }); - const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { const manager = new ChannelSectionSyncManager("pk-live", RELAY); assert.equal(manager.getPersistedWatermark(), 0, "watermark starts at 0"); - - // Start the live subscription. await manager.subscribeToSections(() => {}); assert.ok(liveCallback !== null, "subscribeLive must have captured the callback"); - - // Deliver an undecryptable event via the live callback. - liveCallback({ - pubkey: "pk-live", - content: "!bad-cipher!", - created_at: 1700005555, - id: "live-evt-1", - }); - - // Drain microtasks so the async decryptAndParse resolves. + liveCallback({ pubkey: "pk-live", content: "!bad-cipher!", created_at: 1700005555, id: "live-evt-1" }); await new Promise((r) => setTimeout(r, 0)); - assert.ok( manager.getPersistedWatermark() >= 1700005555, "live undecryptable event must advance the watermark before decrypt is attempted", diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index ba6acaecea4..a4457ab44ec 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -8,174 +8,7 @@ function makeStore(groups = {}) { return { version: 1, groups }; } -// ─── destroy() must cancel pending publish, not flush ───────────────────────── - -// Regression guard for the community-switch cross-relay publish vector: -// change a sort mode in relay A → destroy() is called (relayUrl dep change) → -// no publish should fire. The scoped localStorage write is durable; when the -// user returns to relay A the seed-publish path handles it. -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(); - }); - - let timerCallback = null; - const fakeTimers = []; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - const originalSetTimeout = globalThis.window.setTimeout; - const originalClearTimeout = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - const id = nextId++; - fakeTimers.push({ id, fn }); - timerCallback = fn; - return id; - }; - globalThis.window.clearTimeout = (id) => { - const idx = fakeTimers.findIndex((t) => t.id === id); - if (idx !== -1) { - fakeTimers.splice(idx, 1); - timerCallback = null; - } - }; - - try { - const manager = new ChannelSortSyncManager("pk-test", "wss://r.test"); - const store = makeStore({ channels: "recent" }); - - manager.publishSortPrefs(store); - assert.ok(timerCallback !== null, "debounce timer should be set"); - - manager.destroy(); - - assert.ok( - timerCallback === null, - "debounce timer should be cleared on destroy", - ); - assert.equal( - publishCalls.length, - 0, - "no publish event should have been sent after destroy", - ); - } finally { - if (originalSetTimeout !== undefined) { - globalThis.window.setTimeout = originalSetTimeout; - } - if (originalClearTimeout !== undefined) { - globalThis.window.clearTimeout = originalClearTimeout; - } - mock.reset(); - } -}); - -// Regression guard for the timer-fired race: debounce fires → doPublish starts -// awaiting fetchOwnBlobBeforePublish → destroy() is called (relayUrl dep -// change) → publishEvent must never be called even though the timer already -// fired and cleared itself before destroy() ran. -test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { - let releaseFetch = null; - const publishCalls = []; - - mock.method(relayClient, "fetchEvents", () => { - return new Promise((resolve) => { - releaseFetch = () => resolve([]); - }); - }); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - let capturedCallback = null; - let nextId = 1; - const origSetTimeout = globalThis.window.setTimeout; - const origClearTimeout = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - capturedCallback = fn; - return nextId++; - }; - globalThis.window.clearTimeout = (_id) => { - capturedCallback = null; - }; - - try { - const manager = new ChannelSortSyncManager("pk-race", "wss://r.test"); - const store = makeStore({ dms: "recent" }); - - manager.publishSortPrefs(store); - assert.ok(capturedCallback !== null, "debounce timer should be set"); - - const timerFn = capturedCallback; - capturedCallback = null; // timer cleared itself inside the callback - timerFn(); - - manager.destroy(); - - releaseFetch(); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.equal( - publishCalls.length, - 0, - "publishEvent must not be called after destroy() even when timer already fired", - ); - } finally { - globalThis.window.setTimeout = origSetTimeout; - globalThis.window.clearTimeout = origClearTimeout; - mock.reset(); - } -}); - -test("destroy: is safe to call with no pending publish", () => { - const manager = new ChannelSortSyncManager("pk-no-pending", "wss://r.test"); - assert.doesNotThrow(() => manager.destroy()); -}); - -test("destroy: cancelPendingPublish clears pendingStore", () => { - let timerCallback = null; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - const orig = globalThis.window.setTimeout; - const origClear = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - timerCallback = fn; - return nextId++; - }; - globalThis.window.clearTimeout = (_id) => { - timerCallback = null; - }; - - try { - const manager = new ChannelSortSyncManager("pk-pending-null", "wss://r.test"); - const store = makeStore({ starred: "recent" }); - manager.publishSortPrefs(store); - assert.deepEqual(manager.getPendingStore(), store); - - manager.destroy(); - assert.equal( - manager.getPendingStore(), - null, - "pendingStore must be null after destroy", - ); - assert.ok(timerCallback === null, "timer must be cleared after destroy"); - } finally { - globalThis.window.setTimeout = orig; - globalThis.window.clearTimeout = origClear; - } -}); - -// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── +// ─── Shared test helpers ─────────────────────────────────────────────────────── function makeFakeWindow() { const storage = new Map(); @@ -187,7 +20,7 @@ function makeFakeWindow() { }; let timerCallback = null; let nextTimerId = 100; - const fw = { + return { localStorage: ls, setTimeout: (fn, _ms) => { timerCallback = fn; @@ -203,8 +36,8 @@ function makeFakeWindow() { fn(); } }, + _hasTimer: () => timerCallback !== null, }; - return fw; } function installFakeWindow(fw) { @@ -222,17 +55,17 @@ function installFakeWindow(fw) { }; } -// Watermark key: buzz-sync-watermark.v1:channel-sort:: const RELAY = "wss://r.test"; const RELAY_KEY = encodeURIComponent(RELAY); -// 1. fetch failed (error/timeout) + local non-empty → hold, zero publish calls -// Mutation: removing the failed guard causes bootstrap to call publishSortPrefs → pendingStore set. -test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { +// ─── destroy() must cancel pending publish, not flush ───────────────────────── + +// Regression guard for the community-switch cross-relay publish vector: +// change a sort mode in relay A → destroy() called (relayUrl dep change) → +// no publish should fire. +test("destroy: cancels pending publish without flushing to the relay", () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("relay timeout")), - ); mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); @@ -240,16 +73,71 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstr const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { - const manager = new ChannelSortSyncManager("pk-fail", RELAY); - const local = makeStore({ channels: "recent" }); - const result = await manager.bootstrap(local); - assert.equal(result.action, "hold", "bootstrap must return hold on failed fetch"); - assert.equal( - manager.getPendingStore(), - null, - "pendingStore must be null after failed fetch — no seed was queued", - ); + const manager = new ChannelSortSyncManager("pk-test", RELAY); + manager.publishSortPrefs(makeStore({ channels: "recent" })); + assert.ok(fw._hasTimer(), "debounce timer should be set"); + manager.destroy(); + assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy"); assert.equal(publishCalls.length, 0); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// Regression guard for the timer-fired race: debounce fires → doPublish awaits +// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire. +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 ChannelSortSyncManager("pk-race", RELAY); + manager.publishSortPrefs(makeStore({ dms: "recent" })); + fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish + manager.destroy(); + releaseFetch(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(publishCalls.length, 0, "publishEvent must not fire 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 ChannelSortSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } +}); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── + +// Bootstrap wiring tests (1-3): drive the production bootstrap() path so that +// mutations to the failed/absent+head/first-sync branches fail here. +// Policy logic is tested in sidebarSyncWatermark.test.mjs. + +// 1. fetch failed (error/timeout) + local non-empty → hold, zero publish calls +// Mutation: removing the failed guard causes bootstrap to call publishSortPrefs → pendingStore set. +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 ChannelSortSyncManager("pk-fail", RELAY); + const result = await manager.bootstrap(makeStore({ channels: "recent" })); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); } finally { restore(); mock.reset(); @@ -259,30 +147,17 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstr // 2. absent + persisted head > 0 → hold, zero publish calls (the dev-build stale-copy case) // Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { - const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); - fw.localStorage.setItem( - `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, - "1700000000", - ); + fw.localStorage.setItem(`buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, "1700000000"); const restore = installFakeWindow(fw); try { const manager = new ChannelSortSyncManager("pk-stale", RELAY); assert.ok(manager.getPersistedWatermark() > 0); - const local = makeStore({ channels: "recent" }); - const result = await manager.bootstrap(local); - assert.equal(result.action, "hold", "bootstrap must return hold when watermark > 0"); - assert.equal( - manager.getPendingStore(), - null, - "pendingStore must be null when watermark > 0 — no seed was queued", - ); - assert.equal(publishCalls.length, 0); + const result = await manager.bootstrap(makeStore({ channels: "recent" })); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); } finally { restore(); mock.reset(); @@ -298,14 +173,10 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy const restore = installFakeWindow(fw); try { const manager = new ChannelSortSyncManager("pk-fresh", RELAY); - assert.equal(manager.getPersistedWatermark(), 0, "watermark must start at 0"); - const local = makeStore({ channels: "recent" }); - const result = await manager.bootstrap(local); - assert.equal(result.action, "hold", "bootstrap returns hold (seed is async)"); - assert.ok( - manager.getPendingStore() !== null, - "bootstrap must queue a seed-publish when absent + watermark == 0 + local non-empty", - ); + assert.equal(manager.getPersistedWatermark(), 0); + const result = await manager.bootstrap(makeStore({ channels: "recent" })); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingStore() !== null); } finally { restore(); mock.reset(); @@ -314,10 +185,8 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy // 4. LWW baseline: newer decryptable pre-publish event still wins after an // undecryptable head was recorded. -// Mutation: removing headBeforeFetch snapshot causes remote to never win. +// Mutation test: removing headBeforeFetch snapshot causes remote to never win. test("revert-fix: sort LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { - // Boot fetch: undecryptable event, created_at=100 → head recorded to 100. - // Pre-publish fetch: decryptable event, created_at=200 → should win (200 > 100). let callCount = 0; mock.method(relayClient, "fetchEvents", () => { callCount++; @@ -332,21 +201,17 @@ test("revert-fix: sort LWW — newer decryptable pre-publish event selected afte }); mock.method(relayClient, "publishEvent", () => Promise.resolve()); + // After seeing event@100 (bad-cipher), a pre-publish event@200 must still win. + // Breaks if watermark advance happens before the comparison (200 > 200 → false). const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { const manager = new ChannelSortSyncManager("pk-lww", RELAY); - // Boot fetch: sees event@100 (bad-cipher), records head to 100. await manager.fetchRemoteSortPrefs(); assert.ok(manager.getPersistedWatermark() >= 100, "head must be recorded from boot event"); - // Queue a publish — triggers doPublish which calls fetchOwnBlobBeforePublish. - const store = makeStore({ channels: "recent" }); - manager.publishSortPrefs(store); + manager.publishSortPrefs(makeStore({ channels: "recent" })); fw._fireTimer(); await new Promise((r) => setTimeout(r, 10)); - // The pre-publish fetch (callCount=2) sees created_at=200. - // If headBeforeFetch is correctly snapshotted, 200 > 100 → remote wins → watermark advances to 200. - // If NOT snapshotted (bug), 200 > 200 → false → local wins → watermark stays at 100. assert.ok( manager.getPersistedWatermark() >= 200, "pre-publish event created_at=200 must advance the watermark (LWW comparison uses headBeforeFetch not current head)", @@ -358,7 +223,7 @@ test("revert-fix: sort LWW — newer decryptable pre-publish event selected afte }); // 5. live-sub: undecryptable event on live path records head before decrypt -// Mutation: removing recordRemoteHead before decrypt in the live callback +// Mutation test: removing recordRemoteHead before decrypt in the live callback // leaves watermark at 0 after a live event. test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { let liveCallback = null; @@ -366,27 +231,15 @@ test("revert-fix: undecryptable live event advances watermark before decrypt att liveCallback = onEvent; return Promise.resolve(async () => {}); }); - const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { const manager = new ChannelSortSyncManager("pk-live", RELAY); assert.equal(manager.getPersistedWatermark(), 0, "watermark starts at 0"); - await manager.subscribeToSortPrefs(() => {}); assert.ok(liveCallback !== null, "subscribeLive must have captured the callback"); - - // Deliver an undecryptable event via the live callback. - liveCallback({ - pubkey: "pk-live", - content: "!bad-cipher!", - created_at: 1700005555, - id: "live-evt-1", - }); - - // Drain microtasks so the async decryptAndParse resolves. + liveCallback({ pubkey: "pk-live", content: "!bad-cipher!", created_at: 1700005555, id: "live-evt-1" }); await new Promise((r) => setTimeout(r, 0)); - assert.ok( manager.getPersistedWatermark() >= 1700005555, "live undecryptable event must advance the watermark before decrypt is attempted", diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs index 7bdb2c90eaa..89bfd6f9100 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs @@ -4,11 +4,8 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelStarSyncManager } from "./channelStarsSync.ts"; -// Relay URL used in all tests. Watermark key format: -// buzz-sync-watermark.v1:channel-stars:: -// normalizeRelay: trim + lowercase + strip trailing slash. const RELAY = "wss://r.test"; -const RELAY_KEY = encodeURIComponent(RELAY); // "wss%3A%2F%2Fr.test" +const RELAY_KEY = encodeURIComponent(RELAY); function makeStore(channels = {}) { return { version: 1, channels }; @@ -24,7 +21,7 @@ function makeFakeWindow() { }; let timerCallback = null; let nextTimerId = 100; - const fw = { + return { localStorage: ls, setTimeout: (fn, _ms) => { timerCallback = fn; @@ -41,7 +38,6 @@ function makeFakeWindow() { } }, }; - return fw; } function installFakeWindow(fw) { @@ -67,27 +63,15 @@ function installFakeWindow(fw) { 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(); - }); - + 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); - const store = makeStore({ ch1: { starred: true, updatedAt: 100 } }); - - manager.publishStars(store); - assert.ok(fw.localStorage !== undefined, "window should be set up"); - + manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 100 } })); manager.destroy(); assert.equal(publishCalls.length, 0, "no publish after destroy"); - assert.equal( - manager.getPendingStarStore(), - null, - "pendingStore must be null after destroy", - ); + assert.equal(manager.getPendingStarStore(), null); } finally { restore(); mock.reset(); @@ -97,34 +81,18 @@ test("destroy: cancels pending publish without flushing to the relay", () => { test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { let releaseFetch = null; const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => { - return new Promise((resolve) => { - releaseFetch = () => resolve([]); - }); - }); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - + 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); - const store = makeStore({ ch1: { starred: true, updatedAt: 100 } }); - - manager.publishStars(store); - fw._fireTimer(); // fire debounce → doPublish starts - + manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 100 } })); + fw._fireTimer(); manager.destroy(); - releaseFetch(); // fetchOwnBlobBeforePublish resolves + releaseFetch(); await new Promise((r) => setTimeout(r, 0)); - - assert.equal( - publishCalls.length, - 0, - "publishEvent must not be called after destroy", - ); + assert.equal(publishCalls.length, 0, "publishEvent must not be called after destroy"); } finally { restore(); mock.reset(); @@ -143,88 +111,44 @@ test("destroy: is safe to call with no pending publish", () => { }); // ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── -// -// All tests below drive the production bootstrap() path so that a regression -// in that code — not just a hook wiring change — causes a test failure. -// Mutation-sensitivity note: each guard is named in the comment before the test. -// 1. fetch failed (error/timeout) + local non-empty → zero publish calls -// Mutation: removing the `failed` guard causes bootstrap to call publishStars → pendingStore set. +// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { - const publishCalls = []; - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("relay timeout")), - ); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); + 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 local = makeStore({ ch1: { starred: true, updatedAt: 1 } }); - const result = await manager.bootstrap(local); - assert.equal( - result.action, - "hold", - "bootstrap must return hold on failed fetch", - ); - assert.equal( - manager.getPendingStarStore(), - null, - "no pending publish after failed fetch", - ); - assert.equal(publishCalls.length, 0); + const result = await manager.bootstrap(makeStore({ ch1: { starred: true, updatedAt: 1 } })); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStarStore(), null); } finally { restore(); mock.reset(); } }); -// 2. fetch absent + persisted head > 0 → zero publish calls (the dev-build stale-copy case) -// Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. +// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { - const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); - // Pre-seed a watermark with the relay-scoped key (simulates a prior session). - fw.localStorage.setItem( - `buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`, - "1700000000", - ); + 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); - assert.ok( - manager.getPersistedWatermark() > 0, - "manager must read relay-scoped watermark from localStorage at construction", - ); - const local = makeStore({ ch1: { starred: true, updatedAt: 1 } }); - const result = await manager.bootstrap(local); - assert.equal( - result.action, - "hold", - "bootstrap must return hold when watermark > 0", - ); - assert.equal( - manager.getPendingStarStore(), - null, - "watermark > 0 must block seed-publish even on absent fetch", - ); - assert.equal(publishCalls.length, 0); + assert.ok(manager.getPersistedWatermark() > 0); + const result = await manager.bootstrap(makeStore({ ch1: { starred: true, updatedAt: 1 } })); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStarStore(), null); } finally { restore(); mock.reset(); } }); -// 3. fetch absent + head 0 + local non-empty → seed-publish fires (first-sync preserved) -// Mutation: removing the absent+head-0 seed call leaves pendingStore null. +// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) 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()); @@ -232,23 +156,10 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy const restore = installFakeWindow(fw); try { const manager = new ChannelStarSyncManager("pk-fresh", RELAY); - assert.equal( - manager.getPersistedWatermark(), - 0, - "watermark must start at 0", - ); - const local = makeStore({ ch1: { starred: true, updatedAt: 1 } }); - const result = await manager.bootstrap(local); - assert.equal( - result.action, - "hold", - "bootstrap returns hold (seed is async)", - ); - // bootstrap must have queued a publish via publishStars — pendingStore is set immediately. - assert.ok( - manager.getPendingStarStore() !== null, - "bootstrap must queue a seed-publish when absent + watermark == 0 + local non-empty", - ); + assert.equal(manager.getPersistedWatermark(), 0); + const result = await manager.bootstrap(makeStore({ ch1: { starred: true, updatedAt: 1 } })); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingStarStore() !== null); } finally { restore(); mock.reset(); @@ -263,28 +174,17 @@ test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); - // Simulate relay A having a prior head. fw.localStorage.setItem( `buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayA)}`, "1700000100", ); const restore = installFakeWindow(fw); try { - // Manager on relay B must start with watermark 0 despite relay A having one. const managerB = new ChannelStarSyncManager("pk-iso", relayB); - assert.equal( - managerB.getPersistedWatermark(), - 0, - "relay B watermark must be independent of relay A head", - ); - // And first-sync seed on relay B should be allowed. - const local = makeStore({ ch1: { starred: true, updatedAt: 1 } }); - const result = await managerB.bootstrap(local); + assert.equal(managerB.getPersistedWatermark(), 0, "relay B watermark must be independent of relay A head"); + const result = await managerB.bootstrap(makeStore({ ch1: { starred: true, updatedAt: 1 } })); assert.equal(result.action, "hold"); - assert.ok( - managerB.getPendingStarStore() !== null, - "first-sync seed on relay B must not be blocked by relay A watermark", - ); + assert.ok(managerB.getPendingStarStore() !== null, "first-sync seed on relay B must not be blocked by relay A watermark"); } finally { restore(); mock.reset(); From 194df0aaaa7f1bc71ec9ecebf8a12adc3bec817f Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 6 Aug 2026 15:11:56 -0400 Subject: [PATCH 06/13] test(desktop): compact runBootstrap section comment to one line Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/features/sidebar/lib/sidebarSyncWatermark.test.mjs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs index 26ccff2e506..d99e15092aa 100644 --- a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs @@ -156,10 +156,7 @@ test("relay-A watermark is preserved after relay-B session", () => { }); }); -// ── runBootstrap policy ─────────────────────────────────────────────────────── -// -// The shared policy is tested once here so that a mutation to any one branch -// cannot hide behind per-surface duplication. +// ── runBootstrap policy — tested once; mutations to any branch fail here ───── function makeBootstrapArgs({ fetchResult, lastHead, localNonEmpty }) { let publishCount = 0; From fda9b3e39411f7fe24e3691e26261c38743ac1b4 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 6 Aug 2026 15:12:27 -0400 Subject: [PATCH 07/13] test(desktop): compact boot-guard section comments in sections and sort Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/features/sidebar/lib/channelSectionsSync.test.mjs | 6 ++---- desktop/src/features/sidebar/lib/channelSortSync.test.mjs | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index bd24f8c6d7e..78d1fd61f9b 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -130,10 +130,8 @@ test("destroy: is safe to call with no pending publish", () => { }); // ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── - -// Bootstrap wiring tests (1-3): drive the production bootstrap() path so that -// mutations to the failed/absent+head/first-sync branches fail here. -// Policy logic is tested in sidebarSyncWatermark.test.mjs. +// Wiring tests 1-3 drive the production bootstrap() path; policy tested once +// in sidebarSyncWatermark.test.mjs. // 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index a4457ab44ec..c0a32dbbfb4 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -121,10 +121,8 @@ test("destroy: is safe to call with no pending publish", () => { }); // ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── - -// Bootstrap wiring tests (1-3): drive the production bootstrap() path so that -// mutations to the failed/absent+head/first-sync branches fail here. -// Policy logic is tested in sidebarSyncWatermark.test.mjs. +// Wiring tests 1-3 drive the production bootstrap() path; policy tested once +// in sidebarSyncWatermark.test.mjs. // 1. fetch failed (error/timeout) + local non-empty → hold, zero publish calls // Mutation: removing the failed guard causes bootstrap to call publishSortPrefs → pendingStore set. From 18302c89a088fcef1eafa5ec7c09eba3a94c273d Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 6 Aug 2026 15:48:26 -0400 Subject: [PATCH 08/13] test(desktop): make LWW-baseline tests mutation-sensitive via Tauri intercept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the watermark-advancement assertion in the sections/sort LWW tests with a Tauri invoke intercept that captures the nip44_encrypt_to_self plaintext. When headBeforeFetch is correctly snapshotted the remote store wins the merge and its content is encrypted; when the snapshot is removed (mutation: headBeforeFetch → this.lastRemoteCreatedAt) the comparison becomes 200 > 200 → false, local wins, and the wrong content is encrypted. This makes M4 fail rather than silently pass. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../sidebar/lib/channelSectionsSync.test.mjs | 74 ++++++++++++++++--- .../sidebar/lib/channelSortSync.test.mjs | 64 ++++++++++++++-- 2 files changed, 122 insertions(+), 16 deletions(-) diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 78d1fd61f9b..5ee2473be28 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -192,7 +192,13 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy test("revert-fix: sections LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { // Boot fetch: undecryptable event, created_at=100 → head recorded to 100. // Pre-publish fetch: decryptable event, created_at=200 → should win (200 > 100). + // Mutation: if headBeforeFetch is dropped and this.lastRemoteCreatedAt used instead, + // the comparison becomes 200 > 200 = false → local wins instead of remote → wrong content encrypted. + const REMOTE_SECTION_ID = "remote-section-from-relay"; + const LOCAL_SECTION_ID = "local-section-from-app"; + let capturedEncryptPlaintext = null; let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { callCount++; return Promise.resolve([ @@ -206,23 +212,71 @@ test("revert-fix: sections LWW — newer decryptable pre-publish event selected }); mock.method(relayClient, "publishEvent", () => Promise.resolve()); - // The key invariant: after seeing event@100, a pre-publish event@200 must - // still be accepted. Breaks if the watermark advance happened before the - // comparison (200 > 200 → false instead of 200 > 100 → true). const fw = makeFakeWindow(); const restore = installFakeWindow(fw); + + // Intercept Tauri invokes so decryptAndParse, nip44EncryptToSelf, and signRelayEvent work in Node. + const origTauri = globalThis.window?.__TAURI_INTERNALS__; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") { + // "bad-cipher" fails; "good-cipher" returns a valid sections payload. + if (args?.ciphertext === "bad-cipher") return Promise.reject(new Error("decrypt failed")); + const remotePayload = JSON.stringify({ + version: 1, + sections: [{ id: REMOTE_SECTION_ID, name: "Remote", order: 0 }], + assignments: {}, + }); + return Promise.resolve(remotePayload); + } + if (cmd === "nip44_encrypt_to_self") { + capturedEncryptPlaintext = args?.plaintext ?? null; + return Promise.resolve("encrypted-ciphertext"); + } + if (cmd === "sign_event") { + return Promise.resolve(JSON.stringify({ + id: "signed-event-id", + pubkey: "pk-lww", + content: "encrypted-ciphertext", + created_at: args?.createdAt ?? 999, + kind: args?.kind ?? 0, + tags: args?.tags ?? [], + sig: "fake-sig", + })); + } + return Promise.reject(new Error(`unmocked tauri: ${cmd}`)); + }, + }; + try { - const managerA = new ChannelSectionSyncManager("pk-lww", RELAY); - await managerA.fetchRemoteSections(); - assert.ok(managerA.getPersistedWatermark() >= 100, "head must be recorded from boot event"); - managerA.publishSections(makeSectionsStore([{ id: "s1", name: "A", order: 0 }])); + const manager = new ChannelSectionSyncManager("pk-lww", RELAY); + // Boot fetch: sees event@100 (bad-cipher), records head to 100. Remote = null. + await manager.fetchRemoteSections(); + assert.ok(manager.getPersistedWatermark() >= 100, "head must be recorded from boot event"); + + // Queue a publish with local sections — triggers doPublish → fetchOwnBlobBeforePublish (callCount=2). + const localStore = makeSectionsStore([{ id: LOCAL_SECTION_ID, name: "Local", order: 0 }]); + manager.publishSections(localStore); fw._fireTimer(); - await new Promise((r) => setTimeout(r, 10)); + await new Promise((r) => setTimeout(r, 20)); + + // If headBeforeFetch is correctly snapshotted: remote.createdAt(200) > headBeforeFetch(100) → true + // → fetchOwnBlobBeforePublish returns remote.store → nip44_encrypt_to_self gets remote sections. + // If NOT snapshotted (mutation): 200 > this.lastRemoteCreatedAt(200) → false + // → returns local store → nip44_encrypt_to_self gets local sections. + assert.ok(capturedEncryptPlaintext !== null, "nip44EncryptToSelf must have been called"); + const encrypted = JSON.parse(capturedEncryptPlaintext); assert.ok( - managerA.getPersistedWatermark() >= 200, - "pre-publish event created_at=200 must advance the watermark (LWW comparison uses headBeforeFetch not current head)", + Array.isArray(encrypted.sections) && encrypted.sections.some((s) => s.id === REMOTE_SECTION_ID), + `remote sections must win LWW merge — got: ${capturedEncryptPlaintext}`, ); } finally { + if (origTauri !== undefined) { + globalThis.window.__TAURI_INTERNALS__ = origTauri; + } else { + delete globalThis.window.__TAURI_INTERNALS__; + } restore(); mock.reset(); } diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index c0a32dbbfb4..ee90914bb56 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -185,7 +185,15 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy // undecryptable head was recorded. // Mutation test: removing headBeforeFetch snapshot causes remote to never win. test("revert-fix: sort LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { + // Boot fetch: undecryptable event, created_at=100 → head recorded to 100. + // Pre-publish fetch: decryptable event, created_at=200 → should win (200 > 100). + // Mutation: if headBeforeFetch is dropped and this.lastRemoteCreatedAt used instead, + // the comparison becomes 200 > 200 = false → local wins instead of remote → wrong content encrypted. + const REMOTE_GROUP_KEY = "remote-group-from-relay"; + const LOCAL_GROUP_KEY = "local-group-from-app"; + let capturedEncryptPlaintext = null; let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { callCount++; return Promise.resolve([ @@ -199,22 +207,66 @@ test("revert-fix: sort LWW — newer decryptable pre-publish event selected afte }); mock.method(relayClient, "publishEvent", () => Promise.resolve()); - // After seeing event@100 (bad-cipher), a pre-publish event@200 must still win. - // Breaks if watermark advance happens before the comparison (200 > 200 → false). const fw = makeFakeWindow(); const restore = installFakeWindow(fw); + + // Intercept Tauri invokes so decryptAndParse, nip44EncryptToSelf, and signRelayEvent work in Node. + const origTauri = globalThis.window?.__TAURI_INTERNALS__; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") { + if (args?.ciphertext === "bad-cipher") return Promise.reject(new Error("decrypt failed")); + const remotePayload = JSON.stringify({ + version: 1, + groups: { [REMOTE_GROUP_KEY]: "recent" }, + }); + return Promise.resolve(remotePayload); + } + if (cmd === "nip44_encrypt_to_self") { + capturedEncryptPlaintext = args?.plaintext ?? null; + return Promise.resolve("encrypted-ciphertext"); + } + if (cmd === "sign_event") { + return Promise.resolve(JSON.stringify({ + id: "signed-event-id", + pubkey: "pk-lww", + content: "encrypted-ciphertext", + created_at: args?.createdAt ?? 999, + kind: args?.kind ?? 0, + tags: args?.tags ?? [], + sig: "fake-sig", + })); + } + return Promise.reject(new Error(`unmocked tauri: ${cmd}`)); + }, + }; + try { const manager = new ChannelSortSyncManager("pk-lww", RELAY); + // Boot fetch: sees event@100 (bad-cipher), records head to 100. Remote = null. await manager.fetchRemoteSortPrefs(); assert.ok(manager.getPersistedWatermark() >= 100, "head must be recorded from boot event"); - manager.publishSortPrefs(makeStore({ channels: "recent" })); + // Queue a publish with local sort prefs — triggers doPublish → fetchOwnBlobBeforePublish (callCount=2). + manager.publishSortPrefs(makeStore({ [LOCAL_GROUP_KEY]: "recent" })); fw._fireTimer(); - await new Promise((r) => setTimeout(r, 10)); + await new Promise((r) => setTimeout(r, 20)); + // If headBeforeFetch is correctly snapshotted: remote.createdAt(200) > headBeforeFetch(100) → true + // → fetchOwnBlobBeforePublish returns remote.store → nip44_encrypt_to_self gets remote groups. + // If NOT snapshotted (mutation): 200 > this.lastRemoteCreatedAt(200) → false + // → returns local store → nip44_encrypt_to_self gets local groups. + assert.ok(capturedEncryptPlaintext !== null, "nip44EncryptToSelf must have been called"); + const encrypted = JSON.parse(capturedEncryptPlaintext); assert.ok( - manager.getPersistedWatermark() >= 200, - "pre-publish event created_at=200 must advance the watermark (LWW comparison uses headBeforeFetch not current head)", + encrypted.groups && REMOTE_GROUP_KEY in encrypted.groups, + `remote groups must win LWW merge — got: ${capturedEncryptPlaintext}`, ); } finally { + if (origTauri !== undefined) { + globalThis.window.__TAURI_INTERNALS__ = origTauri; + } else { + delete globalThis.window.__TAURI_INTERNALS__; + } restore(); mock.reset(); } From 0583e5a311d31e7b4ba387433a5fbed40a9255e4 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 6 Aug 2026 15:52:07 -0400 Subject: [PATCH 09/13] test(desktop): extract installTauriMock helper to compact LWW tests Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../sidebar/lib/channelSectionsSync.test.mjs | 120 +++++++----------- .../sidebar/lib/channelSortSync.test.mjs | 115 +++++++---------- 2 files changed, 90 insertions(+), 145 deletions(-) diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 5ee2473be28..02c218fa8c2 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -67,6 +67,38 @@ function makeSectionsStore(sections = []) { const RELAY = "wss://r.test"; const RELAY_KEY = encodeURIComponent(RELAY); +// ─── Tauri mock helper ───────────────────────────────────────────────────────── +// Intercepts nip44_decrypt_from_self, nip44_encrypt_to_self, and sign_event so +// the LWW-baseline test can exercise the full doPublish path in Node. +// `goodCipherPayload`: JSON string returned for any non-"bad-cipher" ciphertext. +// Returns `{ restore, capturedPlaintext }`. +function installTauriMock(goodCipherPayload) { + const orig = globalThis.window?.__TAURI_INTERNALS__; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + let captured = null; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") { + if (args?.ciphertext === "bad-cipher") return Promise.reject(new Error("decrypt failed")); + return Promise.resolve(goodCipherPayload); + } + if (cmd === "nip44_encrypt_to_self") { captured = args?.plaintext ?? null; return Promise.resolve("ct"); } + if (cmd === "sign_event") { + return Promise.resolve(JSON.stringify({ id: "eid", pubkey: "pk-lww", content: "ct", + created_at: args?.createdAt ?? 0, kind: args?.kind ?? 0, tags: args?.tags ?? [], sig: "s" })); + } + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }, + }; + return { + restore: () => { + if (orig !== undefined) globalThis.window.__TAURI_INTERNALS__ = orig; + else delete globalThis.window.__TAURI_INTERNALS__; + }, + capturedPlaintext: () => captured, + }; +} + // ─── destroy() must cancel pending publish, not flush ───────────────────────── // Regression guard for the community-switch cross-relay publish vector: @@ -188,95 +220,37 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy // 4. LWW baseline: newer decryptable pre-publish event still wins after an // undecryptable head was recorded. -// Mutation test: removing headBeforeFetch snapshot causes remote to never win. +// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison +// 200>200=false → local wins instead of remote → wrong content encrypted. test("revert-fix: sections LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { - // Boot fetch: undecryptable event, created_at=100 → head recorded to 100. - // Pre-publish fetch: decryptable event, created_at=200 → should win (200 > 100). - // Mutation: if headBeforeFetch is dropped and this.lastRemoteCreatedAt used instead, - // the comparison becomes 200 > 200 = false → local wins instead of remote → wrong content encrypted. - const REMOTE_SECTION_ID = "remote-section-from-relay"; - const LOCAL_SECTION_ID = "local-section-from-app"; - let capturedEncryptPlaintext = null; + const REMOTE_ID = "remote-section-from-relay"; let callCount = 0; - mock.method(relayClient, "fetchEvents", () => { callCount++; - return Promise.resolve([ - { - pubkey: "pk-lww", - content: callCount === 1 ? "bad-cipher" : "good-cipher", - created_at: callCount === 1 ? 100 : 200, - id: `evt-${callCount}`, - }, - ]); + return Promise.resolve([{ pubkey: "pk-lww", content: callCount === 1 ? "bad-cipher" : "good-cipher", + created_at: callCount === 1 ? 100 : 200, id: `evt-${callCount}` }]); }); mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); const restore = installFakeWindow(fw); - - // Intercept Tauri invokes so decryptAndParse, nip44EncryptToSelf, and signRelayEvent work in Node. - const origTauri = globalThis.window?.__TAURI_INTERNALS__; - if (typeof globalThis.window === "undefined") globalThis.window = {}; - globalThis.window.__TAURI_INTERNALS__ = { - invoke: (cmd, args) => { - if (cmd === "nip44_decrypt_from_self") { - // "bad-cipher" fails; "good-cipher" returns a valid sections payload. - if (args?.ciphertext === "bad-cipher") return Promise.reject(new Error("decrypt failed")); - const remotePayload = JSON.stringify({ - version: 1, - sections: [{ id: REMOTE_SECTION_ID, name: "Remote", order: 0 }], - assignments: {}, - }); - return Promise.resolve(remotePayload); - } - if (cmd === "nip44_encrypt_to_self") { - capturedEncryptPlaintext = args?.plaintext ?? null; - return Promise.resolve("encrypted-ciphertext"); - } - if (cmd === "sign_event") { - return Promise.resolve(JSON.stringify({ - id: "signed-event-id", - pubkey: "pk-lww", - content: "encrypted-ciphertext", - created_at: args?.createdAt ?? 999, - kind: args?.kind ?? 0, - tags: args?.tags ?? [], - sig: "fake-sig", - })); - } - return Promise.reject(new Error(`unmocked tauri: ${cmd}`)); - }, - }; - + const tauri = installTauriMock( + JSON.stringify({ version: 1, sections: [{ id: REMOTE_ID, name: "Remote", order: 0 }], assignments: {} }), + ); try { const manager = new ChannelSectionSyncManager("pk-lww", RELAY); - // Boot fetch: sees event@100 (bad-cipher), records head to 100. Remote = null. await manager.fetchRemoteSections(); - assert.ok(manager.getPersistedWatermark() >= 100, "head must be recorded from boot event"); - - // Queue a publish with local sections — triggers doPublish → fetchOwnBlobBeforePublish (callCount=2). - const localStore = makeSectionsStore([{ id: LOCAL_SECTION_ID, name: "Local", order: 0 }]); - manager.publishSections(localStore); + assert.ok(manager.getPersistedWatermark() >= 100); + manager.publishSections(makeSectionsStore([{ id: "local-s", name: "Local", order: 0 }])); fw._fireTimer(); await new Promise((r) => setTimeout(r, 20)); - - // If headBeforeFetch is correctly snapshotted: remote.createdAt(200) > headBeforeFetch(100) → true - // → fetchOwnBlobBeforePublish returns remote.store → nip44_encrypt_to_self gets remote sections. - // If NOT snapshotted (mutation): 200 > this.lastRemoteCreatedAt(200) → false - // → returns local store → nip44_encrypt_to_self gets local sections. - assert.ok(capturedEncryptPlaintext !== null, "nip44EncryptToSelf must have been called"); - const encrypted = JSON.parse(capturedEncryptPlaintext); + const pt = tauri.capturedPlaintext(); + assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); assert.ok( - Array.isArray(encrypted.sections) && encrypted.sections.some((s) => s.id === REMOTE_SECTION_ID), - `remote sections must win LWW merge — got: ${capturedEncryptPlaintext}`, + JSON.parse(pt).sections?.some((s) => s.id === REMOTE_ID), + `remote sections must win LWW merge — got: ${pt}`, ); } finally { - if (origTauri !== undefined) { - globalThis.window.__TAURI_INTERNALS__ = origTauri; - } else { - delete globalThis.window.__TAURI_INTERNALS__; - } + tauri.restore(); restore(); mock.reset(); } diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index ee90914bb56..b0b4f975dae 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -58,11 +58,37 @@ function installFakeWindow(fw) { const RELAY = "wss://r.test"; const RELAY_KEY = encodeURIComponent(RELAY); -// ─── destroy() must cancel pending publish, not flush ───────────────────────── +// ─── Tauri mock helper ───────────────────────────────────────────────────────── +// Same pattern as sections — intercepts nip44_decrypt_from_self, nip44_encrypt_to_self, +// and sign_event for the LWW-baseline test. `goodCipherPayload`: returned for "good-cipher". +function installTauriMock(goodCipherPayload) { + const orig = globalThis.window?.__TAURI_INTERNALS__; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + let captured = null; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") { + if (args?.ciphertext === "bad-cipher") return Promise.reject(new Error("decrypt failed")); + return Promise.resolve(goodCipherPayload); + } + if (cmd === "nip44_encrypt_to_self") { captured = args?.plaintext ?? null; return Promise.resolve("ct"); } + if (cmd === "sign_event") { + return Promise.resolve(JSON.stringify({ id: "eid", pubkey: "pk-lww", content: "ct", + created_at: args?.createdAt ?? 0, kind: args?.kind ?? 0, tags: args?.tags ?? [], sig: "s" })); + } + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }, + }; + return { + restore: () => { + if (orig !== undefined) globalThis.window.__TAURI_INTERNALS__ = orig; + else delete globalThis.window.__TAURI_INTERNALS__; + }, + capturedPlaintext: () => captured, + }; +} -// Regression guard for the community-switch cross-relay publish vector: -// change a sort mode in relay A → destroy() called (relayUrl dep change) → -// no publish should fire. +// ─── destroy() must cancel pending publish, not flush ───────────────────────── test("destroy: cancels pending publish without flushing to the relay", () => { mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); const publishCalls = []; @@ -183,90 +209,35 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy // 4. LWW baseline: newer decryptable pre-publish event still wins after an // undecryptable head was recorded. -// Mutation test: removing headBeforeFetch snapshot causes remote to never win. +// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison +// 200>200=false → local wins instead of remote → wrong content encrypted. test("revert-fix: sort LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { - // Boot fetch: undecryptable event, created_at=100 → head recorded to 100. - // Pre-publish fetch: decryptable event, created_at=200 → should win (200 > 100). - // Mutation: if headBeforeFetch is dropped and this.lastRemoteCreatedAt used instead, - // the comparison becomes 200 > 200 = false → local wins instead of remote → wrong content encrypted. - const REMOTE_GROUP_KEY = "remote-group-from-relay"; - const LOCAL_GROUP_KEY = "local-group-from-app"; - let capturedEncryptPlaintext = null; + const REMOTE_KEY = "remote-group-from-relay"; let callCount = 0; - mock.method(relayClient, "fetchEvents", () => { callCount++; - return Promise.resolve([ - { - pubkey: "pk-lww", - content: callCount === 1 ? "bad-cipher" : "good-cipher", - created_at: callCount === 1 ? 100 : 200, - id: `evt-${callCount}`, - }, - ]); + return Promise.resolve([{ pubkey: "pk-lww", content: callCount === 1 ? "bad-cipher" : "good-cipher", + created_at: callCount === 1 ? 100 : 200, id: `evt-${callCount}` }]); }); mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); const restore = installFakeWindow(fw); - - // Intercept Tauri invokes so decryptAndParse, nip44EncryptToSelf, and signRelayEvent work in Node. - const origTauri = globalThis.window?.__TAURI_INTERNALS__; - if (typeof globalThis.window === "undefined") globalThis.window = {}; - globalThis.window.__TAURI_INTERNALS__ = { - invoke: (cmd, args) => { - if (cmd === "nip44_decrypt_from_self") { - if (args?.ciphertext === "bad-cipher") return Promise.reject(new Error("decrypt failed")); - const remotePayload = JSON.stringify({ - version: 1, - groups: { [REMOTE_GROUP_KEY]: "recent" }, - }); - return Promise.resolve(remotePayload); - } - if (cmd === "nip44_encrypt_to_self") { - capturedEncryptPlaintext = args?.plaintext ?? null; - return Promise.resolve("encrypted-ciphertext"); - } - if (cmd === "sign_event") { - return Promise.resolve(JSON.stringify({ - id: "signed-event-id", - pubkey: "pk-lww", - content: "encrypted-ciphertext", - created_at: args?.createdAt ?? 999, - kind: args?.kind ?? 0, - tags: args?.tags ?? [], - sig: "fake-sig", - })); - } - return Promise.reject(new Error(`unmocked tauri: ${cmd}`)); - }, - }; - + const tauri = installTauriMock(JSON.stringify({ version: 1, groups: { [REMOTE_KEY]: "recent" } })); try { const manager = new ChannelSortSyncManager("pk-lww", RELAY); - // Boot fetch: sees event@100 (bad-cipher), records head to 100. Remote = null. await manager.fetchRemoteSortPrefs(); - assert.ok(manager.getPersistedWatermark() >= 100, "head must be recorded from boot event"); - // Queue a publish with local sort prefs — triggers doPublish → fetchOwnBlobBeforePublish (callCount=2). - manager.publishSortPrefs(makeStore({ [LOCAL_GROUP_KEY]: "recent" })); + assert.ok(manager.getPersistedWatermark() >= 100); + manager.publishSortPrefs(makeStore({ "local-group": "recent" })); fw._fireTimer(); await new Promise((r) => setTimeout(r, 20)); - // If headBeforeFetch is correctly snapshotted: remote.createdAt(200) > headBeforeFetch(100) → true - // → fetchOwnBlobBeforePublish returns remote.store → nip44_encrypt_to_self gets remote groups. - // If NOT snapshotted (mutation): 200 > this.lastRemoteCreatedAt(200) → false - // → returns local store → nip44_encrypt_to_self gets local groups. - assert.ok(capturedEncryptPlaintext !== null, "nip44EncryptToSelf must have been called"); - const encrypted = JSON.parse(capturedEncryptPlaintext); + const pt = tauri.capturedPlaintext(); + assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); assert.ok( - encrypted.groups && REMOTE_GROUP_KEY in encrypted.groups, - `remote groups must win LWW merge — got: ${capturedEncryptPlaintext}`, + JSON.parse(pt).groups && REMOTE_KEY in JSON.parse(pt).groups, + `remote groups must win LWW merge — got: ${pt}`, ); } finally { - if (origTauri !== undefined) { - globalThis.window.__TAURI_INTERNALS__ = origTauri; - } else { - delete globalThis.window.__TAURI_INTERNALS__; - } + tauri.restore(); restore(); mock.reset(); } From c7137f2b85023052cbd0934255cd5a2dca198306 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 6 Aug 2026 15:54:49 -0400 Subject: [PATCH 10/13] test(desktop): compact watermark test helpers and assertions Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../sidebar/lib/sidebarSyncWatermark.test.mjs | 70 ++++--------------- 1 file changed, 15 insertions(+), 55 deletions(-) diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs index d99e15092aa..a62f54fde82 100644 --- a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs @@ -2,33 +2,16 @@ import assert from "node:assert/strict"; import test from "node:test"; // We need a minimal localStorage stub since we're running in Node. -function makeLocalStorage() { - const store = new Map(); - return { - getItem: (key) => store.get(key) ?? null, - setItem: (key, value) => store.set(key, value), - removeItem: (key) => store.delete(key), - clear: () => store.clear(), - }; -} - -// Inject a fresh localStorage before each test by re-requiring the module. -// node:test doesn't reload modules between tests, so we manipulate the global -// directly and clear between tests. - function withFreshStorage(fn) { - const fake = makeLocalStorage(); + const store = new Map(); + const ls = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), clear: () => store.clear() }; const orig = globalThis.window?.localStorage; if (typeof globalThis.window === "undefined") globalThis.window = {}; - globalThis.window.localStorage = fake; - try { - fn(fake); - } finally { - if (orig !== undefined) { - globalThis.window.localStorage = orig; - } else { - delete globalThis.window.localStorage; - } + globalThis.window.localStorage = ls; + try { fn(ls); } finally { + if (orig !== undefined) globalThis.window.localStorage = orig; + else delete globalThis.window.localStorage; } } @@ -79,14 +62,8 @@ test("readWatermark: normalises relay URL (trailing slash, case)", () => { withFreshStorage(() => { // Write with one form, read with another — must produce the same value. advanceWatermark("pk", "sections", 999, "WSS://Relay.Example.Com/"); - assert.equal( - readWatermark("pk", "sections", "wss://relay.example.com"), - 999, - ); - assert.equal( - readWatermark("pk", "sections", "WSS://Relay.Example.Com/"), - 999, - ); + assert.equal(readWatermark("pk", "sections", "wss://relay.example.com"), 999); + assert.equal(readWatermark("pk", "sections", "WSS://Relay.Example.Com/"), 999); }); }); @@ -131,14 +108,8 @@ test("relay-A watermark does not suppress first-sync on relay-B", () => { withFreshStorage(() => { const relayA = "wss://a.relay.test"; const relayB = "wss://b.relay.test"; - // Session on relay A has seen a blob. advanceWatermark("pk", "sections", 1700000100, relayA); - // Relay B watermark must still be 0. - assert.equal( - readWatermark("pk", "sections", relayB), - 0, - "relay B watermark must be independent of relay A", - ); + assert.equal(readWatermark("pk", "sections", relayB), 0, "relay B watermark must be independent of relay A"); }); }); @@ -148,29 +119,18 @@ test("relay-A watermark is preserved after relay-B session", () => { const relayB = "wss://b.relay.test"; advanceWatermark("pk", "sections", 1700000100, relayA); advanceWatermark("pk", "sections", 1700000200, relayB); - assert.equal( - readWatermark("pk", "sections", relayA), - 1700000100, - "relay A head must not be clobbered by relay B activity", - ); + assert.equal(readWatermark("pk", "sections", relayA), 1700000100, "relay A head must not be clobbered by relay B activity"); }); }); // ── runBootstrap policy — tested once; mutations to any branch fail here ───── function makeBootstrapArgs({ fetchResult, lastHead, localNonEmpty }) { - let publishCount = 0; + let n = 0; return { - args: { - fetchResult, - lastHead, - localStore: { items: localNonEmpty ? ["x"] : [] }, - isLocalNonEmpty: (s) => s.items.length > 0, - publishFn: (_s) => { - publishCount++; - }, - }, - publishCount: () => publishCount, + args: { fetchResult, lastHead, localStore: { items: localNonEmpty ? ["x"] : [] }, + isLocalNonEmpty: (s) => s.items.length > 0, publishFn: () => { n++; } }, + publishCount: () => n, }; } From e514a5bd2f6b366393b0455bcebb6afe6427446e Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 6 Aug 2026 16:00:47 -0400 Subject: [PATCH 11/13] test(desktop): compress Tauri mock comments and collapse sign_event branch to one line Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/sidebar/lib/channelMutesSync.test.mjs | 5 +---- .../features/sidebar/lib/channelSectionsSync.test.mjs | 11 ++--------- .../src/features/sidebar/lib/channelSortSync.test.mjs | 9 ++------- .../features/sidebar/lib/channelStarsSync.test.mjs | 5 +---- 4 files changed, 6 insertions(+), 24 deletions(-) diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs index e86b41ef7c1..7a96181fb8d 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs @@ -174,10 +174,7 @@ test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B 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", - ); + 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); diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 02c218fa8c2..9a211fcc93a 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -67,11 +67,7 @@ function makeSectionsStore(sections = []) { const RELAY = "wss://r.test"; const RELAY_KEY = encodeURIComponent(RELAY); -// ─── Tauri mock helper ───────────────────────────────────────────────────────── -// Intercepts nip44_decrypt_from_self, nip44_encrypt_to_self, and sign_event so -// the LWW-baseline test can exercise the full doPublish path in Node. -// `goodCipherPayload`: JSON string returned for any non-"bad-cipher" ciphertext. -// Returns `{ restore, capturedPlaintext }`. +// Tauri mock: intercepts nip44_decrypt_from_self/encrypt_to_self/sign_event for LWW-baseline tests. function installTauriMock(goodCipherPayload) { const orig = globalThis.window?.__TAURI_INTERNALS__; if (typeof globalThis.window === "undefined") globalThis.window = {}; @@ -83,10 +79,7 @@ function installTauriMock(goodCipherPayload) { return Promise.resolve(goodCipherPayload); } if (cmd === "nip44_encrypt_to_self") { captured = args?.plaintext ?? null; return Promise.resolve("ct"); } - if (cmd === "sign_event") { - return Promise.resolve(JSON.stringify({ id: "eid", pubkey: "pk-lww", content: "ct", - created_at: args?.createdAt ?? 0, kind: args?.kind ?? 0, tags: args?.tags ?? [], sig: "s" })); - } + if (cmd === "sign_event") return Promise.resolve(JSON.stringify({ id: "eid", pubkey: "pk-lww", content: "ct", created_at: args?.createdAt ?? 0, kind: args?.kind ?? 0, tags: args?.tags ?? [], sig: "s" })); return Promise.reject(new Error(`unmocked: ${cmd}`)); }, }; diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index b0b4f975dae..49ad1379f26 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -58,9 +58,7 @@ function installFakeWindow(fw) { const RELAY = "wss://r.test"; const RELAY_KEY = encodeURIComponent(RELAY); -// ─── Tauri mock helper ───────────────────────────────────────────────────────── -// Same pattern as sections — intercepts nip44_decrypt_from_self, nip44_encrypt_to_self, -// and sign_event for the LWW-baseline test. `goodCipherPayload`: returned for "good-cipher". +// Tauri mock: same pattern as sections — intercepts nip44_decrypt_from_self/encrypt_to_self/sign_event. function installTauriMock(goodCipherPayload) { const orig = globalThis.window?.__TAURI_INTERNALS__; if (typeof globalThis.window === "undefined") globalThis.window = {}; @@ -72,10 +70,7 @@ function installTauriMock(goodCipherPayload) { return Promise.resolve(goodCipherPayload); } if (cmd === "nip44_encrypt_to_self") { captured = args?.plaintext ?? null; return Promise.resolve("ct"); } - if (cmd === "sign_event") { - return Promise.resolve(JSON.stringify({ id: "eid", pubkey: "pk-lww", content: "ct", - created_at: args?.createdAt ?? 0, kind: args?.kind ?? 0, tags: args?.tags ?? [], sig: "s" })); - } + if (cmd === "sign_event") return Promise.resolve(JSON.stringify({ id: "eid", pubkey: "pk-lww", content: "ct", created_at: args?.createdAt ?? 0, kind: args?.kind ?? 0, tags: args?.tags ?? [], sig: "s" })); return Promise.reject(new Error(`unmocked: ${cmd}`)); }, }; diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs index 89bfd6f9100..49971ffeef0 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs @@ -174,10 +174,7 @@ test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B 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", - ); + 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); From 6cbe6ab8bbd76f48eced41637b13d0e1532a475c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 6 Aug 2026 17:11:34 -0400 Subject: [PATCH 12/13] fix(desktop): gate sections/sort sync managers on defined relayUrl The sync manager constructors require relayUrl, but the sections/sort hooks passed activeCommunity?.relayUrl unguarded. A boot where identity resolves before the community constructed managers with undefined relayUrl, silently zeroing the watermark guard and re-enabling the stale seed-publish this PR prevents. Mirrors the mutes/stars guard. Signed-off-by: Will Pfleger --- desktop/src/features/sidebar/lib/useChannelSections.ts | 5 ++--- desktop/src/features/sidebar/lib/useChannelSortPreference.ts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index cb8b1d26d36..3d8aa736086 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -45,7 +45,7 @@ export function useChannelSections( const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -54,7 +54,6 @@ export function useChannelSections( setStore(readChannelSectionsStore(pubkey, relayUrl)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - // Pass relayUrl so the manager can scope its watermark key. managerRef.current = new ChannelSectionSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); @@ -103,7 +102,7 @@ export function useChannelSections( ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; const local = readChannelSectionsStore(pubkey, relayUrl); void managerRef.current?.bootstrap(local).then((result) => { diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index 357b8afce37..a7963a11e4f 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -49,7 +49,7 @@ export function useChannelSortPreference( const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -101,7 +101,7 @@ export function useChannelSortPreference( ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; const local = readChannelSortStore(pubkey, relayUrl); void managerRef.current?.bootstrap(local).then((result) => { From d1fed92485330fc879ed94313062abb70361e456 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 6 Aug 2026 17:11:53 -0400 Subject: [PATCH 13/13] refactor(desktop): dedupe sidebar sync helpers and trim review surface Extract shared test helpers into sidebarSyncTestHelpers.mjs; move normalizeRelayUrl to shared/lib so watermark keys no longer depend on the profile feature; drop the dead BootstrapResult re-exports, the test-only getPersistedWatermark(), and redundant post-decrypt recordRemoteHead calls; align advanceWatermark's parameter order with readWatermark; correct destroy() comments to state the real trade-off. Signed-off-by: Will Pfleger --- .../profile/lib/selfProfileStorage.ts | 12 +- .../sidebar/lib/channelMutesSync.test.mjs | 125 ++++++------ .../features/sidebar/lib/channelMutesSync.ts | 33 +-- .../sidebar/lib/channelSectionsStorage.ts | 2 +- .../sidebar/lib/channelSectionsSync.test.mjs | 188 +++++++++--------- .../sidebar/lib/channelSectionsSync.ts | 50 ++--- .../sidebar/lib/channelSortPreference.ts | 2 +- .../sidebar/lib/channelSortSync.test.mjs | 169 ++++++++-------- .../features/sidebar/lib/channelSortSync.ts | 40 ++-- .../sidebar/lib/channelStarsSync.test.mjs | 131 ++++++------ .../features/sidebar/lib/channelStarsSync.ts | 33 +-- .../sidebar/lib/sidebarSyncTestHelpers.mjs | 85 ++++++++ .../sidebar/lib/sidebarSyncWatermark.test.mjs | 104 +++++++--- .../sidebar/lib/sidebarSyncWatermark.ts | 4 +- desktop/src/shared/lib/normalizeRelayUrl.ts | 8 + 15 files changed, 543 insertions(+), 443 deletions(-) create mode 100644 desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs create mode 100644 desktop/src/shared/lib/normalizeRelayUrl.ts diff --git a/desktop/src/features/profile/lib/selfProfileStorage.ts b/desktop/src/features/profile/lib/selfProfileStorage.ts index dbc4f887609..02e083ae1d4 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.ts +++ b/desktop/src/features/profile/lib/selfProfileStorage.ts @@ -11,16 +11,10 @@ * prevents one community's cached identity from bleeding into another. */ -const STORAGE_KEY_PREFIX = "buzz-self-profile.v1"; +export { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; -/** - * Normalizes a relay URL for use in storage keys. - * Trim, strip trailing slashes, lowercase — ensures equivalent URLs map to - * the same key regardless of formatting differences. - */ -export function normalizeRelayUrl(relayUrl: string): string { - return relayUrl.trim().replace(/\/+$/, "").toLowerCase(); -} +const STORAGE_KEY_PREFIX = "buzz-self-profile.v1"; /** * Dispatched on window after a successful writeSelfProfileCache so that any diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs index 7a96181fb8d..845e5a5accc 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs @@ -3,6 +3,10 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelMuteSyncManager } from "./channelMutesSync.ts"; +import { + makeFakeWindow, + installFakeWindow, +} from "./sidebarSyncTestHelpers.mjs"; const RELAY = "wss://r.test"; const RELAY_KEY = encodeURIComponent(RELAY); @@ -11,50 +15,6 @@ function makeStore(channels = {}) { return { version: 1, channels }; } -function makeFakeWindow() { - const storage = new Map(); - const ls = { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - clear: () => storage.clear(), - }; - let timerCallback = null; - let nextTimerId = 100; - return { - localStorage: ls, - setTimeout: (fn, _ms) => { - timerCallback = fn; - return nextTimerId++; - }, - clearTimeout: (_id) => { - timerCallback = null; - }, - _fireTimer: () => { - if (timerCallback) { - const fn = timerCallback; - timerCallback = null; - fn(); - } - }, - }; -} - -function installFakeWindow(fw) { - if (typeof globalThis.window === "undefined") globalThis.window = {}; - const origLs = globalThis.window.localStorage; - const origSt = globalThis.window.setTimeout; - const origCt = globalThis.window.clearTimeout; - globalThis.window.localStorage = fw.localStorage; - globalThis.window.setTimeout = fw.setTimeout; - globalThis.window.clearTimeout = fw.clearTimeout; - return () => { - if (origLs !== undefined) globalThis.window.localStorage = origLs; - if (origSt !== undefined) globalThis.window.setTimeout = origSt; - if (origCt !== undefined) globalThis.window.clearTimeout = origCt; - }; -} - // ─── destroy() must cancel pending publish, not flush ───────────────────────── // Regression guard for the community-switch cross-relay publish vector: @@ -63,7 +23,10 @@ function installFakeWindow(fw) { 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(); }); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { @@ -81,8 +44,18 @@ test("destroy: cancels pending publish without flushing to the relay", () => { 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(); }); + 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 { @@ -114,13 +87,17 @@ test("destroy: is safe to call with no pending publish", () => { // 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) 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, "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: { muted: true, updatedAt: 1 } })); + const result = await manager.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); assert.equal(result.action, "hold"); assert.equal(manager.getPendingMuteStore(), null); } finally { @@ -134,12 +111,23 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish via boot 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"); + 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); - assert.ok(manager.getPersistedWatermark() > 0); - const result = await manager.bootstrap(makeStore({ ch1: { muted: true, updatedAt: 1 } })); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); assert.equal(result.action, "hold"); assert.equal(manager.getPendingMuteStore(), null); } finally { @@ -156,8 +144,15 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy const restore = installFakeWindow(fw); try { const manager = new ChannelMuteSyncManager("pk-fresh", RELAY); - assert.equal(manager.getPersistedWatermark(), 0); - const result = await manager.bootstrap(makeStore({ ch1: { muted: true, updatedAt: 1 } })); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-mutes:pk-fresh:${RELAY_KEY}`, + ), + null, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); assert.equal(result.action, "hold"); assert.ok(manager.getPendingMuteStore() !== null); } finally { @@ -174,14 +169,28 @@ test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B 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"); + 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); - assert.equal(managerB.getPersistedWatermark(), 0, "relay B watermark must be independent of relay A head"); - const result = await managerB.bootstrap(makeStore({ ch1: { muted: true, updatedAt: 1 } })); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayB)}`, + ), + null, + "relay B watermark must be independent of relay A head", + ); + const result = await managerB.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); assert.equal(result.action, "hold"); - assert.ok(managerB.getPendingMuteStore() !== null, "first-sync seed on relay B must not be blocked by relay A watermark"); + assert.ok( + managerB.getPendingMuteStore() !== null, + "first-sync seed on relay B must not be blocked by relay A watermark", + ); } finally { restore(); mock.reset(); diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.ts b/desktop/src/features/sidebar/lib/channelMutesSync.ts index 91fec57c17a..5e8a17e74d9 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.ts +++ b/desktop/src/features/sidebar/lib/channelMutesSync.ts @@ -15,15 +15,11 @@ import { advanceWatermark, readWatermark, runBootstrap, - type BootstrapResult, type FetchResult, } from "./sidebarSyncWatermark"; -/** Result returned by `bootstrap()` — the hook acts on this without publishing. */ -export type { BootstrapResult }; - const D_TAG = "channel-mutes"; -const BLOB_TYPE = "channel-mutes"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteMutes = { @@ -90,11 +86,7 @@ export class ChannelMuteSyncManager { if (createdAt > this.lastRemoteCreatedAt) { this.lastRemoteCreatedAt = createdAt; } - advanceWatermark(this.pubkey, BLOB_TYPE, createdAt, this.relayUrl); - } - - getPersistedWatermark(): number { - return this.lastRemoteCreatedAt; + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); } cancelPendingMutePublish(): void { @@ -135,7 +127,6 @@ export class ChannelMuteSyncManager { this.recordRemoteHead(event.created_at); const remote = await decryptAndParse(event); if (!remote) return store; - this.recordRemoteHead(remote.createdAt); return mergeStores(store, remote.store); } catch { return store; @@ -220,7 +211,6 @@ export class ChannelMuteSyncManager { this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.recordRemoteHead(result.createdAt); onUpdate(result); } }); @@ -229,13 +219,10 @@ export class ChannelMuteSyncManager { } /** - * Bootstrap the manager on first mount. Fetches the remote blob, records - * the raw head before decrypt on every outcome, and — if genuine first-time - * sync is detected — **performs the seed-publish itself**. + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. */ - async bootstrap( - localStore: ChannelMuteStore, - ): Promise> { + async bootstrap(localStore: ChannelMuteStore) { const fetchResult = await this.fetchRemoteMutes(); return runBootstrap({ fetchResult, @@ -248,11 +235,11 @@ export class ChannelMuteSyncManager { destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any - // in-flight doPublish() calls abort before reaching relayClient. The - // scoped localStorage write is already durable; when the user returns to - // this relay the existing seed-publish guard will re-publish from local - // state. Flushing here would race against community switching and could - // publish relay A's mutes to relay B via the shared relayClient singleton. + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's state to relay B via the shared relayClient + // singleton. Local entries survive because the apply/publish paths merge + // per-entry via mergeStores, so no local work is permanently lost. this.destroyed = true; this.cancelPendingMutePublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index 0d6b5768b6d..3900c40c184 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -1,4 +1,4 @@ -import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1"; diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 9a211fcc93a..904ac1f3f24 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -3,6 +3,11 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelSectionSyncManager } from "./channelSectionsSync.ts"; +import { + makeFakeWindow, + installFakeWindow, + installTauriMock, +} from "./sidebarSyncTestHelpers.mjs"; function makeStore(overrides = {}) { return { @@ -13,53 +18,6 @@ function makeStore(overrides = {}) { }; } -// ─── Shared test helpers ─────────────────────────────────────────────────────── - -function makeFakeWindow() { - const storage = new Map(); - const ls = { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - clear: () => storage.clear(), - }; - let timerCallback = null; - let nextTimerId = 100; - return { - localStorage: ls, - setTimeout: (fn, _ms) => { - timerCallback = fn; - return nextTimerId++; - }, - clearTimeout: (_id) => { - timerCallback = null; - }, - _fireTimer: () => { - if (timerCallback) { - const fn = timerCallback; - timerCallback = null; - fn(); - } - }, - _hasTimer: () => timerCallback !== null, - }; -} - -function installFakeWindow(fw) { - if (typeof globalThis.window === "undefined") globalThis.window = {}; - const origLs = globalThis.window.localStorage; - const origSt = globalThis.window.setTimeout; - const origCt = globalThis.window.clearTimeout; - globalThis.window.localStorage = fw.localStorage; - globalThis.window.setTimeout = fw.setTimeout; - globalThis.window.clearTimeout = fw.clearTimeout; - return () => { - if (origLs !== undefined) globalThis.window.localStorage = origLs; - if (origSt !== undefined) globalThis.window.setTimeout = origSt; - if (origCt !== undefined) globalThis.window.clearTimeout = origCt; - }; -} - function makeSectionsStore(sections = []) { return { version: 1, sections, assignments: {} }; } @@ -67,31 +25,6 @@ function makeSectionsStore(sections = []) { const RELAY = "wss://r.test"; const RELAY_KEY = encodeURIComponent(RELAY); -// Tauri mock: intercepts nip44_decrypt_from_self/encrypt_to_self/sign_event for LWW-baseline tests. -function installTauriMock(goodCipherPayload) { - const orig = globalThis.window?.__TAURI_INTERNALS__; - if (typeof globalThis.window === "undefined") globalThis.window = {}; - let captured = null; - globalThis.window.__TAURI_INTERNALS__ = { - invoke: (cmd, args) => { - if (cmd === "nip44_decrypt_from_self") { - if (args?.ciphertext === "bad-cipher") return Promise.reject(new Error("decrypt failed")); - return Promise.resolve(goodCipherPayload); - } - if (cmd === "nip44_encrypt_to_self") { captured = args?.plaintext ?? null; return Promise.resolve("ct"); } - if (cmd === "sign_event") return Promise.resolve(JSON.stringify({ id: "eid", pubkey: "pk-lww", content: "ct", created_at: args?.createdAt ?? 0, kind: args?.kind ?? 0, tags: args?.tags ?? [], sig: "s" })); - return Promise.reject(new Error(`unmocked: ${cmd}`)); - }, - }; - return { - restore: () => { - if (orig !== undefined) globalThis.window.__TAURI_INTERNALS__ = orig; - else delete globalThis.window.__TAURI_INTERNALS__; - }, - capturedPlaintext: () => captured, - }; -} - // ─── destroy() must cancel pending publish, not flush ───────────────────────── // Regression guard for the community-switch cross-relay publish vector: @@ -108,7 +41,9 @@ test("destroy: cancels pending publish without flushing to the relay", () => { const restore = installFakeWindow(fw); try { const manager = new ChannelSectionSyncManager("pk-test", RELAY); - manager.publishSections(makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] })); + manager.publishSections( + makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }), + ); assert.ok(fw._hasTimer(), "debounce timer should be set"); manager.destroy(); assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy"); @@ -125,18 +60,34 @@ test("destroy: cancels pending publish without flushing to the relay", () => { 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(); }); + 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 ChannelSectionSyncManager("pk-race", RELAY); - manager.publishSections(makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] })); + manager.publishSections( + makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }), + ); fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish manager.destroy(); releaseFetch(); await new Promise((r) => setTimeout(r, 0)); - assert.equal(publishCalls.length, 0, "publishEvent must not fire after destroy"); + assert.equal( + publishCalls.length, + 0, + "publishEvent must not fire after destroy", + ); } finally { restore(); mock.reset(); @@ -160,13 +111,17 @@ test("destroy: is safe to call with no pending publish", () => { // 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) 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, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { const manager = new ChannelSectionSyncManager("pk-fail", RELAY); - const result = await manager.bootstrap(makeSectionsStore([{ id: "s1", name: "Work", order: 0 }])); + const result = await manager.bootstrap( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); assert.equal(result.action, "hold"); assert.equal(manager.getPendingStore(), null); } finally { @@ -180,12 +135,23 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish via boot mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); - fw.localStorage.setItem(`buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, "1700000000"); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, + "1700000000", + ); const restore = installFakeWindow(fw); try { const manager = new ChannelSectionSyncManager("pk-stale", RELAY); - assert.ok(manager.getPersistedWatermark() > 0); - const result = await manager.bootstrap(makeSectionsStore([{ id: "s1", name: "Work", order: 0 }])); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); assert.equal(result.action, "hold"); assert.equal(manager.getPendingStore(), null); } finally { @@ -202,7 +168,9 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy const restore = installFakeWindow(fw); try { const manager = new ChannelSectionSyncManager("pk-fresh", RELAY); - const result = await manager.bootstrap(makeSectionsStore([{ id: "s1", name: "Work", order: 0 }])); + const result = await manager.bootstrap( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); assert.equal(result.action, "hold"); assert.ok(manager.getPendingStore() !== null); } finally { @@ -220,20 +188,38 @@ test("revert-fix: sections LWW — newer decryptable pre-publish event selected let callCount = 0; mock.method(relayClient, "fetchEvents", () => { callCount++; - return Promise.resolve([{ pubkey: "pk-lww", content: callCount === 1 ? "bad-cipher" : "good-cipher", - created_at: callCount === 1 ? 100 : 200, id: `evt-${callCount}` }]); + return Promise.resolve([ + { + pubkey: "pk-lww", + content: callCount === 1 ? "bad-cipher" : "good-cipher", + created_at: callCount === 1 ? 100 : 200, + id: `evt-${callCount}`, + }, + ]); }); mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); const restore = installFakeWindow(fw); const tauri = installTauriMock( - JSON.stringify({ version: 1, sections: [{ id: REMOTE_ID, name: "Remote", order: 0 }], assignments: {} }), + JSON.stringify({ + version: 1, + sections: [{ id: REMOTE_ID, name: "Remote", order: 0 }], + assignments: {}, + }), ); try { const manager = new ChannelSectionSyncManager("pk-lww", RELAY); await manager.fetchRemoteSections(); - assert.ok(manager.getPersistedWatermark() >= 100); - manager.publishSections(makeSectionsStore([{ id: "local-s", name: "Local", order: 0 }])); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-lww:${RELAY_KEY}`, + ) ?? "0", + ) >= 100, + ); + manager.publishSections( + makeSectionsStore([{ id: "local-s", name: "Local", order: 0 }]), + ); fw._fireTimer(); await new Promise((r) => setTimeout(r, 20)); const pt = tauri.capturedPlaintext(); @@ -262,13 +248,31 @@ test("revert-fix: undecryptable live event advances watermark before decrypt att const restore = installFakeWindow(fw); try { const manager = new ChannelSectionSyncManager("pk-live", RELAY); - assert.equal(manager.getPersistedWatermark(), 0, "watermark starts at 0"); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`, + ), + null, + "watermark starts absent", + ); await manager.subscribeToSections(() => {}); - assert.ok(liveCallback !== null, "subscribeLive must have captured the callback"); - liveCallback({ pubkey: "pk-live", content: "!bad-cipher!", created_at: 1700005555, id: "live-evt-1" }); + assert.ok( + liveCallback !== null, + "subscribeLive must have captured the callback", + ); + liveCallback({ + pubkey: "pk-live", + content: "!bad-cipher!", + created_at: 1700005555, + id: "live-evt-1", + }); await new Promise((r) => setTimeout(r, 0)); assert.ok( - manager.getPersistedWatermark() >= 1700005555, + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`, + ) ?? "0", + ) >= 1700005555, "live undecryptable event must advance the watermark before decrypt is attempted", ); } finally { diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index 7df496b5f5b..858b62430f3 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -15,15 +15,11 @@ import { advanceWatermark, readWatermark, runBootstrap, - type BootstrapResult, type FetchResult, } from "./sidebarSyncWatermark"; -/** Result returned by `bootstrap()` — the hook acts on this without publishing. */ -export type { BootstrapResult }; - const D_TAG = "channel-sections"; -const BLOB_TYPE = "channel-sections"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteSections = { @@ -98,16 +94,7 @@ export class ChannelSectionSyncManager { if (createdAt > this.lastRemoteCreatedAt) { this.lastRemoteCreatedAt = createdAt; } - advanceWatermark(this.pubkey, BLOB_TYPE, createdAt, this.relayUrl); - } - - /** - * Returns the persisted watermark as read at construction time. A non-zero - * value means this manager has seen a remote blob in a prior session, so - * seed-publish must be skipped even when a boot fetch returns `absent`. - */ - getPersistedWatermark(): number { - return this.lastRemoteCreatedAt; + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); } cancelPendingPublish(): void { @@ -144,10 +131,10 @@ export class ChannelSectionSyncManager { }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; const event = events[0]; - // Snapshot the comparison baseline BEFORE recording the raw head so the - // whole-blob LWW comparison uses the pre-fetch watermark, not the one - // advanced by this event (Thufir pass-2: advancing first would make the - // compare always false and silently kill the merge). + // Snapshot the watermark before advancing it: after recordRemoteHead + // runs, lastRemoteCreatedAt equals event.created_at, so the LWW + // comparison remote.createdAt > lastRemoteCreatedAt would always be + // false and silently suppress the merge. const headBeforeFetch = this.lastRemoteCreatedAt; this.recordRemoteHead(event.created_at); const remote = await decryptAndParse(event); @@ -253,7 +240,6 @@ export class ChannelSectionSyncManager { this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.recordRemoteHead(result.createdAt); onUpdate(result); } }); @@ -262,17 +248,10 @@ export class ChannelSectionSyncManager { } /** - * Bootstrap the manager on first mount. Fetches the remote blob, records - * the raw head before decrypt on every outcome, and — if genuine first-time - * sync is detected — **performs the seed-publish itself** so hooks cannot - * publish during bootstrap at all. - * - * Returns `apply-remote` with the found data so the hook can apply it to - * React state, or `hold` when there is nothing for the hook to do. + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. */ - async bootstrap( - localStore: ChannelSectionStore, - ): Promise> { + async bootstrap(localStore: ChannelSectionStore) { const fetchResult = await this.fetchRemoteSections(); return runBootstrap({ fetchResult, @@ -285,12 +264,11 @@ export class ChannelSectionSyncManager { destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any - // in-flight doPublish() calls abort before reaching relayClient. The - // scoped localStorage write is already durable; when the user returns to - // this relay the existing seed-publish guard will re-publish from local - // state. Flushing here would race against community switching and could - // publish relay A's sections to relay B via the shared relayClient - // singleton. + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's sections to relay B via the shared relayClient + // singleton. On return, bootstrap's found path whole-blob-replaces from + // remote, so any dropped pending edit is lost. this.destroyed = true; this.cancelPendingPublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.ts b/desktop/src/features/sidebar/lib/channelSortPreference.ts index 6bd9b48d7b4..aa67ca3fb16 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -1,4 +1,4 @@ -import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; import type { Channel } from "@/shared/api/types"; const STORAGE_KEY_PREFIX = "buzz-channel-sort.v1"; diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index 49ad1379f26..28159eedd34 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -3,86 +3,19 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelSortSyncManager } from "./channelSortSync.ts"; +import { + makeFakeWindow, + installFakeWindow, + installTauriMock, +} from "./sidebarSyncTestHelpers.mjs"; function makeStore(groups = {}) { return { version: 1, groups }; } -// ─── Shared test helpers ─────────────────────────────────────────────────────── - -function makeFakeWindow() { - const storage = new Map(); - const ls = { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - clear: () => storage.clear(), - }; - let timerCallback = null; - let nextTimerId = 100; - return { - localStorage: ls, - setTimeout: (fn, _ms) => { - timerCallback = fn; - return nextTimerId++; - }, - clearTimeout: (_id) => { - timerCallback = null; - }, - _fireTimer: () => { - if (timerCallback) { - const fn = timerCallback; - timerCallback = null; - fn(); - } - }, - _hasTimer: () => timerCallback !== null, - }; -} - -function installFakeWindow(fw) { - if (typeof globalThis.window === "undefined") globalThis.window = {}; - const origLs = globalThis.window.localStorage; - const origSt = globalThis.window.setTimeout; - const origCt = globalThis.window.clearTimeout; - globalThis.window.localStorage = fw.localStorage; - globalThis.window.setTimeout = fw.setTimeout; - globalThis.window.clearTimeout = fw.clearTimeout; - return () => { - if (origLs !== undefined) globalThis.window.localStorage = origLs; - if (origSt !== undefined) globalThis.window.setTimeout = origSt; - if (origCt !== undefined) globalThis.window.clearTimeout = origCt; - }; -} - const RELAY = "wss://r.test"; const RELAY_KEY = encodeURIComponent(RELAY); -// Tauri mock: same pattern as sections — intercepts nip44_decrypt_from_self/encrypt_to_self/sign_event. -function installTauriMock(goodCipherPayload) { - const orig = globalThis.window?.__TAURI_INTERNALS__; - if (typeof globalThis.window === "undefined") globalThis.window = {}; - let captured = null; - globalThis.window.__TAURI_INTERNALS__ = { - invoke: (cmd, args) => { - if (cmd === "nip44_decrypt_from_self") { - if (args?.ciphertext === "bad-cipher") return Promise.reject(new Error("decrypt failed")); - return Promise.resolve(goodCipherPayload); - } - if (cmd === "nip44_encrypt_to_self") { captured = args?.plaintext ?? null; return Promise.resolve("ct"); } - if (cmd === "sign_event") return Promise.resolve(JSON.stringify({ id: "eid", pubkey: "pk-lww", content: "ct", created_at: args?.createdAt ?? 0, kind: args?.kind ?? 0, tags: args?.tags ?? [], sig: "s" })); - return Promise.reject(new Error(`unmocked: ${cmd}`)); - }, - }; - return { - restore: () => { - if (orig !== undefined) globalThis.window.__TAURI_INTERNALS__ = orig; - else delete globalThis.window.__TAURI_INTERNALS__; - }, - capturedPlaintext: () => captured, - }; -} - // ─── destroy() must cancel pending publish, not flush ───────────────────────── test("destroy: cancels pending publish without flushing to the relay", () => { mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); @@ -112,8 +45,18 @@ test("destroy: cancels pending publish without flushing to the relay", () => { 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(); }); + 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 { @@ -123,7 +66,11 @@ test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolv manager.destroy(); releaseFetch(); await new Promise((r) => setTimeout(r, 0)); - assert.equal(publishCalls.length, 0, "publishEvent must not fire after destroy"); + assert.equal( + publishCalls.length, + 0, + "publishEvent must not fire after destroy", + ); } finally { restore(); mock.reset(); @@ -148,7 +95,9 @@ test("destroy: is safe to call with no pending publish", () => { // 1. fetch failed (error/timeout) + local non-empty → hold, zero publish calls // Mutation: removing the failed guard causes bootstrap to call publishSortPrefs → pendingStore set. 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, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); const restore = installFakeWindow(fw); @@ -169,11 +118,20 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish via boot mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); - fw.localStorage.setItem(`buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, "1700000000"); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, + "1700000000", + ); const restore = installFakeWindow(fw); try { const manager = new ChannelSortSyncManager("pk-stale", RELAY); - assert.ok(manager.getPersistedWatermark() > 0); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); const result = await manager.bootstrap(makeStore({ channels: "recent" })); assert.equal(result.action, "hold"); assert.equal(manager.getPendingStore(), null); @@ -192,7 +150,12 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy const restore = installFakeWindow(fw); try { const manager = new ChannelSortSyncManager("pk-fresh", RELAY); - assert.equal(manager.getPersistedWatermark(), 0); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-fresh:${RELAY_KEY}`, + ), + null, + ); const result = await manager.bootstrap(makeStore({ channels: "recent" })); assert.equal(result.action, "hold"); assert.ok(manager.getPendingStore() !== null); @@ -211,17 +174,31 @@ test("revert-fix: sort LWW — newer decryptable pre-publish event selected afte let callCount = 0; mock.method(relayClient, "fetchEvents", () => { callCount++; - return Promise.resolve([{ pubkey: "pk-lww", content: callCount === 1 ? "bad-cipher" : "good-cipher", - created_at: callCount === 1 ? 100 : 200, id: `evt-${callCount}` }]); + return Promise.resolve([ + { + pubkey: "pk-lww", + content: callCount === 1 ? "bad-cipher" : "good-cipher", + created_at: callCount === 1 ? 100 : 200, + id: `evt-${callCount}`, + }, + ]); }); mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); const restore = installFakeWindow(fw); - const tauri = installTauriMock(JSON.stringify({ version: 1, groups: { [REMOTE_KEY]: "recent" } })); + const tauri = installTauriMock( + JSON.stringify({ version: 1, groups: { [REMOTE_KEY]: "recent" } }), + ); try { const manager = new ChannelSortSyncManager("pk-lww", RELAY); await manager.fetchRemoteSortPrefs(); - assert.ok(manager.getPersistedWatermark() >= 100); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-lww:${RELAY_KEY}`, + ) ?? "0", + ) >= 100, + ); manager.publishSortPrefs(makeStore({ "local-group": "recent" })); fw._fireTimer(); await new Promise((r) => setTimeout(r, 20)); @@ -251,13 +228,31 @@ test("revert-fix: undecryptable live event advances watermark before decrypt att const restore = installFakeWindow(fw); try { const manager = new ChannelSortSyncManager("pk-live", RELAY); - assert.equal(manager.getPersistedWatermark(), 0, "watermark starts at 0"); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`, + ), + null, + "watermark starts absent", + ); await manager.subscribeToSortPrefs(() => {}); - assert.ok(liveCallback !== null, "subscribeLive must have captured the callback"); - liveCallback({ pubkey: "pk-live", content: "!bad-cipher!", created_at: 1700005555, id: "live-evt-1" }); + assert.ok( + liveCallback !== null, + "subscribeLive must have captured the callback", + ); + liveCallback({ + pubkey: "pk-live", + content: "!bad-cipher!", + created_at: 1700005555, + id: "live-evt-1", + }); await new Promise((r) => setTimeout(r, 0)); assert.ok( - manager.getPersistedWatermark() >= 1700005555, + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`, + ) ?? "0", + ) >= 1700005555, "live undecryptable event must advance the watermark before decrypt is attempted", ); } finally { diff --git a/desktop/src/features/sidebar/lib/channelSortSync.ts b/desktop/src/features/sidebar/lib/channelSortSync.ts index 2bfee8ff98e..fe71fe62dfa 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.ts +++ b/desktop/src/features/sidebar/lib/channelSortSync.ts @@ -14,15 +14,11 @@ import { advanceWatermark, readWatermark, runBootstrap, - type BootstrapResult, type FetchResult, } from "./sidebarSyncWatermark"; -/** Result returned by `bootstrap()` — the hook acts on this without publishing. */ -export type { BootstrapResult }; - const D_TAG = "channel-sort"; -const BLOB_TYPE = "channel-sort"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteSortPrefs = { @@ -100,11 +96,7 @@ export class ChannelSortSyncManager { if (createdAt > this.lastRemoteCreatedAt) { this.lastRemoteCreatedAt = createdAt; } - advanceWatermark(this.pubkey, BLOB_TYPE, createdAt, this.relayUrl); - } - - getPersistedWatermark(): number { - return this.lastRemoteCreatedAt; + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); } cancelPendingPublish(): void { @@ -141,9 +133,10 @@ export class ChannelSortSyncManager { }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; const event = events[0]; - // Snapshot the comparison baseline BEFORE recording the raw head so the - // whole-blob LWW comparison uses the pre-fetch watermark, not the one - // advanced by this event. + // Snapshot the watermark before advancing it: after recordRemoteHead + // runs, lastRemoteCreatedAt equals event.created_at, so the LWW + // comparison remote.createdAt > lastRemoteCreatedAt would always be + // false and silently suppress the merge. const headBeforeFetch = this.lastRemoteCreatedAt; this.recordRemoteHead(event.created_at); const remote = await decryptAndParse(event); @@ -234,7 +227,6 @@ export class ChannelSortSyncManager { this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.recordRemoteHead(result.createdAt); onUpdate(result); } }); @@ -243,13 +235,10 @@ export class ChannelSortSyncManager { } /** - * Bootstrap the manager on first mount. Fetches the remote blob, records - * the raw head before decrypt on every outcome, and — if genuine first-time - * sync is detected — **performs the seed-publish itself**. + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. */ - async bootstrap( - localStore: ChannelSortStore, - ): Promise> { + async bootstrap(localStore: ChannelSortStore) { const fetchResult = await this.fetchRemoteSortPrefs(); return runBootstrap({ fetchResult, @@ -262,12 +251,11 @@ export class ChannelSortSyncManager { destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any - // in-flight doPublish() calls abort before reaching relayClient. The - // scoped localStorage write is already durable; when the user returns to - // this relay the existing seed-publish guard will re-publish from local - // state. Flushing here would race against community switching and could - // publish relay A's sort prefs to relay B via the shared relayClient - // singleton. + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's sort prefs to relay B via the shared relayClient + // singleton. On return, bootstrap's found path whole-blob-replaces from + // remote, so any dropped pending edit is lost. this.destroyed = true; this.cancelPendingPublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs index 49971ffeef0..b0235744672 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs @@ -3,6 +3,10 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelStarSyncManager } from "./channelStarsSync.ts"; +import { + makeFakeWindow, + installFakeWindow, +} from "./sidebarSyncTestHelpers.mjs"; const RELAY = "wss://r.test"; const RELAY_KEY = encodeURIComponent(RELAY); @@ -11,50 +15,6 @@ function makeStore(channels = {}) { return { version: 1, channels }; } -function makeFakeWindow() { - const storage = new Map(); - const ls = { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - clear: () => storage.clear(), - }; - let timerCallback = null; - let nextTimerId = 100; - return { - localStorage: ls, - setTimeout: (fn, _ms) => { - timerCallback = fn; - return nextTimerId++; - }, - clearTimeout: (_id) => { - timerCallback = null; - }, - _fireTimer: () => { - if (timerCallback) { - const fn = timerCallback; - timerCallback = null; - fn(); - } - }, - }; -} - -function installFakeWindow(fw) { - if (typeof globalThis.window === "undefined") globalThis.window = {}; - const origLs = globalThis.window.localStorage; - const origSt = globalThis.window.setTimeout; - const origCt = globalThis.window.clearTimeout; - globalThis.window.localStorage = fw.localStorage; - globalThis.window.setTimeout = fw.setTimeout; - globalThis.window.clearTimeout = fw.clearTimeout; - return () => { - if (origLs !== undefined) globalThis.window.localStorage = origLs; - if (origSt !== undefined) globalThis.window.setTimeout = origSt; - if (origCt !== undefined) globalThis.window.clearTimeout = origCt; - }; -} - // ─── destroy() must cancel pending publish, not flush ───────────────────────── // Regression guard for the community-switch cross-relay publish vector: @@ -63,7 +23,10 @@ function installFakeWindow(fw) { 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(); }); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { @@ -81,8 +44,18 @@ test("destroy: cancels pending publish without flushing to the relay", () => { 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(); }); + 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 { @@ -92,7 +65,11 @@ test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolv manager.destroy(); releaseFetch(); await new Promise((r) => setTimeout(r, 0)); - assert.equal(publishCalls.length, 0, "publishEvent must not be called after destroy"); + assert.equal( + publishCalls.length, + 0, + "publishEvent must not be called after destroy", + ); } finally { restore(); mock.reset(); @@ -114,13 +91,17 @@ test("destroy: is safe to call with no pending publish", () => { // 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) 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, "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: { starred: true, updatedAt: 1 } })); + const result = await manager.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); assert.equal(result.action, "hold"); assert.equal(manager.getPendingStarStore(), null); } finally { @@ -134,12 +115,23 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish via boot 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"); + 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); - assert.ok(manager.getPersistedWatermark() > 0); - const result = await manager.bootstrap(makeStore({ ch1: { starred: true, updatedAt: 1 } })); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); assert.equal(result.action, "hold"); assert.equal(manager.getPendingStarStore(), null); } finally { @@ -156,8 +148,15 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy const restore = installFakeWindow(fw); try { const manager = new ChannelStarSyncManager("pk-fresh", RELAY); - assert.equal(manager.getPersistedWatermark(), 0); - const result = await manager.bootstrap(makeStore({ ch1: { starred: true, updatedAt: 1 } })); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-stars:pk-fresh:${RELAY_KEY}`, + ), + null, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); assert.equal(result.action, "hold"); assert.ok(manager.getPendingStarStore() !== null); } finally { @@ -174,14 +173,28 @@ test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B 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"); + 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); - assert.equal(managerB.getPersistedWatermark(), 0, "relay B watermark must be independent of relay A head"); - const result = await managerB.bootstrap(makeStore({ ch1: { starred: true, updatedAt: 1 } })); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayB)}`, + ), + null, + "relay B watermark must be independent of relay A head", + ); + const result = await managerB.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); assert.equal(result.action, "hold"); - assert.ok(managerB.getPendingStarStore() !== null, "first-sync seed on relay B must not be blocked by relay A watermark"); + assert.ok( + managerB.getPendingStarStore() !== null, + "first-sync seed on relay B must not be blocked by relay A watermark", + ); } finally { restore(); mock.reset(); diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.ts b/desktop/src/features/sidebar/lib/channelStarsSync.ts index fe260adbbad..a5abec03fba 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.ts +++ b/desktop/src/features/sidebar/lib/channelStarsSync.ts @@ -15,15 +15,11 @@ import { advanceWatermark, readWatermark, runBootstrap, - type BootstrapResult, type FetchResult, } from "./sidebarSyncWatermark"; -/** Result returned by `bootstrap()` — the hook acts on this without publishing. */ -export type { BootstrapResult }; - const D_TAG = "channel-stars"; -const BLOB_TYPE = "channel-stars"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteStars = { @@ -90,11 +86,7 @@ export class ChannelStarSyncManager { if (createdAt > this.lastRemoteCreatedAt) { this.lastRemoteCreatedAt = createdAt; } - advanceWatermark(this.pubkey, BLOB_TYPE, createdAt, this.relayUrl); - } - - getPersistedWatermark(): number { - return this.lastRemoteCreatedAt; + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); } cancelPendingStarPublish(): void { @@ -135,7 +127,6 @@ export class ChannelStarSyncManager { this.recordRemoteHead(event.created_at); const remote = await decryptAndParse(event); if (!remote) return store; - this.recordRemoteHead(remote.createdAt); return mergeStores(store, remote.store); } catch { return store; @@ -220,7 +211,6 @@ export class ChannelStarSyncManager { this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.recordRemoteHead(result.createdAt); onUpdate(result); } }); @@ -229,13 +219,10 @@ export class ChannelStarSyncManager { } /** - * Bootstrap the manager on first mount. Fetches the remote blob, records - * the raw head before decrypt on every outcome, and — if genuine first-time - * sync is detected — **performs the seed-publish itself**. + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. */ - async bootstrap( - localStore: ChannelStarStore, - ): Promise> { + async bootstrap(localStore: ChannelStarStore) { const fetchResult = await this.fetchRemoteStars(); return runBootstrap({ fetchResult, @@ -248,11 +235,11 @@ export class ChannelStarSyncManager { destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any - // in-flight doPublish() calls abort before reaching relayClient. The - // scoped localStorage write is already durable; when the user returns to - // this relay the existing seed-publish guard will re-publish from local - // state. Flushing here would race against community switching and could - // publish relay A's stars to relay B via the shared relayClient singleton. + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's state to relay B via the shared relayClient + // singleton. Local entries survive because the apply/publish paths merge + // per-entry via mergeStores, so no local work is permanently lost. this.destroyed = true; this.cancelPendingStarPublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs b/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs new file mode 100644 index 00000000000..c94d76db705 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs @@ -0,0 +1,85 @@ +// Shared helpers for sidebar sync manager tests. + +export function makeFakeWindow() { + const storage = new Map(); + const ls = { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + clear: () => storage.clear(), + }; + let timerCallback = null; + let nextTimerId = 100; + return { + localStorage: ls, + setTimeout: (fn, _ms) => { + timerCallback = fn; + return nextTimerId++; + }, + clearTimeout: (_id) => { + timerCallback = null; + }, + _fireTimer: () => { + if (timerCallback) { + const fn = timerCallback; + timerCallback = null; + fn(); + } + }, + _hasTimer: () => timerCallback !== null, + }; +} + +export function installFakeWindow(fw) { + if (typeof globalThis.window === "undefined") globalThis.window = {}; + const origLs = globalThis.window.localStorage; + const origSt = globalThis.window.setTimeout; + const origCt = globalThis.window.clearTimeout; + globalThis.window.localStorage = fw.localStorage; + globalThis.window.setTimeout = fw.setTimeout; + globalThis.window.clearTimeout = fw.clearTimeout; + return () => { + if (origLs !== undefined) globalThis.window.localStorage = origLs; + if (origSt !== undefined) globalThis.window.setTimeout = origSt; + if (origCt !== undefined) globalThis.window.clearTimeout = origCt; + }; +} + +export function installTauriMock(goodCipherPayload) { + const orig = globalThis.window?.__TAURI_INTERNALS__; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + let captured = null; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") { + if (args?.ciphertext === "bad-cipher") + return Promise.reject(new Error("decrypt failed")); + return Promise.resolve(goodCipherPayload); + } + if (cmd === "nip44_encrypt_to_self") { + captured = args?.plaintext ?? null; + return Promise.resolve("ct"); + } + if (cmd === "sign_event") + return Promise.resolve( + JSON.stringify({ + id: "eid", + pubkey: "pk-lww", + content: "ct", + created_at: args?.createdAt ?? 0, + kind: args?.kind ?? 0, + tags: args?.tags ?? [], + sig: "s", + }), + ); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }, + }; + return { + restore: () => { + if (orig !== undefined) globalThis.window.__TAURI_INTERNALS__ = orig; + else delete globalThis.window.__TAURI_INTERNALS__; + }, + capturedPlaintext: () => captured, + }; +} diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs index a62f54fde82..0e8cb373c16 100644 --- a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs @@ -4,12 +4,18 @@ import test from "node:test"; // We need a minimal localStorage stub since we're running in Node. function withFreshStorage(fn) { const store = new Map(); - const ls = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, v), - removeItem: (k) => store.delete(k), clear: () => store.clear() }; + const ls = { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), + clear: () => store.clear(), + }; const orig = globalThis.window?.localStorage; if (typeof globalThis.window === "undefined") globalThis.window = {}; globalThis.window.localStorage = ls; - try { fn(ls); } finally { + try { + fn(ls); + } finally { if (orig !== undefined) globalThis.window.localStorage = orig; else delete globalThis.window.localStorage; } @@ -61,9 +67,15 @@ test("readWatermark: scopes by blobType", () => { test("readWatermark: normalises relay URL (trailing slash, case)", () => { withFreshStorage(() => { // Write with one form, read with another — must produce the same value. - advanceWatermark("pk", "sections", 999, "WSS://Relay.Example.Com/"); - assert.equal(readWatermark("pk", "sections", "wss://relay.example.com"), 999); - assert.equal(readWatermark("pk", "sections", "WSS://Relay.Example.Com/"), 999); + advanceWatermark("pk", "sections", "WSS://Relay.Example.Com/", 999); + assert.equal( + readWatermark("pk", "sections", "wss://relay.example.com"), + 999, + ); + assert.equal( + readWatermark("pk", "sections", "WSS://Relay.Example.Com/"), + 999, + ); }); }); @@ -71,24 +83,24 @@ test("readWatermark: normalises relay URL (trailing slash, case)", () => { test("advanceWatermark: writes when no prior value exists", () => { withFreshStorage(() => { - advanceWatermark("pk", "sections", 1700000000, RELAY); + advanceWatermark("pk", "sections", RELAY, 1700000000); assert.equal(readWatermark("pk", "sections", RELAY), 1700000000); }); }); test("advanceWatermark: advances when next > current", () => { withFreshStorage(() => { - advanceWatermark("pk", "sections", 100, RELAY); - advanceWatermark("pk", "sections", 200, RELAY); + advanceWatermark("pk", "sections", RELAY, 100); + advanceWatermark("pk", "sections", RELAY, 200); assert.equal(readWatermark("pk", "sections", RELAY), 200); }); }); test("advanceWatermark: does not regress when next <= current (monotonic)", () => { withFreshStorage(() => { - advanceWatermark("pk", "sections", 500, RELAY); - advanceWatermark("pk", "sections", 400, RELAY); // older — must not overwrite - advanceWatermark("pk", "sections", 500, RELAY); // equal — must not overwrite + advanceWatermark("pk", "sections", RELAY, 500); + advanceWatermark("pk", "sections", RELAY, 400); // older — must not overwrite + advanceWatermark("pk", "sections", RELAY, 500); // equal — must not overwrite assert.equal(readWatermark("pk", "sections", RELAY), 500); }); }); @@ -96,7 +108,7 @@ test("advanceWatermark: does not regress when next <= current (monotonic)", () = test("advanceWatermark: round-trips across separate reads (simulated restart)", () => { withFreshStorage(() => { // Session A writes watermark. - advanceWatermark("pk", "sections", 1700000042, RELAY); + advanceWatermark("pk", "sections", RELAY, 1700000042); // Session B reads it back. assert.equal(readWatermark("pk", "sections", RELAY), 1700000042); }); @@ -108,8 +120,12 @@ test("relay-A watermark does not suppress first-sync on relay-B", () => { withFreshStorage(() => { const relayA = "wss://a.relay.test"; const relayB = "wss://b.relay.test"; - advanceWatermark("pk", "sections", 1700000100, relayA); - assert.equal(readWatermark("pk", "sections", relayB), 0, "relay B watermark must be independent of relay A"); + advanceWatermark("pk", "sections", relayA, 1700000100); + assert.equal( + readWatermark("pk", "sections", relayB), + 0, + "relay B watermark must be independent of relay A", + ); }); }); @@ -117,9 +133,13 @@ test("relay-A watermark is preserved after relay-B session", () => { withFreshStorage(() => { const relayA = "wss://a.relay.test"; const relayB = "wss://b.relay.test"; - advanceWatermark("pk", "sections", 1700000100, relayA); - advanceWatermark("pk", "sections", 1700000200, relayB); - assert.equal(readWatermark("pk", "sections", relayA), 1700000100, "relay A head must not be clobbered by relay B activity"); + advanceWatermark("pk", "sections", relayA, 1700000100); + advanceWatermark("pk", "sections", relayB, 1700000200); + assert.equal( + readWatermark("pk", "sections", relayA), + 1700000100, + "relay A head must not be clobbered by relay B activity", + ); }); }); @@ -128,8 +148,15 @@ test("relay-A watermark is preserved after relay-B session", () => { function makeBootstrapArgs({ fetchResult, lastHead, localNonEmpty }) { let n = 0; return { - args: { fetchResult, lastHead, localStore: { items: localNonEmpty ? ["x"] : [] }, - isLocalNonEmpty: (s) => s.items.length > 0, publishFn: () => { n++; } }, + args: { + fetchResult, + lastHead, + localStore: { items: localNonEmpty ? ["x"] : [] }, + isLocalNonEmpty: (s) => s.items.length > 0, + publishFn: () => { + n++; + }, + }, publishCount: () => n, }; } @@ -144,7 +171,11 @@ test("runBootstrap: fetch failed returns hold and never calls publishFn", () => }); const result = runBootstrap(args); assert.equal(result.action, "hold"); - assert.equal(publishCount(), 0, "publishFn must not be called on failed fetch"); + assert.equal( + publishCount(), + 0, + "publishFn must not be called on failed fetch", + ); }); // Guard: fetch absent + prior head > 0 → hold, zero publishes (stale-dev-build case). @@ -157,7 +188,11 @@ test("runBootstrap: fetch absent with prior head returns hold and never calls pu }); const result = runBootstrap(args); assert.equal(result.action, "hold"); - assert.equal(publishCount(), 0, "publishFn must not be called when prior head exists"); + assert.equal( + publishCount(), + 0, + "publishFn must not be called when prior head exists", + ); }); // Guard: fetch absent + head 0 + local non-empty → publishFn called exactly once, hold returned. @@ -170,7 +205,11 @@ test("runBootstrap: first-sync (absent + zero head + non-empty local) calls publ }); const result = runBootstrap(args); assert.equal(result.action, "hold"); - assert.equal(publishCount(), 1, "publishFn must be called exactly once on first-sync"); + assert.equal( + publishCount(), + 1, + "publishFn must be called exactly once on first-sync", + ); }); // Guard: fetch absent + head 0 + empty local → no publish, hold returned. @@ -188,14 +227,27 @@ test("runBootstrap: first-sync with empty local store does not call publishFn", // Guard: fetch found → apply-remote returned, no publish. // Mutation: removing the found branch drops the remote data. test("runBootstrap: fetch found returns apply-remote with data and never calls publishFn", () => { - const remoteData = { store: { version: 1, items: [] }, createdAt: 100, eventId: "e1" }; + const remoteData = { + store: { version: 1, items: [] }, + createdAt: 100, + eventId: "e1", + }; const { args, publishCount } = makeBootstrapArgs({ - fetchResult: { status: "found", data: remoteData, createdAt: 100, eventId: "e1" }, + fetchResult: { + status: "found", + data: remoteData, + createdAt: 100, + eventId: "e1", + }, lastHead: 0, localNonEmpty: true, }); const result = runBootstrap(args); assert.equal(result.action, "apply-remote"); assert.deepEqual(result.data, remoteData); - assert.equal(publishCount(), 0, "publishFn must not be called when remote was found"); + assert.equal( + publishCount(), + 0, + "publishFn must not be called when remote was found", + ); }); diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts index 2504c4c6271..d81b188ad67 100644 --- a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts @@ -19,7 +19,7 @@ * two ways never produces two different keys. */ -import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; const PREFIX = "buzz-sync-watermark.v1"; @@ -73,8 +73,8 @@ export function readWatermark( export function advanceWatermark( pubkey: string, blobType: string, - next: number, relayUrl: string, + next: number, ): void { try { const current = readWatermark(pubkey, blobType, relayUrl); diff --git a/desktop/src/shared/lib/normalizeRelayUrl.ts b/desktop/src/shared/lib/normalizeRelayUrl.ts new file mode 100644 index 00000000000..7222b1fe255 --- /dev/null +++ b/desktop/src/shared/lib/normalizeRelayUrl.ts @@ -0,0 +1,8 @@ +/** + * Normalizes a relay URL for use in storage keys. + * Trim, strip trailing slashes, lowercase — ensures equivalent URLs map to + * the same key regardless of formatting differences. + */ +export function normalizeRelayUrl(relayUrl: string): string { + return relayUrl.trim().replace(/\/+$/, "").toLowerCase(); +}