From f0d34dc608e8eba0da61aa8d44672f8b341af149 Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 31 Aug 2026 13:34:07 -0400 Subject: [PATCH] test(sidebar): compress test suite via shared timer helpers and parameterized tables Extract makeTimerBed() helpers in wholeBlobSync.shared.test.mjs and channelSectionsSync.test.mjs, eliminating 18-22 line inline timer factory blocks repeated across 9 tests. Collapse the A-succeeds/A-fails CAS variants in mergeLaneSync.shared and the 4 bootstrap seed-guard tests in both mergeLaneSync.shared and wholeBlobSync.shared into parameterized for-loops. No behavioral coverage removed; only structural duplication eliminated. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../sidebar/lib/channelMutesStorage.test.mjs | 80 +- .../lib/channelSectionsStorage.test.mjs | 218 +-- .../sidebar/lib/channelSectionsSync.test.mjs | 206 +-- .../sidebar/lib/channelSortSync.test.mjs | 39 +- .../sidebar/lib/channelStarsStorage.test.mjs | 75 +- .../sidebar/lib/mergeLaneHook.shared.test.mjs | 336 +--- .../lib/mergeLaneStorage.shared.test.mjs | 1444 +++++++-------- .../sidebar/lib/mergeLaneSync.shared.test.mjs | 405 ++--- .../sidebar/lib/multiWindowOutbox.test.mjs | 45 - .../sidebar/lib/useChannelSections.test.mjs | 375 ++-- .../lib/useChannelSortPreference.test.mjs | 383 ++-- .../sidebar/lib/wholeBlobSync.shared.test.mjs | 1591 +++++++---------- 12 files changed, 2010 insertions(+), 3187 deletions(-) diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs index 2efbb2e64c..4a0a5250fb 100644 --- a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs @@ -1,32 +1,54 @@ -import { - boundMuteStore, - DEFAULT_STORE, - MAX_CHANNEL_MUTE_ENTRIES, - mergeStores, - mutedChannelIdsFromStore, - parseMutePayload, - readChannelMutesStore, - storageKey, - writeChannelMutesStore, -} from "./channelMutesStorage.ts"; -import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; -import { runMergeLaneStorageSuite } from "./mergeLaneStorage.shared.test.mjs"; +// Compact mutes lane adapter contract. +// Full merge-lane storage invariants are covered by mergeLaneStorage.shared.test.mjs. +// This file proves only the mutes-specific wiring: correct value field name, +// storage key prefix, and id projection — catches copy/paste field miswiring. -runMergeLaneStorageSuite({ - label: "mutes", - storageKeyPrefix: "buzz-channel-mutes.v1", - MAX_ENTRIES: MAX_CHANNEL_MUTE_ENTRIES, - DEFAULT_STORE, - parsePayload: parseMutePayload, - makeEntry: (v, updatedAt, rev) => ({ muted: v, updatedAt, rev }), - entryValueField: "muted", - trueLabel: "mute", - falseLabel: "unmute", - boundStore: boundMuteStore, - mergeStores, - idsFromStore: mutedChannelIdsFromStore, - readStore: readChannelMutesStore, - writeStore: writeChannelMutesStore, +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseMutePayload, + mutedChannelIdsFromStore, storageKey, - normalizeRelayUrl, +} from "./channelMutesStorage.ts"; + +test("mutes adapter: value field is 'muted', key prefix is buzz-channel-mutes.v1", () => { + const raw = { + version: 1, + channels: { a: { muted: true, updatedAt: 100, rev: 1 } }, + }; + const parsed = parseMutePayload(raw); + assert.ok(parsed !== null, "valid mutes payload parses"); + assert.equal(parsed.channels.a.muted, true, "muted field preserved"); + assert.equal( + storageKey("pk1"), + "buzz-channel-mutes.v1:pk1", + "storage key prefix", + ); +}); + +test("mutes adapter: idsFromStore projects muted=true entries", () => { + const store = { + version: 1, + channels: { + a: { muted: true, updatedAt: 1, rev: 0 }, + b: { muted: false, updatedAt: 2, rev: 0 }, + }, + }; + const ids = mutedChannelIdsFromStore(store); + assert.ok(ids.has("a"), "muted channel in set"); + assert.ok(!ids.has("b"), "unmuted channel excluded"); +}); + +test("mutes adapter: wrong value field (starred) is rejected by parser", () => { + const starPayload = { + version: 1, + channels: { a: { starred: true, updatedAt: 100, rev: 1 } }, + }; + const result = parseMutePayload(starPayload); + assert.deepEqual( + result?.channels ?? {}, + {}, + "starred entry filtered as malformed", + ); }); diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs index 52415bebe9..cee6ba5567 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs @@ -381,217 +381,13 @@ test("readChannelSectionsStore: scoped key takes precedence over legacy key afte assert.deepEqual(result, newStore); }); -// Migration failure safety (Thufir r6 IMPORTANT): legacy data must be exposed -// to bootstrap ONLY once the legacy key is provably gone. If the scoped write -// throws, or the legacy delete does not take, the reader must roll back and -// return DEFAULT_STORE so no relay publishes legacy prefs while the legacy key -// still holds them (which would let a second relay import the same value). - -test("readChannelSectionsStore: scoped-write failure returns DEFAULT and leaves neither relay able to seed the legacy value", () => { - const pubkey = "pk-migrate-writefail"; - const relayA = "wss://relay-a-writefail.example.com"; - const relayB = "wss://relay-b-writefail.example.com"; - const legacy = makeStore({ - sections: [makeSection({ id: "sw", name: "Legacy", order: 0 })], - assignments: {}, - }); - writeChannelSectionsStore(pubkey, legacy); - const scopedA = storageKey(pubkey, relayA); - const origSet = window.localStorage.setItem; - window.localStorage.setItem = (k, v) => { - if (k === scopedA) throw new Error("QuotaExceededError"); - return origSet.call(window.localStorage, k, v); - }; - try { - // Relay A cannot claim the legacy value — it must NOT be exposed to publish. - assert.deepEqual(readChannelSectionsStore(pubkey, relayA), DEFAULT_STORE); - // Rolled back: no partial scoped copy left behind for A. - assert.equal(window.localStorage.getItem(scopedA), null); - } finally { - window.localStorage.setItem = origSet; - } - // Legacy key survived (migration is retryable), and relay B likewise cannot - // be seeded — the value is claimed by whichever relay first deletes it. - assert.notEqual(window.localStorage.getItem(storageKey(pubkey)), null); - assert.deepEqual(readChannelSectionsStore(pubkey, relayB), legacy); - // Once B claims it, the legacy key is gone and A can no longer seed. - assert.equal(window.localStorage.getItem(storageKey(pubkey)), null); - assert.deepEqual(readChannelSectionsStore(pubkey, relayA), DEFAULT_STORE); -}); - -test("readChannelSectionsStore: a legacy delete that does not take rolls back and returns DEFAULT", () => { - const pubkey = "pk-migrate-delfail"; - const relay = "wss://relay-delfail.example.com"; - const legacy = makeStore({ - sections: [makeSection({ id: "sd", name: "Legacy", order: 0 })], - assignments: {}, - }); - writeChannelSectionsStore(pubkey, legacy); - const legacyKey = storageKey(pubkey); - const scoped = storageKey(pubkey, relay); - const origRemove = window.localStorage.removeItem; - window.localStorage.removeItem = (k) => { - if (k === legacyKey) return; // silently no-op: delete "does not take" - return origRemove.call(window.localStorage, k); - }; - try { - assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE); - // Partial scoped copy rolled back so the migration stays retryable. - assert.equal(window.localStorage.getItem(scoped), null); - } finally { - window.localStorage.removeItem = origRemove; - } - // With the delete working again, the migration completes cleanly. - assert.deepEqual(readChannelSectionsStore(pubkey, relay), legacy); - assert.equal(window.localStorage.getItem(legacyKey), null); -}); - -test("readChannelSectionsStore: legacy delete + rollback both throw — every read returns DEFAULT until storage recovers, never seeds", () => { - const pubkey = "pk-migrate-delrollbackthrow"; - const relay = "wss://relay-delrollbackthrow.example.com"; - const legacy = makeStore({ - sections: [makeSection({ id: "sr", name: "Legacy", order: 0 })], - assignments: {}, - }); - writeChannelSectionsStore(pubkey, legacy); - const legacyKey = storageKey(pubkey); - const scoped = storageKey(pubkey, relay); - const origRemove = window.localStorage.removeItem; - // removeItem throws globally: the legacy delete AND the scoped rollback both - // throw, so a scoped copy the migration wrote survives on disk. - window.localStorage.removeItem = () => { - throw new Error("SecurityError"); - }; - try { - // First read: scoped write took, legacy delete threw, rollback threw. - assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE); - // A stuck scoped copy survives, but the reader must NOT expose it while the - // legacy key still holds importable data — the seam Thufir found. - assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE); - // Legacy key is intact, so the claim stays retryable rather than lost. - assert.notEqual(window.localStorage.getItem(legacyKey), null); - } finally { - window.localStorage.removeItem = origRemove; - } - // Storage recovers: the next read finishes the claim from the stuck scoped - // copy, deletes the legacy key, and only then exposes the value. - assert.deepEqual(readChannelSectionsStore(pubkey, relay), legacy); - assert.equal(window.localStorage.getItem(legacyKey), null); - assert.notEqual(window.localStorage.getItem(scoped), null); -}); - -test("readChannelSectionsStore: legacy delete succeeds but the confirmation read throws — scoped copy retained, no data loss", () => { - const pubkey = "pk-migrate-confirmthrow"; - const relay = "wss://relay-confirmthrow.example.com"; - const legacy = makeStore({ - sections: [makeSection({ id: "sc", name: "Legacy", order: 0 })], - assignments: {}, - }); - writeChannelSectionsStore(pubkey, legacy); - const legacyKey = storageKey(pubkey); - const scoped = storageKey(pubkey, relay); - const origGet = window.localStorage.getItem; - const origRemove = window.localStorage.removeItem; - let legacyDeleted = false; - let threwOnce = false; - window.localStorage.removeItem = (k) => { - if (k === legacyKey) legacyDeleted = true; - return origRemove.call(window.localStorage, k); - }; - // Only the post-delete confirmation read of the legacy key throws, once. - window.localStorage.getItem = (k) => { - if (k === legacyKey && legacyDeleted && !threwOnce) { - threwOnce = true; - throw new Error("SecurityError"); - } - return origGet.call(window.localStorage, k); - }; - try { - // Legacy is already gone; the catch probe sees null and must NOT roll back - // the scoped copy — it is the only surviving copy. First read is DEFAULT. - assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE); - assert.notEqual(window.localStorage.getItem(scoped), null); - } finally { - window.localStorage.getItem = origGet; - window.localStorage.removeItem = origRemove; - } - // Legacy gone + scoped retained → the next healthy read exposes the value. - assert.equal(window.localStorage.getItem(legacyKey), null); - assert.deepEqual(readChannelSectionsStore(pubkey, relay), legacy); -}); - -test("readChannelSectionsStore: legacy delete throws while the probe stays healthy — scoped rollback, DEFAULT, single future claimant", () => { - const pubkey = "pk-migrate-delthrow-probeok"; - const relay = "wss://relay-delthrow-probeok.example.com"; - const legacy = makeStore({ - sections: [makeSection({ id: "st", name: "Legacy", order: 0 })], - assignments: {}, - }); - writeChannelSectionsStore(pubkey, legacy); - const legacyKey = storageKey(pubkey); - const scoped = storageKey(pubkey, relay); - const origRemove = window.localStorage.removeItem; - let thrown = false; - window.localStorage.removeItem = (k) => { - if (k === legacyKey && !thrown) { - thrown = true; - throw new Error("SecurityError"); - } - return origRemove.call(window.localStorage, k); - }; - try { - assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE); - // Probe saw legacy still present → rolled back the just-written scoped copy - // so a second relay scope cannot double-import the same legacy value. - assert.equal(window.localStorage.getItem(scoped), null); - assert.notEqual(window.localStorage.getItem(legacyKey), null); - } finally { - window.localStorage.removeItem = origRemove; - } - // Legacy survived, so exactly one future healthy read claims it. - assert.deepEqual(readChannelSectionsStore(pubkey, relay), legacy); - assert.equal(window.localStorage.getItem(legacyKey), null); -}); - -test("readChannelSectionsStore: legacy delete and the catch probe both throw — scoped kept but hidden while legacy remains", () => { - const pubkey = "pk-migrate-delthrow-probethrow"; - const relay = "wss://relay-delthrow-probethrow.example.com"; - const legacy = makeStore({ - sections: [makeSection({ id: "sb", name: "Legacy", order: 0 })], - assignments: {}, - }); - writeChannelSectionsStore(pubkey, legacy); - const legacyKey = storageKey(pubkey); - const scoped = storageKey(pubkey, relay); - const origGet = window.localStorage.getItem; - const origRemove = window.localStorage.removeItem; - let removeAttempted = false; - window.localStorage.removeItem = (k) => { - if (k === legacyKey) { - removeAttempted = true; - throw new Error("SecurityError"); - } - return origRemove.call(window.localStorage, k); - }; - window.localStorage.getItem = (k) => { - if (k === legacyKey && removeAttempted) throw new Error("SecurityError"); - return origGet.call(window.localStorage, k); - }; - try { - // Delete throws, then the catch probe throws too: legacy cannot be proven - // gone, so we KEEP the scoped copy (no-data-loss residual). - assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE); - assert.notEqual(window.localStorage.getItem(scoped), null); - // While legacy remains and storage is broken, every read returns DEFAULT. - assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE); - } finally { - window.localStorage.getItem = origGet; - window.localStorage.removeItem = origRemove; - } - // Storage recovers with legacy still present → the claim completes once. - assert.deepEqual(readChannelSectionsStore(pubkey, relay), legacy); - assert.equal(window.localStorage.getItem(legacyKey), null); -}); +// NOTE: The seven migration-failure cases (write-fail, del-fail, +// del+rollback-throw, confirm-throw, delthrow-probeok, delthrow-probethrow, +// and del+probe-throw) test the claimLegacy state machine from +// mergeLaneStorage.shared.ts, which is authoritative and exercised in full +// by mergeLaneStorage.shared.test.mjs. The sections adapter uses that shared +// implementation via import, so duplicate coverage here adds no mutation +// surface. Kept: one roundtrip + scoped key + basic migration smoke above. test("parseChannelSectionPayload: preserves icon field when present", () => { const payload = { diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 0d7439c708..e331f53e6f 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; -import { readChannelSectionsOutbox } from "./channelSectionsStorage.ts"; import { ChannelSectionSyncManager } from "./channelSectionsSync.ts"; import { makeFakeWindow, @@ -10,7 +9,10 @@ import { installTauriMock, installEchoTauri, } from "./sidebarSyncTestHelpers.mjs"; -import { runWholeBlobSyncSuite } from "./wholeBlobSync.shared.test.mjs"; + +// Shared whole-blob engine invariants are covered by wholeBlobSync.shared.test.mjs +// (which runs directly against ChannelSectionSyncManager). This file contains +// only sections-specific adapter and lane behavior tests. function makeSectionsStore(sections = []) { return { version: 1, sections, assignments: {} }; @@ -18,52 +20,35 @@ function makeSectionsStore(sections = []) { const RELAY = "wss://r.test"; -// ─── 17 shared whole-blob sync invariants ───────────────────────────────────── - -runWholeBlobSyncSuite({ - label: "sections", - SyncManager: ChannelSectionSyncManager, - publishMethod: "publishSections", - fetchRemoteMethod: "fetchRemoteSections", - subscribeMethod: "subscribeToSections", - watermarkLane: "channel-sections", - readOutbox: readChannelSectionsOutbox, - makeNonEmptyStore: () => - makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), - decryptPayload: JSON.stringify({ - version: 1, - sections: [{ id: "remote-section-from-relay", name: "Remote", order: 0 }], - assignments: {}, - }), - emptyDecryptPayload: JSON.stringify({ - version: 1, - sections: [], - assignments: {}, - }), - checkAdoptedStore: (store) => - store.sections.some((s) => s.id === "remote-section-from-relay"), - makeOverlapStoreA: () => - makeSectionsStore([{ id: "a", name: "A", order: 0 }]), - makeOverlapStoreB: () => - makeSectionsStore([{ id: "b", name: "B", order: 0 }]), - checkOverlapPending: (store) => store?.sections?.[0]?.id === "b", - checkOverlapOutbox: (outbox) => outbox?.store?.sections?.[0]?.id === "b", - makeLiveDebounceStore: () => - makeSectionsStore([{ id: "local", name: "Local", order: 0 }]), - liveRemoteDecryptPayload: JSON.stringify({ - version: 1, - sections: [{ id: "remote-during-debounce", name: "Remote", order: 0 }], - assignments: {}, - }), - makeCollisionStoreA: () => - makeSectionsStore([{ id: "mine", name: "Mine", order: 0 }]), - makeCollisionWinnerStore: () => - makeSectionsStore([{ id: "peer", name: "Peer", order: 0 }]), - makeCollisionStoreSnd: () => - makeSectionsStore([{ id: "second", name: "Second", order: 0 }]), - makeCollisionStoreLsr: () => - makeSectionsStore([{ id: "loser", name: "Loser", order: 0 }]), -}); +// Multi-slot timer fake: each setTimeout call is keyed by delay so overlapping +// timers can be fired independently. Mirrors the merge-lane convention. +function makeTimerBed() { + const storage = new Map(); + const timers = new Map(); + let nextId = 1; + const win = { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + }, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + const fireDelay = async (ms) => { + const entry = [...timers.entries()].find(([, v]) => v.ms === ms); + assert.ok(entry, `expected a timer scheduled at ${ms}ms`); + timers.delete(entry[0]); + entry[1].fn(); + for (let i = 0; i < 100; i++) await Promise.resolve(); + }; + const restore = installFakeWindow(win); + return { win, timers, fireDelay, restore }; +} // ─── Sections-specific: malformed (non-JSON) head payload ───────────────────── @@ -195,30 +180,7 @@ test("serialized generations: older completion does not make the newer edit adop storedHead = [event]; return Promise.resolve(); }); - const storage = new Map(); - const timers = new Map(); - let nextId = 1; - const fakeWindow = { - localStorage: { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - }, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, ms }); - return id; - }, - clearTimeout: (id) => timers.delete(id), - }; - const fireDelay = async (ms) => { - const entry = [...timers.entries()].find(([, v]) => v.ms === ms); - assert.ok(entry, `expected a timer scheduled at ${ms}ms`); - timers.delete(entry[0]); - entry[1].fn(); - for (let i = 0; i < 100; i++) await Promise.resolve(); - }; - const restore = installFakeWindow(fakeWindow); + const { timers, fireDelay, restore } = makeTimerBed(); const tauri = installEchoTauri("pk-serial"); try { const manager = new ChannelSectionSyncManager("pk-serial", RELAY); @@ -277,30 +239,7 @@ test("serialized generations: a stale generation aborts before publishing", asyn publishCalls.push(args); return Promise.resolve(); }); - const storage = new Map(); - const timers = new Map(); - let nextId = 1; - const fakeWindow = { - localStorage: { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - }, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, ms }); - return id; - }, - clearTimeout: (id) => timers.delete(id), - }; - const fireDelay = async (ms) => { - const entry = [...timers.entries()].find(([, v]) => v.ms === ms); - assert.ok(entry, `expected a timer scheduled at ${ms}ms`); - timers.delete(entry[0]); - entry[1].fn(); - for (let i = 0; i < 100; i++) await Promise.resolve(); - }; - const restore = installFakeWindow(fakeWindow); + const { fireDelay, restore } = makeTimerBed(); const tauri = installTauriMock("{}"); try { const manager = new ChannelSectionSyncManager("pk-stale", RELAY); @@ -428,30 +367,7 @@ test("ambiguous ACK: an accepted-but-unacked A does not make B adopt and disappe ]; return Promise.resolve(); }); - const storage = new Map(); - const timers = new Map(); - let nextId = 1; - const fakeWindow = { - localStorage: { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - }, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, ms }); - return id; - }, - clearTimeout: (id) => timers.delete(id), - }; - const fireDelay = async (ms) => { - const entry = [...timers.entries()].find(([, v]) => v.ms === ms); - assert.ok(entry, `expected a timer scheduled at ${ms}ms`); - timers.delete(entry[0]); - entry[1].fn(); - for (let i = 0; i < 100; i++) await Promise.resolve(); - }; - const restore = installFakeWindow(fakeWindow); + const { timers, fireDelay, restore } = makeTimerBed(); const tauri = installSeamTauriMock( JSON.stringify({ version: 1, @@ -528,30 +444,7 @@ test("ambiguous ACK: a foreign head is adopted, not folded as our own", async () ); return Promise.resolve(); }); - const storage = new Map(); - const timers = new Map(); - let nextId = 1; - const fakeWindow = { - localStorage: { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - }, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, ms }); - return id; - }, - clearTimeout: (id) => timers.delete(id), - }; - const fireDelay = async (ms) => { - const entry = [...timers.entries()].find(([, v]) => v.ms === ms); - assert.ok(entry, `expected a timer scheduled at ${ms}ms`); - timers.delete(entry[0]); - entry[1].fn(); - for (let i = 0; i < 100; i++) await Promise.resolve(); - }; - const restore = installFakeWindow(fakeWindow); + const { timers, fireDelay, restore } = makeTimerBed(); const tauri = installSeamTauriMock( JSON.stringify({ version: 1, @@ -605,30 +498,7 @@ test("serialized generations: a newer edit during encrypt/sign aborts the pre-si publishCalls.push(event); return Promise.resolve(); }); - const storage = new Map(); - const timers = new Map(); - let nextId = 1; - const fakeWindow = { - localStorage: { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - }, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, ms }); - return id; - }, - clearTimeout: (id) => timers.delete(id), - }; - const fireDelay = async (ms) => { - const entry = [...timers.entries()].find(([, v]) => v.ms === ms); - assert.ok(entry, `expected a timer scheduled at ${ms}ms`); - timers.delete(entry[0]); - entry[1].fn(); - for (let i = 0; i < 100; i++) await Promise.resolve(); - }; - const restore = installFakeWindow(fakeWindow); + const { timers, fireDelay, restore } = makeTimerBed(); const tauri = installSeamTauriMock("{}", ["event-a", "event-b"]); try { const manager = new ChannelSectionSyncManager("pk-seam", RELAY); diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index 874e3baf0e..21f8b2314c 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -10,7 +10,10 @@ import { installTauriMock, installEchoTauri, } from "./sidebarSyncTestHelpers.mjs"; -import { runWholeBlobSyncSuite } from "./wholeBlobSync.shared.test.mjs"; + +// Shared whole-blob engine invariants are covered by wholeBlobSync.shared.test.mjs +// (which runs directly against ChannelSectionSyncManager). This file contains +// only sort-specific adapter and lane behavior tests. function makeStore(groups = {}) { return { version: 1, groups }; @@ -18,40 +21,6 @@ function makeStore(groups = {}) { const RELAY = "wss://r.test"; -// ─── 17 shared whole-blob sync invariants ───────────────────────────────────── - -runWholeBlobSyncSuite({ - label: "sort", - SyncManager: ChannelSortSyncManager, - publishMethod: "publishSortPrefs", - fetchRemoteMethod: "fetchRemoteSortPrefs", - subscribeMethod: "subscribeToSortPrefs", - watermarkLane: "channel-sort", - readOutbox: readChannelSortOutbox, - makeNonEmptyStore: () => makeStore({ channels: "recent" }), - decryptPayload: JSON.stringify({ - version: 1, - groups: { "remote-group-from-relay": "recent" }, - }), - emptyDecryptPayload: JSON.stringify({ version: 1, groups: {} }), - checkAdoptedStore: (store) => "remote-group-from-relay" in store.groups, - makeOverlapStoreA: () => makeStore({ channels: "recent" }), - makeOverlapStoreB: () => makeStore({ dms: "alpha" }), - checkOverlapPending: (store) => - Object.keys(store?.groups ?? {}).includes("dms"), - checkOverlapOutbox: (outbox) => - Object.keys(outbox?.store?.groups ?? {}).includes("dms"), - makeLiveDebounceStore: () => makeStore({ "local-group": "alpha" }), - liveRemoteDecryptPayload: JSON.stringify({ - version: 1, - groups: { "remote-during-debounce": "recent" }, - }), - makeCollisionStoreA: () => makeStore({ channels: "recent" }), - makeCollisionWinnerStore: () => makeStore({ channels: "alpha" }), - makeCollisionStoreSnd: () => makeStore({ dms: "recent" }), - makeCollisionStoreLsr: () => makeStore({ loser: "recent" }), -}); - // ─── Sort-specific: unsupported head payload version ───────────────────────── // An unsupported/unparseable payload version is equally unreadable: it decrypts diff --git a/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs index 0757704d82..deec41dc80 100644 --- a/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs @@ -1,32 +1,57 @@ +// Compact stars lane adapter contract. +// Full merge-lane storage invariants (parsePayload, mergeStores algebra, +// boundStore, storageKey, claimLegacy state machine) are covered by +// mergeLaneStorage.shared.test.mjs. This file proves only the stars-specific +// wiring: correct value field name, storage key prefix, and id projection. + +import assert from "node:assert/strict"; +import test from "node:test"; + import { - boundStarStore, - DEFAULT_STORE, - MAX_CHANNEL_STAR_ENTRIES, - mergeStores, parseStarPayload, - readChannelStarsStore, starredChannelIdsFromStore, storageKey, - writeChannelStarsStore, } from "./channelStarsStorage.ts"; -import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; -import { runMergeLaneStorageSuite } from "./mergeLaneStorage.shared.test.mjs"; -runMergeLaneStorageSuite({ - label: "stars", - storageKeyPrefix: "buzz-channel-stars.v1", - MAX_ENTRIES: MAX_CHANNEL_STAR_ENTRIES, - DEFAULT_STORE, - parsePayload: parseStarPayload, - makeEntry: (v, updatedAt, rev) => ({ starred: v, updatedAt, rev }), - entryValueField: "starred", - trueLabel: "star", - falseLabel: "unstar", - boundStore: boundStarStore, - mergeStores, - idsFromStore: starredChannelIdsFromStore, - readStore: readChannelStarsStore, - writeStore: writeChannelStarsStore, - storageKey, - normalizeRelayUrl, +test("stars adapter: value field is 'starred', key prefix is buzz-channel-stars.v1", () => { + // A round-trip through the parser confirms the field name and version gate. + const raw = { + version: 1, + channels: { a: { starred: true, updatedAt: 100, rev: 1 } }, + }; + const parsed = parseStarPayload(raw); + assert.ok(parsed !== null, "valid stars payload parses"); + assert.equal(parsed.channels.a.starred, true, "starred field preserved"); + assert.equal( + storageKey("pk1"), + "buzz-channel-stars.v1:pk1", + "storage key prefix", + ); +}); + +test("stars adapter: idsFromStore projects starred=true entries", () => { + const store = { + version: 1, + channels: { + a: { starred: true, updatedAt: 1, rev: 0 }, + b: { starred: false, updatedAt: 2, rev: 0 }, + }, + }; + const ids = starredChannelIdsFromStore(store); + assert.ok(ids.has("a"), "starred channel in set"); + assert.ok(!ids.has("b"), "unstarred channel excluded"); +}); + +test("stars adapter: wrong value field (muted) is rejected by parser", () => { + const mutePayload = { + version: 1, + channels: { a: { muted: true, updatedAt: 100, rev: 1 } }, + }; + const result = parseStarPayload(mutePayload); + // muted field is filtered out as malformed (no 'starred' field), leaving empty channels. + assert.deepEqual( + result?.channels ?? {}, + {}, + "muted entry filtered as malformed", + ); }); diff --git a/desktop/src/features/sidebar/lib/mergeLaneHook.shared.test.mjs b/desktop/src/features/sidebar/lib/mergeLaneHook.shared.test.mjs index 153dfa4c87..2745dfc68a 100644 --- a/desktop/src/features/sidebar/lib/mergeLaneHook.shared.test.mjs +++ b/desktop/src/features/sidebar/lib/mergeLaneHook.shared.test.mjs @@ -43,7 +43,7 @@ function stubRelay(relayClient, { live } = {}) { } /** - * Run the full merge-lane hook invariant suite for a single lane. + * Run the merge-lane hook invariant suite for a single lane. * * @param {object} cfg * @param {string} cfg.label - Human-readable lane name for test titles. @@ -119,145 +119,52 @@ export function runMergeLaneHookSuite({ } }); - test(`${label}: persisted-local first click mints seen+1 on both dimensions`, async () => { + // Monotonic mint: combines persisted-local high-water, far-future observation, + // and same-second click sequence into one scenario. After observing a remote + // with high updatedAt and rev, local clicks must advance both dimensions. + // Mutation: dropping maxUpdatedAtSeen or maxRevSeen tracking makes later + // clicks mint below the observed high-water and lose on merge. + test(`${label}: monotonic mint — persisted high-water + far-future live event + same-second clicks advance rev`, async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); const { relayClient } = await import("@/shared/api/relayClient"); - const restore = stubRelay(relayClient); + const live = {}; + const restore = stubRelay(relayClient, { live }); + const origTauri = window.__TAURI_INTERNALS__; const origDateNow = Date.now; + // Local clock: 100s. Persisted head has updatedAt 500, rev 4. Date.now = () => 100 * 1_000; - const pubkey = `pk-${label}-persist`; + const FUTURE = 500; + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + if (cmd === "nip44_decrypt_from_self") + return Promise.resolve( + makePayload({ + shared: { [entryValueField]: false, updatedAt: FUTURE, rev: 7 }, + }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + const pubkey = `pk-${label}-mono`; + // Seed a persisted store at rev 4, updatedAt 500. window.localStorage.setItem( storageKey(pubkey, "wss://r"), makePayload({ - shared: { [entryValueField]: true, updatedAt: 500, rev: 4 }, + shared: { [entryValueField]: true, updatedAt: FUTURE, rev: 4 }, }), ); - try { - const { result, unmount } = renderHook(() => useHook(pubkey, "wss://r")); - act(() => result.current[falseAction]("shared")); - const persisted = readStore(pubkey, "wss://r"); - assert.equal( - persisted.channels.shared[entryValueField], - false, - `${falseLabel} applied`, - ); - assert.equal( - persisted.channels.shared.updatedAt, - 500, - "updatedAt held at persisted-local high-water (max(100,500,seen))", - ); - assert.equal( - persisted.channels.shared.rev, - 5, - "rev minted as persisted-local rev + 1", - ); - unmount(); - } finally { - cleanup(); - Date.now = origDateNow; - restore(); - } - }); - - test(`${label}: fast-clock veto fix: click after observing a future-stamped head wins`, async () => { - const { act, cleanup, renderHook } = await import("@testing-library/react"); - const { relayClient } = await import("@/shared/api/relayClient"); - - const live = {}; - const restore = stubRelay(relayClient, { live }); - const origTauri = window.__TAURI_INTERNALS__; - const origDateNow = Date.now; - Date.now = () => 100 * 1_000; - window.__TAURI_INTERNALS__ = { - invoke: (cmd) => { - if (cmd === "nip44_decrypt_from_self") - return Promise.resolve( - makePayload({ - shared: { [entryValueField]: false, updatedAt: 400, rev: 7 }, - }), - ); - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; - const pubkey = `pk-${label}-fastclock`; let hook = null; try { await act(async () => { hook = renderHook(() => useHook(pubkey, "wss://r")); for (let i = 0; i < 20; i++) await Promise.resolve(); }); + // Deliver a live event with rev 7 (higher than persisted 4). await act(async () => { live.cb({ id: "future-head", pubkey, - created_at: 400, - content: "cipher", - kind: 30078, - tags: [["d", dTag]], - sig: "s", - }); - for (let i = 0; i < 20; i++) await Promise.resolve(); - }); - assert.equal( - hook.result.current[idsField].has("shared"), - false, - "future head applied → false", - ); - await act(async () => hook.result.current[trueAction]("shared")); - assert.equal( - hook.result.current[idsField].has("shared"), - true, - "click must win despite the observed future timestamp", - ); - const persisted = readStore(pubkey, "wss://r"); - assert.equal( - persisted.channels.shared.updatedAt, - 400, - "mint lifted to t+300", - ); - assert.equal(persisted.channels.shared.rev, 8, "rev = maxRevSeen+1"); - hook.unmount(); - } finally { - cleanup(); - Date.now = origDateNow; - window.__TAURI_INTERNALS__ = origTauri; - restore(); - } - }); - - test(`${label}: far-future observation: timestamp stays fixed, rev advances, latest click wins`, async () => { - const { act, cleanup, renderHook } = await import("@testing-library/react"); - const { relayClient } = await import("@/shared/api/relayClient"); - - const live = {}; - const restore = stubRelay(relayClient, { live }); - const origTauri = window.__TAURI_INTERNALS__; - const origDateNow = Date.now; - Date.now = () => 100 * 1_000; - const FUTURE = 100 + 31_536_000; - window.__TAURI_INTERNALS__ = { - invoke: (cmd) => { - if (cmd === "nip44_decrypt_from_self") - return Promise.resolve( - makePayload({ - shared: { [entryValueField]: true, updatedAt: FUTURE, rev: 1 }, - }), - ); - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; - const pubkey = `pk-${label}-future`; - let hook = null; - try { - await act(async () => { - hook = renderHook(() => useHook(pubkey, "wss://r")); - for (let i = 0; i < 20; i++) await Promise.resolve(); - }); - await act(async () => { - live.cb({ - id: "far-future", - pubkey, created_at: FUTURE, content: "cipher", kind: 30078, @@ -266,32 +173,34 @@ export function runMergeLaneHookSuite({ }); for (let i = 0; i < 20; i++) await Promise.resolve(); }); - await act(async () => hook.result.current[falseAction]("shared")); + // First click: must mint above max(FUTURE, rev 7). + await act(async () => hook.result.current[trueAction]("shared")); let p = readStore(pubkey, "wss://r"); assert.equal( p.channels.shared[entryValueField], - false, + true, "first click applied", ); assert.equal( p.channels.shared.updatedAt, FUTURE, - "timestamp stays fixed", + "updatedAt held at observed high-water", ); - assert.equal(p.channels.shared.rev, 2, "rev advanced 1→2"); - await act(async () => hook.result.current[trueAction]("shared")); + assert.equal(p.channels.shared.rev, 8, "rev = maxRevSeen(7) + 1"); + // Second same-second click: rev must strictly advance. + await act(async () => hook.result.current[falseAction]("shared")); p = readStore(pubkey, "wss://r"); assert.equal( p.channels.shared[entryValueField], - true, - "latest click wins", + false, + "second click applied", ); assert.equal( p.channels.shared.updatedAt, FUTURE, - "timestamp still fixed", + "updatedAt still fixed", ); - assert.equal(p.channels.shared.rev, 3, "rev advanced 2→3"); + assert.equal(p.channels.shared.rev, 9, "rev advanced 8→9"); hook.unmount(); } finally { cleanup(); @@ -301,115 +210,13 @@ export function runMergeLaneHookSuite({ } }); - test(`${label}: empty-store click survives a later higher-rev head with an older updatedAt`, async () => { - const { act, cleanup, renderHook } = await import("@testing-library/react"); - const { relayClient } = await import("@/shared/api/relayClient"); - - const live = {}; - const restore = stubRelay(relayClient, { live }); - const origTauri = window.__TAURI_INTERNALS__; - const origDateNow = Date.now; - Date.now = () => 1000 * 1_000; - window.__TAURI_INTERNALS__ = { - invoke: (cmd) => { - if (cmd === "nip44_decrypt_from_self") - return Promise.resolve( - makePayload({ - shared: { [entryValueField]: false, updatedAt: 500, rev: 99 }, - }), - ); - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; - const pubkey = `pk-${label}-empty`; - let hook = null; - try { - await act(async () => { - hook = renderHook(() => useHook(pubkey, "wss://r")); - for (let i = 0; i < 20; i++) await Promise.resolve(); - }); - await act(async () => hook.result.current[trueAction]("shared")); - await act(async () => { - live.cb({ - id: "older-higher-rev", - pubkey, - created_at: 500, - content: "cipher", - kind: 30078, - tags: [["d", dTag]], - sig: "s", - }); - for (let i = 0; i < 20; i++) await Promise.resolve(); - }); - assert.equal( - hook.result.current[idsField].has("shared"), - true, - "click at newer updatedAt survives an older higher-rev head", - ); - hook.unmount(); - } finally { - cleanup(); - Date.now = origDateNow; - window.__TAURI_INTERNALS__ = origTauri; - restore(); - } - }); - - test(`${label}: unobserved future head wins over an empty-high-water click on primary updatedAt`, async () => { - const { act, cleanup, renderHook } = await import("@testing-library/react"); - const { relayClient } = await import("@/shared/api/relayClient"); - - const live = {}; - const restore = stubRelay(relayClient, { live }); - const origTauri = window.__TAURI_INTERNALS__; - const origDateNow = Date.now; - Date.now = () => 1000 * 1_000; - window.__TAURI_INTERNALS__ = { - invoke: (cmd) => { - if (cmd === "nip44_decrypt_from_self") - return Promise.resolve( - makePayload({ - shared: { [entryValueField]: false, updatedAt: 1300, rev: 1 }, - }), - ); - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; - const pubkey = `pk-${label}-unobserved-future`; - let hook = null; - try { - await act(async () => { - hook = renderHook(() => useHook(pubkey, "wss://r")); - for (let i = 0; i < 20; i++) await Promise.resolve(); - }); - await act(async () => hook.result.current[trueAction]("shared")); - await act(async () => { - live.cb({ - id: "unobserved-future", - pubkey, - created_at: 1300, - content: "cipher", - kind: 30078, - tags: [["d", dTag]], - sig: "s", - }); - for (let i = 0; i < 20; i++) await Promise.resolve(); - }); - assert.equal( - hook.result.current[idsField].has("shared"), - false, - "unobserved future head wins on primary updatedAt (accepted residual)", - ); - hook.unmount(); - } finally { - cleanup(); - Date.now = origDateNow; - window.__TAURI_INTERNALS__ = origTauri; - restore(); - } - }); - - test(`${label}: cross-window storage event is observed and max-merged`, async () => { + // Cross-window storage merge + outbox resume in one compact scenario. + // (a) A cross-window storage event is observed and max-merged into this window. + // (b) A click after the merge mints above the observed peer high-water. + // (c) A persisted outbox record is retained until the publish completes. + // Mutations: (a) removing the storageEvent listener; (b) not tracking peer + // maxRevSeen; (c) dropping writeOwnOutbox in the bootstrap path. + test(`${label}: cross-window merge, subsequent click mints above peer high-water, outbox retained`, async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); const { relayClient } = await import("@/shared/api/relayClient"); @@ -417,12 +224,27 @@ export function runMergeLaneHookSuite({ const origDateNow = Date.now; Date.now = () => 100 * 1_000; const pubkey = `pk-${label}-xwin`; + const outboxKey = `${outboxKeyPrefix}:${pubkey}:${encodeURIComponent("wss://r")}`; + // Pre-seed a persisted outbox record (simulates resumed intent). + window.localStorage.setItem( + outboxKey, + makePayload({ + resumed: { [entryValueField]: true, updatedAt: 90, rev: 2 }, + }), + ); let hook = null; try { await act(async () => { hook = renderHook(() => useHook(pubkey, "wss://r")); - for (let i = 0; i < 20; i++) await Promise.resolve(); + for (let i = 0; i < 40; i++) await Promise.resolve(); }); + // (c) Outbox retained until publish completes. + assert.ok( + window.localStorage.getItem(outboxKey) !== null, + "outbox retained during bootstrap", + ); + + // (a) Peer window writes a store with rev 12 and fires a storage event. window.localStorage.setItem( storageKey(pubkey, "wss://r"), makePayload({ @@ -442,6 +264,8 @@ export function runMergeLaneHookSuite({ true, "peer write merged into this window", ); + + // (b) Click after merge mints above peer high-water (updatedAt 900, rev 12). await act(async () => hook.result.current[falseAction]("shared")); const p = readStore(pubkey, "wss://r"); assert.equal(p.channels.shared[entryValueField], false, "click applied"); @@ -454,38 +278,4 @@ export function runMergeLaneHookSuite({ restore(); } }); - - test(`${label}: bootstrap resumes a persisted outbox edit`, async () => { - const { act, cleanup, renderHook } = await import("@testing-library/react"); - const { relayClient } = await import("@/shared/api/relayClient"); - - const restore = stubRelay(relayClient); - const origDateNow = Date.now; - Date.now = () => 100 * 1_000; - const pubkey = `pk-${label}-outbox`; - const relayUrl = `wss://r.${label}-outbox`; - const outboxKey = `${outboxKeyPrefix}:${pubkey}:${encodeURIComponent(relayUrl)}`; - window.localStorage.setItem( - outboxKey, - makePayload({ - resumed: { [entryValueField]: true, updatedAt: 90, rev: 2 }, - }), - ); - let hook = null; - try { - await act(async () => { - hook = renderHook(() => useHook(pubkey, relayUrl)); - for (let i = 0; i < 40; i++) await Promise.resolve(); - }); - assert.ok( - window.localStorage.getItem(outboxKey) !== null, - "outbox retained until the resumed publish completes", - ); - hook.unmount(); - } finally { - cleanup(); - Date.now = origDateNow; - restore(); - } - }); } diff --git a/desktop/src/features/sidebar/lib/mergeLaneStorage.shared.test.mjs b/desktop/src/features/sidebar/lib/mergeLaneStorage.shared.test.mjs index 4e4b75507a..3cbdb65b1c 100644 --- a/desktop/src/features/sidebar/lib/mergeLaneStorage.shared.test.mjs +++ b/desktop/src/features/sidebar/lib/mergeLaneStorage.shared.test.mjs @@ -1,30 +1,26 @@ -// Shared parameterized test suite for merge-lane storage modules -// (channelStarsStorage.ts, channelMutesStorage.ts). +// Authoritative merge-lane storage suite, run directly against +// channelStarsStorage.ts (the canonical merge-lane implementation). // -// Usage: -// import { runMergeLaneStorageSuite } from "./mergeLaneStorage.shared.test.mjs"; -// runMergeLaneStorageSuite({ -// label: "stars", -// storageKeyPrefix: "buzz-channel-stars.v1", -// MAX_ENTRIES: MAX_CHANNEL_STAR_ENTRIES, -// DEFAULT_STORE, -// parsePayload: parseStarPayload, -// makeEntry: (v, updatedAt, rev) => ({ starred: v, updatedAt, rev }), -// entryValueField: "starred", -// trueLabel: "star", -// falseLabel: "unstar", -// boundStore: boundStarStore, -// mergeStores, -// idsFromStore: starredChannelIdsFromStore, -// readStore: readChannelStarsStore, -// writeStore: writeChannelStarsStore, -// storageKey, -// normalizeRelayUrl, -// }); +// Covers all merge-lane storage invariants: parsePayload contract, mergeStores +// algebra, boundStore, idsFromStore, storageKey, readStore/writeStore, and the +// full claimLegacy state machine. channelStarsStorage.test.mjs and +// channelMutesStorage.test.mjs each carry a compact adapter contract that catches +// field/key/prefix wiring divergence without replaying this full suite. import assert from "node:assert/strict"; import test from "node:test"; import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; +import { + boundStarStore, + DEFAULT_STORE, + MAX_CHANNEL_STAR_ENTRIES, + mergeStores, + parseStarPayload, + readChannelStarsStore, + starredChannelIdsFromStore, + storageKey, + writeChannelStarsStore, +} from "./channelStarsStorage.ts"; if (typeof globalThis.window === "undefined") { const storage = new Map(); @@ -37,737 +33,677 @@ if (typeof globalThis.window === "undefined") { }; } -/** - * Run the full merge-lane storage invariant suite for a single lane. - * - * @param {object} cfg - * @param {string} cfg.label - Human-readable lane name for test titles. - * @param {string} cfg.storageKeyPrefix - The localStorage key prefix (e.g. "buzz-channel-stars.v1"). - * @param {number} cfg.MAX_ENTRIES - The lane's capacity cap. - * @param {object} cfg.DEFAULT_STORE - The lane's empty default store. - * @param {Function} cfg.parsePayload - parse{Star|Mute}Payload. - * @param {Function} cfg.makeEntry - (value, updatedAt, rev) => entry object. - * @param {string} cfg.entryValueField - "starred" or "muted". - * @param {string} cfg.trueLabel - Action name when value=true (e.g. "star"). - * @param {string} cfg.falseLabel - Action name when value=false (e.g. "unstar"). - * @param {Function} cfg.boundStore - bound{Star|Mute}Store(store, preservedKey?). - * @param {Function} cfg.mergeStores - mergeStores(a, b, preservedKey?). - * @param {Function} cfg.idsFromStore - {starred|muted}ChannelIdsFromStore(store). - * @param {Function} cfg.readStore - readChannel{Stars|Mutes}Store(pubkey, relay?). - * @param {Function} cfg.writeStore - writeChannel{Stars|Mutes}Store(pubkey, store, relay?). - * @param {Function} cfg.storageKey - storageKey(pubkey, relay?). - */ -export function runMergeLaneStorageSuite({ - label, - storageKeyPrefix, - MAX_ENTRIES, - DEFAULT_STORE, - parsePayload, - makeEntry, - entryValueField, - trueLabel, - falseLabel, - boundStore, - mergeStores, - idsFromStore, - readStore, - writeStore, - storageKey, -}) { - function makeStore(channels = {}) { - return { version: 1, channels }; - } - const E = (v, updatedAt, rev) => makeEntry(v, updatedAt, rev); - const S = (entry) => makeStore({ c: entry }); +const storageKeyPrefix = "buzz-channel-stars.v1"; +const MAX_ENTRIES = MAX_CHANNEL_STAR_ENTRIES; +const entryValueField = "starred"; - // ── parsePayload ────────────────────────────────────────────────────────── - - test(`${label}: parsePayload: valid payload with channels returns store (rev preserved)`, () => { - const payload = { - version: 1, - channels: { - "chan-1": makeEntry(true, 1000, 3), - "chan-2": makeEntry(false, 2000, 0), - }, - }; - assert.deepEqual(parsePayload(payload), payload); - }); - - test(`${label}: parsePayload: missing rev normalizes to 0 (old-build blob, entry kept)`, () => { - const raw = { - version: 1, - channels: { "chan-1": { [entryValueField]: true, updatedAt: 1000 } }, - }; - const result = parsePayload(raw); - assert.deepEqual(result.channels["chan-1"], makeEntry(true, 1000, 0)); - }); - - test(`${label}: parsePayload: malformed rev (string / negative / non-integer / NaN / unsafe) normalizes to 0`, () => { - const raw = { - version: 1, - channels: { - str: { [entryValueField]: true, updatedAt: 1, rev: "5" }, - neg: { [entryValueField]: true, updatedAt: 1, rev: -2 }, - frac: { [entryValueField]: true, updatedAt: 1, rev: 1.5 }, - nan: { [entryValueField]: true, updatedAt: 1, rev: NaN }, - boundary: { - [entryValueField]: true, - updatedAt: 1, - rev: Number.MAX_SAFE_INTEGER, - }, - huge: { - [entryValueField]: true, - updatedAt: 1, - rev: Number.MAX_SAFE_INTEGER + 1, - }, - }, - }; - const result = parsePayload(raw); - for (const id of ["str", "neg", "frac", "nan", "boundary", "huge"]) { - assert.equal(result.channels[id].rev, 0, `${id} rev normalized to 0`); - assert.equal( - result.channels[id][entryValueField], - true, - `${id} entry kept`, - ); - } - }); - - test(`${label}: parsePayload: missing version returns null`, () => { - assert.equal( - parsePayload({ channels: { "chan-1": makeEntry(true, 1, 0) } }), - null, - ); - }); - - test(`${label}: parsePayload: wrong version returns null`, () => { - assert.equal( - parsePayload({ - version: 2, - channels: { "chan-1": makeEntry(true, 1, 0) }, - }), - null, - ); - }); - - test(`${label}: parsePayload: null / non-object input returns null`, () => { - assert.equal(parsePayload(null), null); - assert.equal(parsePayload("string"), null); - assert.equal(parsePayload(42), null); - assert.equal(parsePayload(true), null); - }); - - test(`${label}: parsePayload: malformed channel entries missing value field/updatedAt are filtered out`, () => { - const result = parsePayload({ - version: 1, - channels: { - [`no-${entryValueField}`]: { updatedAt: 1000 }, - "no-updated-at": { [entryValueField]: true }, - valid: { [entryValueField]: false, updatedAt: 500 }, - "value-wrong-type": { [entryValueField]: "yes", updatedAt: 1000 }, - "updated-at-wrong-type": { [entryValueField]: true, updatedAt: "now" }, - null: null, - }, - }); - assert.deepEqual(result, makeStore({ valid: makeEntry(false, 500, 0) })); - }); - - test(`${label}: parsePayload: NaN/Infinity/negative/unsafe updatedAt entries are filtered out`, () => { - const result = parsePayload({ - version: 1, - channels: { - nan: { [entryValueField]: true, updatedAt: NaN }, - inf: { [entryValueField]: true, updatedAt: Infinity }, - "neg-inf": { [entryValueField]: true, updatedAt: -Infinity }, - neg: { [entryValueField]: true, updatedAt: -1 }, - unsafe: { - [entryValueField]: true, - updatedAt: Number.MAX_SAFE_INTEGER + 1, - }, - valid: { [entryValueField]: true, updatedAt: 100, rev: 2 }, - }, - }); - assert.deepEqual(result, makeStore({ valid: makeEntry(true, 100, 2) })); - }); - - test(`${label}: parsePayload: empty channels / no channels key returns empty store`, () => { - assert.deepEqual(parsePayload({ version: 1, channels: {} }), makeStore()); - assert.deepEqual(parsePayload({ version: 1 }), makeStore()); - }); - - // ── mergeStores: tuple order ────────────────────────────────────────────── - - test(`${label}: mergeStores: non-overlapping channels returns union`, () => { - const result = mergeStores( - makeStore({ a: E(true, 100, 1) }), - makeStore({ b: E(false, 200, 1) }), - ); - assert.deepEqual( - result, - makeStore({ a: E(true, 100, 1), b: E(false, 200, 1) }), - ); - }); - - test(`${label}: mergeStores: strictly-later updatedAt wins regardless of rev (primary key)`, () => { - const result = mergeStores(S(E(false, 200, 0)), S(E(true, 100, 7))); - assert.deepEqual(result.channels.c, E(false, 200, 0)); - }); - - test(`${label}: mergeStores: equal updatedAt → higher rev wins (same-second tiebreak)`, () => { - const result = mergeStores(S(E(false, 100, 5)), S(E(true, 100, 2))); - assert.deepEqual(result.channels.c, E(false, 100, 5)); - }); - - test(`${label}: mergeStores: equal updatedAt AND equal rev → true-value wins (leaf)`, () => { - const result = mergeStores(S(E(false, 100, 3)), S(E(true, 100, 3))); - assert.deepEqual(result.channels.c, E(true, 100, 3)); - }); - - test(`${label}: mergeStores: same-second old-build click (rev 0) loses to an earlier new-build rev, heals next second`, () => { - // ACCEPTED MIXED-FLEET RESIDUAL (Carl P1 #5). - const newBuildTrue = S(E(true, 100, 2)); - const oldBuildFalseSameSecond = S(E(false, 100, 0)); - assert.deepEqual( - mergeStores(newBuildTrue, oldBuildFalseSameSecond).channels.c, - E(true, 100, 2), - "the earlier new-build rev-2 true wins the same-second tie", - ); - const oldBuildFalseNextSecond = S(E(false, 101, 0)); - assert.deepEqual( - mergeStores(newBuildTrue, oldBuildFalseNextSecond).channels.c, - E(false, 101, 0), - "a strictly-later-second old-build false wins — the residual is transient", - ); - }); - - test(`${label}: mergeStores: a boundary (MAX_SAFE_INTEGER) rev cannot wedge later same-second toggles`, () => { - const raw = { - version: 1, - channels: { - c: { - [entryValueField]: true, - updatedAt: 100, - rev: Number.MAX_SAFE_INTEGER, - }, - }, - }; - const wedged = parsePayload(raw); - assert.equal(wedged.channels.c.rev, 0, "boundary rev is normalized to 0"); - const mint = (store, v) => { - const local = store.channels.c; - const rev = Math.max(local?.rev ?? 0, 0) + 1; - return { store: S(E(v, 100, rev)), rev }; - }; - let state = wedged; - let prevRev = state.channels.c.rev; - for (const v of [false, true, false, true]) { - const { store: click, rev } = mint(state, v); - assert.ok( - rev > prevRev, - `mint rev ${rev} strictly advances past ${prevRev}`, - ); - state = mergeStores(state, click); - assert.deepEqual( - state.channels.c, - E(v, 100, rev), - `same-second toggle to ${v} (rev ${rev}) wins`, - ); - prevRev = rev; - } - }); - - test(`${label}: mergeStores: false-value with higher updatedAt overrides true-value`, () => { - const result = mergeStores(S(E(true, 100, 9)), S(E(false, 999, 1))); - assert.deepEqual(result.channels.c, E(false, 999, 1)); - }); - - test(`${label}: mergeStores: empty local / empty remote / both empty`, () => { - assert.deepEqual( - mergeStores(makeStore(), S(E(true, 42, 1))).channels.c, - E(true, 42, 1), - ); - assert.deepEqual( - mergeStores(S(E(false, 10, 2)), makeStore()).channels.c, - E(false, 10, 2), - ); - assert.deepEqual(mergeStores(makeStore(), makeStore()), makeStore()); - }); - - // ── mergeStores: algebra ────────────────────────────────────────────────── - - function randEntry(rng) { - return makeEntry(rng() > 0.5, Math.floor(rng() * 5), Math.floor(rng() * 5)); - } - function randStore(rng, ids) { - const channels = {}; - for (const id of ids) if (rng() > 0.3) channels[id] = randEntry(rng); - return makeStore(channels); - } - function lcg(seed) { - let s = seed >>> 0; - return () => { - s = (s * 1664525 + 1013904223) >>> 0; - return s / 4294967296; - }; - } - - test(`${label}: mergeStores: commutative — merge(a,b) === merge(b,a)`, () => { - const rng = lcg(12345); - const ids = ["a", "b", "c", "d"]; - for (let i = 0; i < 200; i++) { - const a = randStore(rng, ids); - const b = randStore(rng, ids); - assert.deepEqual(mergeStores(a, b), mergeStores(b, a)); - } - }); - - test(`${label}: mergeStores: associative — merge(merge(a,b),c) === merge(a,merge(b,c))`, () => { - const rng = lcg(67890); - const ids = ["a", "b", "c", "d"]; - for (let i = 0; i < 200; i++) { - const a = randStore(rng, ids); - const b = randStore(rng, ids); - const c = randStore(rng, ids); - assert.deepEqual( - mergeStores(mergeStores(a, b), c), - mergeStores(a, mergeStores(b, c)), - ); - } - }); - - test(`${label}: mergeStores: idempotent — merge(a, merge(a,b)) === merge(a,b)`, () => { - const rng = lcg(24680); - const ids = ["a", "b", "c", "d"]; - for (let i = 0; i < 200; i++) { - const a = randStore(rng, ids); - const b = randStore(rng, ids); - const ab = mergeStores(a, b); - assert.deepEqual(mergeStores(a, ab), ab); - assert.deepEqual(mergeStores(ab, ab), ab); - } - }); - - // ── v1-blob bidirectional compatibility ─────────────────────────────────── - - test(`${label}: v1 compat: a rev-carrying blob round-trips through a rev-less parser view`, () => { - const ours = makeStore({ c: makeEntry(true, 100, 7) }); - const roundTripped = parsePayload(JSON.parse(JSON.stringify(ours))); - assert.equal(roundTripped.version, 1, "version stays 1"); - assert.equal(roundTripped.channels.c[entryValueField], true); - assert.equal(roundTripped.channels.c.updatedAt, 100); - }); - - test(`${label}: v1 compat: old-build ${falseLabel} (no rev, updatedAt+1) beats our stale ${trueLabel}`, () => { - const ours = S(E(true, 100, 7)); - const oldBuildFalse = parsePayload({ - version: 1, - channels: { c: { [entryValueField]: false, updatedAt: 101 } }, - }); - assert.deepEqual( - mergeStores(ours, oldBuildFalse).channels.c, - makeEntry(false, 101, 0), - ); - }); - - // ── boundStore ──────────────────────────────────────────────────────────── - - test(`${label}: boundStore: retains newest entries regardless of value`, () => { - const channels = Object.fromEntries( - Array.from({ length: MAX_ENTRIES }, (_, i) => [ - `active-${i}`, - E(true, i + 1, 0), - ]), - ); - channels["old-false"] = E(false, 0, 0); - channels["new-false"] = E(false, 9999, 0); - const result = boundStore(makeStore(channels)); - assert.equal(Object.keys(result.channels).length, MAX_ENTRIES); - assert.equal(result.channels["old-false"], undefined); - assert.deepEqual(result.channels["new-false"], E(false, 9999, 0)); - assert.equal(result.channels["active-0"], undefined); - }); - - test(`${label}: boundStore: uses channel ID as an updatedAt tie-breaker`, () => { - const channels = Object.fromEntries( - Array.from({ length: MAX_ENTRIES + 1 }, (_, i) => [ - `channel-${String(MAX_ENTRIES - i).padStart(3, "0")}`, - E(true, 1, 0), - ]), - ); - const result = boundStore(makeStore(channels)); - assert.equal(result.channels["channel-000"], undefined); - assert.deepEqual(result.channels["channel-500"], E(true, 1, 0)); - }); - - test(`${label}: boundStore: preserves a same-second mutation by key`, () => { - const channels = Object.fromEntries( - Array.from({ length: MAX_ENTRIES }, (_, i) => [ - `z-channel-${String(i).padStart(3, "0")}`, - E(true, 1, 0), - ]), - ); - channels["a-target"] = E(false, 1, 1); - const result = boundStore(makeStore(channels), "a-target"); - assert.equal(Object.keys(result.channels).length, MAX_ENTRIES); - assert.deepEqual(result.channels["a-target"], E(false, 1, 1)); - assert.equal(result.channels["z-channel-000"], undefined); - }); - - test(`${label}: mergeStores: a fresh at-capacity ${falseLabel} defeats an older remote ${trueLabel}`, () => { - const channels = Object.fromEntries( - Array.from({ length: MAX_ENTRIES }, (_, i) => [ - `active-${i}`, - E(true, i + 1, 0), - ]), - ); - channels.toggled = E(false, 9999, 1); - const bounded = boundStore(makeStore(channels)); - const result = mergeStores( - bounded, - makeStore({ toggled: E(true, 9998, 5) }), - ); - assert.deepEqual(result.channels.toggled, E(false, 9999, 1)); - }); - - test(`${label}: mergeStores: evicted remote ID re-enters and the oldest state is re-trimmed`, () => { - const localChannels = Object.fromEntries( - Array.from({ length: MAX_ENTRIES }, (_, i) => [ - `active-${i}`, - E(true, i + 10, 0), - ]), - ); - const result = mergeStores( - makeStore(localChannels), - makeStore({ - "evicted-id": E(true, 9999, 0), - "active-0": E(false, 9998, 0), - }), - ); - assert.equal(Object.keys(result.channels).length, MAX_ENTRIES); - assert.deepEqual(result.channels["evicted-id"], E(true, 9999, 0)); - assert.deepEqual(result.channels["active-0"], E(false, 9998, 0)); - assert.equal(result.channels["active-1"], undefined); - }); - - // ── Eviction / remount (finding 3) ──────────────────────────────────────── - - test(`${label}: finding 3 easy branch: a fresh click beats an evicted high-rev entry at an older updatedAt`, () => { - const click = S(E(true, 1000, 1)); - const remote = S(E(false, 500, 100)); - assert.deepEqual( - mergeStores(click, remote).channels.c, - E(true, 1000, 1), - "fresh click wins on the primary updatedAt key", - ); - }); - - test(`${label}: finding 3 hard branch: equal-second evicted click (rev 1) loses to observed remote (rev 100)`, () => { - const NOW = 777; - const TARGET = "aaa-target"; - const channels = { [TARGET]: E(true, NOW, 7) }; - for (let i = 0; i < MAX_ENTRIES; i++) - channels[`z-${String(i).padStart(3, "0")}`] = E(true, NOW, 0); - const bounded = boundStore(makeStore(channels)); - assert.equal( - Object.keys(bounded.channels).length, - MAX_ENTRIES, - "bound trims to the cap", - ); - assert.equal( - bounded.channels[TARGET], - undefined, - "TARGET evicted by the id tiebreak", - ); - const click = makeStore({ [TARGET]: E(true, NOW, 1) }); - const remote = makeStore({ [TARGET]: E(false, NOW, 100) }); - assert.deepEqual( - mergeStores(click, remote).channels[TARGET], - E(false, NOW, 100), - "equal updatedAt → higher rev wins", - ); - assert.deepEqual( - mergeStores(remote, click).channels[TARGET], - E(false, NOW, 100), - ); - }); - - // ── idsFromStore ───────────────────────────────────────────────────────── - - test(`${label}: idsFromStore: returns set of IDs where ${entryValueField}=true`, () => { - const result = idsFromStore({ - version: 1, - channels: { - a: E(true, 100, 0), - b: E(true, 200, 0), - c: E(false, 300, 0), - }, - }); - assert.deepEqual([...result].sort(), ["a", "b"]); - }); - - test(`${label}: idsFromStore: all-false / empty returns empty set`, () => { - assert.equal( - idsFromStore({ - version: 1, - channels: { x: E(false, 1, 0) }, - }).size, - 0, - ); - assert.equal(idsFromStore({ version: 1, channels: {} }).size, 0); - }); - - // ── storageKey + readStore/writeStore ───────────────────────────────────── - - test(`${label}: storageKey: with relayUrl includes normalized+encoded relay in key`, () => { - const relay = "wss://relay.example.com"; - assert.equal( - storageKey("pk1", relay), - `${storageKeyPrefix}:pk1:${encodeURIComponent(normalizeRelayUrl(relay))}`, - ); - }); - - test(`${label}: storageKey: without relayUrl returns legacy pubkey-only key`, () => { - assert.equal(storageKey("pk1"), `${storageKeyPrefix}:pk1`); - assert.equal(storageKey("pk1", undefined), `${storageKeyPrefix}:pk1`); - }); - - test(`${label}: storageKey: two different relays produce different keys for same pubkey`, () => { - assert.notEqual( - storageKey("pk1", "wss://relay-a.example.com"), - storageKey("pk1", "wss://relay-b.example.com"), - ); - }); - - test(`${label}: storageKey: equivalent relay URLs (case + trailing slash) map to the same key`, () => { - assert.equal( - storageKey("pk1", "WSS://Relay.Example/"), - storageKey("pk1", "wss://relay.example"), - ); - }); - - test(`${label}: readStore + writeStore: scoped write/read roundtrip`, () => { - const pubkey = `pk-${label}-roundtrip`; - const relay = "wss://relay.example.com"; - const store = makeStore({ chan1: E(true, 1000, 1) }); - assert.ok(writeStore(pubkey, store, relay) !== null); - assert.deepEqual(readStore(pubkey, relay), store); - }); - - test(`${label}: readStore: scoped key is isolated from other relay's data (A→B no seed leak)`, () => { - const pubkey = `pk-${label}-isolation`; - writeStore( - pubkey, - makeStore({ cha: E(true, 100, 1) }), - "wss://relay-a.example.com", - ); - assert.deepEqual( - readStore(pubkey, "wss://relay-b.example.com"), - DEFAULT_STORE, - ); - }); - - test(`${label}: readStore: migrates legacy unscoped data on first scoped read`, () => { - const pubkey = `pk-${label}-migrate`; - const relay = "wss://relay-migrate.example.com"; - const legacy = makeStore({ chl: E(true, 500, 2) }); - writeStore(pubkey, legacy); - assert.deepEqual(readStore(pubkey, relay), legacy); - assert.equal(window.localStorage.getItem(storageKey(pubkey)), null); - assert.deepEqual(readStore(pubkey, relay), legacy); - }); - - test(`${label}: readStore: migration is globally one-time — relay B sees DEFAULT_STORE after relay A migrates`, () => { - const pubkey = `pk-${label}-migrate-once`; - writeStore(pubkey, makeStore({ chm: E(true, 1, 1) })); - readStore(pubkey, "wss://relay-a-once.example.com"); - assert.deepEqual( - readStore(pubkey, "wss://relay-b-once.example.com"), - DEFAULT_STORE, - ); - assert.equal( - window.localStorage.getItem( - storageKey(pubkey, "wss://relay-b-once.example.com"), - ), - null, - ); - }); - - test(`${label}: readStore: migration only copies non-empty legacy stores`, () => { - const pubkey = `pk-${label}-migrate-empty`; - const relay = "wss://relay-empty.example.com"; - writeStore(pubkey, DEFAULT_STORE); - assert.deepEqual(readStore(pubkey, relay), DEFAULT_STORE); - assert.notEqual(window.localStorage.getItem(storageKey(pubkey)), null); - }); - - test(`${label}: readStore: scoped key takes precedence over legacy key`, () => { - const pubkey = `pk-${label}-precedence`; - const relay = "wss://relay-precedence.example.com"; - writeStore(pubkey, makeStore({ old: E(true, 1, 1) })); - const scoped = makeStore({ new: E(true, 2, 1) }); - writeStore(pubkey, scoped, relay); - assert.deepEqual(readStore(pubkey, relay), scoped); - }); - - test(`${label}: readStore: scoped-write failure returns DEFAULT and leaves neither relay able to seed the legacy value`, () => { - const pubkey = `pk-${label}-migrate-writefail`; - const relayA = `wss://relay-a-writefail-${label}.example.com`; - const relayB = `wss://relay-b-writefail-${label}.example.com`; - const legacy = makeStore({ chw: E(true, 500, 2) }); - writeStore(pubkey, legacy); - const scopedA = storageKey(pubkey, relayA); - const origSet = window.localStorage.setItem; - window.localStorage.setItem = (k, v) => { - if (k === scopedA) throw new Error("QuotaExceededError"); - return origSet.call(window.localStorage, k, v); - }; - try { - assert.deepEqual(readStore(pubkey, relayA), DEFAULT_STORE); - assert.equal(window.localStorage.getItem(scopedA), null); - } finally { - window.localStorage.setItem = origSet; - } - assert.notEqual(window.localStorage.getItem(storageKey(pubkey)), null); - assert.deepEqual(readStore(pubkey, relayB), legacy); - assert.equal(window.localStorage.getItem(storageKey(pubkey)), null); - assert.deepEqual(readStore(pubkey, relayA), DEFAULT_STORE); - }); - - test(`${label}: readStore: a legacy delete that does not take rolls back and returns DEFAULT`, () => { - const pubkey = `pk-${label}-migrate-delfail`; - const relay = `wss://relay-delfail-${label}.example.com`; - const legacy = makeStore({ chd: E(true, 700, 3) }); - writeStore(pubkey, legacy); - const legacyKey = storageKey(pubkey); - const scoped = storageKey(pubkey, relay); - const origRemove = window.localStorage.removeItem; - window.localStorage.removeItem = (k) => { - if (k === legacyKey) return; - return origRemove.call(window.localStorage, k); - }; - try { - assert.deepEqual(readStore(pubkey, relay), DEFAULT_STORE); - assert.equal(window.localStorage.getItem(scoped), null); - } finally { - window.localStorage.removeItem = origRemove; - } - assert.deepEqual(readStore(pubkey, relay), legacy); - assert.equal(window.localStorage.getItem(legacyKey), null); - }); - - test(`${label}: readStore: legacy delete + rollback both throw — every read returns DEFAULT until storage recovers, never seeds`, () => { - const pubkey = `pk-${label}-migrate-delrollbackthrow`; - const relay = `wss://relay-delrollbackthrow-${label}.example.com`; - const legacy = makeStore({ chr: E(true, 900, 4) }); - writeStore(pubkey, legacy); - const legacyKey = storageKey(pubkey); - const scoped = storageKey(pubkey, relay); - const origRemove = window.localStorage.removeItem; - window.localStorage.removeItem = () => { - throw new Error("SecurityError"); - }; - try { - assert.deepEqual(readStore(pubkey, relay), DEFAULT_STORE); - assert.deepEqual(readStore(pubkey, relay), DEFAULT_STORE); - assert.notEqual(window.localStorage.getItem(legacyKey), null); - } finally { - window.localStorage.removeItem = origRemove; - } - assert.deepEqual(readStore(pubkey, relay), legacy); - assert.equal(window.localStorage.getItem(legacyKey), null); - assert.notEqual(window.localStorage.getItem(scoped), null); - }); - - test(`${label}: readStore: legacy delete succeeds but the confirmation read throws — scoped copy retained, no data loss`, () => { - const pubkey = `pk-${label}-migrate-confirmthrow`; - const relay = `wss://relay-confirmthrow-${label}.example.com`; - const legacy = makeStore({ chc: E(true, 800, 3) }); - writeStore(pubkey, legacy); - const legacyKey = storageKey(pubkey); - const scoped = storageKey(pubkey, relay); - const origGet = window.localStorage.getItem; - const origRemove = window.localStorage.removeItem; - let legacyDeleted = false; - let threwOnce = false; - window.localStorage.removeItem = (k) => { - if (k === legacyKey) legacyDeleted = true; - return origRemove.call(window.localStorage, k); - }; - window.localStorage.getItem = (k) => { - if (k === legacyKey && legacyDeleted && !threwOnce) { - threwOnce = true; - throw new Error("SecurityError"); - } - return origGet.call(window.localStorage, k); - }; - try { - assert.deepEqual(readStore(pubkey, relay), DEFAULT_STORE); - assert.notEqual(window.localStorage.getItem(scoped), null); - } finally { - window.localStorage.getItem = origGet; - window.localStorage.removeItem = origRemove; - } - assert.equal(window.localStorage.getItem(legacyKey), null); - assert.deepEqual(readStore(pubkey, relay), legacy); - }); - - test(`${label}: readStore: legacy delete throws while the probe stays healthy — scoped rollback, DEFAULT, single future claimant`, () => { - const pubkey = `pk-${label}-migrate-delthrow-probeok`; - const relay = `wss://relay-delthrow-probeok-${label}.example.com`; - const legacy = makeStore({ cht: E(true, 600, 2) }); - writeStore(pubkey, legacy); - const legacyKey = storageKey(pubkey); - const scoped = storageKey(pubkey, relay); - const origRemove = window.localStorage.removeItem; - let thrown = false; - window.localStorage.removeItem = (k) => { - if (k === legacyKey && !thrown) { - thrown = true; - throw new Error("SecurityError"); - } - return origRemove.call(window.localStorage, k); - }; - try { - assert.deepEqual(readStore(pubkey, relay), DEFAULT_STORE); - assert.equal(window.localStorage.getItem(scoped), null); - assert.notEqual(window.localStorage.getItem(legacyKey), null); - } finally { - window.localStorage.removeItem = origRemove; - } - assert.deepEqual(readStore(pubkey, relay), legacy); - assert.equal(window.localStorage.getItem(legacyKey), null); - }); - - test(`${label}: readStore: legacy delete and the catch probe both throw — scoped kept but hidden while legacy remains`, () => { - const pubkey = `pk-${label}-migrate-delthrow-probethrow`; - const relay = `wss://relay-delthrow-probethrow-${label}.example.com`; - const legacy = makeStore({ chb: E(true, 500, 1) }); - writeStore(pubkey, legacy); - const legacyKey = storageKey(pubkey); - const scoped = storageKey(pubkey, relay); - const origGet = window.localStorage.getItem; - const origRemove = window.localStorage.removeItem; - let removeAttempted = false; - window.localStorage.removeItem = (k) => { - if (k === legacyKey) { - removeAttempted = true; - throw new Error("SecurityError"); - } - return origRemove.call(window.localStorage, k); - }; - window.localStorage.getItem = (k) => { - if (k === legacyKey && removeAttempted) throw new Error("SecurityError"); - return origGet.call(window.localStorage, k); - }; - try { - assert.deepEqual(readStore(pubkey, relay), DEFAULT_STORE); - assert.notEqual(window.localStorage.getItem(scoped), null); - assert.deepEqual(readStore(pubkey, relay), DEFAULT_STORE); - } finally { - window.localStorage.getItem = origGet; - window.localStorage.removeItem = origRemove; - } - assert.deepEqual(readStore(pubkey, relay), legacy); - assert.equal(window.localStorage.getItem(legacyKey), null); - }); +function makeStore(channels = {}) { + return { version: 1, channels }; } +const E = (v, updatedAt, rev) => ({ starred: v, updatedAt, rev }); +const S = (entry) => makeStore({ c: entry }); + +// ── parsePayload ────────────────────────────────────────────────────────── + +test("parseStarPayload: valid payload with channels returns store (rev preserved)", () => { + const payload = { + version: 1, + channels: { + "chan-1": E(true, 1000, 3), + "chan-2": E(false, 2000, 0), + }, + }; + assert.deepEqual(parseStarPayload(payload), payload); +}); + +test("parseStarPayload: missing rev normalizes to 0 (old-build blob, entry kept)", () => { + const raw = { + version: 1, + channels: { "chan-1": { starred: true, updatedAt: 1000 } }, + }; + const result = parseStarPayload(raw); + assert.deepEqual(result.channels["chan-1"], E(true, 1000, 0)); +}); + +test("parseStarPayload: malformed rev (string / negative / non-integer / NaN / unsafe) normalizes to 0", () => { + const raw = { + version: 1, + channels: { + str: { starred: true, updatedAt: 1, rev: "5" }, + neg: { starred: true, updatedAt: 1, rev: -2 }, + frac: { starred: true, updatedAt: 1, rev: 1.5 }, + nan: { starred: true, updatedAt: 1, rev: NaN }, + boundary: { starred: true, updatedAt: 1, rev: Number.MAX_SAFE_INTEGER }, + huge: { starred: true, updatedAt: 1, rev: Number.MAX_SAFE_INTEGER + 1 }, + }, + }; + const result = parseStarPayload(raw); + for (const id of ["str", "neg", "frac", "nan", "boundary", "huge"]) { + assert.equal(result.channels[id].rev, 0, `${id} rev normalized to 0`); + assert.equal(result.channels[id].starred, true, `${id} entry kept`); + } +}); + +test("parseStarPayload: missing version returns null", () => { + assert.equal( + parseStarPayload({ channels: { "chan-1": E(true, 1, 0) } }), + null, + ); +}); + +test("parseStarPayload: wrong version returns null", () => { + assert.equal( + parseStarPayload({ version: 2, channels: { "chan-1": E(true, 1, 0) } }), + null, + ); +}); + +test("parseStarPayload: null / non-object input returns null", () => { + assert.equal(parseStarPayload(null), null); + assert.equal(parseStarPayload("string"), null); + assert.equal(parseStarPayload(42), null); + assert.equal(parseStarPayload(true), null); +}); + +test("parseStarPayload: malformed channel entries missing value field/updatedAt are filtered out", () => { + const result = parseStarPayload({ + version: 1, + channels: { + "no-starred": { updatedAt: 1000 }, + "no-updated-at": { starred: true }, + valid: { starred: false, updatedAt: 500 }, + "value-wrong-type": { starred: "yes", updatedAt: 1000 }, + "updated-at-wrong-type": { starred: true, updatedAt: "now" }, + null: null, + }, + }); + assert.deepEqual(result, makeStore({ valid: E(false, 500, 0) })); +}); + +test("parseStarPayload: NaN/Infinity/negative/unsafe updatedAt entries are filtered out", () => { + const result = parseStarPayload({ + version: 1, + channels: { + nan: { starred: true, updatedAt: NaN }, + inf: { starred: true, updatedAt: Infinity }, + "neg-inf": { starred: true, updatedAt: -Infinity }, + neg: { starred: true, updatedAt: -1 }, + unsafe: { starred: true, updatedAt: Number.MAX_SAFE_INTEGER + 1 }, + valid: { starred: true, updatedAt: 100, rev: 2 }, + }, + }); + assert.deepEqual(result, makeStore({ valid: E(true, 100, 2) })); +}); + +test("parseStarPayload: empty channels / no channels key returns empty store", () => { + assert.deepEqual(parseStarPayload({ version: 1, channels: {} }), makeStore()); + assert.deepEqual(parseStarPayload({ version: 1 }), makeStore()); +}); + +// ── mergeStores: tuple order ────────────────────────────────────────────── + +test("mergeStores: non-overlapping channels returns union", () => { + const result = mergeStores( + makeStore({ a: E(true, 100, 1) }), + makeStore({ b: E(false, 200, 1) }), + ); + assert.deepEqual( + result, + makeStore({ a: E(true, 100, 1), b: E(false, 200, 1) }), + ); +}); + +test("mergeStores: strictly-later updatedAt wins regardless of rev (primary key)", () => { + const result = mergeStores(S(E(false, 200, 0)), S(E(true, 100, 7))); + assert.deepEqual(result.channels.c, E(false, 200, 0)); +}); + +test("mergeStores: equal updatedAt → higher rev wins (same-second tiebreak)", () => { + const result = mergeStores(S(E(false, 100, 5)), S(E(true, 100, 2))); + assert.deepEqual(result.channels.c, E(false, 100, 5)); +}); + +test("mergeStores: equal updatedAt AND equal rev → true-value wins (leaf)", () => { + const result = mergeStores(S(E(false, 100, 3)), S(E(true, 100, 3))); + assert.deepEqual(result.channels.c, E(true, 100, 3)); +}); + +test("mergeStores: same-second old-build click (rev 0) loses to an earlier new-build rev, heals next second", () => { + // ACCEPTED MIXED-FLEET RESIDUAL (Carl P1 #5). + const newBuildTrue = S(E(true, 100, 2)); + const oldBuildFalseSameSecond = S(E(false, 100, 0)); + assert.deepEqual( + mergeStores(newBuildTrue, oldBuildFalseSameSecond).channels.c, + E(true, 100, 2), + "the earlier new-build rev-2 true wins the same-second tie", + ); + const oldBuildFalseNextSecond = S(E(false, 101, 0)); + assert.deepEqual( + mergeStores(newBuildTrue, oldBuildFalseNextSecond).channels.c, + E(false, 101, 0), + "a strictly-later-second old-build false wins — the residual is transient", + ); +}); + +test("mergeStores: a boundary (MAX_SAFE_INTEGER) rev cannot wedge later same-second toggles", () => { + const raw = { + version: 1, + channels: { + c: { starred: true, updatedAt: 100, rev: Number.MAX_SAFE_INTEGER }, + }, + }; + const wedged = parseStarPayload(raw); + assert.equal(wedged.channels.c.rev, 0, "boundary rev is normalized to 0"); + const mint = (store, v) => { + const local = store.channels.c; + const rev = Math.max(local?.rev ?? 0, 0) + 1; + return { store: S(E(v, 100, rev)), rev }; + }; + let state = wedged; + let prevRev = state.channels.c.rev; + for (const v of [false, true, false, true]) { + const { store: click, rev } = mint(state, v); + assert.ok( + rev > prevRev, + `mint rev ${rev} strictly advances past ${prevRev}`, + ); + state = mergeStores(state, click); + assert.deepEqual( + state.channels.c, + E(v, 100, rev), + `same-second toggle to ${v} (rev ${rev}) wins`, + ); + prevRev = rev; + } +}); + +test("mergeStores: false-value with higher updatedAt overrides true-value", () => { + const result = mergeStores(S(E(true, 100, 9)), S(E(false, 999, 1))); + assert.deepEqual(result.channels.c, E(false, 999, 1)); +}); + +test("mergeStores: empty local / empty remote / both empty", () => { + assert.deepEqual( + mergeStores(makeStore(), S(E(true, 42, 1))).channels.c, + E(true, 42, 1), + ); + assert.deepEqual( + mergeStores(S(E(false, 10, 2)), makeStore()).channels.c, + E(false, 10, 2), + ); + assert.deepEqual(mergeStores(makeStore(), makeStore()), makeStore()); +}); + +// ── mergeStores: algebra ────────────────────────────────────────────────── + +function randEntry(rng) { + return E(rng() > 0.5, Math.floor(rng() * 5), Math.floor(rng() * 5)); +} +function randStore(rng, ids) { + const channels = {}; + for (const id of ids) if (rng() > 0.3) channels[id] = randEntry(rng); + return makeStore(channels); +} +function lcg(seed) { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 4294967296; + }; +} + +test("mergeStores: commutative — merge(a,b) === merge(b,a)", () => { + const rng = lcg(12345); + const ids = ["a", "b", "c", "d"]; + for (let i = 0; i < 200; i++) { + const a = randStore(rng, ids); + const b = randStore(rng, ids); + assert.deepEqual(mergeStores(a, b), mergeStores(b, a)); + } +}); + +test("mergeStores: associative — merge(merge(a,b),c) === merge(a,merge(b,c))", () => { + const rng = lcg(67890); + const ids = ["a", "b", "c", "d"]; + for (let i = 0; i < 200; i++) { + const a = randStore(rng, ids); + const b = randStore(rng, ids); + const c = randStore(rng, ids); + assert.deepEqual( + mergeStores(mergeStores(a, b), c), + mergeStores(a, mergeStores(b, c)), + ); + } +}); + +test("mergeStores: idempotent — merge(a, merge(a,b)) === merge(a,b)", () => { + const rng = lcg(24680); + const ids = ["a", "b", "c", "d"]; + for (let i = 0; i < 200; i++) { + const a = randStore(rng, ids); + const b = randStore(rng, ids); + const ab = mergeStores(a, b); + assert.deepEqual(mergeStores(a, ab), ab); + assert.deepEqual(mergeStores(ab, ab), ab); + } +}); + +// ── v1-blob bidirectional compatibility ─────────────────────────────────── + +test("v1 compat: a rev-carrying blob round-trips through a rev-less parser view", () => { + const ours = makeStore({ c: E(true, 100, 7) }); + const roundTripped = parseStarPayload(JSON.parse(JSON.stringify(ours))); + assert.equal(roundTripped.version, 1, "version stays 1"); + assert.equal(roundTripped.channels.c.starred, true); + assert.equal(roundTripped.channels.c.updatedAt, 100); +}); + +test("v1 compat: old-build unstar (no rev, updatedAt+1) beats our stale star", () => { + const ours = S(E(true, 100, 7)); + const oldBuildFalse = parseStarPayload({ + version: 1, + channels: { c: { starred: false, updatedAt: 101 } }, + }); + assert.deepEqual( + mergeStores(ours, oldBuildFalse).channels.c, + E(false, 101, 0), + ); +}); + +// ── boundStore ──────────────────────────────────────────────────────────── + +test("boundStarStore: retains newest entries regardless of value", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_ENTRIES }, (_, i) => [ + `active-${i}`, + E(true, i + 1, 0), + ]), + ); + channels["old-false"] = E(false, 0, 0); + channels["new-false"] = E(false, 9999, 0); + const result = boundStarStore(makeStore(channels)); + assert.equal(Object.keys(result.channels).length, MAX_ENTRIES); + assert.equal(result.channels["old-false"], undefined); + assert.deepEqual(result.channels["new-false"], E(false, 9999, 0)); + assert.equal(result.channels["active-0"], undefined); +}); + +test("boundStarStore: uses channel ID as an updatedAt tie-breaker", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_ENTRIES + 1 }, (_, i) => [ + `channel-${String(MAX_ENTRIES - i).padStart(3, "0")}`, + E(true, 1, 0), + ]), + ); + const result = boundStarStore(makeStore(channels)); + assert.equal(result.channels["channel-000"], undefined); + assert.deepEqual(result.channels["channel-500"], E(true, 1, 0)); +}); + +test("boundStarStore: preserves a same-second mutation by key", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_ENTRIES }, (_, i) => [ + `z-channel-${String(i).padStart(3, "0")}`, + E(true, 1, 0), + ]), + ); + channels["a-target"] = E(false, 1, 1); + const result = boundStarStore(makeStore(channels), "a-target"); + assert.equal(Object.keys(result.channels).length, MAX_ENTRIES); + assert.deepEqual(result.channels["a-target"], E(false, 1, 1)); + assert.equal(result.channels["z-channel-000"], undefined); +}); + +test("mergeStores: a fresh at-capacity unstar defeats an older remote star", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_ENTRIES }, (_, i) => [ + `active-${i}`, + E(true, i + 1, 0), + ]), + ); + channels.toggled = E(false, 9999, 1); + const bounded = boundStarStore(makeStore(channels)); + const result = mergeStores(bounded, makeStore({ toggled: E(true, 9998, 5) })); + assert.deepEqual(result.channels.toggled, E(false, 9999, 1)); +}); + +test("mergeStores: evicted remote ID re-enters and the oldest state is re-trimmed", () => { + const localChannels = Object.fromEntries( + Array.from({ length: MAX_ENTRIES }, (_, i) => [ + `active-${i}`, + E(true, i + 10, 0), + ]), + ); + const result = mergeStores( + makeStore(localChannels), + makeStore({ + "evicted-id": E(true, 9999, 0), + "active-0": E(false, 9998, 0), + }), + ); + assert.equal(Object.keys(result.channels).length, MAX_ENTRIES); + assert.deepEqual(result.channels["evicted-id"], E(true, 9999, 0)); + assert.deepEqual(result.channels["active-0"], E(false, 9998, 0)); + assert.equal(result.channels["active-1"], undefined); +}); + +// ── Eviction / remount (finding 3) ──────────────────────────────────────── + +test("finding 3 easy branch: a fresh click beats an evicted high-rev entry at an older updatedAt", () => { + const click = S(E(true, 1000, 1)); + const remote = S(E(false, 500, 100)); + assert.deepEqual( + mergeStores(click, remote).channels.c, + E(true, 1000, 1), + "fresh click wins on the primary updatedAt key", + ); +}); + +test("finding 3 hard branch: equal-second evicted click (rev 1) loses to observed remote (rev 100)", () => { + const NOW = 777; + const TARGET = "aaa-target"; + const channels = { [TARGET]: E(true, NOW, 7) }; + for (let i = 0; i < MAX_ENTRIES; i++) + channels[`z-${String(i).padStart(3, "0")}`] = E(true, NOW, 0); + const bounded = boundStarStore(makeStore(channels)); + assert.equal( + Object.keys(bounded.channels).length, + MAX_ENTRIES, + "bound trims to the cap", + ); + assert.equal( + bounded.channels[TARGET], + undefined, + "TARGET evicted by the id tiebreak", + ); + const click = makeStore({ [TARGET]: E(true, NOW, 1) }); + const remote = makeStore({ [TARGET]: E(false, NOW, 100) }); + assert.deepEqual( + mergeStores(click, remote).channels[TARGET], + E(false, NOW, 100), + "equal updatedAt → higher rev wins", + ); + assert.deepEqual( + mergeStores(remote, click).channels[TARGET], + E(false, NOW, 100), + ); +}); + +// ── idsFromStore ───────────────────────────────────────────────────────── + +test("starredChannelIdsFromStore: returns set of IDs where starred=true", () => { + const result = starredChannelIdsFromStore({ + version: 1, + channels: { a: E(true, 100, 0), b: E(true, 200, 0), c: E(false, 300, 0) }, + }); + assert.deepEqual([...result].sort(), ["a", "b"]); +}); + +test("starredChannelIdsFromStore: all-false / empty returns empty set", () => { + assert.equal( + starredChannelIdsFromStore({ version: 1, channels: { x: E(false, 1, 0) } }) + .size, + 0, + ); + assert.equal( + starredChannelIdsFromStore({ version: 1, channels: {} }).size, + 0, + ); +}); + +// ── storageKey + readStore/writeStore ───────────────────────────────────── + +test("storageKey: with relayUrl includes normalized+encoded relay in key", () => { + const relay = "wss://relay.example.com"; + assert.equal( + storageKey("pk1", relay), + `${storageKeyPrefix}:pk1:${encodeURIComponent(normalizeRelayUrl(relay))}`, + ); +}); + +test("storageKey: without relayUrl returns legacy pubkey-only key", () => { + assert.equal(storageKey("pk1"), `${storageKeyPrefix}:pk1`); + assert.equal(storageKey("pk1", undefined), `${storageKeyPrefix}:pk1`); +}); + +test("storageKey: two different relays produce different keys for same pubkey", () => { + assert.notEqual( + storageKey("pk1", "wss://relay-a.example.com"), + storageKey("pk1", "wss://relay-b.example.com"), + ); +}); + +test("storageKey: equivalent relay URLs (case + trailing slash) map to the same key", () => { + assert.equal( + storageKey("pk1", "WSS://Relay.Example/"), + storageKey("pk1", "wss://relay.example"), + ); +}); + +test("readChannelStarsStore + writeChannelStarsStore: scoped write/read roundtrip", () => { + const pubkey = "pk-stars-roundtrip"; + const relay = "wss://relay.example.com"; + const store = makeStore({ chan1: E(true, 1000, 1) }); + assert.ok(writeChannelStarsStore(pubkey, store, relay) !== null); + assert.deepEqual(readChannelStarsStore(pubkey, relay), store); +}); + +test("readChannelStarsStore: scoped key is isolated from other relay's data (A→B no seed leak)", () => { + const pubkey = "pk-stars-isolation"; + writeChannelStarsStore( + pubkey, + makeStore({ cha: E(true, 100, 1) }), + "wss://relay-a.example.com", + ); + assert.deepEqual( + readChannelStarsStore(pubkey, "wss://relay-b.example.com"), + DEFAULT_STORE, + ); +}); + +test("readChannelStarsStore: migrates legacy unscoped data on first scoped read", () => { + const pubkey = "pk-stars-migrate"; + const relay = "wss://relay-migrate.example.com"; + const legacy = makeStore({ chl: E(true, 500, 2) }); + writeChannelStarsStore(pubkey, legacy); + assert.deepEqual(readChannelStarsStore(pubkey, relay), legacy); + assert.equal(window.localStorage.getItem(storageKey(pubkey)), null); + assert.deepEqual(readChannelStarsStore(pubkey, relay), legacy); +}); + +test("readChannelStarsStore: migration is globally one-time — relay B sees DEFAULT_STORE after relay A migrates", () => { + const pubkey = "pk-stars-migrate-once"; + writeChannelStarsStore(pubkey, makeStore({ chm: E(true, 1, 1) })); + readChannelStarsStore(pubkey, "wss://relay-a-once.example.com"); + assert.deepEqual( + readChannelStarsStore(pubkey, "wss://relay-b-once.example.com"), + DEFAULT_STORE, + ); + assert.equal( + window.localStorage.getItem( + storageKey(pubkey, "wss://relay-b-once.example.com"), + ), + null, + ); +}); + +test("readChannelStarsStore: migration only copies non-empty legacy stores", () => { + const pubkey = "pk-stars-migrate-empty"; + const relay = "wss://relay-empty.example.com"; + writeChannelStarsStore(pubkey, DEFAULT_STORE); + assert.deepEqual(readChannelStarsStore(pubkey, relay), DEFAULT_STORE); + assert.notEqual(window.localStorage.getItem(storageKey(pubkey)), null); +}); + +test("readChannelStarsStore: scoped key takes precedence over legacy key", () => { + const pubkey = "pk-stars-precedence"; + const relay = "wss://relay-precedence.example.com"; + writeChannelStarsStore(pubkey, makeStore({ old: E(true, 1, 1) })); + const scoped = makeStore({ new: E(true, 2, 1) }); + writeChannelStarsStore(pubkey, scoped, relay); + assert.deepEqual(readChannelStarsStore(pubkey, relay), scoped); +}); + +// ── claimLegacy state machine (authoritative — not duplicated in lane files) ── + +test("claimLegacy: scoped-write failure returns DEFAULT and leaves neither relay able to seed the legacy value", () => { + const pubkey = "pk-stars-migrate-writefail"; + const relayA = "wss://relay-a-writefail-stars.example.com"; + const relayB = "wss://relay-b-writefail-stars.example.com"; + const legacy = makeStore({ chw: E(true, 500, 2) }); + writeChannelStarsStore(pubkey, legacy); + const scopedA = storageKey(pubkey, relayA); + const origSet = window.localStorage.setItem; + window.localStorage.setItem = (k, v) => { + if (k === scopedA) throw new Error("QuotaExceededError"); + return origSet.call(window.localStorage, k, v); + }; + try { + assert.deepEqual(readChannelStarsStore(pubkey, relayA), DEFAULT_STORE); + assert.equal(window.localStorage.getItem(scopedA), null); + } finally { + window.localStorage.setItem = origSet; + } + assert.notEqual(window.localStorage.getItem(storageKey(pubkey)), null); + assert.deepEqual(readChannelStarsStore(pubkey, relayB), legacy); + assert.equal(window.localStorage.getItem(storageKey(pubkey)), null); + assert.deepEqual(readChannelStarsStore(pubkey, relayA), DEFAULT_STORE); +}); + +test("claimLegacy: a legacy delete that does not take rolls back and returns DEFAULT", () => { + const pubkey = "pk-stars-migrate-delfail"; + const relay = "wss://relay-delfail-stars.example.com"; + const legacy = makeStore({ chd: E(true, 700, 3) }); + writeChannelStarsStore(pubkey, legacy); + const legacyKey = storageKey(pubkey); + const scoped = storageKey(pubkey, relay); + const origRemove = window.localStorage.removeItem; + window.localStorage.removeItem = (k) => { + if (k === legacyKey) return; + return origRemove.call(window.localStorage, k); + }; + try { + assert.deepEqual(readChannelStarsStore(pubkey, relay), DEFAULT_STORE); + assert.equal(window.localStorage.getItem(scoped), null); + } finally { + window.localStorage.removeItem = origRemove; + } + assert.deepEqual(readChannelStarsStore(pubkey, relay), legacy); + assert.equal(window.localStorage.getItem(legacyKey), null); +}); + +test("claimLegacy: legacy delete + rollback both throw — every read returns DEFAULT until storage recovers, never seeds", () => { + const pubkey = "pk-stars-migrate-delrollbackthrow"; + const relay = "wss://relay-delrollbackthrow-stars.example.com"; + const legacy = makeStore({ chr: E(true, 900, 4) }); + writeChannelStarsStore(pubkey, legacy); + const legacyKey = storageKey(pubkey); + const scoped = storageKey(pubkey, relay); + const origRemove = window.localStorage.removeItem; + window.localStorage.removeItem = () => { + throw new Error("SecurityError"); + }; + try { + assert.deepEqual(readChannelStarsStore(pubkey, relay), DEFAULT_STORE); + assert.deepEqual(readChannelStarsStore(pubkey, relay), DEFAULT_STORE); + assert.notEqual(window.localStorage.getItem(legacyKey), null); + } finally { + window.localStorage.removeItem = origRemove; + } + assert.deepEqual(readChannelStarsStore(pubkey, relay), legacy); + assert.equal(window.localStorage.getItem(legacyKey), null); + assert.notEqual(window.localStorage.getItem(scoped), null); +}); + +test("claimLegacy: legacy delete succeeds but the confirmation read throws — scoped copy retained, no data loss", () => { + const pubkey = "pk-stars-migrate-confirmthrow"; + const relay = "wss://relay-confirmthrow-stars.example.com"; + const legacy = makeStore({ chc: E(true, 800, 3) }); + writeChannelStarsStore(pubkey, legacy); + const legacyKey = storageKey(pubkey); + const scoped = storageKey(pubkey, relay); + const origGet = window.localStorage.getItem; + const origRemove = window.localStorage.removeItem; + let legacyDeleted = false; + let threwOnce = false; + window.localStorage.removeItem = (k) => { + if (k === legacyKey) legacyDeleted = true; + return origRemove.call(window.localStorage, k); + }; + window.localStorage.getItem = (k) => { + if (k === legacyKey && legacyDeleted && !threwOnce) { + threwOnce = true; + throw new Error("SecurityError"); + } + return origGet.call(window.localStorage, k); + }; + try { + assert.deepEqual(readChannelStarsStore(pubkey, relay), DEFAULT_STORE); + assert.notEqual(window.localStorage.getItem(scoped), null); + } finally { + window.localStorage.getItem = origGet; + window.localStorage.removeItem = origRemove; + } + assert.equal(window.localStorage.getItem(legacyKey), null); + assert.deepEqual(readChannelStarsStore(pubkey, relay), legacy); +}); + +test("claimLegacy: legacy delete throws while the probe stays healthy — scoped rollback, DEFAULT, single future claimant", () => { + const pubkey = "pk-stars-migrate-delthrow-probeok"; + const relay = "wss://relay-delthrow-probeok-stars.example.com"; + const legacy = makeStore({ cht: E(true, 600, 2) }); + writeChannelStarsStore(pubkey, legacy); + const legacyKey = storageKey(pubkey); + const scoped = storageKey(pubkey, relay); + const origRemove = window.localStorage.removeItem; + let thrown = false; + window.localStorage.removeItem = (k) => { + if (k === legacyKey && !thrown) { + thrown = true; + throw new Error("SecurityError"); + } + return origRemove.call(window.localStorage, k); + }; + try { + assert.deepEqual(readChannelStarsStore(pubkey, relay), DEFAULT_STORE); + assert.equal(window.localStorage.getItem(scoped), null); + assert.notEqual(window.localStorage.getItem(legacyKey), null); + } finally { + window.localStorage.removeItem = origRemove; + } + assert.deepEqual(readChannelStarsStore(pubkey, relay), legacy); + assert.equal(window.localStorage.getItem(legacyKey), null); +}); + +test("claimLegacy: legacy delete and the catch probe both throw — scoped kept but hidden while legacy remains", () => { + const pubkey = "pk-stars-migrate-delthrow-probethrow"; + const relay = "wss://relay-delthrow-probethrow-stars.example.com"; + const legacy = makeStore({ chb: E(true, 500, 1) }); + writeChannelStarsStore(pubkey, legacy); + const legacyKey = storageKey(pubkey); + const scoped = storageKey(pubkey, relay); + const origGet = window.localStorage.getItem; + const origRemove = window.localStorage.removeItem; + let removeAttempted = false; + window.localStorage.removeItem = (k) => { + if (k === legacyKey) { + removeAttempted = true; + throw new Error("SecurityError"); + } + return origRemove.call(window.localStorage, k); + }; + window.localStorage.getItem = (k) => { + if (k === legacyKey && removeAttempted) throw new Error("SecurityError"); + return origGet.call(window.localStorage, k); + }; + try { + assert.deepEqual(readChannelStarsStore(pubkey, relay), DEFAULT_STORE); + assert.notEqual(window.localStorage.getItem(scoped), null); + assert.deepEqual(readChannelStarsStore(pubkey, relay), DEFAULT_STORE); + } finally { + window.localStorage.getItem = origGet; + window.localStorage.removeItem = origRemove; + } + assert.deepEqual(readChannelStarsStore(pubkey, relay), legacy); + assert.equal(window.localStorage.getItem(legacyKey), null); +}); diff --git a/desktop/src/features/sidebar/lib/mergeLaneSync.shared.test.mjs b/desktop/src/features/sidebar/lib/mergeLaneSync.shared.test.mjs index 882b1ce476..1bbda88d71 100644 --- a/desktop/src/features/sidebar/lib/mergeLaneSync.shared.test.mjs +++ b/desktop/src/features/sidebar/lib/mergeLaneSync.shared.test.mjs @@ -178,157 +178,105 @@ export function runMergeLaneSyncSuite({ } }); - test(`${label}: destroy: is safe to call with no pending publish`, () => { - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new Manager("pk-no-pending", RELAY); - assert.doesNotThrow(() => manager.destroy()); - } finally { - restore(); - } - }); - // ─── Generation CAS: A-in-flight → B-click → A-completes (both variants) ──── - // Finding 2 (A succeeds): an older in-flight publish that completes after a - // newer edit is queued must NOT clear the newer edit's pending store/outbox, - // and B must reach the relay via the completion re-drive. Mutation: dropping - // the generation CAS in discardPending lets A's success null out B's - // pending+outbox. - test(`${label}: A-in-flight → B-click → A-succeeds: B stays pending and B publishes`, async () => { - let releaseFirst = null; - let publishCount = 0; - let storedHead = []; - mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); - mock.method(relayClient, "publishEvent", (event) => { - publishCount++; - if (publishCount === 1) { - // A reaches the relay; hold its ACK open until the test releases it, - // then record it as the retained head. - return new Promise((res) => { - releaseFirst = () => { - storedHead = [event]; - res(); - }; - }); + // Finding 2: an older in-flight publish that completes (succeeds or fails) after + // a newer edit is queued must NOT clear B, and B must reach the relay via the + // serialized re-drive / completion re-drive. Mutation: dropping the generation + // CAS in discardPending lets A's outcome null out B's pending+outbox. + for (const { name, resolveFirst } of [ + { + name: "A-succeeds", + resolveFirst: (event, storedHead, res) => { + storedHead.push(event); + res(); + }, + }, + { + name: "A-fails", + resolveFirst: (_event, _storedHead, _res, rej) => + rej(new Error("socket error")), + }, + ]) { + test(`${label}: A-in-flight → B-click → ${name}: B stays pending and B publishes`, async () => { + let releaseFirst = null; + let publishCount = 0; + const storedHead = []; + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([...storedHead]), + ); + mock.method(relayClient, "publishEvent", (event) => { + publishCount++; + if (publishCount === 1) { + return new Promise((res, rej) => { + releaseFirst = () => resolveFirst(event, storedHead, res, rej); + }); + } + storedHead.splice(0, storedHead.length, event); + return Promise.resolve(); + }); + const t = makeMultiTimerWindow(); + const restore = installFakeWindow(t.win); + const tauri = installEchoTauri(`pk-ab-${name}`); + try { + const manager = new Manager(`pk-ab-${name}`, RELAY); + const storeA = makeStore({ a: makeEntry(true, 100, 1) }); + const storeB = makeStore({ b: makeEntry(true, 101, 1) }); + + publish(manager, storeA); + await t.fireDelay(2000); // doPublish(A) awaits publishEvent + while (releaseFirst === null) await Promise.resolve(); + + // B arrives while A is in flight. + publish(manager, storeB); + assert.deepEqual( + Object.keys(getPending(manager).channels), + ["b"], + "B is now pending", + ); + assert.ok(readOutbox(`pk-ab-${name}`, RELAY), "outbox holds B"); + + // A completes (success or failure) — must NOT clear B. + releaseFirst(); + for (let i = 0; i < 50; i++) await Promise.resolve(); + assert.deepEqual( + Object.keys(getPending(manager)?.channels ?? {}), + ["b"], + `older A completion (${name}) leaves B pending`, + ); + assert.ok( + readOutbox(`pk-ab-${name}`, RELAY), + `older A completion (${name}) leaves B outbox`, + ); + + // B's own debounce fires and B reaches the relay; the retained-head fetch + // confirms subsumption, so B's outbox clears. + const capturedBefore = tauri.capturedPlaintext(); + await t.fireDelay(2000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + const captured = tauri.capturedPlaintext(); + assert.ok( + captured && captured !== capturedBefore && captured.includes('"b"'), + "B is published to the relay", + ); + assert.equal( + getPending(manager), + null, + "B cleared after confirmed publish", + ); + assert.equal( + readOutbox(`pk-ab-${name}`, RELAY), + null, + "B outbox cleared", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); } - storedHead = [event]; - return Promise.resolve(); }); - const t = makeMultiTimerWindow(); - const restore = installFakeWindow(t.win); - const tauri = installEchoTauri("pk-ab"); - try { - const manager = new Manager("pk-ab", RELAY); - const storeA = makeStore({ a: makeEntry(true, 100, 1) }); - const storeB = makeStore({ b: makeEntry(true, 101, 1) }); - - publish(manager, storeA); - await t.fireDelay(2000); // doPublish(A) awaits publishEvent - while (releaseFirst === null) await Promise.resolve(); - - // B arrives while A is in flight. - publish(manager, storeB); - assert.deepEqual( - Object.keys(getPending(manager).channels), - ["b"], - "B is now pending", - ); - assert.ok(readOutbox("pk-ab", RELAY), "outbox holds B"); - - // A completes — must NOT clear B. - releaseFirst(); - for (let i = 0; i < 50; i++) await Promise.resolve(); - assert.deepEqual( - Object.keys(getPending(manager)?.channels ?? {}), - ["b"], - "older A completion leaves B pending", - ); - assert.ok( - readOutbox("pk-ab", RELAY), - "older A completion leaves B outbox", - ); - - // B's own debounce fires and B reaches the relay with no kick; the - // post-publish retained-head fetch reads B's own write back and confirms - // subsumption, so B's outbox clears. - const capturedBefore = tauri.capturedPlaintext(); - await t.fireDelay(2000); - for (let i = 0; i < 50; i++) await Promise.resolve(); - const captured = tauri.capturedPlaintext(); - assert.ok( - captured && captured !== capturedBefore && captured.includes('"b"'), - "B is published to the relay", - ); - assert.equal( - getPending(manager), - null, - "B cleared after confirmed publish", - ); - assert.equal(readOutbox("pk-ab", RELAY), null, "B outbox cleared"); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); - - // Finding 2 (A fails): A's publish rejects after B is queued. B must remain - // pending and be published by the serialized re-drive / retry — no manual kick. - test(`${label}: A-in-flight → B-click → A-fails: B remains pending and B publishes`, async () => { - let rejectFirst = null; - let publishCount = 0; - let storedHead = []; - mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); - mock.method(relayClient, "publishEvent", (event) => { - publishCount++; - if (publishCount === 1) { - return new Promise((_res, rej) => { - rejectFirst = () => rej(new Error("socket error")); - }); - } - storedHead = [event]; - return Promise.resolve(); - }); - const t = makeMultiTimerWindow(); - const restore = installFakeWindow(t.win); - const tauri = installEchoTauri("pk-abfail"); - try { - const manager = new Manager("pk-abfail", RELAY); - publish(manager, makeStore({ a: makeEntry(true, 100, 1) })); - await t.fireDelay(2000); - while (rejectFirst === null) await Promise.resolve(); - - publish(manager, makeStore({ b: makeEntry(true, 101, 1) })); - rejectFirst(); // A fails - for (let i = 0; i < 50; i++) await Promise.resolve(); - - assert.deepEqual( - Object.keys(getPending(manager)?.channels ?? {}), - ["b"], - "B still pending after A's failure", - ); - assert.ok( - readOutbox("pk-abfail", RELAY), - "B outbox intact after A's failure", - ); - - // B's debounce fires and B publishes successfully; the retained-head fetch - // confirms B's own write and clears it. - await t.fireDelay(2000); - for (let i = 0; i < 50; i++) await Promise.resolve(); - const captured = tauri.capturedPlaintext(); - assert.ok(captured?.includes('"b"'), "B published"); - assert.equal(getPending(manager), null, "B cleared"); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); + } // ─── Bounded-backoff retry: failed publish on a healthy socket, no later edit @@ -450,92 +398,83 @@ export function runMergeLaneSyncSuite({ // ─── Boot seed-publish guard (the revert-fix regression suite) ─────────────── - test(`${label}: revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap`, async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("relay timeout")), - ); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new Manager("pk-fail", RELAY); - const result = await manager.bootstrap( - makeStore({ ch1: makeEntry(true, 1, 0) }), - ); - assert.equal(result.action, "hold"); - assert.equal(getPending(manager), null); - } finally { - restore(); - mock.reset(); - } - }); - - test(`${label}: revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap`, async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - fw.localStorage.setItem( - `buzz-sync-watermark.v1:${watermarkKind}:pk-stale:${RELAY_KEY}`, - "1700000000", - ); - const restore = installFakeWindow(fw); - try { - const manager = new Manager("pk-stale", RELAY); - const result = await manager.bootstrap( - makeStore({ ch1: makeEntry(true, 1, 0) }), - ); - assert.equal(result.action, "hold"); - assert.equal(getPending(manager), null); - } finally { - restore(); - mock.reset(); - } - }); - - test(`${label}: revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)`, async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new Manager("pk-fresh", RELAY); - const result = await manager.bootstrap( - makeStore({ ch1: makeEntry(true, 1, 0) }), - ); - assert.equal(result.action, "hold"); - assert.ok(getPending(manager) !== null); - } finally { - restore(); - mock.reset(); - } - }); - - test(`${label}: revert-fix: relay-A watermark does not suppress first-sync seed on relay-B`, async () => { - const relayA = "wss://a.relay.test"; - const relayB = "wss://b.relay.test"; - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - fw.localStorage.setItem( - `buzz-sync-watermark.v1:${watermarkKind}:pk-iso:${encodeURIComponent(relayA)}`, - "1700000100", - ); - const restore = installFakeWindow(fw); - try { - const managerB = new Manager("pk-iso", relayB); - const result = await managerB.bootstrap( - makeStore({ ch1: makeEntry(true, 1, 0) }), - ); - assert.equal(result.action, "hold"); - assert.ok( - getPending(managerB) !== null, - `first-sync seed on relay B must not be blocked by relay A watermark`, - ); - } finally { - restore(); - mock.reset(); - } - }); + for (const { + title, + setupFetch, + setupWatermark, + relayOverride, + pubkey, + assertPending, + } of [ + { + title: "fetch failed (error) does not trigger seed-publish via bootstrap", + setupFetch: () => + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ), + assertPending: (m) => assert.equal(getPending(m), null), + }, + { + title: + "absent fetch with prior watermark blocks seed-publish via bootstrap", + setupFetch: () => + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])), + setupWatermark: (fw) => + fw.localStorage.setItem( + `buzz-sync-watermark.v1:${watermarkKind}:pk-stale:${RELAY_KEY}`, + "1700000000", + ), + pubkey: "pk-stale", + assertPending: (m) => assert.equal(getPending(m), null), + }, + { + title: + "absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", + setupFetch: () => + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])), + pubkey: "pk-fresh", + assertPending: (m) => assert.ok(getPending(m) !== null), + }, + { + title: "relay-A watermark does not suppress first-sync seed on relay-B", + setupFetch: () => + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])), + setupWatermark: (fw) => + fw.localStorage.setItem( + `buzz-sync-watermark.v1:${watermarkKind}:pk-iso:${encodeURIComponent("wss://a.relay.test")}`, + "1700000100", + ), + relayOverride: "wss://b.relay.test", + pubkey: "pk-iso", + assertPending: (m) => + assert.ok( + getPending(m) !== null, + "first-sync seed on relay B must not be blocked by relay A watermark", + ), + }, + ]) { + test(`${label}: revert-fix: ${title}`, async () => { + setupFetch(); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + setupWatermark?.(fw); + const restore = installFakeWindow(fw); + try { + const manager = new Manager( + pubkey ?? "pk-fail", + relayOverride ?? RELAY, + ); + const result = await manager.bootstrap( + makeStore({ ch1: makeEntry(true, 1, 0) }), + ); + assert.equal(result.action, "hold"); + assertPending(manager); + } finally { + restore(); + mock.reset(); + } + }); + } // ─── Failed pre-publish fetch: retain, never publish (Carl P1) ─────────────── diff --git a/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs b/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs index 948d5744bd..237311a0f3 100644 --- a/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs +++ b/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs @@ -90,7 +90,6 @@ function withStorage(fn) { const { normalizeRelayUrl } = await import("@/shared/lib/normalizeRelayUrl"); const { outboxWindowNonce } = await import("./sidebarSyncWatermark.ts"); const stars = await import("./channelStarsStorage.ts"); -const mutes = await import("./channelMutesStorage.ts"); const sort = await import("./channelSortPreference.ts"); const sections = await import("./channelSectionsStorage.ts"); @@ -100,7 +99,6 @@ const SCOPE = `${PK}:${encodeURIComponent(normalizeRelayUrl(RELAY))}`; const PREFIX = { stars: "buzz-channel-stars-outbox.v1", - mutes: "buzz-channel-mutes-outbox.v1", sort: "buzz-channel-sort-outbox.v1", sections: "buzz-channel-sections-outbox.v1", }; @@ -118,8 +116,6 @@ const writeAt = (ls, key, store, queuedAt) => const starStore = (channels) => ({ version: 1, channels }); const starEntry = (starred, updatedAt, rev) => ({ starred, updatedAt, rev }); -const muteStore = (channels) => ({ version: 1, channels }); -const muteEntry = (muted, updatedAt, rev) => ({ muted, updatedAt, rev }); const sortStore = (groups) => ({ version: 1, groups }); const sectionStore = (secs, assignments = {}) => ({ version: 1, @@ -218,26 +214,6 @@ test("(ii) stars (merge): both windows' distinct-channel edits resume", () => { }); }); -test("(ii) mutes (merge): both windows' distinct-channel edits resume", () => { - withStorage((ls) => { - writeAt( - ls, - foreignKey("mutes", "A", 0), - muteStore({ a: muteEntry(true, 100, 1) }), - 100, - ); - writeAt( - ls, - foreignKey("mutes", "B", 0), - muteStore({ b: muteEntry(true, 200, 1) }), - 200, - ); - const resumed = mutes.readChannelMutesOutbox(PK, RELAY); - assert.deepEqual(resumed.channels.a, muteEntry(true, 100, 1)); - assert.deepEqual(resumed.channels.b, muteEntry(true, 200, 1)); - }); -}); - test("(ii) sort (whole-blob): the newest queued window resumes; older is LWW-superseded", () => { withStorage((ls) => { writeAt( @@ -259,27 +235,6 @@ test("(ii) sort (whole-blob): the newest queued window resumes; older is LWW-sup }); }); -test("(ii) sections (whole-blob): the newest queued window resumes; older is LWW-superseded", () => { - withStorage((ls) => { - writeAt( - ls, - foreignKey("sections", "A", 0), - sectionStore([{ id: "s1", name: "One", order: 0 }]), - 100, - ); - writeAt( - ls, - foreignKey("sections", "B", 0), - sectionStore([{ id: "s2", name: "Two", order: 0 }]), - 200, - ); - assert.deepEqual( - sections.readChannelSectionsOutbox(PK, RELAY).store.sections, - [{ id: "s2", name: "Two", order: 0 }], - ); - }); -}); - // ── (iii) Same-second whole-blob: head at t keeps a record queued at t ───────── // // One-second clock granularity means a record queued in the same second as the diff --git a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs index ae6098c3dd..a61917c8b2 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs @@ -18,6 +18,69 @@ before(() => { after(() => dom.window.close()); +// ── Shared test helpers ─────────────────────────────────────────────────────── + +/** Install relay no-ops and return a restore function. */ +function stubRelay(relayClient, { live, reconnect, publishCalls } = {}) { + const orig = { + fetchEvents: relayClient.fetchEvents, + subscribeLive: relayClient.subscribeLive, + subscribeToReconnects: relayClient.subscribeToReconnects, + publishEvent: relayClient.publishEvent, + }; + relayClient.fetchEvents = async () => []; + relayClient.subscribeLive = async (_f, cb) => { + if (live) live.cb = cb; + return async () => {}; + }; + relayClient.subscribeToReconnects = (cb) => { + if (reconnect) reconnect.cb = cb; + return () => {}; + }; + relayClient.publishEvent = async (...args) => { + if (publishCalls) publishCalls.push(args); + }; + return () => Object.assign(relayClient, orig); +} + +/** + * Install a Tauri mock that: + * - encrypt returns "ct" + * - sign returns a minimal event with the given pubkey + * - decrypt returns `decryptPayload` (a JSON string or callback) + */ +function stubTauri(pubkey, decryptPayload) { + const orig = window.__TAURI_INTERNALS__; + window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") { + const payload = + typeof decryptPayload === "function" + ? decryptPayload(args) + : decryptPayload; + return Promise.resolve(payload); + } + if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct"); + if (cmd === "sign_event") + return Promise.resolve( + JSON.stringify({ + id: "signed", + pubkey, + content: "ct", + created_at: 0, + kind: 30078, + tags: [], + sig: "s", + }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + return () => { + window.__TAURI_INTERNALS__ = orig; + }; +} + test("assignChannel refreshes an existing assignment before the next eviction", async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); const { relayClient } = await import("@/shared/api/relayClient"); @@ -26,12 +89,7 @@ test("assignChannel refreshes an existing assignment before the next eviction", ); const { useChannelSections } = await import("./useChannelSections.ts"); - const originalFetchEvents = relayClient.fetchEvents; - const originalSubscribeLive = relayClient.subscribeLive; - const originalSubscribeToReconnects = relayClient.subscribeToReconnects; - relayClient.fetchEvents = async () => []; - relayClient.subscribeLive = async () => async () => {}; - relayClient.subscribeToReconnects = () => () => {}; + const restoreRelay = stubRelay(relayClient); const pubkey = "pk-at-capacity"; const relayUrl = "wss://relay.example"; @@ -71,9 +129,7 @@ test("assignChannel refreshes an existing assignment before the next eviction", unmount(); } finally { cleanup(); - relayClient.fetchEvents = originalFetchEvents; - relayClient.subscribeLive = originalSubscribeLive; - relayClient.subscribeToReconnects = originalSubscribeToReconnects; + restoreRelay(); } }); @@ -100,35 +156,8 @@ test("storage event while a local edit is pending is deferred, not applied", asy const { useChannelSections } = await import("./useChannelSections.ts"); const { storageKey } = await import("./channelSectionsStorage.ts"); - const origFetch = relayClient.fetchEvents; - const origLive = relayClient.subscribeLive; - const origReconnect = relayClient.subscribeToReconnects; - const origPublish = relayClient.publishEvent; - const origTauri = window.__TAURI_INTERNALS__; - - // Block all publishes so the debounce never fires during the test. - relayClient.fetchEvents = async () => []; - relayClient.subscribeLive = async () => async () => {}; - relayClient.subscribeToReconnects = () => () => {}; - relayClient.publishEvent = async () => {}; - window.__TAURI_INTERNALS__ = { - invoke: (cmd) => { - if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct"); - if (cmd === "sign_event") - return Promise.resolve( - JSON.stringify({ - id: "signed", - pubkey: "pk-storage-guard", - content: "ct", - created_at: 0, - kind: 30078, - tags: [], - sig: "s", - }), - ); - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; + const restoreRelay = stubRelay(relayClient); + const restoreTauri = stubTauri("pk-storage-guard", null); const pubkey = "pk-storage-guard"; const relayUrl = "wss://r.storage-guard"; @@ -150,8 +179,7 @@ test("storage event while a local edit is pending is deferred, not applied", asy "A1 section created", ); - // Window B writes B1 to the shared scoped cache key (simulates a peer - // window's edit landing in localStorage and firing the storage event). + // Window B writes B1 to the shared scoped cache key. const b1Store = JSON.stringify({ version: 1, sections: [{ id: "b1-section", name: "B1", order: 0 }], @@ -184,9 +212,7 @@ test("storage event while a local edit is pending is deferred, not applied", asy "optimistic state must be unchanged after the deferred storage event", ); - // A2: user makes a second edit inside the debounce window. It is derived - // from A1's state (since B1 was deferred), so the result contains A1's - // section plus A2's. + // A2: derived from A1's state (since B1 was deferred). await act(async () => { hook.result.current.createSection("A2-Section"); }); @@ -199,11 +225,8 @@ test("storage event while a local edit is pending is deferred, not applied", asy hook.unmount(); } finally { cleanup(); - relayClient.fetchEvents = origFetch; - relayClient.subscribeLive = origLive; - relayClient.subscribeToReconnects = origReconnect; - relayClient.publishEvent = origPublish; - window.__TAURI_INTERNALS__ = origTauri; + restoreRelay(); + restoreTauri(); } }); @@ -220,46 +243,15 @@ test("live remote while a local edit is pending defers to the pending edit", asy "./channelSectionsStorage.ts" ); - const origFetch = relayClient.fetchEvents; - const origLive = relayClient.subscribeLive; - const origReconnect = relayClient.subscribeToReconnects; - const origPublish = relayClient.publishEvent; - const origTauri = window.__TAURI_INTERNALS__; - - let live = null; - relayClient.fetchEvents = async () => []; - relayClient.subscribeLive = async (_f, cb) => { - live = cb; - return async () => {}; - }; - relayClient.subscribeToReconnects = () => () => {}; - relayClient.publishEvent = async () => {}; - window.__TAURI_INTERNALS__ = { - invoke: (cmd) => { - if (cmd === "nip44_decrypt_from_self") - return Promise.resolve( - JSON.stringify({ - version: 1, - sections: [{ id: "remote", name: "Remote", order: 0 }], - assignments: {}, - }), - ); - if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct"); - if (cmd === "sign_event") - return Promise.resolve( - JSON.stringify({ - id: "signed", - pubkey: "pk-live-pending", - content: "ct", - created_at: 0, - kind: 30078, - tags: [], - sig: "s", - }), - ); - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; + const live = {}; + const restoreRelay = stubRelay(relayClient, { live }); + const restoreTauri = stubTauri("pk-live-pending", () => + JSON.stringify({ + version: 1, + sections: [{ id: "remote", name: "Remote", order: 0 }], + assignments: {}, + }), + ); const pubkey = "pk-live-pending"; const relayUrl = "wss://r.live"; @@ -272,7 +264,7 @@ test("live remote while a local edit is pending defers to the pending edit", asy await Promise.resolve(); await Promise.resolve(); }); - assert.ok(live, "live subscription installed"); + assert.ok(live.cb, "live subscription installed"); // Make a local edit — it becomes the pending store and persists to outbox. await act(async () => { @@ -286,7 +278,7 @@ test("live remote while a local edit is pending defers to the pending edit", asy // A remote live event arrives while the edit is still pending. await act(async () => { - live({ + live.cb({ id: "remote-event", pubkey, created_at: 500, @@ -311,11 +303,8 @@ test("live remote while a local edit is pending defers to the pending edit", asy hook.unmount(); } finally { cleanup(); - relayClient.fetchEvents = origFetch; - relayClient.subscribeLive = origLive; - relayClient.subscribeToReconnects = origReconnect; - relayClient.publishEvent = origPublish; - window.__TAURI_INTERNALS__ = origTauri; + restoreRelay(); + restoreTauri(); } }); @@ -328,35 +317,17 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn const { relayClient } = await import("@/shared/api/relayClient"); const { useChannelSections } = await import("./useChannelSections.ts"); - const origFetch = relayClient.fetchEvents; - const origLive = relayClient.subscribeLive; - const origReconnect = relayClient.subscribeToReconnects; - const origTauri = window.__TAURI_INTERNALS__; - - let live = null; - relayClient.fetchEvents = async () => []; - relayClient.subscribeLive = async (_f, cb) => { - live = cb; - return async () => {}; - }; - relayClient.subscribeToReconnects = () => () => {}; - // Decrypt payload keyed off the event id embedded in the ciphertext so each - // delivered event yields a distinct store we can assert on. - window.__TAURI_INTERNALS__ = { - invoke: (cmd, args) => { - if (cmd === "nip44_decrypt_from_self") { - const id = args?.ciphertext ?? ""; - return Promise.resolve( - JSON.stringify({ - version: 1, - sections: [{ id, name: id, order: 0 }], - assignments: {}, - }), - ); - } - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; + const live = {}; + const restoreRelay = stubRelay(relayClient, { live }); + // Decrypt payload keyed off the event id so each event yields a distinct store. + const restoreTauri = stubTauri("pk-tie", (args) => { + const id = args?.ciphertext ?? ""; + return JSON.stringify({ + version: 1, + sections: [{ id, name: id, order: 0 }], + assignments: {}, + }); + }); const pubkey = "pk-tie"; const relayUrl = "wss://r.tie"; @@ -368,11 +339,11 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn await Promise.resolve(); await Promise.resolve(); }); - assert.ok(live, "live subscription installed"); + assert.ok(live.cb, "live subscription installed"); const deliver = async (id) => { await act(async () => { - live({ + live.cb({ id, pubkey, created_at: 1000, @@ -386,10 +357,8 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn }); }; - // Larger id first (would win under the old <= comparator)... - await deliver("bbbb"); - // ...then the lower id at the same timestamp — the relay's canonical winner. - await deliver("aaaa"); + await deliver("bbbb"); // larger id first + await deliver("aaaa"); // lower id — relay canonical winner assert.deepEqual( hook.result.current.sections.map((s) => s.id), @@ -399,10 +368,8 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn hook.unmount(); } finally { cleanup(); - relayClient.fetchEvents = origFetch; - relayClient.subscribeLive = origLive; - relayClient.subscribeToReconnects = origReconnect; - window.__TAURI_INTERNALS__ = origTauri; + restoreRelay(); + restoreTauri(); } }); @@ -420,58 +387,26 @@ test("reconnect adopts a remote that advanced while the edit was pending, never const { relayClient } = await import("@/shared/api/relayClient"); const { useChannelSections } = await import("./useChannelSections.ts"); - const origFetch = relayClient.fetchEvents; - const origLive = relayClient.subscribeLive; - const origReconnect = relayClient.subscribeToReconnects; - const origPublish = relayClient.publishEvent; - const origTauri = window.__TAURI_INTERNALS__; - - let reconnect = null; - // A single mutable head; bumped to created_at 200 (a remote that won LWW) - // right before the reconnect fires. + const reconnect = {}; + const publishCalls = []; + // A mutable head: bumped to 200 right before reconnect fires. let head = { pubkey: "pk-sec-recon", content: "remote-cipher", created_at: 100, id: "evt-100", }; - const publishCalls = []; + const origFetch = relayClient.fetchEvents; relayClient.fetchEvents = async () => [head]; - relayClient.subscribeLive = async () => async () => {}; - relayClient.subscribeToReconnects = (cb) => { - reconnect = cb; - return () => {}; - }; - relayClient.publishEvent = async (...args) => { - publishCalls.push(args); - }; - window.__TAURI_INTERNALS__ = { - invoke: (cmd) => { - // The head decrypts to a remote store with a single "remote" section. - if (cmd === "nip44_decrypt_from_self") - return Promise.resolve( - JSON.stringify({ - version: 1, - sections: [{ id: "remote", name: "Remote", order: 0 }], - assignments: {}, - }), - ); - if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct"); - if (cmd === "sign_event") - return Promise.resolve( - JSON.stringify({ - id: "signed", - pubkey: "pk-sec-recon", - content: "ct", - created_at: 0, - kind: 30078, - tags: [], - sig: "s", - }), - ); - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; + const restoreRelay = stubRelay(relayClient, { reconnect, publishCalls }); + relayClient.fetchEvents = async () => [head]; // override stubRelay's fetchEvents + const restoreTauri = stubTauri("pk-sec-recon", () => + JSON.stringify({ + version: 1, + sections: [{ id: "remote", name: "Remote", order: 0 }], + assignments: {}, + }), + ); const pubkey = "pk-sec-recon"; const relayUrl = "wss://r.recon"; @@ -483,10 +418,8 @@ test("reconnect adopts a remote that advanced while the edit was pending, never await Promise.resolve(); await Promise.resolve(); }); - assert.ok(reconnect, "reconnect handler installed"); + assert.ok(reconnect.cb, "reconnect handler installed"); - // Local edit while the head stands at created_at 100 — the baseline the - // edit is frozen against. await act(async () => { hook.result.current.createSection("Local"); }); @@ -495,8 +428,7 @@ test("reconnect adopts a remote that advanced while the edit was pending, never "optimistic local section applied", ); - // The remote advances to created_at 200 (a peer won LWW), then reconnect - // fires: the hook re-fetches and wakes the existing generation. + // Remote advances to 200 (peer won LWW), then reconnect fires. head = { pubkey: "pk-sec-recon", content: "remote-cipher", @@ -504,7 +436,7 @@ test("reconnect adopts a remote that advanced while the edit was pending, never id: "evt-200", }; await act(async () => { - reconnect(); + reconnect.cb(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -514,11 +446,8 @@ test("reconnect adopts a remote that advanced while the edit was pending, never assert.equal( publishCalls.length, 0, - "must adopt the advanced remote on reconnect, never publish the stale edit over it", + "must adopt the advanced remote on reconnect, never publish", ); - // On adopt the whole-blob store is replaced by the remote (only a "remote" - // section), so the losing local edit is dropped. Under the mutation - // (re-queue) the pending edit is never adopted away and "Local" survives. assert.deepEqual( hook.result.current.sections.map((s) => s.id), ["remote"], @@ -528,10 +457,8 @@ test("reconnect adopts a remote that advanced while the edit was pending, never } finally { cleanup(); relayClient.fetchEvents = origFetch; - relayClient.subscribeLive = origLive; - relayClient.subscribeToReconnects = origReconnect; - relayClient.publishEvent = origPublish; - window.__TAURI_INTERNALS__ = origTauri; + restoreRelay(); + restoreTauri(); } }); @@ -551,18 +478,13 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed ); const { normalizeRelayUrl } = await import("@/shared/lib/normalizeRelayUrl"); - const origFetch = relayClient.fetchEvents; - const origLive = relayClient.subscribeLive; - const origReconnect = relayClient.subscribeToReconnects; - const origPublish = relayClient.publishEvent; - const origTauri = window.__TAURI_INTERNALS__; const origLocalStorage = window.localStorage; const pubkey = "pk-sec-quota"; const relayUrl = "wss://r.sec-quota"; const scope = `${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; const legacyKey = `buzz-channel-sections-outbox.v1:${scope}`; - const v2Prefix = `buzz-channel-sections-outbox.v1:${scope}:`; // nonce/seq suffix + const v2Prefix = `buzz-channel-sections-outbox.v1:${scope}:`; const legacyRaw = JSON.stringify({ store: { version: 1, @@ -572,9 +494,7 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed queuedAt: 0, }); - // A backing localStorage that throws on any v2 outbox write (the quota - // failure), while allowing the legacy read, the marker write, and applied - // state through — so the ONLY failure is the durability transfer. + // localStorage that throws on any v2 outbox write, allowing everything else. const map = new Map([[legacyKey, legacyRaw]]); const throwingStorage = { getItem: (k) => (map.has(k) ? map.get(k) : null), @@ -590,54 +510,26 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed key: (i) => [...map.keys()][i] ?? null, }; - const head = null; - relayClient.fetchEvents = async () => (head ? [head] : []); - relayClient.subscribeLive = async () => async () => {}; - relayClient.subscribeToReconnects = () => () => {}; - relayClient.publishEvent = async () => {}; - window.__TAURI_INTERNALS__ = { - invoke: (cmd) => { - if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct"); - if (cmd === "sign_event") - return Promise.resolve( - JSON.stringify({ - id: "signed", - pubkey, - content: "ct", - created_at: 0, - kind: 30078, - tags: [], - sig: "s", - }), - ); - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; + const restoreRelay = stubRelay(relayClient); + const restoreTauri = stubTauri(pubkey, null); let hook = null; try { - // Seed a prior watermark so bootstrap holds (no first-sync seed) and the - // legacy blob is the sole outbox record resumed. Object.defineProperty(window, "localStorage", { value: throwingStorage, configurable: true, }); + // Seed watermark so bootstrap holds (no first-sync seed). window.localStorage.setItem( - `buzz-sync-watermark.v1:channel-sections:${pubkey}:${encodeURIComponent( - normalizeRelayUrl(relayUrl), - )}`, + `buzz-sync-watermark.v1:channel-sections:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`, "1700000000", ); await act(async () => { hook = renderHook(() => useChannelSections(pubkey, relayUrl)); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); + for (let i = 0; i < 4; i++) await Promise.resolve(); }); - // The marker must NOT have been written: the v2 transfer threw. const markerWritten = [...map.keys()].some((k) => k.includes("-legacy-consumed:"), ); @@ -646,17 +538,17 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed false, "consumed marker must not be written after a failed v2 transfer", ); - // The legacy blob is still enumerated as replayable — not silently lost. + const resumed = readChannelSectionsOutbox(pubkey, relayUrl); assert.ok(resumed !== null, "legacy blob must remain replayable"); assert.equal( resumed.store.sections[0]?.id, "legacy", - "the exact legacy intent survives for a later boot", + "exact legacy intent survives", ); assert.ok( resumed.legacyRawToConsume !== null, - "legacy record still reports itself for consumption on a later boot", + "legacy record still reports itself for consumption", ); hook.unmount(); } finally { @@ -665,10 +557,7 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed value: origLocalStorage, configurable: true, }); - relayClient.fetchEvents = origFetch; - relayClient.subscribeLive = origLive; - relayClient.subscribeToReconnects = origReconnect; - relayClient.publishEvent = origPublish; - window.__TAURI_INTERNALS__ = origTauri; + restoreRelay(); + restoreTauri(); } }); diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs b/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs index 7d41ecd68d..61e989f56b 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs @@ -18,16 +18,70 @@ before(() => { after(() => dom.window.close()); +// ── Shared test helpers ─────────────────────────────────────────────────────── + +/** Install relay no-ops and return a restore function. */ +function stubRelay(relayClient, { live, reconnect, publishCalls } = {}) { + const orig = { + fetchEvents: relayClient.fetchEvents, + subscribeLive: relayClient.subscribeLive, + subscribeToReconnects: relayClient.subscribeToReconnects, + publishEvent: relayClient.publishEvent, + }; + relayClient.fetchEvents = async () => []; + relayClient.subscribeLive = async (_f, cb) => { + if (live) live.cb = cb; + return async () => {}; + }; + relayClient.subscribeToReconnects = (cb) => { + if (reconnect) reconnect.cb = cb; + return () => {}; + }; + relayClient.publishEvent = async (...args) => { + if (publishCalls) publishCalls.push(args); + }; + return () => Object.assign(relayClient, orig); +} + +/** + * Install a Tauri mock for sort tests. + * - encrypt returns "ct" / sign returns a minimal event + * - decrypt returns `decryptPayload` (JSON string or callback) + */ +function stubTauri(pubkey, decryptPayload) { + const orig = window.__TAURI_INTERNALS__; + window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") { + const payload = + typeof decryptPayload === "function" + ? decryptPayload(args) + : decryptPayload; + return Promise.resolve(payload); + } + if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct"); + if (cmd === "sign_event") + return Promise.resolve( + JSON.stringify({ + id: "signed", + pubkey, + content: "ct", + created_at: 0, + kind: 30078, + tags: [], + sig: "s", + }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + return () => { + window.__TAURI_INTERNALS__ = orig; + }; +} + // Carl P1.1 regression (sort): a peer-cache storage event arriving while a // local whole-blob sort edit is pending must NOT clobber the optimistic edit. -// The storage handler must defer to the pending edit just as `applyRemote` does -// for relay arrivals. Peer writes arrive immediately when nothing is pending. -// -// Scenario: window A sets sort mode for "channels" (pending). Window B writes -// B1 (sort mode "recent" for "channels") to the shared cache. A's storage event -// fires. A2 is then set inside the debounce window. The resulting sort mode for -// A must reflect A2, not B1's value — B1's write was ignored while A1 was live. -// // Mutation: removing the `hasPendingEdit()` guard in the storage handler lets // B1 apply, so A2's `setStore` updater derives from B1, and the visible mode // reverts to B1's value. @@ -39,34 +93,8 @@ test("storage event while a local sort edit is pending is deferred, not applied" ); const { storageKey } = await import("./channelSortPreference.ts"); - const origFetch = relayClient.fetchEvents; - const origLive = relayClient.subscribeLive; - const origReconnect = relayClient.subscribeToReconnects; - const origPublish = relayClient.publishEvent; - const origTauri = window.__TAURI_INTERNALS__; - - relayClient.fetchEvents = async () => []; - relayClient.subscribeLive = async () => async () => {}; - relayClient.subscribeToReconnects = () => () => {}; - relayClient.publishEvent = async () => {}; - window.__TAURI_INTERNALS__ = { - invoke: (cmd) => { - if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct"); - if (cmd === "sign_event") - return Promise.resolve( - JSON.stringify({ - id: "signed", - pubkey: "pk-sort-sg", - content: "ct", - created_at: 0, - kind: 30078, - tags: [], - sig: "s", - }), - ); - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; + const restoreRelay = stubRelay(relayClient); + const restoreTauri = stubTauri("pk-sort-sg", null); const pubkey = "pk-sort-sg"; const relayUrl = "wss://r.sort-sg"; @@ -88,8 +116,7 @@ test("storage event while a local sort edit is pending is deferred, not applied" "A1: channels sort set to alpha", ); - // Window B writes "recent" for "channels" to the shared cache and fires a - // storage event. + // Window B writes "recent" for "channels" to the shared cache. const b1Store = JSON.stringify({ version: 1, groups: { channels: "recent" }, @@ -107,40 +134,33 @@ test("storage event while a local sort edit is pending is deferred, not applied" await Promise.resolve(); }); - // B1's "recent" must NOT have replaced A1's "alpha". assert.equal( hook.result.current.sortModeFor("channels"), "alpha", "storage event while pending must not apply B1 (recent) over A1 (alpha)", ); - // A2: set "starred" to "recent" — derived from A1 state (channels stays alpha). + // A2: derived from A1 state (channels stays alpha). await act(async () => { hook.result.current.setSortModeFor("starred", "recent"); }); - // channels must still reflect A1 (alpha), not B1 (recent). assert.equal( hook.result.current.sortModeFor("channels"), "alpha", - "A2 must be derived from A1 state — channels must remain alpha, not revert to B1 recent", + "A2 must be derived from A1 state — channels must remain alpha", ); hook.unmount(); } finally { cleanup(); - relayClient.fetchEvents = origFetch; - relayClient.subscribeLive = origLive; - relayClient.subscribeToReconnects = origReconnect; - relayClient.publishEvent = origPublish; - window.__TAURI_INTERNALS__ = origTauri; + restoreRelay(); + restoreTauri(); } }); -// Carl P1-sort regression: a live remote arriving while a local sort edit is -// still pending must NOT overwrite the optimistic edit or strand its durable -// outbox. Reverting applyRemote's `hasPendingEdit()` guard (or restoring the -// old `cancelPendingPublish()` on remote arrival) lets the remote clobber the -// pending edit and drop it. +// Carl P1-sort regression: a live remote while a local sort edit is pending +// must NOT overwrite the optimistic edit or strand its durable outbox. +// Mutation: reverting applyRemote's hasPendingEdit() guard lets the remote clobber. test("live remote while a local edit is pending defers to the pending edit", async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); const { relayClient } = await import("@/shared/api/relayClient"); @@ -149,49 +169,14 @@ test("live remote while a local edit is pending defers to the pending edit", asy ); const { readChannelSortOutbox } = await import("./channelSortPreference.ts"); - const origFetch = relayClient.fetchEvents; - const origLive = relayClient.subscribeLive; - const origReconnect = relayClient.subscribeToReconnects; - const origPublish = relayClient.publishEvent; - const origTauri = window.__TAURI_INTERNALS__; - - let live = null; - relayClient.fetchEvents = async () => []; - relayClient.subscribeLive = async (_f, cb) => { - live = cb; - return async () => {}; - }; - relayClient.subscribeToReconnects = () => () => {}; - relayClient.publishEvent = async () => {}; - window.__TAURI_INTERNALS__ = { - invoke: (cmd) => { - if (cmd === "nip44_decrypt_from_self") - return Promise.resolve( - JSON.stringify({ - version: 1, - groups: { "remote-group": "recent" }, - }), - ); - if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct"); - if (cmd === "sign_event") - return Promise.resolve( - JSON.stringify({ - id: "signed", - pubkey: "pk-sort-pending", - content: "ct", - created_at: 0, - kind: 30078, - tags: [], - sig: "s", - }), - ); - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; + const live = {}; + const restoreRelay = stubRelay(relayClient, { live }); + const restoreTauri = stubTauri("pk-sort-pending", () => + JSON.stringify({ version: 1, groups: { "remote-group": "recent" } }), + ); const pubkey = "pk-sort-pending"; const relayUrl = "wss://r.live"; - let hook = null; try { await act(async () => { @@ -200,9 +185,8 @@ test("live remote while a local edit is pending defers to the pending edit", asy await Promise.resolve(); await Promise.resolve(); }); - assert.ok(live, "live subscription installed"); + assert.ok(live.cb, "live subscription installed"); - // Make a local edit — it becomes the pending store and persists to outbox. await act(async () => { hook.result.current.setSortModeFor("channels", "recent"); }); @@ -216,9 +200,8 @@ test("live remote while a local edit is pending defers to the pending edit", asy "optimistic local edit applied", ); - // A remote live event arrives while the edit is still pending. await act(async () => { - live({ + live.cb({ id: "remote-event", pubkey, created_at: 500, @@ -234,7 +217,7 @@ test("live remote while a local edit is pending defers to the pending edit", asy assert.equal( hook.result.current.sortModeFor("channels"), "recent", - "pending local edit must NOT be overwritten by the live remote", + "pending local edit must NOT be overwritten", ); assert.equal( hook.result.current.sortModeFor("remote-group"), @@ -248,19 +231,13 @@ test("live remote while a local edit is pending defers to the pending edit", asy hook.unmount(); } finally { cleanup(); - relayClient.fetchEvents = origFetch; - relayClient.subscribeLive = origLive; - relayClient.subscribeToReconnects = origReconnect; - relayClient.publishEvent = origPublish; - window.__TAURI_INTERNALS__ = origTauri; + restoreRelay(); + restoreTauri(); } }); -// Equal-timestamp tie-break must match the relay's canonical winner -// (`created_at DESC, id ASC` → LOWEST id wins). Deliver the larger id first, -// then the lower id at the same timestamp; the lower id is the stored winner -// and its whole-blob store must replace the applied state. Reverting -// applyRemote's `>=` back to `<=` wrongly ignores the lower id (the relay winner). +// Equal-timestamp tie-break must match the relay's canonical winner (lowest id wins). +// Mutation: reverting applyRemote's `>=` back to `<=` wrongly ignores the lower id. test("equal-timestamp tie-break applies the lower event id (relay canonical winner)", async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); const { relayClient } = await import("@/shared/api/relayClient"); @@ -268,31 +245,13 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn "./useChannelSortPreference.ts" ); - const origFetch = relayClient.fetchEvents; - const origLive = relayClient.subscribeLive; - const origReconnect = relayClient.subscribeToReconnects; - const origTauri = window.__TAURI_INTERNALS__; - - let live = null; - relayClient.fetchEvents = async () => []; - relayClient.subscribeLive = async (_f, cb) => { - live = cb; - return async () => {}; - }; - relayClient.subscribeToReconnects = () => () => {}; - // Decrypt payload keyed off the event id embedded in the ciphertext so each - // delivered event yields a store setting a distinct group's mode to "recent". - window.__TAURI_INTERNALS__ = { - invoke: (cmd, args) => { - if (cmd === "nip44_decrypt_from_self") { - const id = args?.ciphertext ?? ""; - return Promise.resolve( - JSON.stringify({ version: 1, groups: { [id]: "recent" } }), - ); - } - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; + const live = {}; + const restoreRelay = stubRelay(relayClient, { live }); + // Decrypt echoes ciphertext as the group key so each event yields a distinct store. + const restoreTauri = stubTauri("pk-sort-tie", (args) => { + const id = args?.ciphertext ?? ""; + return JSON.stringify({ version: 1, groups: { [id]: "recent" } }); + }); const pubkey = "pk-sort-tie"; const relayUrl = "wss://r.tie"; @@ -304,15 +263,15 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn await Promise.resolve(); await Promise.resolve(); }); - assert.ok(live, "live subscription installed"); + assert.ok(live.cb, "live subscription installed"); const deliver = async (id) => { await act(async () => { - live({ + live.cb({ id, pubkey, created_at: 1000, - content: id, // decrypt echoes this into the group key + content: id, kind: 30078, tags: [["d", "channel-sort"]], sig: "s", @@ -322,15 +281,13 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn }); }; - // Larger id first (applied), then the lower id at the same timestamp — the - // relay's canonical winner, whose whole-blob store must replace the state. await deliver("bbbb"); await deliver("aaaa"); assert.equal( hook.result.current.sortModeFor("aaaa"), "recent", - "lower event id (relay canonical winner) must be applied, not rejected", + "lower event id (relay canonical winner) must be applied", ); assert.equal( hook.result.current.sortModeFor("bbbb"), @@ -340,21 +297,15 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn hook.unmount(); } finally { cleanup(); - relayClient.fetchEvents = origFetch; - relayClient.subscribeLive = origLive; - relayClient.subscribeToReconnects = origReconnect; - window.__TAURI_INTERNALS__ = origTauri; + restoreRelay(); + restoreTauri(); } }); // Carl P1-sort regression: on reconnect the hook must wake the EXISTING pending // edit (retryPendingPublish) rather than re-queue it via publishSortPrefs(). -// A re-queue bumps the generation and resets the frozen publishBaseline to the -// just-fetched head, so a remote that won whole-blob LWW while the edit was -// pending would be published over instead of adopted. Here the head advances -// (100 → 200) while a local edit is pending; on reconnect the advanced remote -// must be adopted and NOTHING published. Reverting the reconnect handler to -// publishSortPrefs(pending) resets the baseline and publishes the stale edit. +// Mutation: reverting the reconnect handler to publishSortPrefs(pending) resets +// the baseline and publishes the stale edit over the advanced remote. test("reconnect adopts a remote that advanced while the edit was pending, never publishing over it", async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); const { relayClient } = await import("@/shared/api/relayClient"); @@ -362,54 +313,21 @@ test("reconnect adopts a remote that advanced while the edit was pending, never "./useChannelSortPreference.ts" ); - const origFetch = relayClient.fetchEvents; - const origLive = relayClient.subscribeLive; - const origReconnect = relayClient.subscribeToReconnects; - const origPublish = relayClient.publishEvent; - const origTauri = window.__TAURI_INTERNALS__; - - let reconnect = null; - // A single mutable head the fetch returns; bumped to created_at 200 (a remote - // that won LWW) right before the reconnect fires. + const reconnect = {}; + const publishCalls = []; let head = { pubkey: "pk-sort-recon", content: "remote-cipher", created_at: 100, id: "evt-100", }; - const publishCalls = []; + const origFetch = relayClient.fetchEvents; relayClient.fetchEvents = async () => [head]; - relayClient.subscribeLive = async () => async () => {}; - relayClient.subscribeToReconnects = (cb) => { - reconnect = cb; - return () => {}; - }; - relayClient.publishEvent = async (...args) => { - publishCalls.push(args); - }; - window.__TAURI_INTERNALS__ = { - invoke: (cmd) => { - // The head decrypts to a remote store setting `remote-group`. - if (cmd === "nip44_decrypt_from_self") - return Promise.resolve( - JSON.stringify({ version: 1, groups: { "remote-group": "recent" } }), - ); - if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct"); - if (cmd === "sign_event") - return Promise.resolve( - JSON.stringify({ - id: "signed", - pubkey: "pk-sort-recon", - content: "ct", - created_at: 0, - kind: 30078, - tags: [], - sig: "s", - }), - ); - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; + const restoreRelay = stubRelay(relayClient, { reconnect, publishCalls }); + relayClient.fetchEvents = async () => [head]; // override stubRelay's fetchEvents + const restoreTauri = stubTauri("pk-sort-recon", () => + JSON.stringify({ version: 1, groups: { "remote-group": "recent" } }), + ); const pubkey = "pk-sort-recon"; const relayUrl = "wss://r.recon"; @@ -421,16 +339,12 @@ test("reconnect adopts a remote that advanced while the edit was pending, never await Promise.resolve(); await Promise.resolve(); }); - assert.ok(reconnect, "reconnect handler installed"); + assert.ok(reconnect.cb, "reconnect handler installed"); - // Local edit while the head stands at created_at 100 — the baseline the - // edit is frozen against. await act(async () => { hook.result.current.setSortModeFor("channels", "recent"); }); - // The remote advances to created_at 200 (a peer won LWW), then reconnect - // fires: the hook re-fetches and wakes the existing generation. head = { pubkey: "pk-sort-recon", content: "remote-cipher", @@ -438,7 +352,7 @@ test("reconnect adopts a remote that advanced while the edit was pending, never id: "evt-200", }; await act(async () => { - reconnect(); + reconnect.cb(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -448,16 +362,12 @@ test("reconnect adopts a remote that advanced while the edit was pending, never assert.equal( publishCalls.length, 0, - "must adopt the advanced remote on reconnect, never publish the stale edit over it", + "must adopt the advanced remote on reconnect, never publish", ); - // On adopt the whole-blob store is replaced by the remote (which has no - // `channels` entry), so the losing local edit is dropped and `channels` - // reverts to the default. Under the mutation (re-queue) the pending edit is - // never adopted away and `channels` stays "recent". assert.equal( hook.result.current.sortModeFor("channels"), "alpha", - "the losing local edit must be adopted away by the advanced remote", + "the losing local edit must be adopted away", ); assert.equal( hook.result.current.sortModeFor("remote-group"), @@ -468,21 +378,14 @@ test("reconnect adopts a remote that advanced while the edit was pending, never } finally { cleanup(); relayClient.fetchEvents = origFetch; - relayClient.subscribeLive = origLive; - relayClient.subscribeToReconnects = origReconnect; - relayClient.publishEvent = origPublish; - window.__TAURI_INTERNALS__ = origTauri; + restoreRelay(); + restoreTauri(); } }); -// Thufir pass-3 blocker (Carl P1 durability): a legacy (old-build) blob is -// replayed by copying it into this window's own v2 outbox key, then writing a -// "consumed" marker. writeOwnOutbox swallows setItem failures; the hook must -// therefore write the marker ONLY when the v2 transfer actually succeeded. -// Here the v2 outbox setItem throws (quota), so the transfer failed: the marker -// must NOT be written, leaving the legacy blob replayable on a later boot rather -// than silently suppressed. Mutation: writing the marker unconditionally (the -// pre-fix code) consumes the legacy blob after a failed transfer and it is lost. +// Thufir pass-3 blocker: a legacy blob whose v2 transfer fails (quota) must NOT +// write the consumed marker. Mutation: writing the marker unconditionally +// consumes the legacy blob after a failed transfer and it is lost. test("legacy replay whose v2 transfer fails (quota) does not write the consumed marker", async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); const { relayClient } = await import("@/shared/api/relayClient"); @@ -492,26 +395,18 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed const { readChannelSortOutbox } = await import("./channelSortPreference.ts"); const { normalizeRelayUrl } = await import("@/shared/lib/normalizeRelayUrl"); - const origFetch = relayClient.fetchEvents; - const origLive = relayClient.subscribeLive; - const origReconnect = relayClient.subscribeToReconnects; - const origPublish = relayClient.publishEvent; - const origTauri = window.__TAURI_INTERNALS__; const origLocalStorage = window.localStorage; const pubkey = "pk-quota"; const relayUrl = "wss://r.quota"; const scope = `${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; const legacyKey = `buzz-channel-sort-outbox.v1:${scope}`; - const v2Prefix = `buzz-channel-sort-outbox.v1:${scope}:`; // nonce/seq suffix + const v2Prefix = `buzz-channel-sort-outbox.v1:${scope}:`; const legacyRaw = JSON.stringify({ store: { version: 1, groups: { "legacy-group": "recent" } }, queuedAt: 0, }); - // A backing localStorage that throws on any v2 outbox write (the quota - // failure), while allowing the legacy read, the marker write, and applied - // state through — so the ONLY failure is the durability transfer. const map = new Map([[legacyKey, legacyRaw]]); const throwingStorage = { getItem: (k) => (map.has(k) ? map.get(k) : null), @@ -527,42 +422,17 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed key: (i) => [...map.keys()][i] ?? null, }; - const head = null; - relayClient.fetchEvents = async () => (head ? [head] : []); - relayClient.subscribeLive = async () => async () => {}; - relayClient.subscribeToReconnects = () => () => {}; - relayClient.publishEvent = async () => {}; - window.__TAURI_INTERNALS__ = { - invoke: (cmd) => { - if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct"); - if (cmd === "sign_event") - return Promise.resolve( - JSON.stringify({ - id: "signed", - pubkey, - content: "ct", - created_at: 0, - kind: 30078, - tags: [], - sig: "s", - }), - ); - return Promise.reject(new Error(`unmocked ${cmd}`)); - }, - }; + const restoreRelay = stubRelay(relayClient); + const restoreTauri = stubTauri(pubkey, null); let hook = null; try { - // Seed a prior watermark so bootstrap holds (no first-sync seed) and the - // legacy blob is the sole outbox record resumed. Object.defineProperty(window, "localStorage", { value: throwingStorage, configurable: true, }); window.localStorage.setItem( - `buzz-sync-watermark.v1:channel-sort:${pubkey}:${encodeURIComponent( - normalizeRelayUrl(relayUrl), - )}`, + `buzz-sync-watermark.v1:channel-sort:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`, "1700000000", ); @@ -574,7 +444,6 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed await Promise.resolve(); }); - // The marker must NOT have been written: the v2 transfer threw. const markerWritten = [...map.keys()].some((k) => k.includes("-legacy-consumed:"), ); @@ -583,17 +452,16 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed false, "consumed marker must not be written after a failed v2 transfer", ); - // The legacy blob is still enumerated as replayable — not silently lost. const resumed = readChannelSortOutbox(pubkey, relayUrl); assert.ok(resumed !== null, "legacy blob must remain replayable"); assert.equal( resumed.store.groups["legacy-group"], "recent", - "the exact legacy intent survives for a later boot", + "the exact legacy intent survives", ); assert.ok( resumed.legacyRawToConsume !== null, - "legacy record still reports itself for consumption on a later boot", + "legacy record still reports itself for consumption", ); hook.unmount(); } finally { @@ -602,10 +470,7 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed value: origLocalStorage, configurable: true, }); - relayClient.fetchEvents = origFetch; - relayClient.subscribeLive = origLive; - relayClient.subscribeToReconnects = origReconnect; - relayClient.publishEvent = origPublish; - window.__TAURI_INTERNALS__ = origTauri; + restoreRelay(); + restoreTauri(); } }); diff --git a/desktop/src/features/sidebar/lib/wholeBlobSync.shared.test.mjs b/desktop/src/features/sidebar/lib/wholeBlobSync.shared.test.mjs index 1ed6dfe105..ee56570509 100644 --- a/desktop/src/features/sidebar/lib/wholeBlobSync.shared.test.mjs +++ b/desktop/src/features/sidebar/lib/wholeBlobSync.shared.test.mjs @@ -1,19 +1,18 @@ -// Shared parameterized test suite for whole-blob sync managers -// (ChannelSectionSyncManager, ChannelSortSyncManager). +// Authoritative whole-blob sync suite, run directly against +// ChannelSectionSyncManager. // -// Covers all 17 structural invariants common to both lanes. Lane-specific +// Covers the 15 structural invariants common to whole-blob lanes. Lane-specific // tests (serialized generations, ambiguous ACK, malformed payload, -// unsupported-version, reconnect adopts, durable outbox, failed publish) -// stay in the lane test files. -// -// Usage: -// import { runWholeBlobSyncSuite } from "./wholeBlobSync.shared.test.mjs"; -// runWholeBlobSyncSuite({ label: "sections", ... }); +// unsupported-version, reconnect adopts, durable outbox, failed publish) stay +// in the lane test files. channelSortSync.test.mjs covers the same engine via +// its compact adapter invocation. import assert from "node:assert/strict"; import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; +import { ChannelSectionSyncManager } from "./channelSectionsSync.ts"; +import { readChannelSectionsOutbox } from "./channelSectionsStorage.ts"; import { makeFakeWindow, installFakeWindow, @@ -23,921 +22,157 @@ import { const RELAY = "wss://r.test"; const RELAY_KEY = encodeURIComponent(RELAY); +const watermarkLane = "channel-sections"; -/** - * Run the 17-invariant whole-blob sync suite for one lane. - * - * @param {object} cfg - * @param {string} cfg.label - Human-readable lane name. - * @param {Function} cfg.SyncManager - The manager class to instantiate. - * @param {string} cfg.publishMethod - e.g. "publishSections" or "publishSortPrefs". - * @param {string} cfg.fetchRemoteMethod - e.g. "fetchRemoteSections" or "fetchRemoteSortPrefs". - * @param {string} cfg.subscribeMethod - e.g. "subscribeToSections" or "subscribeToSortPrefs". - * @param {string} cfg.watermarkLane - e.g. "channel-sections" or "channel-sort". - * @param {Function} cfg.readOutbox - readChannelSectionsOutbox | readChannelSortOutbox - * @param {Function} cfg.makeNonEmptyStore - () => non-empty store for bootstrap seed test - * @param {string} cfg.decryptPayload - JSON.stringify of a valid remote store for adopt tests - * @param {string} cfg.emptyDecryptPayload - JSON.stringify of valid but empty/cleared store - * @param {Function} cfg.checkAdoptedStore - (store) => bool — verify adopted store has remote content - * @param {Function} cfg.makeOverlapStoreA - () => store for overlap test's first edit - * @param {Function} cfg.makeOverlapStoreB - () => store for overlap test's second edit (in-flight) - * @param {Function} cfg.checkOverlapPending - (store) => bool — verify B is pending - * @param {Function} cfg.checkOverlapOutbox - (outbox) => bool — verify outbox holds B - * @param {Function} cfg.makeLiveDebounceStore - () => local edit store for live-during-debounce test - * @param {string} cfg.liveRemoteDecryptPayload - decrypt payload for live-during-debounce test - * @param {Function} cfg.makeCollisionStoreA - () => first edit for single-edit collision test - * @param {Function} cfg.makeCollisionWinnerStore - () => peer winner store for collision test - * @param {Function} cfg.makeCollisionStoreSnd - () => second edit for second-edit-survives test - * @param {Function} cfg.makeCollisionStoreLsr - () => loser edit for loser-adopts test - */ -export function runWholeBlobSyncSuite({ - label, - SyncManager, - publishMethod, - fetchRemoteMethod, - subscribeMethod, - watermarkLane, - readOutbox, - makeNonEmptyStore, - decryptPayload, - emptyDecryptPayload, - checkAdoptedStore, - makeOverlapStoreA, - makeOverlapStoreB, - checkOverlapPending, - checkOverlapOutbox, - makeLiveDebounceStore, - liveRemoteDecryptPayload, - makeCollisionStoreA, - makeCollisionWinnerStore, - makeCollisionStoreSnd, - makeCollisionStoreLsr, -}) { - // ─── destroy() ──────────────────────────────────────────────────────────── +function makeStore(sections = []) { + return { version: 1, sections, assignments: {} }; +} +function nonEmptyStore() { + return makeStore([{ id: "s1", name: "Work", order: 0 }]); +} +const decryptPayload = JSON.stringify( + makeStore([{ id: "remote-section-from-relay", name: "Remote", order: 0 }]), +); +const emptyDecryptPayload = JSON.stringify(makeStore([])); - test(`${label}: destroy: cancels pending publish without flushing to the relay`, () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new SyncManager("pk-test", RELAY); - manager[publishMethod](makeNonEmptyStore()); - 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(); - } +// Multi-slot timer fake: each setTimeout is keyed by delay so overlapping +// timers can be fired independently. +function makeTimerBed() { + const storage = new Map(); + const timers = new Map(); + let nextId = 1; + const win = { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + get length() { + return storage.size; + }, + key: (i) => [...storage.keys()][i] ?? null, + }, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + const fireDelay = async (ms) => { + const entry = [...timers.entries()].find(([, v]) => v.ms === ms); + assert.ok(entry, `expected a timer scheduled at ${ms}ms`); + timers.delete(entry[0]); + entry[1].fn(); + for (let i = 0; i < 100; i++) await Promise.resolve(); + }; + const restore = installFakeWindow(win); + return { win, timers, fireDelay, restore }; +} + +// ─── destroy() ──────────────────────────────────────────────────────────── + +test("destroy: cancels pending publish without flushing to the relay", () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + const publishCalls = []; + 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(nonEmptyStore()); + 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(); + } +}); - test(`${label}: destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves`, async () => { - let releaseFetch = null; - const publishCalls = []; - mock.method( - relayClient, - "fetchEvents", - () => - new Promise((res) => { - releaseFetch = () => res([]); - }), +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(nonEmptyStore()); + fw._fireTimer(); + manager.destroy(); + releaseFetch(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal( + publishCalls.length, + 0, + "publishEvent must not fire after destroy", ); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new SyncManager("pk-race", RELAY); - manager[publishMethod](makeNonEmptyStore()); - fw._fireTimer(); - 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(); - } - }); + } finally { + restore(); + mock.reset(); + } +}); - test(`${label}: destroy: is safe to call with no pending publish`, () => { - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new SyncManager("pk-no-pending", RELAY); - assert.doesNotThrow(() => manager.destroy()); - } finally { - restore(); - } - }); +// ─── Boot seed-publish guard (revert-fix regression suite) ──────────────── - // ─── Boot seed-publish guard (revert-fix regression suite) ──────────────── - - test(`${label}: revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap`, async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("relay timeout")), - ); +for (const { title, setupFetch, setupWatermark, pubkey, assertPending } of [ + { + title: "fetch failed (error) does not trigger seed-publish via bootstrap", + setupFetch: () => + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ), + assertPending: (m) => assert.equal(m.getPendingStore(), null), + }, + { + title: + "absent fetch with prior watermark blocks seed-publish via bootstrap", + setupFetch: () => + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])), + setupWatermark: (fw) => + fw.localStorage.setItem( + `buzz-sync-watermark.v1:${watermarkLane}:pk-stale:${RELAY_KEY}`, + "1700000000", + ), + pubkey: "pk-stale", + assertPending: (m) => assert.equal(m.getPendingStore(), null), + }, + { + title: + "absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", + setupFetch: () => + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])), + pubkey: "pk-fresh", + assertPending: (m) => assert.ok(m.getPendingStore() !== null), + }, +]) { + test(`revert-fix: ${title}`, async () => { + setupFetch(); mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); + setupWatermark?.(fw); const restore = installFakeWindow(fw); try { - const manager = new SyncManager("pk-fail", RELAY); - const result = await manager.bootstrap(makeNonEmptyStore()); + const manager = new ChannelSectionSyncManager(pubkey ?? "pk-fail", RELAY); + const result = await manager.bootstrap(nonEmptyStore()); assert.equal(result.action, "hold"); - assert.equal(manager.getPendingStore(), null); - manager.destroy(); - } finally { - restore(); - mock.reset(); - } - }); - - test(`${label}: revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap`, async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - fw.localStorage.setItem( - `buzz-sync-watermark.v1:${watermarkLane}:pk-stale:${RELAY_KEY}`, - "1700000000", - ); - const restore = installFakeWindow(fw); - try { - const manager = new SyncManager("pk-stale", RELAY); - const result = await manager.bootstrap(makeNonEmptyStore()); - assert.equal(result.action, "hold"); - assert.equal(manager.getPendingStore(), null); - manager.destroy(); - } finally { - restore(); - mock.reset(); - } - }); - - test(`${label}: revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)`, async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new SyncManager("pk-fresh", RELAY); - const result = await manager.bootstrap(makeNonEmptyStore()); - assert.equal(result.action, "hold"); - assert.ok(manager.getPendingStore() !== null); - manager.destroy(); - } finally { - restore(); - mock.reset(); - } - }); - - // ─── Adopt-winner / local-winner ───────────────────────────────────────── - - test(`${label}: adopt-winner: newer remote head at pre-publish adopts remote and skips publish`, async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-lww", - content: "good-cipher", - created_at: 200, - id: "evt-remote", - }, - ]), - ); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installTauriMock(decryptPayload); - try { - const manager = new SyncManager("pk-lww", RELAY); - const adopted = []; - manager.setOnRemoteAdopted((r) => adopted.push(r)); - manager[publishMethod](makeNonEmptyStore()); - assert.ok( - readOutbox("pk-lww", RELAY) !== null, - "edit must be persisted to the durable outbox", - ); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - publishCalls.length, - 0, - "must not publish when a newer remote head wins LWW", - ); - assert.equal(adopted.length, 1, "adopt sink must receive the remote"); - assert.ok( - checkAdoptedStore(adopted[0].store), - "adopted store must be the remote content", - ); - assert.equal(manager.getPendingStore(), null, "pending must be cleared"); - assert.equal( - readOutbox("pk-lww", RELAY), - null, - "outbox must be cleared on adopt", - ); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); - - test(`${label}: adopt-winner: local edit at/ahead of head publishes and clears outbox`, async () => { - let storedHead = []; - mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (event) => { - publishCalls.push(event); - storedHead = [event]; - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installEchoTauri("pk-win"); - try { - const manager = new SyncManager("pk-win", RELAY); - manager[publishMethod](makeNonEmptyStore()); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal(publishCalls.length, 1, "local edit must be published"); - assert.equal( - readOutbox("pk-win", RELAY), - null, - "outbox must be cleared once the edit is confirmed retained", - ); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); - - // ─── Timestamp clamp ────────────────────────────────────────────────────── - - test(`${label}: timestamp clamp: published createdAt stays inside the relay future window`, async () => { - const nowSecs = Math.floor(Date.now() / 1000); - const farFutureHead = nowSecs + 3_600; - let call = 0; - mock.method(relayClient, "fetchEvents", () => { - call++; - return Promise.resolve([ - { - pubkey: "pk-clamp", - content: "good-cipher", - created_at: call === 1 ? farFutureHead : 0, - id: "evt-clamp", - }, - ]); - }); - let signedCreatedAt = null; - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installTauriMock(emptyDecryptPayload); - mock.method(relayClient, "publishEvent", (evt) => { - signedCreatedAt = evt.created_at; - return Promise.resolve(); - }); - try { - const manager = new SyncManager("pk-clamp", RELAY); - await manager[fetchRemoteMethod](); - manager[publishMethod](makeNonEmptyStore()); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.ok(signedCreatedAt !== null, "publish must have been attempted"); - assert.ok( - signedCreatedAt <= Math.floor(Date.now() / 1000) + 840, - `createdAt must be clamped inside the future window — got ${signedCreatedAt}`, - ); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); - - // ─── Unreadable head: retain, never overwrite ───────────────────────────── - - test(`${label}: unreadable head (decrypt failure) retains the pending edit and retries, never publishing`, async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-undec", - content: "bad-cipher", - created_at: 500, - id: "evt-undec", - }, - ]), - ); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installTauriMock("{}"); - try { - const manager = new SyncManager("pk-undec", RELAY); - manager[publishMethod](makeNonEmptyStore()); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - publishCalls.length, - 0, - "must not publish over a head we could not read", - ); - assert.ok( - manager.getPendingStore() !== null, - "unreadable head must retain the pending edit", - ); - assert.ok( - readOutbox("pk-undec", RELAY) !== null, - "durable outbox must survive an unreadable head", - ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); - - // ─── Failed pre-publish fetch: retain, never publish ────────────────────── - - test(`${label}: failed pre-publish fetch retains the pending edit and retries, never publishing`, async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("socket timeout")), - ); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installTauriMock("{}"); - try { - const manager = new SyncManager("pk-fetchfail", RELAY); - manager[publishMethod](makeNonEmptyStore()); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - publishCalls.length, - 0, - "must not publish when the pre-publish fetch failed", - ); - assert.ok( - manager.getPendingStore() !== null, - "a failed fetch must retain the pending edit", - ); - assert.ok( - readOutbox("pk-fetchfail", RELAY) !== null, - "durable outbox must survive a failed fetch", - ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); - - // ─── Overlapping publishes ───────────────────────────────────────────────── - - test(`${label}: overlapping publishes: older completion does not erase a newer queued edit`, async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - let releaseFirst = null; - let publishCalls = 0; - mock.method(relayClient, "publishEvent", () => { - publishCalls++; - if (publishCalls === 1) { - return new Promise((res) => { - releaseFirst = res; - }); - } - return Promise.resolve(); - }); - const storage = new Map(); - const timers = new Map(); - let nextId = 1; - const fakeWindow = { - localStorage: { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - get length() { - return storage.size; - }, - key: (i) => [...storage.keys()][i] ?? null, - }, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, ms }); - return id; - }, - clearTimeout: (id) => timers.delete(id), - }; - const fireDelay = async (ms) => { - const entry = [...timers.entries()].find(([, v]) => v.ms === ms); - assert.ok(entry, `expected a timer scheduled at ${ms}ms`); - timers.delete(entry[0]); - entry[1].fn(); - await Promise.resolve(); - await Promise.resolve(); - }; - const restore = installFakeWindow(fakeWindow); - const tauri = installTauriMock("{}"); - try { - const manager = new SyncManager("pk-overlap", RELAY); - manager[publishMethod](makeOverlapStoreA()); - await fireDelay(2000); - while (releaseFirst === null) await Promise.resolve(); - - manager[publishMethod](makeOverlapStoreB()); - assert.ok( - checkOverlapPending(manager.getPendingStore()), - "B is now the pending edit", - ); - assert.ok( - checkOverlapOutbox(readOutbox("pk-overlap", RELAY)), - "outbox holds B", - ); - - releaseFirst(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - - assert.ok( - checkOverlapPending(manager.getPendingStore()), - "older completion must leave B pending", - ); - assert.ok( - readOutbox("pk-overlap", RELAY) !== null, - "older completion must leave B's outbox intact", - ); - assert.ok( - [...timers.values()].some((t) => t.ms === 2000), - "B's debounce timer must survive so it still publishes", - ); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); - - // ─── Live remote during debounce is adopted at pre-publish ──────────────── - - test(`${label}: live remote during debounce is adopted at pre-publish, not overwritten`, async () => { - let liveCallback = null; - mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { - liveCallback = onEvent; - return Promise.resolve(async () => {}); - }); - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-live-deb", - content: "good-cipher", - created_at: 500, - id: "evt-live", - }, - ]), - ); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - // Multi-slot fakeWindow: the live-event callback may schedule its own timer - // (retry, watermark flush) before the debounce fires, which would clobber a - // single-slot fake window. Use a keyed map so we can fire the 2000ms debounce - // independently of any 0ms or other timers the live path may schedule. - const storage = new Map(); - const timers = new Map(); - let nextId = 1; - const fakeWindow = { - localStorage: { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - get length() { - return storage.size; - }, - key: (i) => [...storage.keys()][i] ?? null, - }, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, ms }); - return id; - }, - clearTimeout: (id) => timers.delete(id), - }; - const fireDelay = async (ms) => { - const entry = [...timers.entries()].find(([, v]) => v.ms === ms); - assert.ok(entry, `expected a timer scheduled at ${ms}ms`); - timers.delete(entry[0]); - entry[1].fn(); - for (let i = 0; i < 50; i++) await Promise.resolve(); - }; - const restore = installFakeWindow(fakeWindow); - const tauri = installTauriMock(liveRemoteDecryptPayload); - try { - const manager = new SyncManager("pk-live-deb", RELAY); - const adopted = []; - manager.setOnRemoteAdopted((r) => adopted.push(r)); - await manager[subscribeMethod](() => {}); - manager[publishMethod](makeLiveDebounceStore()); - // Live event arrives during the debounce window — advances watermark past - // the frozen baseline. - liveCallback({ - pubkey: "pk-live-deb", - content: "good-cipher", - created_at: 500, - id: "evt-live", - }); - for (let i = 0; i < 50; i++) await Promise.resolve(); - await fireDelay(2000); // debounce → doPublish → pre-publish sees advanced head - assert.equal( - publishCalls.length, - 0, - "must not publish over a remote that became head during the debounce", - ); - assert.equal(adopted.length, 1, "the newer remote must be adopted"); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); - - // ─── Same-second collision: single-edit retry adopts winner ─────────────── - - test(`${label}: same-second collision: single-edit retry adopts winner, does not republish`, async () => { - let fetchCalls = 0; - let publishCalls = 0; - let storedHead = []; - const storage = new Map(); - const timers = new Map(); - let nextId = 1; - const win = { - localStorage: { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - }, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, ms }); - return id; - }, - clearTimeout: (id) => timers.delete(id), - }; - const fireDelay = async (ms) => { - const entry = [...timers.entries()].find(([, v]) => v.ms === ms); - assert.ok(entry, `expected a timer scheduled at ${ms}ms`); - timers.delete(entry[0]); - entry[1].fn(); - for (let i = 0; i < 100; i++) await Promise.resolve(); - }; - const restore = installFakeWindow(win); - const tauri = installEchoTauri("pk-collide3"); - mock.method(relayClient, "fetchEvents", () => { - fetchCalls++; - if (fetchCalls === 1) return Promise.resolve([]); - if (fetchCalls === 2) return Promise.resolve([]); - return Promise.resolve(storedHead); - }); - mock.method(relayClient, "publishEvent", (event) => { - publishCalls++; - storedHead = [ - tauri.mintHead( - makeCollisionWinnerStore(), - event.created_at, - "0-peer-winner", - ), - ]; - return Promise.resolve(); - }); - try { - const manager = new SyncManager("pk-collide3", RELAY); - const adopted = []; - manager.setOnRemoteAdopted((r) => adopted.push(r.eventId)); - manager[publishMethod](makeCollisionStoreA()); - await fireDelay(2000); - for (let i = 0; i < 100; i++) await Promise.resolve(); - await fireDelay(2000); - for (let i = 0; i < 100; i++) await Promise.resolve(); - assert.equal( - publishCalls, - 1, - "retry does not republish above the winner", - ); - assert.equal(adopted.length, 1, "retry adopts the peer winner"); - assert.equal( - manager.getPendingStore(), - null, - "pending edit is cleared by the adopt", - ); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); - - // ─── Same-second collision: second edit queued during confirmation survives ─ - - test(`${label}: same-second collision: second edit queued during confirmation survives`, async () => { - let fetchCalls = 0; - let releaseConfirmation = null; - let publishCalls = 0; - let storedHead = []; - const storage = new Map(); - const timers = new Map(); - let nextId = 1; - const win = { - localStorage: { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - }, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, ms }); - return id; - }, - clearTimeout: (id) => timers.delete(id), - }; - const fireDelay = async (ms) => { - const entry = [...timers.entries()].find(([, v]) => v.ms === ms); - assert.ok(entry, `expected a timer scheduled at ${ms}ms`); - timers.delete(entry[0]); - entry[1].fn(); - for (let i = 0; i < 100; i++) await Promise.resolve(); - }; - const restore = installFakeWindow(win); - const tauri = installEchoTauri("pk-collide2"); - // fetchEvents call sequence: - // 1 — edit-1 pre-publish fetch (empty → edit-1 publishes) - // 2 — edit-1 confirmation (paused; edit-2 queued during pause, then - // released with peer winner so adoptRemote fires with stale gen) - // 3 — edit-2 pre-publish fetch (peer winner → publishBaseline fold → - // edit-2 publishes above winner) - // 4 — edit-2 confirmation (edit-2's own event → confirmed) - mock.method(relayClient, "fetchEvents", () => { - fetchCalls++; - if (fetchCalls === 1) return Promise.resolve([]); // edit-1 pre-pub: empty - if (fetchCalls === 2) - return new Promise((res) => { - releaseConfirmation = () => res(storedHead); - }); // edit-1 confirmation: paused until second edit is queued - return Promise.resolve(storedHead); - }); - mock.method(relayClient, "publishEvent", (event) => { - publishCalls++; - if (publishCalls === 1) { - // Edit-1 ACK resolves; relay retained the peer's same-second blob. - // Minted through the echo seam so it decrypts. Explicit id "0-peer-winner" - // is lexicographically lower than our attempted evt-N, so - // remoteAdvancedSince returns true for edit-2's baseline check and the - // foldSupersedingAttemptWinner repair path is exercised. - storedHead = [ - tauri.mintHead( - makeCollisionWinnerStore(), - event.created_at, - "0-peer-winner", - ), - ]; - return Promise.resolve(); - } - // Edit-2 publishes above the winner and is retained by the relay. - // Storing the actual signed event so confirmRetainedHead returns - // { kind: "confirmed" } and discardPending fires rather than adoptRemote. - storedHead = [event]; - return Promise.resolve(); - }); - try { - const manager = new SyncManager("pk-collide2", RELAY); - const adopted = []; - manager.setOnRemoteAdopted((r) => adopted.push(r)); - - // Queue edit-1 and let its debounce fire. ACK resolves immediately, but - // the confirmation fetch pauses at fetchCalls===2. - manager[publishMethod](makeCollisionStoreA()); - await fireDelay(2000); // debounce → fc=1 (empty) → publish → fc=2 (confirmation hangs) - // releaseConfirmation is set synchronously when fc=2 is called; the while - // loop is a safety net for the rare case the chain hasn't flushed yet. - while (releaseConfirmation === null) await Promise.resolve(); - - // Queue edit-2 AFTER the ACK but BEFORE confirmation resolves. - manager[publishMethod](makeCollisionStoreSnd()); - - // Release confirmation with the peer winner. adoptRemote fires stale-gen - // (gen=1 vs pendingGeneration=2) and returns early without clearing pending. - // foldSupersedingAttemptWinner repairs edit-2's poisoned baseline so its - // pre-publish fetch publishes above the true retained head rather than - // adopting it away. - releaseConfirmation(); - for (let i = 0; i < 100; i++) await Promise.resolve(); - - // Stale-gen adopt does not fire onRemoteAdopted and does not clear pending. - assert.equal( - adopted.length, - 0, - "stale-gen adopt must not fire onRemoteAdopted for the newer pending edit", - ); - assert.notEqual( - manager.getPendingStore(), - null, - "edit-2 must still be pending after the stale-gen adopt", - ); - - // Fire edit-2's debounce. Its pre-publish fetch sees the peer winner; - // remoteAdvancedSince returns true (0-peer-winner < evt-N) but - // foldSupersedingAttemptWinner already advanced publishBaseline to the - // winner during the stale-gen adopt, so the repair fold is applied and - // edit-2 publishes above the winner rather than adopting it. - await fireDelay(2000); - for (let i = 0; i < 100; i++) await Promise.resolve(); - - assert.equal(publishCalls, 2, "edit-2 publishes above the peer winner"); - assert.equal( - adopted.length, - 0, - "edit-2 must not be adopted away — no phantom-baseline poisoning", - ); - assert.equal( - manager.getPendingStore(), - null, - "edit-2 clears via its own confirmed publish", - ); - assert.equal( - readOutbox("pk-collide2", RELAY), - null, - "edit-2's durable outbox is cleared on confirmation", - ); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); - - // ─── Same-second collision: loser adopts the winner, next edit survives ─── - - test(`${label}: same-second collision: loser adopts the winner and its next edit survives`, async () => { - let fetchCalls = 0; - let publishCalls = 0; - let storedHead = []; - const storage = new Map(); - const timers = new Map(); - let nextId = 1; - const win = { - localStorage: { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - }, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, ms }); - return id; - }, - clearTimeout: (id) => timers.delete(id), - }; - const fireDelay = async (ms) => { - const entry = [...timers.entries()].find(([, v]) => v.ms === ms); - assert.ok(entry, `expected a timer scheduled at ${ms}ms`); - timers.delete(entry[0]); - entry[1].fn(); - for (let i = 0; i < 100; i++) await Promise.resolve(); - }; - const restore = installFakeWindow(win); - const tauri = installEchoTauri("pk-loser"); - mock.method(relayClient, "fetchEvents", () => { - fetchCalls++; - if (fetchCalls === 1) return Promise.resolve([]); - if (fetchCalls === 2) return Promise.resolve(storedHead); // confirmation → peer winner retained - return Promise.resolve(storedHead); - }); - mock.method(relayClient, "publishEvent", (event) => { - publishCalls++; - if (publishCalls === 1) { - // First publish: relay retained the peer's same-second winner instead. - storedHead = [ - tauri.mintHead( - makeCollisionWinnerStore(), - event.created_at, - "0-peer-winner", - ), - ]; - } - return Promise.resolve(); - }); - try { - const manager = new SyncManager("pk-loser", RELAY); - const adopted = []; - manager.setOnRemoteAdopted((r) => adopted.push(r)); - - manager[publishMethod](makeCollisionStoreLsr()); - await fireDelay(2000); // publishes, confirmation returns peer winner → adopt - for (let i = 0; i < 100; i++) await Promise.resolve(); - - assert.equal(adopted.length, 1, "loser must adopt the peer winner"); - assert.equal( - manager.getPendingStore(), - null, - "pending cleared after adopt", - ); - - // A further edit after the adopt must succeed (loser advances past winner). - manager[publishMethod](makeCollisionStoreA()); - await fireDelay(2000); - for (let i = 0; i < 100; i++) await Promise.resolve(); - - assert.equal( - publishCalls, - 2, - "the edit after the adopt must publish successfully", - ); - manager.destroy(); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); - - // ─── live-sub: undecryptable event advances watermark before decrypt ─────── - // NOTE: this test uses subscribeLive and fires a live-event callback that - // spawns a dangling async chain (void decryptAndParse().then(...)). Placing - // it last in the suite keeps those trailing microtasks from leaking into - // timer-based tests that follow it. - - test(`${label}: 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 SyncManager("pk-live", RELAY); - assert.equal( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:${watermarkLane}:pk-live:${RELAY_KEY}`, - ), - null, - "watermark starts absent", - ); - await manager[subscribeMethod](() => {}); - 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( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:${watermarkLane}:pk-live:${RELAY_KEY}`, - ) ?? "0", - ) >= 1700005555, - "live undecryptable event must advance the watermark before decrypt is attempted", - ); + assertPending(manager); manager.destroy(); } finally { restore(); @@ -945,3 +180,535 @@ export function runWholeBlobSyncSuite({ } }); } + +// ─── Adopt-winner / local-winner ───────────────────────────────────────── + +test("adopt-winner: newer remote head at pre-publish adopts remote and skips publish", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-lww", + content: "good-cipher", + created_at: 200, + id: "evt-remote", + }, + ]), + ); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock(decryptPayload); + try { + const manager = new ChannelSectionSyncManager("pk-lww", RELAY); + const adopted = []; + manager.setOnRemoteAdopted((r) => adopted.push(r)); + manager.publishSections(nonEmptyStore()); + assert.ok( + readChannelSectionsOutbox("pk-lww", RELAY) !== null, + "edit must be persisted to the durable outbox", + ); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal( + publishCalls.length, + 0, + "must not publish when a newer remote head wins LWW", + ); + assert.equal(adopted.length, 1, "adopt sink must receive the remote"); + assert.ok( + adopted[0].store.sections.some( + (s) => s.id === "remote-section-from-relay", + ), + "adopted store must be the remote content", + ); + assert.equal(manager.getPendingStore(), null, "pending must be cleared"); + assert.equal( + readChannelSectionsOutbox("pk-lww", RELAY), + null, + "outbox must be cleared on adopt", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +test("adopt-winner: local edit at/ahead of head publishes and clears outbox", async () => { + let storedHead = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (event) => { + publishCalls.push(event); + storedHead = [event]; + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installEchoTauri("pk-win"); + try { + const manager = new ChannelSectionSyncManager("pk-win", RELAY); + manager.publishSections(nonEmptyStore()); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal(publishCalls.length, 1, "local edit must be published"); + assert.equal( + readChannelSectionsOutbox("pk-win", RELAY), + null, + "outbox must be cleared once the edit is confirmed retained", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// ─── Timestamp clamp ────────────────────────────────────────────────────── + +test("timestamp clamp: published createdAt stays inside the relay future window", async () => { + const nowSecs = Math.floor(Date.now() / 1000); + const farFutureHead = nowSecs + 3_600; + let call = 0; + mock.method(relayClient, "fetchEvents", () => { + call++; + return Promise.resolve([ + { + pubkey: "pk-clamp", + content: "good-cipher", + created_at: call === 1 ? farFutureHead : 0, + id: "evt-clamp", + }, + ]); + }); + let signedCreatedAt = null; + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock(emptyDecryptPayload); + mock.method(relayClient, "publishEvent", (evt) => { + signedCreatedAt = evt.created_at; + return Promise.resolve(); + }); + try { + const manager = new ChannelSectionSyncManager("pk-clamp", RELAY); + await manager.fetchRemoteSections(); + manager.publishSections(nonEmptyStore()); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.ok(signedCreatedAt !== null, "publish must have been attempted"); + assert.ok( + signedCreatedAt <= Math.floor(Date.now() / 1000) + 840, + `createdAt must be clamped inside the future window — got ${signedCreatedAt}`, + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// ─── Unreadable head: retain, never overwrite ───────────────────────────── + +test("unreadable head (decrypt failure) retains the pending edit and retries, never publishing", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-undec", + content: "bad-cipher", + created_at: 500, + id: "evt-undec", + }, + ]), + ); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelSectionSyncManager("pk-undec", RELAY); + manager.publishSections(nonEmptyStore()); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal( + publishCalls.length, + 0, + "must not publish over a head we could not read", + ); + assert.ok( + manager.getPendingStore() !== null, + "unreadable head must retain the pending edit", + ); + assert.ok( + readChannelSectionsOutbox("pk-undec", RELAY) !== null, + "durable outbox must survive an unreadable head", + ); + assert.ok(fw._hasTimer(), "a retry must be scheduled"); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// ─── Failed pre-publish fetch: retain, never publish ────────────────────── + +test("failed pre-publish fetch retains the pending edit and retries, never publishing", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("socket timeout")), + ); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelSectionSyncManager("pk-fetchfail", RELAY); + manager.publishSections(nonEmptyStore()); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal( + publishCalls.length, + 0, + "must not publish when the pre-publish fetch failed", + ); + assert.ok( + manager.getPendingStore() !== null, + "a failed fetch must retain the pending edit", + ); + assert.ok( + readChannelSectionsOutbox("pk-fetchfail", RELAY) !== null, + "durable outbox must survive a failed fetch", + ); + assert.ok(fw._hasTimer(), "a retry must be scheduled"); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// ─── Overlapping publishes ───────────────────────────────────────────────── + +test("overlapping publishes: older completion does not erase a newer queued edit", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + let releaseFirst = null; + let publishCalls = 0; + mock.method(relayClient, "publishEvent", () => { + publishCalls++; + if (publishCalls === 1) { + return new Promise((res) => { + releaseFirst = res; + }); + } + return Promise.resolve(); + }); + const { timers, fireDelay, restore } = makeTimerBed(); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelSectionSyncManager("pk-overlap", RELAY); + const storeA = makeStore([{ id: "a", name: "A", order: 0 }]); + const storeB = makeStore([{ id: "b", name: "B", order: 0 }]); + manager.publishSections(storeA); + await fireDelay(2000); + while (releaseFirst === null) await Promise.resolve(); + + manager.publishSections(storeB); + assert.ok( + manager.getPendingStore()?.sections?.[0]?.id === "b", + "B is now the pending edit", + ); + assert.ok( + readChannelSectionsOutbox("pk-overlap", RELAY)?.store?.sections?.[0] + ?.id === "b", + "outbox holds B", + ); + + releaseFirst(); + for (let i = 0; i < 10; i++) await Promise.resolve(); + + assert.ok( + manager.getPendingStore()?.sections?.[0]?.id === "b", + "older completion must leave B pending", + ); + assert.ok( + readChannelSectionsOutbox("pk-overlap", RELAY) !== null, + "older completion must leave B's outbox intact", + ); + assert.ok( + [...timers.values()].some((t) => t.ms === 2000), + "B's debounce timer must survive so it still publishes", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// ─── Live remote during debounce is adopted at pre-publish ──────────────── + +test("live remote during debounce is adopted at pre-publish, not overwritten", async () => { + let liveCallback = null; + mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { + liveCallback = onEvent; + return Promise.resolve(async () => {}); + }); + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-live-deb", + content: "good-cipher", + created_at: 500, + id: "evt-live", + }, + ]), + ); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const liveRemoteDecryptPayload = JSON.stringify( + makeStore([{ id: "remote-during-debounce", name: "Remote", order: 0 }]), + ); + const { fireDelay, restore } = makeTimerBed(); + const tauri = installTauriMock(liveRemoteDecryptPayload); + try { + const manager = new ChannelSectionSyncManager("pk-live-deb", RELAY); + const adopted = []; + manager.setOnRemoteAdopted((r) => adopted.push(r)); + await manager.subscribeToSections(() => {}); + manager.publishSections( + makeStore([{ id: "local", name: "Local", order: 0 }]), + ); + liveCallback({ + pubkey: "pk-live-deb", + content: "good-cipher", + created_at: 500, + id: "evt-live", + }); + for (let i = 0; i < 50; i++) await Promise.resolve(); + await fireDelay(2000); + assert.equal( + publishCalls.length, + 0, + "must not publish over a remote that became head during the debounce", + ); + assert.equal(adopted.length, 1, "the newer remote must be adopted"); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// ─── Same-second collision: second edit queued during confirmation survives ─ + +test("same-second collision: second edit queued during confirmation survives", async () => { + let fetchCalls = 0; + let releaseConfirmation = null; + let publishCalls = 0; + let storedHead = []; + const { fireDelay, restore } = makeTimerBed(); + const tauri = installEchoTauri("pk-collide2"); + mock.method(relayClient, "fetchEvents", () => { + fetchCalls++; + if (fetchCalls === 1) return Promise.resolve([]); + if (fetchCalls === 2) + return new Promise((res) => { + releaseConfirmation = () => res(storedHead); + }); + return Promise.resolve(storedHead); + }); + const winnerStore = makeStore([{ id: "peer", name: "Peer", order: 0 }]); + mock.method(relayClient, "publishEvent", (event) => { + publishCalls++; + if (publishCalls === 1) { + storedHead = [ + tauri.mintHead(winnerStore, event.created_at, "0-peer-winner"), + ]; + return Promise.resolve(); + } + storedHead = [event]; + return Promise.resolve(); + }); + try { + const manager = new ChannelSectionSyncManager("pk-collide2", RELAY); + const adopted = []; + manager.setOnRemoteAdopted((r) => adopted.push(r)); + + manager.publishSections( + makeStore([{ id: "mine", name: "Mine", order: 0 }]), + ); + await fireDelay(2000); + while (releaseConfirmation === null) await Promise.resolve(); + + manager.publishSections( + makeStore([{ id: "second", name: "Second", order: 0 }]), + ); + + releaseConfirmation(); + for (let i = 0; i < 100; i++) await Promise.resolve(); + + assert.equal( + adopted.length, + 0, + "stale-gen adopt must not fire onRemoteAdopted for the newer pending edit", + ); + assert.notEqual( + manager.getPendingStore(), + null, + "edit-2 must still be pending after the stale-gen adopt", + ); + + await fireDelay(2000); + for (let i = 0; i < 100; i++) await Promise.resolve(); + + assert.equal(publishCalls, 2, "edit-2 publishes above the peer winner"); + assert.equal( + adopted.length, + 0, + "edit-2 must not be adopted away — no phantom-baseline poisoning", + ); + assert.equal( + manager.getPendingStore(), + null, + "edit-2 clears via its own confirmed publish", + ); + assert.equal( + readChannelSectionsOutbox("pk-collide2", RELAY), + null, + "edit-2's durable outbox is cleared on confirmation", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// ─── Same-second collision: loser adopts the winner and its next edit survives ─ + +test("same-second collision: loser adopts the winner and its next edit survives", async () => { + let fetchCalls = 0; + let publishCalls = 0; + let storedHead = []; + const { fireDelay, restore } = makeTimerBed(); + const tauri = installEchoTauri("pk-loser"); + mock.method(relayClient, "fetchEvents", () => { + fetchCalls++; + if (fetchCalls === 1) return Promise.resolve([]); + if (fetchCalls === 2) return Promise.resolve(storedHead); + return Promise.resolve(storedHead); + }); + const winnerStore = makeStore([{ id: "peer", name: "Peer", order: 0 }]); + mock.method(relayClient, "publishEvent", (event) => { + publishCalls++; + if (publishCalls === 1) { + storedHead = [ + tauri.mintHead(winnerStore, event.created_at, "0-peer-winner"), + ]; + } + return Promise.resolve(); + }); + try { + const manager = new ChannelSectionSyncManager("pk-loser", RELAY); + const adopted = []; + manager.setOnRemoteAdopted((r) => adopted.push(r)); + + manager.publishSections( + makeStore([{ id: "loser", name: "Loser", order: 0 }]), + ); + await fireDelay(2000); + for (let i = 0; i < 100; i++) await Promise.resolve(); + + assert.equal(adopted.length, 1, "loser must adopt the peer winner"); + assert.equal( + manager.getPendingStore(), + null, + "pending cleared after adopt", + ); + + manager.publishSections( + makeStore([{ id: "mine", name: "Mine", order: 0 }]), + ); + await fireDelay(2000); + for (let i = 0; i < 100; i++) await Promise.resolve(); + + assert.equal( + publishCalls, + 2, + "the edit after the adopt must publish successfully", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// ─── live-sub: undecryptable event advances watermark before decrypt ─────── + +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( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:${watermarkLane}: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", + }); + await new Promise((r) => setTimeout(r, 0)); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:${watermarkLane}:pk-live:${RELAY_KEY}`, + ) ?? "0", + ) >= 1700005555, + "live undecryptable event must advance the watermark before decrypt is attempted", + ); + manager.destroy(); + } finally { + restore(); + mock.reset(); + } +});