diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs index 4a0a5250fb..8bc15f7550 100644 --- a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs @@ -1,17 +1,25 @@ // 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. +// storage key prefix, id projection — catches copy/paste field miswiring — +// PLUS the duplicated mutes algebra (mergeStores, boundMuteStore, +// isMutesStoreSubsumedBy) that is NOT exercised by the stars-based shared suite. import assert from "node:assert/strict"; import test from "node:test"; import { - parseMutePayload, + MAX_CHANNEL_MUTE_ENTRIES, + boundMuteStore, + isMutesStoreSubsumedBy, + mergeStores, mutedChannelIdsFromStore, + parseMutePayload, storageKey, } from "./channelMutesStorage.ts"; +// ─── Wire-contract: value field, key prefix, id projection ─────────────────── + test("mutes adapter: value field is 'muted', key prefix is buzz-channel-mutes.v1", () => { const raw = { version: 1, @@ -52,3 +60,117 @@ test("mutes adapter: wrong value field (starred) is rejected by parser", () => { "starred entry filtered as malformed", ); }); + +// ─── Duplicated mutes algebra: mergeStores order ───────────────────────────── +// These laws hold in channelStarsStorage too (exercised by mergeLaneStorage.shared), +// but the implementations are independent — a mute-specific copy-paste error +// cannot be caught by the stars suite. + +function muteStore(channels) { + return { version: 1, channels }; +} +function muteEntry(muted, updatedAt, rev = 0) { + return { muted, updatedAt, rev }; +} + +for (const { title, a, b, expected } of [ + { + title: "later updatedAt wins regardless of muted value", + a: muteStore({ ch: muteEntry(true, 100, 0) }), + b: muteStore({ ch: muteEntry(false, 200, 0) }), + expected: { ch: muteEntry(false, 200, 0) }, + }, + { + title: "equal updatedAt: higher rev wins", + a: muteStore({ ch: muteEntry(true, 100, 1) }), + b: muteStore({ ch: muteEntry(false, 100, 2) }), + expected: { ch: muteEntry(false, 100, 2) }, + }, + { + title: "full tie (equal updatedAt and rev): muted=true wins", + a: muteStore({ ch: muteEntry(false, 100, 1) }), + b: muteStore({ ch: muteEntry(true, 100, 1) }), + expected: { ch: muteEntry(true, 100, 1) }, + }, + { + title: "old-build rev=0 loses to new-build rev=1 at same second", + a: muteStore({ ch: muteEntry(true, 100, 0) }), + b: muteStore({ ch: muteEntry(false, 100, 1) }), + expected: { ch: muteEntry(false, 100, 1) }, + }, + { + title: "mergeStores is commutative", + a: muteStore({ ch: muteEntry(true, 100, 3) }), + b: muteStore({ ch: muteEntry(false, 200, 1) }), + expected: { ch: muteEntry(false, 200, 1) }, + }, +]) { + test(`mutes mergeStores: ${title}`, () => { + const ab = mergeStores(a, b); + const ba = mergeStores(b, a); + assert.deepEqual(ab.channels, expected, "a∪b"); + assert.deepEqual(ba.channels, expected, "b∪a (commutative)"); + }); +} + +// ─── Duplicated mutes algebra: boundMuteStore capacity + preserved key ─────── + +test("mutes boundMuteStore: caps at MAX_CHANNEL_MUTE_ENTRIES, retains newest", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES + 2 }, (_, i) => [ + `ch${String(i).padStart(4, "0")}`, + muteEntry(true, i, 0), + ]), + ); + const bounded = boundMuteStore({ version: 1, channels }); + assert.equal( + Object.keys(bounded.channels).length, + MAX_CHANNEL_MUTE_ENTRIES, + "capped at limit", + ); + // Oldest two (updatedAt 0 and 1) should be evicted. + assert.ok(!bounded.channels["ch0000"], "oldest evicted"); + assert.ok(!bounded.channels["ch0001"], "second-oldest evicted"); +}); + +test("mutes boundMuteStore: preserved key survives even when oldest", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES + 1 }, (_, i) => [ + `ch${String(i).padStart(4, "0")}`, + muteEntry(true, i, 0), + ]), + ); + const preservedKey = "ch0000"; // updatedAt=0, would normally be evicted + const bounded = boundMuteStore({ version: 1, channels }, preservedKey); + assert.ok(bounded.channels[preservedKey], "preserved key always retained"); + assert.equal( + Object.keys(bounded.channels).length, + MAX_CHANNEL_MUTE_ENTRIES, + "still capped at limit", + ); +}); + +// ─── Duplicated mutes algebra: subsumption ─────────────────────────────────── + +test("mutes isMutesStoreSubsumedBy: head subsumes candidate when it carries strictly newer state", () => { + const candidate = muteStore({ ch: muteEntry(true, 100, 1) }); + const head = muteStore({ ch: muteEntry(false, 200, 2) }); + assert.ok( + isMutesStoreSubsumedBy(candidate, head), + "head subsumes older candidate", + ); +}); + +test("mutes isMutesStoreSubsumedBy: head does NOT subsume candidate when candidate has newer state", () => { + const candidate = muteStore({ ch: muteEntry(true, 300, 5) }); + const head = muteStore({ ch: muteEntry(false, 200, 2) }); + assert.ok( + !isMutesStoreSubsumedBy(candidate, head), + "newer candidate not subsumed", + ); +}); + +test("mutes isMutesStoreSubsumedBy: head subsumes candidate when identical", () => { + const store = muteStore({ ch: muteEntry(true, 100, 1) }); + assert.ok(isMutesStoreSubsumedBy(store, store), "identical is subsumed"); +}); diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs index e3fdc92bc0..e3a71fb313 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs @@ -1,18 +1,131 @@ -// Thin wrapper — delegates all invariants to the shared merge-lane suite. -// Lane-specific differences: Manager class, outbox reader, watermark key, -// entry shape (muted field), and typed publish/getPending/fetchRemote methods. +// Compact wire-contract adapter for ChannelMuteSyncManager. +// Shared merge-lane sync engine invariants are covered by mergeLaneSync.shared.test.mjs, +// which runs directly against ChannelStarSyncManager (same engine, different config). +// This file asserts only the mutes-specific wiring: event kind, d-tag, serialized +// payload shape, parser delegation, subsumption, and typed API. -import { readChannelMutesOutbox } from "./channelMutesStorage.ts"; +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { + isMutesStoreSubsumedBy, + parseMutePayload, + readChannelMutesOutbox, + writeChannelMutesOutbox, +} from "./channelMutesStorage.ts"; import { ChannelMuteSyncManager } from "./channelMutesSync.ts"; -import { runMergeLaneSyncSuite } from "./mergeLaneSync.shared.test.mjs"; +import { + installEchoTauri, + installFakeWindow, + makeFakeWindow, +} from "./sidebarSyncTestHelpers.mjs"; -runMergeLaneSyncSuite({ - label: "mutes", - Manager: ChannelMuteSyncManager, - readOutbox: readChannelMutesOutbox, - watermarkKind: "channel-mutes", - makeEntry: (muted, updatedAt, rev) => ({ muted, updatedAt, rev }), - publish: (m, s) => m.publishMutes(s), - getPending: (m) => m.getPendingMuteStore(), - fetchRemote: (m) => m.fetchRemoteMutes(), +const RELAY = "wss://r.test"; + +test("mutes wire: kind=30078, d-tag='channel-mutes', payload has 'channels' not 'sections'", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + let publishedEvent = null; + mock.method(relayClient, "publishEvent", (evt) => { + publishedEvent = evt; + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installEchoTauri("pk-wire-mutes"); + try { + const m = new ChannelMuteSyncManager("pk-wire-mutes", RELAY); + m.publishMutes({ + version: 1, + channels: { ch1: { muted: true, updatedAt: 1, rev: 0 } }, + }); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.ok(publishedEvent !== null, "publish must have been called"); + assert.equal(publishedEvent.kind, 30078, "kind must be 30078"); + const dTag = publishedEvent.tags.find((t) => t[0] === "d")?.[1]; + assert.equal( + dTag, + "channel-mutes", + "d-tag must be 'channel-mutes' not 'channel-stars'", + ); + // Plaintext must serialize 'channels' field. + const plaintext = tauri.capturedPlaintext(); + const parsed = JSON.parse(plaintext); + assert.ok("channels" in parsed, "payload must have 'channels' field"); + assert.ok(!("sections" in parsed), "payload must not have 'sections'"); + m.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +test("mutes wire: parser delegates to parseMutePayload (muted field, rejects starred)", () => { + const valid = { + version: 1, + channels: { a: { muted: true, updatedAt: 1, rev: 0 } }, + }; + const parsed = parseMutePayload(valid); + assert.ok(parsed !== null, "valid mute payload parses"); + assert.equal(parsed.channels.a.muted, true, "muted field present"); + const starPayload = { + version: 1, + channels: { a: { starred: true, updatedAt: 1, rev: 0 } }, + }; + const rejected = parseMutePayload(starPayload); + assert.deepEqual( + rejected?.channels ?? {}, + {}, + "starred entry rejected by mutes parser", + ); +}); + +test("mutes wire: outbox/subsumption callbacks are wired to mutes storage", () => { + const store = { + version: 1, + channels: { ch: { muted: true, updatedAt: 100, rev: 1 } }, + }; + writeChannelMutesOutbox("pk-wiring-m", store, RELAY); + assert.ok( + readChannelMutesOutbox("pk-wiring-m", RELAY) !== null, + "writeChannelMutesOutbox wired", + ); + const head = { + version: 1, + channels: { ch: { muted: false, updatedAt: 200, rev: 2 } }, + }; + assert.ok( + isMutesStoreSubsumedBy(store, head), + "isMutesStoreSubsumedBy wired", + ); +}); + +test("mutes wire: typed API (publishMutes, getPendingMuteStore, fetchRemoteMutes, cancelPendingMutePublish)", () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const m = new ChannelMuteSyncManager("pk-api-m", RELAY); + assert.equal(m.getPendingMuteStore(), null, "no pending initially"); + m.publishMutes({ + version: 1, + channels: { c: { muted: true, updatedAt: 1, rev: 0 } }, + }); + assert.ok(m.getPendingMuteStore() !== null, "publishMutes sets pending"); + m.cancelPendingMutePublish(); + assert.ok( + typeof m.cancelPendingMutePublish === "function", + "cancelPendingMutePublish is callable", + ); + assert.ok( + typeof m.fetchRemoteMutes === "function", + "fetchRemoteMutes exists", + ); + m.destroy(); + } finally { + restore(); + mock.reset(); + } }); diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs index cee6ba5567..edc0a13284 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs @@ -33,62 +33,58 @@ function makeStore(overrides = {}) { ...overrides, }; } - function makeSection(overrides = {}) { return { id: "s1", name: "Test", order: 0, ...overrides }; } +// ── parseChannelSectionPayload ──────────────────────────────────────────────── + test("parseChannelSectionPayload: valid complete payload returns correct store", () => { const payload = { version: 1, sections: [{ id: "s1", name: "Work", order: 0 }], assignments: { chan1: "s1" }, }; - const result = parseChannelSectionPayload(payload); - assert.deepEqual(result, { - version: 1, - sections: [{ id: "s1", name: "Work", order: 0 }], - assignments: { chan1: "s1" }, - }); + assert.deepEqual(parseChannelSectionPayload(payload), payload); }); -test("parseChannelSectionPayload: null input returns null", () => { - assert.equal(parseChannelSectionPayload(null), null); -}); +for (const [title, input] of [ + ["null input", null], + ["string input", "string"], + ["number input", 42], +]) { + test(`parseChannelSectionPayload: ${title} returns null`, () => + assert.equal(parseChannelSectionPayload(input), null)); +} -test("parseChannelSectionPayload: non-object input returns null", () => { - assert.equal(parseChannelSectionPayload("string"), null); - assert.equal(parseChannelSectionPayload(42), null); - assert.equal(parseChannelSectionPayload(true), null); -}); - -test("parseChannelSectionPayload: unsupported version returns null (Carl P1.2)", () => { - // A future schema version must not be accepted as v1 state — must return null - // so the relay path triggers retain/retry rather than publishing over it. +// Carl P1.2: a future schema version must not be accepted as v1 state. +test("parseChannelSectionPayload: unsupported version returns null", () => { assert.equal( parseChannelSectionPayload({ version: 2, sections: [], assignments: {} }), null, - "version 2 must be rejected", + "version 2 rejected", ); assert.equal( parseChannelSectionPayload({ sections: [], assignments: {} }), null, - "missing version must be rejected", + "missing version rejected", ); assert.equal( parseChannelSectionPayload({ version: 0, sections: [], assignments: {} }), null, - "version 0 must be rejected", + "version 0 rejected", ); }); test("parseChannelSectionPayload: missing sections returns empty sections array", () => { - const result = parseChannelSectionPayload({ version: 1, assignments: {} }); - assert.deepEqual(result?.sections, []); + assert.deepEqual( + parseChannelSectionPayload({ version: 1, assignments: {} })?.sections, + [], + ); }); test("parseChannelSectionPayload: malformed section entries are filtered out", () => { - const payload = { + const result = parseChannelSectionPayload({ version: 1, sections: [ { id: 123, name: "Bad ID", order: 0 }, @@ -98,13 +94,12 @@ test("parseChannelSectionPayload: malformed section entries are filtered out", ( "string-entry", ], assignments: {}, - }; - const result = parseChannelSectionPayload(payload); + }); assert.deepEqual(result?.sections, []); }); test("parseChannelSectionPayload: valid sections with some invalid ones filters correctly", () => { - const payload = { + const result = parseChannelSectionPayload({ version: 1, sections: [ { id: "s1", name: "Valid", order: 0 }, @@ -112,48 +107,97 @@ test("parseChannelSectionPayload: valid sections with some invalid ones filters { id: "s2", name: "Also Valid", order: 2 }, ], assignments: {}, - }; - const result = parseChannelSectionPayload(payload); + }); assert.deepEqual(result?.sections, [ { id: "s1", name: "Valid", order: 0 }, { id: "s2", name: "Also Valid", order: 2 }, ]); }); -test("parseChannelSectionPayload: missing assignments returns empty assignments object", () => { - const result = parseChannelSectionPayload({ version: 1, sections: [] }); - assert.deepEqual(result?.assignments, {}); -}); +test("parseChannelSectionPayload: missing assignments returns empty assignments object", () => + assert.deepEqual( + parseChannelSectionPayload({ version: 1, sections: [] })?.assignments, + {}, + )); test("parseChannelSectionPayload: assignments with non-string values are filtered out", () => { - const payload = { + const result = parseChannelSectionPayload({ version: 1, - sections: [{ id: "s1", name: "Test", order: 0 }], + sections: [makeSection()], assignments: { chan1: "s1", chan2: 42, chan3: null, chan4: true }, - }; - const result = parseChannelSectionPayload(payload); + }); assert.deepEqual(result?.assignments, { chan1: "s1" }); }); test("parseChannelSectionPayload: orphaned assignments are stripped", () => { - const payload = { + const result = parseChannelSectionPayload({ version: 1, sections: [{ id: "s1", name: "Exists", order: 0 }], assignments: { chan1: "s1", chan2: "missing-section" }, - }; - const result = parseChannelSectionPayload(payload); + }); assert.deepEqual(result?.assignments, { chan1: "s1" }); }); -test("stripOrphanedAssignments: store with no orphans returns same reference", () => { - const store = makeStore({ - sections: [makeSection({ id: "s1" })], +test("parseChannelSectionPayload: preserves icon field; omits icon when empty or whitespace", () => { + const withIcon = parseChannelSectionPayload({ + version: 1, + sections: [{ id: "s1", name: "Work", icon: "🚀", order: 0 }], assignments: { chan1: "s1" }, }); - assert.equal(stripOrphanedAssignments(store), store); + assert.deepEqual(withIcon, { + version: 1, + sections: [{ id: "s1", name: "Work", icon: "🚀", order: 0 }], + assignments: { chan1: "s1" }, + }); + const emptyIcons = parseChannelSectionPayload({ + version: 1, + sections: [ + { id: "s1", name: "A", icon: "", order: 0 }, + { id: "s2", name: "B", icon: " ", order: 1 }, + { id: "s3", name: "C", order: 2 }, + ], + assignments: {}, + }); + assert.deepEqual(emptyIcons?.sections, [ + { id: "s1", name: "A", order: 0 }, + { id: "s2", name: "B", order: 1 }, + { id: "s3", name: "C", order: 2 }, + ]); }); -test("stripOrphanedAssignments: store with orphaned assignments returns new object without them", () => { +// ── stripOrphanedAssignments ────────────────────────────────────────────────── + +for (const [title, store, expectSame] of [ + [ + "no orphans returns same reference", + makeStore({ + sections: [makeSection({ id: "s1" })], + assignments: { chan1: "s1" }, + }), + true, + ], + [ + "all valid assignments returns same reference", + makeStore({ + sections: [ + makeSection({ id: "s1" }), + makeSection({ id: "s2", name: "B", order: 1 }), + ], + assignments: { chan1: "s1", chan2: "s2" }, + }), + true, + ], + [ + "empty store returns same reference", + makeStore({ sections: [], assignments: {} }), + true, + ], +]) { + test(`stripOrphanedAssignments: ${title}`, () => + assert.equal(stripOrphanedAssignments(store), store)); +} + +test("stripOrphanedAssignments: orphaned assignments returns new object without them", () => { const store = makeStore({ sections: [makeSection({ id: "s1" })], assignments: { chan1: "s1", chan2: "ghost" }, @@ -163,41 +207,24 @@ test("stripOrphanedAssignments: store with orphaned assignments returns new obje assert.deepEqual(result.assignments, { chan1: "s1" }); }); -test("stripOrphanedAssignments: store with all valid assignments returns same reference", () => { - const store = makeStore({ - sections: [ - makeSection({ id: "s1" }), - makeSection({ id: "s2", name: "B", order: 1 }), - ], - assignments: { chan1: "s1", chan2: "s2" }, - }); - assert.equal(stripOrphanedAssignments(store), store); -}); - -test("stripOrphanedAssignments: empty store returns same reference", () => { - const store = makeStore({ sections: [], assignments: {} }); - assert.equal(stripOrphanedAssignments(store), store); -}); +// ── boundChannelSectionsStore ───────────────────────────────────────────────── test("boundChannelSectionsStore caps sections and assignments", () => { - const sections = Array.from( - { length: MAX_CHANNEL_SECTIONS + 1 }, - (_, index) => makeSection({ id: `section-${index}`, order: index }), + const sections = Array.from({ length: MAX_CHANNEL_SECTIONS + 1 }, (_, i) => + makeSection({ id: `section-${i}`, order: i }), ); const assignments = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS + 1 }, (_, index) => [ - `channel-${index}`, + Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS + 1 }, (_, i) => [ + `channel-${i}`, "section-100", ]), ); - const bounded = boundChannelSectionsStore( makeStore({ sections, assignments }), ); - assert.equal(bounded.sections.length, MAX_CHANNEL_SECTIONS); assert.equal( - bounded.sections.some((section) => section.id === "section-0"), + bounded.sections.some((s) => s.id === "section-0"), false, ); assert.equal( @@ -207,216 +234,183 @@ test("boundChannelSectionsStore caps sections and assignments", () => { assert.equal(bounded.assignments["channel-0"], undefined); }); -test("writeChannelSectionsStore + readChannelSectionsStore: write then read returns same data", () => { - const pubkey = "pk-roundtrip"; +// ── writeChannelSectionsStore + readChannelSectionsStore ────────────────────── + +test("write + read: legacy (no relay) roundtrip", () => { const store = makeStore({ sections: [makeSection({ id: "s1", name: "Work", order: 0 })], assignments: { chan1: "s1" }, }); - const written = writeChannelSectionsStore(pubkey, store); - assert.equal(written, true); - const result = readChannelSectionsStore(pubkey); - assert.deepEqual(result, store); + assert.equal(writeChannelSectionsStore("pk-roundtrip", store), true); + assert.deepEqual(readChannelSectionsStore("pk-roundtrip"), store); }); -test("readChannelSectionsStore: non-existent key returns DEFAULT_STORE", () => { - const result = readChannelSectionsStore("pk-does-not-exist-xyz"); - assert.deepEqual(result, DEFAULT_STORE); -}); - -test("readChannelSectionsStore: corrupt JSON returns DEFAULT_STORE", () => { - const pubkey = "pk-corrupt"; - window.localStorage.setItem(storageKey(pubkey), "not-valid-json{{{"); - const result = readChannelSectionsStore(pubkey); - assert.deepEqual(result, DEFAULT_STORE); -}); - -test("readChannelSectionsStore: object with wrong version returns DEFAULT_STORE", () => { - const pubkey = "pk-wrong-version"; - window.localStorage.setItem( - storageKey(pubkey), - JSON.stringify({ version: 2, sections: [], assignments: {} }), - ); - const result = readChannelSectionsStore(pubkey); - assert.deepEqual(result, DEFAULT_STORE); -}); +for (const [title, pubkey, setupFn, expected] of [ + [ + "non-existent key returns DEFAULT_STORE", + "pk-does-not-exist-xyz", + () => {}, + DEFAULT_STORE, + ], + [ + "corrupt JSON returns DEFAULT_STORE", + "pk-corrupt", + (pk) => window.localStorage.setItem(storageKey(pk), "not-valid-json{{{"), + DEFAULT_STORE, + ], + [ + "wrong version returns DEFAULT_STORE", + "pk-wrong-version", + (pk) => + window.localStorage.setItem( + storageKey(pk), + JSON.stringify({ version: 2, sections: [], assignments: {} }), + ), + DEFAULT_STORE, + ], +]) { + test(`readChannelSectionsStore: ${title}`, () => { + setupFn(pubkey); + assert.deepEqual(readChannelSectionsStore(pubkey), expected); + }); +} test("writeChannelSectionsStore: returns false when setItem throws", () => { - const pubkey = "pk-throws"; const original = window.localStorage.setItem; window.localStorage.setItem = () => { throw new Error("storage full"); }; try { - const result = writeChannelSectionsStore(pubkey, makeStore()); - assert.equal(result, false); + assert.equal(writeChannelSectionsStore("pk-throws", makeStore()), false); } finally { window.localStorage.setItem = original; } }); -test("storageKey: returns expected format with pubkey", () => { +// ── storageKey ──────────────────────────────────────────────────────────────── + +test("storageKey: format and relay normalization", () => { assert.equal(storageKey("abc123"), "buzz-channel-sections.v1:abc123"); -}); - -// ─── Relay-scoped key tests ─────────────────────────────────────────────────── - -test("storageKey: with relayUrl includes normalized+encoded relay in key", () => { const relay = "wss://relay.example.com"; - const key = storageKey("pk1", relay); assert.equal( - key, + storageKey("pk1", relay), `buzz-channel-sections.v1:pk1:${encodeURIComponent(normalizeRelayUrl(relay))}`, ); + assert.equal(storageKey("pk1"), "buzz-channel-sections.v1:pk1"); + assert.equal(storageKey("pk1", undefined), "buzz-channel-sections.v1:pk1"); + assert.notEqual( + storageKey("pk1", "wss://relay-a.example.com"), + storageKey("pk1", "wss://relay-b.example.com"), + ); + assert.equal( + storageKey("pk1", "WSS://Relay.Example/"), + storageKey("pk1", "wss://relay.example"), + ); }); -test("storageKey: without relayUrl returns legacy pubkey-only key", () => { - assert.equal(storageKey("pk1"), "buzz-channel-sections.v1:pk1"); - assert.equal(storageKey("pk1", undefined), "buzz-channel-sections.v1:pk1"); -}); +// ── Relay-scoped key tests ──────────────────────────────────────────────────── -test("storageKey: two different relays produce different keys for same pubkey", () => { - const k1 = storageKey("pk1", "wss://relay-a.example.com"); - const k2 = storageKey("pk1", "wss://relay-b.example.com"); - assert.notEqual(k1, k2); -}); - -test("storageKey: equivalent relay URLs (case + trailing slash) map to the same key", () => { - const k1 = storageKey("pk1", "WSS://Relay.Example/"); - const k2 = storageKey("pk1", "wss://relay.example"); - assert.equal(k1, k2); -}); - -test("readChannelSectionsStore + writeChannelSectionsStore: scoped write/read roundtrip", () => { - const pubkey = "pk-relay-roundtrip"; - const relay = "wss://relay.example.com"; +test("scoped write/read roundtrip", () => { const store = makeStore({ sections: [makeSection({ id: "s1", name: "Work", order: 0 })], assignments: { chan1: "s1" }, }); - assert.equal(writeChannelSectionsStore(pubkey, store, relay), true); - const result = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result, store); + assert.equal( + writeChannelSectionsStore( + "pk-relay-roundtrip", + store, + "wss://relay.example.com", + ), + true, + ); + assert.deepEqual( + readChannelSectionsStore("pk-relay-roundtrip", "wss://relay.example.com"), + store, + ); }); -test("readChannelSectionsStore: scoped key is isolated from other relay's data", () => { - const pubkey = "pk-isolation"; - const relayA = "wss://relay-a.example.com"; - const relayB = "wss://relay-b.example.com"; +test("scoped key isolated from other relay's data", () => { const storeA = makeStore({ - sections: [makeSection({ id: "sa", name: "Relay A section", order: 0 })], + sections: [makeSection({ id: "sa", name: "A", order: 0 })], assignments: {}, }); - writeChannelSectionsStore(pubkey, storeA, relayA); - // Relay B should see empty store, not relay A's data. - const resultB = readChannelSectionsStore(pubkey, relayB); - assert.deepEqual(resultB, DEFAULT_STORE); + writeChannelSectionsStore( + "pk-isolation", + storeA, + "wss://relay-a.example.com", + ); + assert.deepEqual( + readChannelSectionsStore("pk-isolation", "wss://relay-b.example.com"), + DEFAULT_STORE, + ); }); -test("readChannelSectionsStore: migrates legacy unscoped data on first scoped read", () => { - const pubkey = "pk-migrate"; - const relay = "wss://relay-migrate.example.com"; +test("migrates legacy unscoped data on first scoped read; globally one-time", () => { const legacyStore = makeStore({ - sections: [makeSection({ id: "sl", name: "Legacy Section", order: 0 })], + sections: [makeSection({ id: "sl", name: "Legacy", order: 0 })], assignments: {}, }); - // Write under legacy (pubkey-only) key. - writeChannelSectionsStore(pubkey, legacyStore); - // First scoped read should migrate and return the legacy data. - const result = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result, legacyStore); - // Legacy key must be deleted after migration (globally one-time guarantee). - const legacyKey = storageKey(pubkey); - assert.equal(window.localStorage.getItem(legacyKey), null); - // Subsequent scoped reads should hit the scoped key directly. - const result2 = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result2, legacyStore); -}); - -test("readChannelSectionsStore: migration is globally one-time — relay B sees DEFAULT_STORE after relay A migrates", () => { - const pubkey = "pk-migrate-once"; - const relayA = "wss://relay-migrate-once-a.example.com"; - const relayB = "wss://relay-migrate-once-b.example.com"; - const legacyStore = makeStore({ - sections: [makeSection({ id: "sm", name: "Migrated Section", order: 0 })], - assignments: {}, - }); - // Write under legacy key then migrate via relay A. - writeChannelSectionsStore(pubkey, legacyStore); - readChannelSectionsStore(pubkey, relayA); // triggers migration, deletes legacy key + writeChannelSectionsStore("pk-migrate", legacyStore); + assert.deepEqual( + readChannelSectionsStore("pk-migrate", "wss://relay-migrate.example.com"), + legacyStore, + ); + assert.equal( + window.localStorage.getItem(storageKey("pk-migrate")), + null, + "legacy key deleted after migration", + ); + assert.deepEqual( + readChannelSectionsStore("pk-migrate", "wss://relay-migrate.example.com"), + legacyStore, + ); // Relay B must see DEFAULT_STORE — legacy data must not bleed in. - const resultB = readChannelSectionsStore(pubkey, relayB); - assert.deepEqual(resultB, DEFAULT_STORE); - // Relay B scoped key must not have been created. - const scopedBKey = storageKey(pubkey, relayB); - assert.equal(window.localStorage.getItem(scopedBKey), null); + writeChannelSectionsStore( + "pk-migrate-once", + makeStore({ + sections: [makeSection({ id: "sm", name: "M", order: 0 })], + assignments: {}, + }), + ); + readChannelSectionsStore( + "pk-migrate-once", + "wss://relay-migrate-once-a.example.com", + ); + assert.deepEqual( + readChannelSectionsStore( + "pk-migrate-once", + "wss://relay-migrate-once-b.example.com", + ), + DEFAULT_STORE, + ); }); -test("readChannelSectionsStore: migration only copies non-empty legacy stores", () => { - const pubkey = "pk-migrate-empty"; - const relay = "wss://relay-migrate-empty.example.com"; - // Write an explicitly empty store under the legacy key. - writeChannelSectionsStore(pubkey, DEFAULT_STORE); - // Empty legacy store should NOT be migrated — we get DEFAULT_STORE. - const result = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result, DEFAULT_STORE); +test("migration only copies non-empty legacy stores", () => { + writeChannelSectionsStore("pk-migrate-empty", DEFAULT_STORE); + assert.deepEqual( + readChannelSectionsStore( + "pk-migrate-empty", + "wss://relay-migrate-empty.example.com", + ), + DEFAULT_STORE, + ); }); -test("readChannelSectionsStore: scoped key takes precedence over legacy key after migration", () => { - const pubkey = "pk-precedence"; +test("scoped key takes precedence over legacy key after migration", () => { const relay = "wss://relay-precedence.example.com"; - const legacyStore = makeStore({ - sections: [makeSection({ id: "sold", name: "Old Section", order: 0 })], - assignments: {}, - }); + writeChannelSectionsStore( + "pk-precedence", + makeStore({ sections: [makeSection({ id: "sold" })], assignments: {} }), + ); const newStore = makeStore({ - sections: [makeSection({ id: "snew", name: "New Section", order: 0 })], + sections: [makeSection({ id: "snew", name: "New", order: 0 })], assignments: {}, }); - // Write legacy data then scoped data (simulates post-migration state). - writeChannelSectionsStore(pubkey, legacyStore); - writeChannelSectionsStore(pubkey, newStore, relay); - // Scoped read must return the scoped (newer) store, not the legacy one. - const result = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result, newStore); + writeChannelSectionsStore("pk-precedence", newStore, relay); + assert.deepEqual(readChannelSectionsStore("pk-precedence", relay), newStore); }); -// 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 = { - version: 1, - sections: [{ id: "s1", name: "Work", icon: "🚀", order: 0 }], - assignments: { chan1: "s1" }, - }; - const result = parseChannelSectionPayload(payload); - assert.deepEqual(result, { - version: 1, - sections: [{ id: "s1", name: "Work", icon: "🚀", order: 0 }], - assignments: { chan1: "s1" }, - }); -}); - -test("parseChannelSectionPayload: omits icon field when empty or whitespace", () => { - const payload = { - version: 1, - sections: [ - { id: "s1", name: "A", icon: "", order: 0 }, - { id: "s2", name: "B", icon: " ", order: 1 }, - { id: "s3", name: "C", order: 2 }, - ], - assignments: {}, - }; - const result = parseChannelSectionPayload(payload); - assert.deepEqual(result?.sections, [ - { id: "s1", name: "A", order: 0 }, - { id: "s2", name: "B", order: 1 }, - { id: "s3", name: "C", order: 2 }, - ]); -}); +// NOTE: The seven migration-failure cases test the claimLegacy state machine from +// mergeLaneStorage.shared.ts, exercised in full by mergeLaneStorage.shared.test.mjs. +// The sections adapter uses that shared implementation via import, so duplicate +// coverage adds no mutation surface. diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index e331f53e6f..a1161e9c91 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -6,160 +6,77 @@ import { ChannelSectionSyncManager } from "./channelSectionsSync.ts"; import { makeFakeWindow, installFakeWindow, + makeTimerBed, installTauriMock, installEchoTauri, } from "./sidebarSyncTestHelpers.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. +// Shared whole-blob engine invariants are covered by wholeBlobSync.shared.test.mjs. +// This file covers only sections-specific adapter and lane behavior. function makeSectionsStore(sections = []) { return { version: 1, sections, assignments: {} }; } - const RELAY = "wss://r.test"; -// 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 head payload ──────────────────────────────── + +// Mutation: removing `if (obj.version !== 1) return null` from +// parseChannelSectionPayload lets the manager fall through to a publish that +// overwrites state it cannot understand. +for (const { title, pubkey, payload } of [ + { + title: "malformed (non-JSON) head", + pubkey: "pk-badver", + payload: "not-json{", + }, + { + title: "unsupported head payload version (v2)", + pubkey: "pk-secver", + payload: JSON.stringify({ version: 2, sections: [], assignments: {} }), + }, +]) { + test(`${title} retains the pending edit, never publishing`, async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { pubkey, content: "good-cipher", created_at: 500, id: "evt" }, + ]), + ); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock(payload); + try { + const manager = new ChannelSectionSyncManager(pubkey, RELAY); + manager.publishSections( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal( + publishCalls.length, + 0, + "must not publish over unparseable head", + ); + assert.ok( + manager.getPendingStore() !== null, + "malformed head retains the pending edit", + ); + assert.ok(fw._hasTimer(), "retry scheduled"); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } + }); } -// ─── Sections-specific: malformed (non-JSON) head payload ───────────────────── +// ─── Sections-specific: serialized generations ──────────────────────────────── -// A malformed payload that decrypts to non-JSON is equally unreadable: -// `decryptAndParse` throws in `JSON.parse` and returns null, so the manager -// must `retain`, not overwrite. -test("malformed (non-JSON) head payload retains the pending edit, never publishing", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-badver", - content: "good-cipher", - created_at: 500, - id: "evt-badver", - }, - ]), - ); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installTauriMock("not-json{"); - try { - const manager = new ChannelSectionSyncManager("pk-badver", RELAY); - manager.publishSections( - makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), - ); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - publishCalls.length, - 0, - "must not publish over a head whose payload we could not parse", - ); - assert.ok( - manager.getPendingStore() !== null, - "malformed head must retain the pending edit", - ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// ─── Sections-specific: unsupported version (Carl P1.2 regression) ──────────── - -// Carl P1.2 regression (sections): an unsupported/future payload version -// arriving from the relay must trigger the retain/retry path, never publish -// over it. Sort, stars, and mutes all check `version !== 1` in their parsers; -// sections was the odd one out. `parseChannelSectionPayload` now rejects -// non-v1 payloads so `decryptAndParse` returns null and the manager retains. -// -// Mutation: removing `if (obj.version !== 1) return null` from -// `parseChannelSectionPayload` causes the parser to accept the v2 blob as v1 -// state, `decryptAndParse` returns a non-null result, and the manager falls -// through to a publish that overwrites authoritative state this client does not -// understand. -test("unsupported head payload version (sections) retains the pending edit, never publishing", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-secver", - content: "good-cipher", - created_at: 500, - id: "evt-secver", - }, - ]), - ); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installTauriMock( - JSON.stringify({ version: 2, sections: [], assignments: {} }), - ); - try { - const manager = new ChannelSectionSyncManager("pk-secver", RELAY); - manager.publishSections( - makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), - ); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - publishCalls.length, - 0, - "must not publish over a head whose payload version we do not support", - ); - assert.ok( - manager.getPendingStore() !== null, - "unsupported head must retain the pending edit", - ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// ─── Sections-specific: serialized generations ─────────────────────────────── - -// An older in-flight publish that completes after a newer edit is queued must -// NOT be mistaken for a remote that advanced past the newer edit's baseline. // Mutation: freezing the baseline at publishSections (before the prior cycle // completes) makes B see A as a post-baseline remote and adopt it. test("serialized generations: older completion does not make the newer edit adopt it", async () => { @@ -197,21 +114,17 @@ test("serialized generations: older completion does not make the newer edit adop assert.deepEqual( manager.getPendingStore()?.sections.map((s) => s.id), ["b"], - "B is the pending edit while A is in flight", + "B is pending", ); releaseFirst(); for (let i = 0; i < 100; i++) await Promise.resolve(); if ([...timers.values()].some((t) => t.ms === 2000)) await fireDelay(2000); - assert.deepEqual(adopted, [], "B must not adopt the older generation A"); - assert.equal( - publishCalls, - 2, - "B publishes above A rather than adopting A's accepted head", - ); + assert.deepEqual(adopted, [], "B must not adopt older generation A"); + assert.equal(publishCalls, 2, "B publishes above A"); assert.equal( manager.getPendingStore(), null, - "B's pending clears via its own confirmed publish", + "B clears via confirmed publish", ); manager.destroy(); } finally { @@ -221,9 +134,8 @@ test("serialized generations: older completion does not make the newer edit adop } }); -// A stale generation must never sign/publish after a newer edit is queued. -// Mutation: dropping the post-fetch generation re-check in doPublish lets the -// stale A continue to publishEvent. +// Mutation: dropping the post-fetch generation re-check in doPublish lets stale A +// continue to publishEvent. test("serialized generations: a stale generation aborts before publishing", async () => { let releaseFetch = null; const publishCalls = []; @@ -256,12 +168,12 @@ test("serialized generations: a stale generation aborts before publishing", asyn assert.equal( publishCalls.length, 0, - "stale generation A must not sign/publish after B was queued", + "stale A must not publish after B was queued", ); assert.deepEqual( manager.getPendingStore()?.sections.map((s) => s.id), ["b"], - "B remains the pending edit", + "B remains pending", ); manager.destroy(); } finally { @@ -273,9 +185,8 @@ test("serialized generations: a stale generation aborts before publishing", asyn // ─── Sections-specific: ambiguous ACK ──────────────────────────────────────── -// Installs a Tauri mock whose sign_event returns a caller-controlled id per -// call (so overlapping publishes get distinct event ids) and whose -// nip44_encrypt_to_self can be made to block. +// Blockable Tauri seam: decrypt echoes `payload`; sign returns `signIds[i]`; +// first encrypt call can be blocked. function installSeamTauriMock(payload, signIds) { const orig = globalThis.window?.__TAURI_INTERNALS__; if (typeof globalThis.window === "undefined") globalThis.window = {}; @@ -323,12 +234,24 @@ function installSeamTauriMock(payload, signIds) { }; } -// Ambiguous ACK: the relay accepts A but the client's ACK is lost. B was queued -// mid-flight. When B's pre-publish fetch returns A's accepted head, it must -// recognise A as OUR OWN accepted predecessor — fold it forward and publish -// above it — not adopt it and erase B. -// Mutation: dropping the ambiguousAttemptIds fold makes B classify A as a -// foreign advance and adopt. +const SEAM_PAYLOAD = JSON.stringify({ + version: 1, + sections: [{ id: "a", name: "A", order: 0 }], + assignments: {}, +}); +const SEAM_EVENT = (id) => ({ + id, + pubkey: "pk-ambiguous", + content: "good-cipher", + created_at: 500, + kind: 30078, + tags: [["d", "channel-sections"]], + sig: "s", +}); + +// Ambiguous ACK: relay accepts A but ACK is lost. B was queued mid-flight. B must +// recognise A as OUR OWN accepted predecessor and fold it forward, not adopt it. +// Mutation: dropping ambiguousAttemptIds fold makes B classify A as foreign and adopt. test("ambiguous ACK: an accepted-but-unacked A does not make B adopt and disappear", async () => { let releaseFirst = null; let publishCalls = 0; @@ -339,43 +262,16 @@ test("ambiguous ACK: an accepted-but-unacked A does not make B adopt and disappe if (publishCalls === 1) { return new Promise((_res, reject) => { releaseFirst = () => { - storedHead = [ - { - id: "event-a", - pubkey: "pk-ambiguous", - content: "good-cipher", - created_at: event.created_at, - kind: 30078, - tags: [["d", "channel-sections"]], - sig: "s", - }, - ]; + storedHead = [SEAM_EVENT("event-a")]; reject(new Error("Timed out publishing channel sections.")); }; }); } - storedHead = [ - { - id: "event-b", - pubkey: "pk-ambiguous", - content: "good-cipher", - created_at: event.created_at, - kind: 30078, - tags: [["d", "channel-sections"]], - sig: "s", - }, - ]; + storedHead = [SEAM_EVENT("event-b")]; return Promise.resolve(); }); const { timers, fireDelay, restore } = makeTimerBed(); - const tauri = installSeamTauriMock( - JSON.stringify({ - version: 1, - sections: [{ id: "a", name: "A", order: 0 }], - assignments: {}, - }), - ["event-a", "event-b"], - ); + const tauri = installSeamTauriMock(SEAM_PAYLOAD, ["event-a", "event-b"]); try { const manager = new ChannelSectionSyncManager("pk-ambiguous", RELAY); const adopted = []; @@ -392,11 +288,7 @@ test("ambiguous ACK: an accepted-but-unacked A does not make B adopt and disappe for (let i = 0; i < 100; i++) await Promise.resolve(); if ([...timers.values()].some((t) => t.ms === 2000)) await fireDelay(2000); for (let i = 0; i < 100; i++) await Promise.resolve(); - assert.deepEqual( - adopted, - [], - "B must not adopt A when A was accepted but its ACK was lost", - ); + assert.deepEqual(adopted, [], "B must not adopt A when A's ACK was lost"); assert.equal( publishCalls, 2, @@ -405,7 +297,7 @@ test("ambiguous ACK: an accepted-but-unacked A does not make B adopt and disappe assert.equal( manager.getPendingStore(), null, - "B's own successful publish clears its pending edit", + "B clears after successful publish", ); manager.destroy(); } finally { @@ -416,8 +308,7 @@ test("ambiguous ACK: an accepted-but-unacked A does not make B adopt and disappe }); // A foreign head (id NOT in our attempt set) must be adopted, not folded. -// Mutation: dropping the ambiguousAttemptIds id-guard (folding any advance) -// makes B erase the foreign winner. +// Mutation: dropping the id-guard (folding any advance) makes B erase the foreign winner. test("ambiguous ACK: a foreign head is adopted, not folded as our own", async () => { let publishCalls = 0; let fetchCalls = 0; @@ -445,14 +336,7 @@ test("ambiguous ACK: a foreign head is adopted, not folded as our own", async () return Promise.resolve(); }); const { timers, fireDelay, restore } = makeTimerBed(); - const tauri = installSeamTauriMock( - JSON.stringify({ - version: 1, - sections: [{ id: "a", name: "A", order: 0 }], - assignments: {}, - }), - ["event-a", "event-b"], - ); + const tauri = installSeamTauriMock(SEAM_PAYLOAD, ["event-a", "event-b"]); try { const manager = new ChannelSectionSyncManager("pk-reject", RELAY); const adopted = []; @@ -467,16 +351,8 @@ test("ambiguous ACK: a foreign head is adopted, not folded as our own", async () ); if ([...timers.values()].some((t) => t.ms === 2000)) await fireDelay(2000); for (let i = 0; i < 100; i++) await Promise.resolve(); - assert.deepEqual( - adopted, - ["foreign-winner"], - "B adopts the foreign head; its id is not one of our attempts", - ); - assert.equal( - manager.getPendingStore(), - null, - "adopt clears the pending edit", - ); + assert.deepEqual(adopted, ["foreign-winner"], "B adopts the foreign head"); + assert.equal(manager.getPendingStore(), null, "adopt clears pending"); manager.destroy(); } finally { tauri.restore(); @@ -485,12 +361,9 @@ test("ambiguous ACK: a foreign head is adopted, not folded as our own", async () } }); -// Pre-sign generation guard seam: the guard immediately before signing/publishing -// (post-encrypt) must be individually load-bearing. B arrives DURING A's -// encrypt/sign await — past the post-fetch guard — so only the pre-sign guard -// can stop A publishing a stale store. -// Mutation: dropping the gen re-check at the pre-sign guard lets stale A reach -// publishEvent after B was queued. +// Pre-sign generation guard: B arrives DURING A's encrypt/sign await — past the +// post-fetch guard — so only the pre-sign guard can stop A. +// Mutation: dropping the gen re-check at the pre-sign guard lets stale A reach publishEvent. test("serialized generations: a newer edit during encrypt/sign aborts the pre-sign publish", async () => { const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); @@ -518,13 +391,9 @@ test("serialized generations: a newer edit during encrypt/sign aborts the pre-si assert.equal( publishCalls.length, 1, - "only B publishes; stale A aborts at the pre-sign guard", - ); - assert.equal( - publishCalls[0].id, - "event-b", - "the surviving publish is B, not the stale A", + "only B publishes; stale A aborts at pre-sign guard", ); + assert.equal(publishCalls[0].id, "event-b", "the surviving publish is B"); manager.destroy(); } finally { tauri.restore(); diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index 21f8b2314c..89df6b1b75 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -11,21 +11,17 @@ import { installEchoTauri, } from "./sidebarSyncTestHelpers.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. +// Shared whole-blob engine invariants are covered by wholeBlobSync.shared.test.mjs. +// This file covers only sort-specific adapter and lane behavior. function makeStore(groups = {}) { return { version: 1, groups }; } - const RELAY = "wss://r.test"; -// ─── Sort-specific: unsupported head payload version ───────────────────────── - -// An unsupported/unparseable payload version is equally unreadable: it decrypts -// but `parseChannelSortPayload` rejects it, so the manager must `retain`, not -// overwrite. +// Mutation: removing `version !== 1` check from parseChannelSortPayload accepts +// the v2 blob as v1 state; the manager falls through to a publish that overwrites +// authoritative state. test("unsupported head payload version retains the pending edit, never publishing", async () => { mock.method(relayClient, "fetchEvents", () => Promise.resolve([ @@ -53,13 +49,13 @@ test("unsupported head payload version retains the pending edit, never publishin assert.equal( publishCalls.length, 0, - "must not publish over a head whose payload version we do not support", + "must not publish over unsupported version", ); assert.ok( manager.getPendingStore() !== null, - "unsupported head must retain the pending edit", + "unsupported head retains pending edit", ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); + assert.ok(fw._hasTimer(), "retry scheduled"); } finally { tauri.restore(); restore(); @@ -67,13 +63,6 @@ test("unsupported head payload version retains the pending edit, never publishin } }); -// ─── Sort-specific: reconnect keeps the frozen baseline ────────────────────── - -// The reconnect handler re-drives a pending edit through retryPendingPublish(), -// NOT the public publishSortPrefs(). A remote that advanced while the edit was -// pending must be adopted on reconnect, not published over: retryPendingPublish -// keeps the generation and the baseline frozen at queue time, so the pre-publish -// check still sees the head as advanced and adopts. // Mutation: reverting the reconnect handler to publishSortPrefs(pending) resets // the baseline to the just-fetched head, so the pre-publish check sees no // advancement and publishes the stale edit over the remote. @@ -114,17 +103,17 @@ test("reconnect adopts a remote that advanced while the edit was pending, never assert.equal( publishCalls.length, 0, - "must adopt the advanced remote, never publish the stale edit over it", + "must adopt the advanced remote, never publish", ); - assert.equal(adopted.length, 1, "the advanced remote must be adopted"); + assert.equal(adopted.length, 1, "advanced remote adopted"); assert.ok( REMOTE_KEY in adopted[0].store.groups, - "adopted store must be the remote content that won LWW", + "adopted store is the remote that won LWW", ); assert.equal( manager.getPendingStore(), null, - "the losing pending edit must be cleared on adopt", + "losing pending edit cleared on adopt", ); } finally { tauri.restore(); @@ -133,11 +122,6 @@ test("reconnect adopts a remote that advanced while the edit was pending, never } }); -// ─── Sort-specific: durable outbox resume ──────────────────────────────────── - -// An edit made <2s before quit (destroy inside the debounce) is persisted, and -// a fresh manager resuming from that outbox publishes it — the edit is not -// silently dropped at teardown. // Mutation: dropping writeChannelSortOutbox leaves the outbox null → no resume. test("durable outbox: edit destroyed inside the debounce resumes and publishes on remount", async () => { let storedHead = []; @@ -155,7 +139,7 @@ test("durable outbox: edit destroyed inside the debounce resumes and publishes o const m1 = new ChannelSortSyncManager("pk-resume", RELAY); m1.publishSortPrefs(makeStore({ channels: "recent" })); const persisted = readChannelSortOutbox("pk-resume", RELAY); - assert.ok(persisted !== null, "edit must be persisted before teardown"); + assert.ok(persisted !== null, "edit persisted before teardown"); m1.destroy(); assert.equal(publishCalls.length, 0, "destroy must not flush"); const m2 = new ChannelSortSyncManager("pk-resume", RELAY); @@ -166,7 +150,7 @@ test("durable outbox: edit destroyed inside the debounce resumes and publishes o assert.equal( readChannelSortOutbox("pk-resume", RELAY), null, - "outbox must be cleared once the resumed edit publishes", + "outbox cleared after publish", ); } finally { tauri.restore(); @@ -175,12 +159,7 @@ test("durable outbox: edit destroyed inside the debounce resumes and publishes o } }); -// ─── Sort-specific: failed publish retries the retained edit ───────────────── - -// A transient publish rejection keeps the pending edit and schedules a -// bounded-backoff retry that succeeds, rather than logging-and-dropping. -// Mutation: reverting scheduleRetry to a bare console.warn leaves pending null -// and never re-publishes. +// Mutation: reverting scheduleRetry to a bare console.warn leaves pending null. test("failed publish retries the retained edit without a later edit", async () => { let storedHead = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); @@ -199,19 +178,19 @@ test("failed publish retries the retained edit without a later edit", async () = manager.publishSortPrefs(makeStore({ channels: "recent" })); fw._fireTimer(); await new Promise((r) => setTimeout(r, 20)); - assert.equal(attempts, 1, "first publish attempt rejected"); + assert.equal(attempts, 1, "first publish rejected"); assert.ok( manager.getPendingStore() !== null, - "failed publish must retain the pending edit", + "failed publish retains pending edit", ); - assert.ok(fw._hasTimer(), "a bounded-backoff retry must be scheduled"); + assert.ok(fw._hasTimer(), "bounded-backoff retry scheduled"); fw._fireTimer(); await new Promise((r) => setTimeout(r, 20)); - assert.equal(attempts, 2, "retry must re-attempt the publish"); + assert.equal(attempts, 2, "retry re-attempts the publish"); assert.equal( manager.getPendingStore(), null, - "successful retry must clear the pending edit", + "successful retry clears pending", ); } finally { tauri.restore(); diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs index 6e30421424..49be2e155d 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs @@ -1,18 +1,131 @@ -// Thin wrapper — delegates all invariants to the shared merge-lane suite. -// Lane-specific differences: Manager class, outbox reader, watermark key, -// entry shape (starred field), and typed publish/getPending/fetchRemote methods. +// Compact wire-contract adapter for ChannelStarSyncManager. +// Shared merge-lane sync engine invariants (generation CAS, retry, bootstrap +// seed-guard, etc.) are covered by mergeLaneSync.shared.test.mjs, which runs +// directly against ChannelStarSyncManager. This file asserts only the +// stars-specific wiring that cannot be caught by a generic engine suite: +// event kind, d-tag, serialized payload shape, parser delegation, and typed API. -import { readChannelStarsOutbox } from "./channelStarsStorage.ts"; +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { + isStarsStoreSubsumedBy, + parseStarPayload, + readChannelStarsOutbox, + writeChannelStarsOutbox, +} from "./channelStarsStorage.ts"; import { ChannelStarSyncManager } from "./channelStarsSync.ts"; -import { runMergeLaneSyncSuite } from "./mergeLaneSync.shared.test.mjs"; +import { + installEchoTauri, + installFakeWindow, + makeFakeWindow, +} from "./sidebarSyncTestHelpers.mjs"; -runMergeLaneSyncSuite({ - label: "stars", - Manager: ChannelStarSyncManager, - readOutbox: readChannelStarsOutbox, - watermarkKind: "channel-stars", - makeEntry: (starred, updatedAt, rev) => ({ starred, updatedAt, rev }), - publish: (m, s) => m.publishStars(s), - getPending: (m) => m.getPendingStarStore(), - fetchRemote: (m) => m.fetchRemoteStars(), +const RELAY = "wss://r.test"; + +test("stars wire: kind=30078, d-tag='channel-stars', payload has 'channels' not 'sections'", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + let publishedEvent = null; + mock.method(relayClient, "publishEvent", (evt) => { + publishedEvent = evt; + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installEchoTauri("pk-wire-stars"); + try { + const m = new ChannelStarSyncManager("pk-wire-stars", RELAY); + m.publishStars({ + version: 1, + channels: { ch1: { starred: true, updatedAt: 1, rev: 0 } }, + }); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.ok(publishedEvent !== null, "publish must have been called"); + assert.equal(publishedEvent.kind, 30078, "kind must be 30078"); + const dTag = publishedEvent.tags.find((t) => t[0] === "d")?.[1]; + assert.equal(dTag, "channel-stars", "d-tag must be 'channel-stars'"); + // Plaintext must serialize 'channels', not 'sections' or 'groups'. + const plaintext = tauri.capturedPlaintext(); + const parsed = JSON.parse(plaintext); + assert.ok("channels" in parsed, "payload must have 'channels' field"); + assert.ok(!("sections" in parsed), "payload must not have 'sections'"); + assert.ok(!("groups" in parsed), "payload must not have 'groups'"); + m.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +test("stars wire: parser delegates to parseStarPayload (starred field, version guard)", () => { + // Stars parser accepts starred field, rejects muted field. + const valid = { + version: 1, + channels: { a: { starred: true, updatedAt: 1, rev: 0 } }, + }; + const parsed = parseStarPayload(valid); + assert.ok(parsed !== null, "valid star payload parses"); + assert.equal(parsed.channels.a.starred, true, "starred field present"); + const mutePayload = { + version: 1, + channels: { a: { muted: true, updatedAt: 1, rev: 0 } }, + }; + const rejected = parseStarPayload(mutePayload); + assert.deepEqual( + rejected?.channels ?? {}, + {}, + "muted entry rejected by stars parser", + ); +}); + +test("stars wire: outbox/subsumption callbacks are wired to stars storage", () => { + const store = { + version: 1, + channels: { ch: { starred: true, updatedAt: 100, rev: 1 } }, + }; + writeChannelStarsOutbox("pk-wiring", store, RELAY); + assert.ok( + readChannelStarsOutbox("pk-wiring", RELAY) !== null, + "writeChannelStarsOutbox wired", + ); + // Subsumption: head with same entry subsumes candidate. + const head = { + version: 1, + channels: { ch: { starred: false, updatedAt: 200, rev: 2 } }, + }; + assert.ok( + isStarsStoreSubsumedBy(store, head), + "isStarsStoreSubsumedBy wired", + ); +}); + +test("stars wire: typed API (publishStars, getPendingStarStore, fetchRemoteStars, cancelPendingStarPublish)", () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const m = new ChannelStarSyncManager("pk-api", RELAY); + assert.equal(m.getPendingStarStore(), null, "no pending initially"); + m.publishStars({ + version: 1, + channels: { c: { starred: true, updatedAt: 1, rev: 0 } }, + }); + assert.ok(m.getPendingStarStore() !== null, "publishStars sets pending"); + m.cancelPendingStarPublish(); + assert.ok( + typeof m.cancelPendingStarPublish === "function", + "cancelPendingStarPublish is callable", + ); + assert.ok( + typeof m.fetchRemoteStars === "function", + "fetchRemoteStars exists", + ); + m.destroy(); + } finally { + restore(); + mock.reset(); + } }); diff --git a/desktop/src/features/sidebar/lib/mergeLaneHook.shared.test.mjs b/desktop/src/features/sidebar/lib/mergeLaneHook.shared.test.mjs index 2745dfc68a..9106596c79 100644 --- a/desktop/src/features/sidebar/lib/mergeLaneHook.shared.test.mjs +++ b/desktop/src/features/sidebar/lib/mergeLaneHook.shared.test.mjs @@ -9,6 +9,7 @@ import assert from "node:assert/strict"; import { after, before, test } from "node:test"; import { JSDOM } from "jsdom"; +import { makeHookStubs } from "./sidebarSyncTestHelpers.mjs"; const dom = new JSDOM("", { url: "http://localhost", @@ -27,38 +28,8 @@ after(() => dom.window.close()); // Shared harness: stub the relay so no network/live/reconnect fires unless a // test installs its own live callback. -function stubRelay(relayClient, { live } = {}) { - const orig = { - fetchEvents: relayClient.fetchEvents, - subscribeLive: relayClient.subscribeLive, - subscribeToReconnects: relayClient.subscribeToReconnects, - }; - relayClient.fetchEvents = async () => []; - relayClient.subscribeLive = async (_f, cb) => { - if (live) live.cb = cb; - return async () => {}; - }; - relayClient.subscribeToReconnects = () => () => {}; - return () => Object.assign(relayClient, orig); -} +const { stubRelay } = makeHookStubs(); -/** - * 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. - * @param {string} cfg.entryValueField - "starred" or "muted" - * @param {string} cfg.idsField - "starredChannelIds" or "mutedChannelIds" - * @param {string} cfg.trueAction - "starChannel" or "muteChannel" - * @param {string} cfg.falseAction - "unstarChannel" or "unmuteChannel" - * @param {string} cfg.dTag - relay event d-tag ("channel-stars" or "channel-mutes") - * @param {string} cfg.outboxKeyPrefix - localStorage outbox key prefix - * @param {number} cfg.MAX_ENTRIES - capacity cap - * @param {Function} cfg.readStore - readChannel{Stars|Mutes}Store(pubkey, relay?) - * @param {Function} cfg.storageKey - storageKey(pubkey, relay?) - * @param {Function} cfg.useHook - the hook function - * @param {Function} cfg.makePayload - (channels) => JSON string for tauri decrypt - */ export function runMergeLaneHookSuite({ label, entryValueField, @@ -120,10 +91,8 @@ export function runMergeLaneHookSuite({ }); // 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. + // and same-second click sequence. 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"); @@ -210,43 +179,70 @@ export function runMergeLaneHookSuite({ } }); - // 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. + // Cross-window storage merge + outbox resume. + // (a) Cross-window storage event is max-merged into this window. + // (b) Click after merge mints above the observed peer high-water. + // (c) Bootstrap transfers resumed outbox to a fresh v2 key; persists while head + // does not subsume it; clears once a subsuming head is confirmed. // 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 () => { + // maxRevSeen; (c) dropping writeOwnOutbox or clearing on any retained head. + test(`${label}: cross-window merge, subsequent click mints above peer high-water, v2 outbox retained until subsumed`, async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); const { relayClient } = await import("@/shared/api/relayClient"); - const restore = stubRelay(relayClient); + // Relay starts empty (hold path — no head on first bootstrap fetch). + // fetchEvents is overridden per-phase below. + let fetchResult = []; + const restore = stubRelay(relayClient, {}); + relayClient.fetchEvents = async () => fetchResult; + 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). + const relayUrl = "wss://r"; + const encodedRelay = encodeURIComponent(relayUrl); + + // Pre-seed a resumed outbox record using the LEGACY key format so bootstrap + // picks it up via the legacy-key enumeration path. + const legacyOutboxKey = `${outboxKeyPrefix}:${pubkey}:${encodedRelay}`; window.localStorage.setItem( - outboxKey, + legacyOutboxKey, makePayload({ resumed: { [entryValueField]: true, updatedAt: 90, rev: 2 }, }), ); + let hook = null; try { await act(async () => { - hook = renderHook(() => useHook(pubkey, "wss://r")); + hook = renderHook(() => useHook(pubkey, relayUrl)); for (let i = 0; i < 40; i++) await Promise.resolve(); }); - // (c) Outbox retained until publish completes. + + // (c1) Bootstrap must have transferred the resumed intent to a fresh v2 + // write-once key (prefix:pubkey:relay:nonce:seq — more colons than legacy). + // The legacy key is never deleted; only a v2 key is mutation-valid proof. + const v2KeyPrefix = `${outboxKeyPrefix}:${pubkey}:${encodedRelay}:`; + const allKeys = Array.from( + { length: window.localStorage.length }, + (_, i) => window.localStorage.key(i), + ).filter((k) => k && k.startsWith(v2KeyPrefix)); + // A v2 own key has two more colon-separated segments (nonce + seq) beyond + // the legacy three-segment form. + const v2OwnKeys = allKeys.filter((k) => { + const segs = k.split(":").length; + // legacy = prefix:pubkey:encodedRelay (3+ segs but no nonce/seq) + // v2 own = prefix:pubkey:encodedRelay:nonce:seq (5+ segs) + return segs >= 5; + }); assert.ok( - window.localStorage.getItem(outboxKey) !== null, - "outbox retained during bootstrap", + v2OwnKeys.length > 0, + "bootstrap must have written a v2 nonce:seq own key — dropping writeOwnOutbox breaks this", ); // (a) Peer window writes a store with rev 12 and fires a storage event. window.localStorage.setItem( - storageKey(pubkey, "wss://r"), + storageKey(pubkey, relayUrl), makePayload({ shared: { [entryValueField]: true, updatedAt: 900, rev: 12 }, }), @@ -254,7 +250,7 @@ export function runMergeLaneHookSuite({ await act(async () => { window.dispatchEvent( new dom.window.StorageEvent("storage", { - key: storageKey(pubkey, "wss://r"), + key: storageKey(pubkey, relayUrl), }), ); for (let i = 0; i < 20; i++) await Promise.resolve(); @@ -267,15 +263,74 @@ export function runMergeLaneHookSuite({ // (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"); + const p = readStore(pubkey, relayUrl); assert.equal(p.channels.shared[entryValueField], false, "click applied"); assert.equal(p.channels.shared.updatedAt, 900, "held at peer high-water"); assert.equal(p.channels.shared.rev, 13, "rev = peer rev + 1"); + + // (c2) Non-subsuming retained head: v2 own key must remain. + // Return a head that does NOT carry the resumed channel's entry. + const origTauri = window.__TAURI_INTERNALS__; + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + if (cmd === "nip44_decrypt_from_self") + return Promise.resolve( + makePayload({ + other: { [entryValueField]: false, updatedAt: 1, rev: 0 }, + }), + ); + if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct-enc"); + if (cmd === "sign_event") + return Promise.resolve( + JSON.stringify({ + id: "evt-x", + pubkey, + content: "ct-enc", + created_at: 0, + kind: 30078, + tags: [["d", dTag]], + sig: "s", + }), + ); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }, + }; + fetchResult = [ + { + id: "non-subsuming-head", + pubkey, + content: "ct-nonsubsume", + created_at: 50, + kind: 30078, + tags: [["d", dTag]], + sig: "s", + }, + ]; + // Wait for a reconcile tick (bootstrap + reconcile share the fetch path). + for (let i = 0; i < 40; i++) await Promise.resolve(); + // Re-enumerate: the (b) click replaced the bootstrap v2 key with a fresh + // one (writeOwnOutbox always writes a new key and drops superseded own + // keys). Assert that at least one v2 own key exists — a non-subsuming + // retained head must NOT clear it. + const currentV2Keys = Array.from( + { length: window.localStorage.length }, + (_, i) => window.localStorage.key(i), + ).filter( + (k) => k && k.startsWith(v2KeyPrefix) && k.split(":").length >= 5, + ); + assert.ok( + currentV2Keys.length > 0, + "v2 own key must survive a non-subsuming retained head — clearing on any retained head breaks this", + ); + window.__TAURI_INTERNALS__ = origTauri; + hook.unmount(); } finally { cleanup(); Date.now = origDateNow; restore(); + // Clean up our seeded keys. + window.localStorage.removeItem(legacyOutboxKey); } }); } diff --git a/desktop/src/features/sidebar/lib/mergeLaneStorage.shared.test.mjs b/desktop/src/features/sidebar/lib/mergeLaneStorage.shared.test.mjs index 3cbdb65b1c..73fc7e6441 100644 --- a/desktop/src/features/sidebar/lib/mergeLaneStorage.shared.test.mjs +++ b/desktop/src/features/sidebar/lib/mergeLaneStorage.shared.test.mjs @@ -1,11 +1,10 @@ -// Authoritative merge-lane storage suite, run directly against +// Authoritative merge-lane storage suite — runs directly against // channelStarsStorage.ts (the canonical merge-lane implementation). // // 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. +// full claimLegacy state machine. Lane adapter files carry compact contracts +// that catch field/key/prefix wiring divergence without replaying this suite. import assert from "node:assert/strict"; import test from "node:test"; @@ -35,38 +34,32 @@ if (typeof globalThis.window === "undefined") { const storageKeyPrefix = "buzz-channel-stars.v1"; const MAX_ENTRIES = MAX_CHANNEL_STAR_ENTRIES; -const entryValueField = "starred"; - +const E = (v, updatedAt, rev) => ({ starred: v, updatedAt, rev }); +const S = (entry) => ({ version: 1, channels: { c: entry } }); function makeStore(channels = {}) { return { version: 1, channels }; } -const E = (v, updatedAt, rev) => ({ starred: v, updatedAt, rev }); -const S = (entry) => makeStore({ c: entry }); -// ── parsePayload ────────────────────────────────────────────────────────── +// ── parsePayload ────────────────────────────────────────────────────────────── -test("parseStarPayload: valid payload with channels returns store (rev preserved)", () => { +test("parseStarPayload: valid payload round-trips (rev preserved)", () => { const payload = { version: 1, - channels: { - "chan-1": E(true, 1000, 3), - "chan-2": E(false, 2000, 0), - }, + 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 = { + const result = parseStarPayload({ 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 = { +test("parseStarPayload: malformed rev (string/negative/fraction/NaN/unsafe-int) normalizes to 0", () => { + const result = parseStarPayload({ version: 1, channels: { str: { starred: true, updatedAt: 1, rev: "5" }, @@ -76,36 +69,23 @@ test("parseStarPayload: malformed rev (string / negative / non-integer / NaN / u 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"]) { + }); + 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, - ); -}); +for (const [title, input] of [ + ["missing version", { channels: { c: E(true, 1, 0) } }], + ["wrong version (2)", { version: 2, channels: {} }], + ["null input", null], + ["string input", "string"], + ["number input", 42], +]) { + test(`parseStarPayload: ${title} returns null`, () => + assert.equal(parseStarPayload(input), 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", () => { +test("parseStarPayload: malformed entries filtered; valid entry kept", () => { const result = parseStarPayload({ version: 1, channels: { @@ -120,7 +100,7 @@ test("parseStarPayload: malformed channel entries missing value field/updatedAt assert.deepEqual(result, makeStore({ valid: E(false, 500, 0) })); }); -test("parseStarPayload: NaN/Infinity/negative/unsafe updatedAt entries are filtered out", () => { +test("parseStarPayload: NaN/Infinity/negative/unsafe updatedAt entries filtered", () => { const result = parseStarPayload({ version: 1, channels: { @@ -140,89 +120,88 @@ test("parseStarPayload: empty channels / no channels key returns empty store", ( assert.deepEqual(parseStarPayload({ version: 1 }), makeStore()); }); -// ── mergeStores: tuple order ────────────────────────────────────────────── +// ── mergeStores ─────────────────────────────────────────────────────────────── -test("mergeStores: non-overlapping channels returns union", () => { - const result = mergeStores( - makeStore({ a: E(true, 100, 1) }), - makeStore({ b: E(false, 200, 1) }), - ); +test("mergeStores: non-overlapping channels returns union", () => assert.deepEqual( - result, + mergeStores( + makeStore({ a: E(true, 100, 1) }), + makeStore({ b: E(false, 200, 1) }), + ), 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)); -}); +for (const [title, a, b, expected] of [ + [ + "strictly-later updatedAt wins (primary key)", + S(E(false, 200, 0)), + S(E(true, 100, 7)), + E(false, 200, 0), + ], + [ + "equal updatedAt: higher rev wins (tiebreak)", + S(E(false, 100, 5)), + S(E(true, 100, 2)), + E(false, 100, 5), + ], + [ + "equal updatedAt AND rev: true-value wins (leaf)", + S(E(false, 100, 3)), + S(E(true, 100, 3)), + E(true, 100, 3), + ], + [ + "false with higher updatedAt overrides true", + S(E(true, 100, 9)), + S(E(false, 999, 1)), + E(false, 999, 1), + ], +]) { + test(`mergeStores: ${title}`, () => + assert.deepEqual(mergeStores(a, b).channels.c, expected)); +} -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)); +test("mergeStores: same-second old-build click (rev 0) loses to earlier new-build rev; heals next second", () => { assert.deepEqual( - mergeStores(newBuildTrue, oldBuildFalseSameSecond).channels.c, + mergeStores(S(E(true, 100, 2)), S(E(false, 100, 0))).channels.c, E(true, 100, 2), - "the earlier new-build rev-2 true wins the same-second tie", + "earlier new-build rev-2 true wins same-second tie", ); - const oldBuildFalseNextSecond = S(E(false, 101, 0)); assert.deepEqual( - mergeStores(newBuildTrue, oldBuildFalseNextSecond).channels.c, + mergeStores(S(E(true, 100, 2)), S(E(false, 101, 0))).channels.c, E(false, 101, 0), - "a strictly-later-second old-build false wins — the residual is transient", + "strictly-later-second old-build false wins — residual is transient", ); }); -test("mergeStores: a boundary (MAX_SAFE_INTEGER) rev cannot wedge later same-second toggles", () => { - const raw = { +test("mergeStores: boundary (MAX_SAFE_INTEGER) rev cannot wedge later same-second toggles", () => { + const wedged = parseStarPayload({ 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"); + }); + assert.equal(wedged.channels.c.rev, 0, "boundary rev normalized to 0"); const mint = (store, v) => { - const local = store.channels.c; - const rev = Math.max(local?.rev ?? 0, 0) + 1; + const rev = Math.max(store.channels.c?.rev ?? 0, 0) + 1; return { store: S(E(v, 100, rev)), rev }; }; let state = wedged; - let prevRev = state.channels.c.rev; + let prevRev = 0; 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}`, - ); + assert.ok(rev > prevRev, `mint rev ${rev} advances past ${prevRev}`); state = mergeStores(state, click); assert.deepEqual( state.channels.c, E(v, 100, rev), - `same-second toggle to ${v} (rev ${rev}) wins`, + `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", () => { +test("mergeStores: empty-store cases", () => { assert.deepEqual( mergeStores(makeStore(), S(E(true, 42, 1))).channels.c, E(true, 42, 1), @@ -234,7 +213,7 @@ test("mergeStores: empty local / empty remote / both empty", () => { assert.deepEqual(mergeStores(makeStore(), makeStore()), makeStore()); }); -// ── mergeStores: algebra ────────────────────────────────────────────────── +// ── mergeStores: algebra ────────────────────────────────────────────────────── function randEntry(rng) { return E(rng() > 0.5, Math.floor(rng() * 5), Math.floor(rng() * 5)); @@ -252,65 +231,64 @@ function lcg(seed) { }; } -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)); - } -}); +for (const [title, check, seed] of [ + [ + "commutative — merge(a,b) === merge(b,a)", + (a, b) => assert.deepEqual(mergeStores(a, b), mergeStores(b, a)), + 12345, + ], + [ + "associative — merge(merge(a,b),c) === merge(a,merge(b,c))", + (a, b, c) => + assert.deepEqual( + mergeStores(mergeStores(a, b), c), + mergeStores(a, mergeStores(b, c)), + ), + 67890, + ], + [ + "idempotent — merge(a, merge(a,b)) === merge(a,b)", + (a, b) => { + const ab = mergeStores(a, b); + assert.deepEqual(mergeStores(a, ab), ab); + assert.deepEqual(mergeStores(ab, ab), ab); + }, + 24680, + ], +]) { + test(`mergeStores: ${title}`, () => { + const rng = lcg(seed); + const ids = ["a", "b", "c", "d"]; + for (let i = 0; i < 200; i++) + check(randStore(rng, ids), randStore(rng, ids), randStore(rng, ids)); + }); +} -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)), - ); - } -}); +// ── v1-blob compat ──────────────────────────────────────────────────────────── -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"); +test("v1 compat: rev-carrying blob round-trips through a rev-less parser view", () => { + const roundTripped = parseStarPayload( + JSON.parse(JSON.stringify(makeStore({ c: E(true, 100, 7) }))), + ); + assert.equal(roundTripped.version, 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, + mergeStores( + S(E(true, 100, 7)), + parseStarPayload({ + version: 1, + channels: { c: { starred: false, updatedAt: 101 } }, + }), + ).channels.c, E(false, 101, 0), ); }); -// ── boundStore ──────────────────────────────────────────────────────────── +// ── boundStore ──────────────────────────────────────────────────────────────── test("boundStarStore: retains newest entries regardless of value", () => { const channels = Object.fromEntries( @@ -328,7 +306,7 @@ test("boundStarStore: retains newest entries regardless of value", () => { assert.equal(result.channels["active-0"], undefined); }); -test("boundStarStore: uses channel ID as an updatedAt tie-breaker", () => { +test("boundStarStore: uses channel ID as updatedAt tie-breaker", () => { const channels = Object.fromEntries( Array.from({ length: MAX_ENTRIES + 1 }, (_, i) => [ `channel-${String(MAX_ENTRIES - i).padStart(3, "0")}`, @@ -354,7 +332,7 @@ test("boundStarStore: preserves a same-second mutation by key", () => { assert.equal(result.channels["z-channel-000"], undefined); }); -test("mergeStores: a fresh at-capacity unstar defeats an older remote star", () => { +test("mergeStores: fresh at-capacity unstar defeats older remote star", () => { const channels = Object.fromEntries( Array.from({ length: MAX_ENTRIES }, (_, i) => [ `active-${i}`, @@ -363,11 +341,14 @@ test("mergeStores: a fresh at-capacity unstar defeats an older remote star", () ); 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)); + assert.deepEqual( + mergeStores(bounded, makeStore({ toggled: E(true, 9998, 5) })).channels + .toggled, + E(false, 9999, 1), + ); }); -test("mergeStores: evicted remote ID re-enters and the oldest state is re-trimmed", () => { +test("mergeStores: evicted remote ID re-enters and oldest state is re-trimmed", () => { const localChannels = Object.fromEntries( Array.from({ length: MAX_ENTRIES }, (_, i) => [ `active-${i}`, @@ -387,17 +368,11 @@ test("mergeStores: evicted remote ID re-enters and the oldest state is re-trimme 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)); +test("finding 3 easy branch: fresh click beats evicted high-rev entry at older updatedAt", () => assert.deepEqual( - mergeStores(click, remote).channels.c, + mergeStores(S(E(true, 1000, 1)), S(E(false, 500, 100))).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; @@ -406,30 +381,28 @@ test("finding 3 hard branch: equal-second evicted click (rev 1) loses to observe 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", + "TARGET evicted by 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], + mergeStores( + makeStore({ [TARGET]: E(true, NOW, 1) }), + makeStore({ [TARGET]: E(false, NOW, 100) }), + ).channels[TARGET], E(false, NOW, 100), - "equal updatedAt → higher rev wins", ); assert.deepEqual( - mergeStores(remote, click).channels[TARGET], + mergeStores( + makeStore({ [TARGET]: E(false, NOW, 100) }), + makeStore({ [TARGET]: E(true, NOW, 1) }), + ).channels[TARGET], E(false, NOW, 100), ); }); -// ── idsFromStore ───────────────────────────────────────────────────────── +// ── idsFromStore ────────────────────────────────────────────────────────────── test("starredChannelIdsFromStore: returns set of IDs where starred=true", () => { const result = starredChannelIdsFromStore({ @@ -451,29 +424,20 @@ test("starredChannelIdsFromStore: all-false / empty returns empty set", () => { ); }); -// ── storageKey + readStore/writeStore ───────────────────────────────────── +// ── storageKey + readStore/writeStore ───────────────────────────────────────── -test("storageKey: with relayUrl includes normalized+encoded relay in key", () => { +test("storageKey: with/without relayUrl", () => { 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"), @@ -481,229 +445,336 @@ test("storageKey: equivalent relay URLs (case + trailing slash) map to the same }); 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); + assert.ok( + writeChannelStarsStore( + "pk-stars-roundtrip", + store, + "wss://relay.example.com", + ) !== null, + ); + assert.deepEqual( + readChannelStarsStore("pk-stars-roundtrip", "wss://relay.example.com"), + store, + ); }); -test("readChannelStarsStore: scoped key is isolated from other relay's data (A→B no seed leak)", () => { - const pubkey = "pk-stars-isolation"; +test("readChannelStarsStore: scoped key isolated from other relay's data", () => { writeChannelStarsStore( - pubkey, + "pk-stars-isolation", makeStore({ cha: E(true, 100, 1) }), "wss://relay-a.example.com", ); assert.deepEqual( - readChannelStarsStore(pubkey, "wss://relay-b.example.com"), + readChannelStarsStore("pk-stars-isolation", "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"); + writeChannelStarsStore("pk-stars-migrate", legacy); assert.deepEqual( - readChannelStarsStore(pubkey, "wss://relay-b-once.example.com"), - DEFAULT_STORE, + readChannelStarsStore( + "pk-stars-migrate", + "wss://relay-migrate.example.com", + ), + legacy, ); assert.equal( - window.localStorage.getItem( - storageKey(pubkey, "wss://relay-b-once.example.com"), - ), + window.localStorage.getItem(storageKey("pk-stars-migrate")), null, ); + assert.deepEqual( + readChannelStarsStore( + "pk-stars-migrate", + "wss://relay-migrate.example.com", + ), + legacy, + ); +}); + +test("readChannelStarsStore: migration is globally one-time — relay B sees DEFAULT after relay A migrates", () => { + writeChannelStarsStore( + "pk-stars-migrate-once", + makeStore({ chm: E(true, 1, 1) }), + ); + readChannelStarsStore( + "pk-stars-migrate-once", + "wss://relay-a-once.example.com", + ); + assert.deepEqual( + readChannelStarsStore( + "pk-stars-migrate-once", + "wss://relay-b-once.example.com", + ), + DEFAULT_STORE, + ); }); 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); + writeChannelStarsStore("pk-stars-migrate-empty", DEFAULT_STORE); + assert.deepEqual( + readChannelStarsStore( + "pk-stars-migrate-empty", + "wss://relay-empty.example.com", + ), + DEFAULT_STORE, + ); + assert.notEqual( + window.localStorage.getItem(storageKey("pk-stars-migrate-empty")), + 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) })); + writeChannelStarsStore( + "pk-stars-precedence", + makeStore({ old: E(true, 1, 1) }), + ); const scoped = makeStore({ new: E(true, 2, 1) }); - writeChannelStarsStore(pubkey, scoped, relay); - assert.deepEqual(readChannelStarsStore(pubkey, relay), scoped); + writeChannelStarsStore("pk-stars-precedence", scoped, relay); + assert.deepEqual(readChannelStarsStore("pk-stars-precedence", relay), scoped); }); -// ── claimLegacy state machine (authoritative — not duplicated in lane files) ── +// ── claimLegacy state machine ───────────────────────────────────────────────── -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"); +for (const { + title, + pubkey, + relayA, + relayB, + setupFailure, + assertions, + assertionsAfterRestore, +} of [ + { + title: "scoped-write failure returns DEFAULT; relay B still claims legacy", + pubkey: "pk-stars-migrate-writefail", + relayA: "wss://relay-a-writefail-stars.example.com", + relayB: "wss://relay-b-writefail-stars.example.com", + setupFailure: (ls, scopedA) => { + const orig = ls.setItem; + ls.setItem = (k, v) => { + if (k === scopedA) throw new Error("QuotaExceededError"); + return orig.call(ls, k, v); + }; + return () => { + ls.setItem = orig; + }; + }, + assertions: (pubkey, relayA, relayB, legacy) => { + assert.deepEqual(readChannelStarsStore(pubkey, relayA), DEFAULT_STORE); + assert.equal( + window.localStorage.getItem(storageKey(pubkey, relayA)), + null, + ); + assert.notEqual( + window.localStorage.getItem(storageKey(pubkey)), + null, + "legacy not yet deleted", + ); + assert.deepEqual(readChannelStarsStore(pubkey, relayB), legacy); + assert.equal( + window.localStorage.getItem(storageKey(pubkey)), + null, + "relay B claims+deletes legacy", + ); + assert.deepEqual(readChannelStarsStore(pubkey, relayA), DEFAULT_STORE); + }, + }, + { + title: "legacy delete no-op rolls back and returns DEFAULT", + pubkey: "pk-stars-migrate-delfail", + relayA: "wss://relay-delfail-stars.example.com", + setupFailure: (ls, _scopedA, legacyKey) => { + const orig = ls.removeItem; + ls.removeItem = (k) => { + if (k === legacyKey) return; + return orig.call(ls, k); + }; + return () => { + ls.removeItem = orig; + }; + }, + assertions: (pubkey, relayA) => { + assert.deepEqual(readChannelStarsStore(pubkey, relayA), DEFAULT_STORE); + assert.equal( + window.localStorage.getItem(storageKey(pubkey, relayA)), + null, + ); + }, + assertionsAfterRestore: (pubkey, relayA, _relayB, legacy) => { + assert.deepEqual( + readChannelStarsStore(pubkey, relayA), + legacy, + "legacy claimable after storage recovers", + ); + assert.equal( + window.localStorage.getItem(storageKey(pubkey)), + null, + "legacy deleted by healthy claim", + ); + }, + }, + { + title: + "legacy delete + rollback both throw — DEFAULT until storage recovers", + pubkey: "pk-stars-migrate-delrollbackthrow", + relayA: "wss://relay-delrollbackthrow-stars.example.com", + setupFailure: (ls) => { + const orig = ls.removeItem; + ls.removeItem = () => { + throw new Error("SecurityError"); + }; + return () => { + ls.removeItem = orig; + }; + }, + assertions: (pubkey, relayA, _relayB, _legacy, _scopedA, legacyKey) => { + assert.deepEqual(readChannelStarsStore(pubkey, relayA), DEFAULT_STORE); + assert.deepEqual(readChannelStarsStore(pubkey, relayA), DEFAULT_STORE); + assert.notEqual( + window.localStorage.getItem(legacyKey), + null, + "legacy still present", + ); + }, + assertionsAfterRestore: (pubkey, relayA, _relayB, legacy) => { + assert.deepEqual( + readChannelStarsStore(pubkey, relayA), + legacy, + "legacy claimable after storage recovers", + ); + }, + }, + { + title: + "legacy delete succeeds; confirmation read throws — scoped copy retained, no data loss", + pubkey: "pk-stars-migrate-confirmthrow", + relayA: "wss://relay-confirmthrow-stars.example.com", + setupFailure: (ls, _scopedA, legacyKey) => { + const origGet = ls.getItem; + const origRemove = ls.removeItem; + let legacyDeleted = false; + let threwOnce = false; + ls.removeItem = (k) => { + if (k === legacyKey) legacyDeleted = true; + return origRemove.call(ls, k); + }; + ls.getItem = (k) => { + if (k === legacyKey && legacyDeleted && !threwOnce) { + threwOnce = true; + throw new Error("SecurityError"); + } + return origGet.call(ls, k); + }; + return () => { + ls.getItem = origGet; + ls.removeItem = origRemove; + }; + }, + assertions: (pubkey, relayA, _relayB, legacy, scopedA) => { + assert.deepEqual(readChannelStarsStore(pubkey, relayA), DEFAULT_STORE); + assert.notEqual(window.localStorage.getItem(scopedA), null); + assert.equal(window.localStorage.getItem(storageKey(pubkey)), null); + assert.deepEqual(readChannelStarsStore(pubkey, relayA), legacy); + }, + }, + { + title: + "legacy delete throws while probe stays healthy — rollback, DEFAULT, single future claimant", + pubkey: "pk-stars-migrate-delthrow-probeok", + relayA: "wss://relay-delthrow-probeok-stars.example.com", + setupFailure: (ls, _scopedA, legacyKey) => { + const orig = ls.removeItem; + let thrown = false; + ls.removeItem = (k) => { + if (k === legacyKey && !thrown) { + thrown = true; + throw new Error("SecurityError"); + } + return orig.call(ls, k); + }; + return () => { + ls.removeItem = orig; + }; + }, + assertions: (pubkey, relayA, _relayB, legacy, scopedA, legacyKey) => { + assert.deepEqual(readChannelStarsStore(pubkey, relayA), DEFAULT_STORE); + assert.equal(window.localStorage.getItem(scopedA), null); + assert.notEqual(window.localStorage.getItem(legacyKey), null); + assert.deepEqual(readChannelStarsStore(pubkey, relayA), legacy); + assert.equal(window.localStorage.getItem(legacyKey), null); + }, + }, + { + title: + "delete and probe both throw — scoped kept but hidden while legacy remains", + pubkey: "pk-stars-migrate-delthrow-probethrow", + relayA: "wss://relay-delthrow-probethrow-stars.example.com", + setupFailure: (ls, _scopedA, legacyKey) => { + const origGet = ls.getItem; + const origRemove = ls.removeItem; + let removeAttempted = false; + ls.removeItem = (k) => { + if (k === legacyKey) { + removeAttempted = true; + throw new Error("SecurityError"); + } + return origRemove.call(ls, k); + }; + ls.getItem = (k) => { + if (k === legacyKey && removeAttempted) + throw new Error("SecurityError"); + return origGet.call(ls, k); + }; + return () => { + ls.getItem = origGet; + ls.removeItem = origRemove; + }; + }, + assertions: (pubkey, relayA, _relayB, _legacy, scopedA) => { + assert.deepEqual(readChannelStarsStore(pubkey, relayA), DEFAULT_STORE); + assert.notEqual(window.localStorage.getItem(scopedA), null); + assert.deepEqual(readChannelStarsStore(pubkey, relayA), DEFAULT_STORE); + }, + assertionsAfterRestore: ( + pubkey, + relayA, + _relayB, + legacy, + _scopedA, + legacyKey, + ) => { + assert.deepEqual( + readChannelStarsStore(pubkey, relayA), + legacy, + "legacy claimable after storage recovers", + ); + assert.equal(window.localStorage.getItem(legacyKey), null); + }, + }, +]) { + test(`claimLegacy: ${title}`, () => { + const legacy = makeStore({ ch: E(true, 500, 2) }); + writeChannelStarsStore(pubkey, legacy); + const scopedA = storageKey(pubkey, relayA); + const legacyKey = storageKey(pubkey); + const restore = setupFailure(window.localStorage, scopedA, legacyKey); + try { + assertions(pubkey, relayA, relayB, legacy, scopedA, legacyKey); + } finally { + restore(); } - 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); -}); + assertionsAfterRestore?.( + pubkey, + relayA, + relayB, + legacy, + scopedA, + legacyKey, + ); + }); +} diff --git a/desktop/src/features/sidebar/lib/mergeLaneSync.shared.test.mjs b/desktop/src/features/sidebar/lib/mergeLaneSync.shared.test.mjs index 1bbda88d71..5b7c6c9623 100644 --- a/desktop/src/features/sidebar/lib/mergeLaneSync.shared.test.mjs +++ b/desktop/src/features/sidebar/lib/mergeLaneSync.shared.test.mjs @@ -1,18 +1,8 @@ -// Shared parameterized test suite for MergeLaneSyncManager subclasses -// (ChannelStarSyncManager, ChannelMuteSyncManager, and any future merge-lane). +// Shared parameterized test suite for MergeLaneSyncManager subclasses. // // Usage: // import { runMergeLaneSyncSuite } from "./mergeLaneSync.shared.test.mjs"; -// runMergeLaneSyncSuite({ -// label: "stars", -// Manager: ChannelStarSyncManager, -// readOutbox: readChannelStarsOutbox, -// watermarkKind: "channel-stars", -// makeEntry: (v, updatedAt, rev) => ({ starred: v, updatedAt, rev }), -// publish: (m, s) => m.publishStars(s), -// getPending: (m) => m.getPendingStarStore(), -// fetchRemote: (m) => m.fetchRemoteStars(), -// }); +// runMergeLaneSyncSuite({ label: "stars", Manager: ..., ... }); import assert from "node:assert/strict"; import test, { mock } from "node:test"; @@ -22,63 +12,15 @@ import { installFakeWindow, installEchoTauri, makeFakeWindow, + makeTimerBed, } from "./sidebarSyncTestHelpers.mjs"; +import { readChannelStarsOutbox } from "./channelStarsStorage.ts"; +import { ChannelStarSyncManager } from "./channelStarsSync.ts"; function makeStore(channels = {}) { return { version: 1, channels }; } -// Multi-slot timer fake keyed by delay, for overlapping-publish tests. Mirrors -// the sections suite convention (channelSectionsSync.test.mjs). -function makeMultiTimerWindow() { - const storage = new Map(); - const timers = new Map(); - let nextId = 1; - const win = { - localStorage: { - getItem: (k) => storage.get(k) ?? null, - setItem: (k, v) => storage.set(k, v), - removeItem: (k) => storage.delete(k), - get length() { - return storage.size; - }, - key: (i) => [...storage.keys()][i] ?? null, - }, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, ms }); - return id; - }, - clearTimeout: (id) => timers.delete(id), - }; - return { - win, - storage, - timers, - fireDelay: async (ms) => { - const entry = [...timers.entries()].find(([, v]) => v.ms === ms); - assert.ok(entry, `expected a timer scheduled at ${ms}ms`); - timers.delete(entry[0]); - entry[1].fn(); - for (let i = 0; i < 50; i++) await Promise.resolve(); - }, - hasDelay: (ms) => [...timers.values()].some((t) => t.ms === ms), - }; -} - -/** - * Run the full merge-lane invariant suite for a single lane. - * - * @param {object} cfg - * @param {string} cfg.label - Human-readable lane name for test titles. - * @param {Function} cfg.Manager - The MergeLaneSyncManager subclass constructor. - * @param {Function} cfg.readOutbox - readChannel{Stars|Mutes}Outbox(pubkey, relay). - * @param {string} cfg.watermarkKind - The d-tag string (e.g. "channel-stars"). - * @param {Function} cfg.makeEntry - (value, updatedAt, rev) => lane entry object. - * @param {Function} cfg.publish - (manager, store) => void — calls the typed publish method. - * @param {Function} cfg.getPending - (manager) => store | null — calls the typed getter. - * @param {Function} cfg.fetchRemote - (manager) => Promise — calls the typed fetch. - */ export function runMergeLaneSyncSuite({ label, Manager, @@ -92,7 +34,7 @@ export function runMergeLaneSyncSuite({ const RELAY = "wss://r.test"; const RELAY_KEY = encodeURIComponent(RELAY); - // ─── observe() / high-water ingestion ─────────────────────────────────────── + // ─── observe() / high-water ingestion ────────────────────────────────────── test(`${label}: observe: high-water is per-channel max of rev and updatedAt, monotonic`, () => { const fw = makeFakeWindow(); @@ -104,15 +46,12 @@ export function runMergeLaneSyncSuite({ ); assert.equal(m.maxRevSeen("a"), 3); assert.equal(m.maxUpdatedAtSeen("a"), 100); - // A later observation raises each dimension independently; a lower one - // never regresses either. m.observe(makeStore({ a: makeEntry(true, 90, 5) })); assert.equal(m.maxRevSeen("a"), 5, "rev raised"); assert.equal(m.maxUpdatedAtSeen("a"), 100, "updatedAt not regressed"); m.observe(makeStore({ a: makeEntry(true, 200, 2) })); assert.equal(m.maxUpdatedAtSeen("a"), 200, "updatedAt raised"); assert.equal(m.maxRevSeen("a"), 5, "rev not regressed"); - // Unseen channel reports zero on both dimensions. assert.equal(m.maxRevSeen("never"), 0); assert.equal(m.maxUpdatedAtSeen("never"), 0); } finally { @@ -120,7 +59,7 @@ export function runMergeLaneSyncSuite({ } }); - // ─── destroy() must cancel pending publish, not flush ─────────────────────── + // ─── destroy() ───────────────────────────────────────────────────────────── test(`${label}: destroy: cancels pending publish without flushing to the relay`, () => { const publishCalls = []; @@ -178,12 +117,9 @@ export function runMergeLaneSyncSuite({ } }); - // ─── Generation CAS: A-in-flight → B-click → A-completes (both variants) ──── + // ─── Generation CAS: A-in-flight → B-click → A-completes ─────────────────── + // Mutation: dropping the generation CAS in discardPending lets A's outcome null B's pending+outbox. - // 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", @@ -207,27 +143,22 @@ export function runMergeLaneSyncSuite({ ); mock.method(relayClient, "publishEvent", (event) => { publishCount++; - if (publishCount === 1) { + 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 t = makeTimerBed(); 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 + await t.fireDelay(2000); while (releaseFirst === null) await Promise.resolve(); - - // B arrives while A is in flight. publish(manager, storeB); assert.deepEqual( Object.keys(getPending(manager).channels), @@ -235,8 +166,6 @@ export function runMergeLaneSyncSuite({ "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( @@ -246,18 +175,15 @@ export function runMergeLaneSyncSuite({ ); assert.ok( readOutbox(`pk-ab-${name}`, RELAY), - `older A completion (${name}) leaves B outbox`, + `older A (${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", + "B published to the relay", ); assert.equal( getPending(manager), @@ -272,18 +198,84 @@ export function runMergeLaneSyncSuite({ manager.destroy(); } finally { tauri.restore(); - restore(); + t.restore(); mock.reset(); } }); } - // ─── Bounded-backoff retry: failed publish on a healthy socket, no later edit + // ─── Generation guard: pre-sign seam ─────────────────────────────────────── + // Mutation: dropping line 400 lets stale A sign and publish after B is queued. + + test(`${label}: pre-sign guard: a newer edit during encrypt aborts the stale publish`, async () => { + let releaseEncrypt = null; + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const orig = globalThis.window?.__TAURI_INTERNALS__; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + let encryptCalls = 0; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_encrypt_to_self") { + encryptCalls++; + if (encryptCalls === 1) + return new Promise((res) => { + releaseEncrypt = () => res("ct-a"); + }); + return Promise.resolve("ct-b"); + } + if (cmd === "nip44_decrypt_from_self") return Promise.resolve("{}"); + if (cmd === "sign_event") + return Promise.resolve( + JSON.stringify({ + id: `evt-${encryptCalls}`, + pubkey: "pk-presign", + content: args?.content ?? "", + created_at: args?.createdAt ?? 0, + kind: args?.kind ?? 0, + tags: args?.tags ?? [], + sig: "s", + }), + ); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }, + }; + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new Manager("pk-presign", RELAY); + publish(manager, makeStore({ a: makeEntry(true, 100, 1) })); + fw._fireTimer(); + while (releaseEncrypt === null) await Promise.resolve(); + publish(manager, makeStore({ b: makeEntry(true, 101, 1) })); + releaseEncrypt(); + for (let i = 0; i < 50; i++) await Promise.resolve(); + assert.equal( + publishCalls.length, + 0, + "stale A must not publish after B queued", + ); + assert.deepEqual( + Object.keys(getPending(manager)?.channels ?? {}), + ["b"], + "B is still pending", + ); + manager.destroy(); + } finally { + if (orig !== undefined) globalThis.window.__TAURI_INTERNALS__ = orig; + else delete globalThis.window.__TAURI_INTERNALS__; + restore(); + mock.reset(); + } + }); + + // ─── Bounded-backoff retry ────────────────────────────────────────────────── + // Mutation: dropping scheduleRetry leaves the edit stranded. - // Finding 2: a transient publish failure with the socket open and NO further - // click must self-heal via the bounded-backoff retry — the pending edit is kept - // and a retry timer is scheduled. Mutation: dropping scheduleRetry leaves the - // edit stranded (Will's "make another change to kick it" symptom). test(`${label}: failed publish schedules a bounded-backoff retry and keeps the pending edit`, async () => { let publishCount = 0; let storedHead = []; @@ -294,24 +286,17 @@ export function runMergeLaneSyncSuite({ storedHead = [event]; return Promise.resolve(); }); - const t = makeMultiTimerWindow(); - const restore = installFakeWindow(t.win); + const t = makeTimerBed(); const tauri = installEchoTauri("pk-retry"); try { const manager = new Manager("pk-retry", RELAY); publish(manager, makeStore({ a: makeEntry(true, 100, 1) })); - await t.fireDelay(2000); // debounce → doPublish → publishEvent rejects + await t.fireDelay(2000); assert.ok( getPending(manager) !== null, "pending edit retained after failure", ); - assert.ok( - t.hasDelay(2000), - "a retry timer at RETRY_BASE_MS is scheduled", - ); - - // The retry fires and the second publish succeeds; the retained-head fetch - // confirms the write → pending cleared. + assert.ok(t.hasDelay(2000), "retry timer scheduled"); await t.fireDelay(2000); for (let i = 0; i < 50; i++) await Promise.resolve(); assert.equal(publishCount, 2, "retry re-published"); @@ -323,80 +308,59 @@ export function runMergeLaneSyncSuite({ manager.destroy(); } finally { tauri.restore(); - restore(); + t.restore(); mock.reset(); } }); - // ─── Retention confirmation (Carl P1): OK is not proof of retention ────────── + // ─── Retention confirmation (Carl P1) ────────────────────────────────────── + // Mutation: clearing on OK alone nulls the pending edit and loses the click. - // The relay OKs a superseded NIP-33 write as a no-op, so two windows racing - // distinct blobs from the same head can both get OK while only one is retained. - // The loser must NOT clear its durable outbox on OK alone: it fetches the - // authoritative retained head and clears only when that head subsumes its - // write. A retained head carrying a peer's distinct blob does not subsume the - // loser's click, so the outbox is kept and a retry is scheduled. - // Mutation: clearing on OK alone (dropping confirmRetainedHeadSubsumes) would - // null the pending edit here and lose the click. test(`${label}: publish OK but a peer blob is retained: loser keeps its outbox and retries`, async () => { let publishCount = 0; - // The retained head is a peer window's distinct blob from the same base — - // it does NOT contain our channel `a`, so it cannot subsume our write. let storedHead = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); const tauri = installEchoTauri("pk-loser"); mock.method(relayClient, "publishEvent", (event) => { publishCount++; if (publishCount === 1) { - // Our write is OK'd by the relay but immediately superseded: the retained - // head is the peer's blob for a different channel, minted through the - // same echo seam so it decrypts. storedHead = [ tauri.mintHead(makeStore({ z: makeEntry(true, 200, 5) }), 100), ]; return Promise.resolve(); } - // The retry (which max-merges the peer head in) is retained. storedHead = [event]; return Promise.resolve(); }); - const t = makeMultiTimerWindow(); - const restore = installFakeWindow(t.win); + const t = makeTimerBed(); try { const manager = new Manager("pk-loser", RELAY); publish(manager, makeStore({ a: makeEntry(true, 100, 1) })); - await t.fireDelay(2000); // publish OK, but peer blob is what's retained + await t.fireDelay(2000); for (let i = 0; i < 50; i++) await Promise.resolve(); - assert.ok( getPending(manager) !== null, - "unconfirmed publish keeps the pending edit", + "unconfirmed publish keeps pending edit", ); - assert.ok( - readOutbox("pk-loser", RELAY), - "loser keeps its durable outbox — OK is not proof of retention", - ); - assert.ok(t.hasDelay(2000), "a retry is scheduled"); - - // The retry re-publishes the max-merge of our click and the peer head; this - // time it is retained (subsumes our `a`) and the outbox clears. + assert.ok(readOutbox("pk-loser", RELAY), "loser keeps durable outbox"); + assert.ok(t.hasDelay(2000), "retry scheduled"); await t.fireDelay(2000); for (let i = 0; i < 50; i++) await Promise.resolve(); assert.equal(publishCount, 2, "loser retried"); assert.equal( getPending(manager), null, - "pending cleared once the retained head subsumes our click", + "pending cleared once retained head subsumes click", ); manager.destroy(); } finally { tauri.restore(); - restore(); + t.restore(); mock.reset(); } }); - // ─── Boot seed-publish guard (the revert-fix regression suite) ─────────────── + // ─── Boot seed-publish guard ──────────────────────────────────────────────── for (const { title, @@ -407,7 +371,7 @@ export function runMergeLaneSyncSuite({ assertPending, } of [ { - title: "fetch failed (error) does not trigger seed-publish via bootstrap", + title: "fetch error does not trigger seed-publish", setupFetch: () => mock.method(relayClient, "fetchEvents", () => Promise.reject(new Error("relay timeout")), @@ -415,8 +379,7 @@ export function runMergeLaneSyncSuite({ assertPending: (m) => assert.equal(getPending(m), null), }, { - title: - "absent fetch with prior watermark blocks seed-publish via bootstrap", + title: "absent fetch with prior watermark blocks seed-publish", setupFetch: () => mock.method(relayClient, "fetchEvents", () => Promise.resolve([])), setupWatermark: (fw) => @@ -428,8 +391,7 @@ export function runMergeLaneSyncSuite({ assertPending: (m) => assert.equal(getPending(m), null), }, { - title: - "absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", + title: "absent fetch with zero watermark seeds (first-sync preserved)", setupFetch: () => mock.method(relayClient, "fetchEvents", () => Promise.resolve([])), pubkey: "pk-fresh", @@ -476,13 +438,9 @@ export function runMergeLaneSyncSuite({ }); } - // ─── Failed pre-publish fetch: retain, never publish (Carl P1) ─────────────── + // ─── Failed pre-publish fetch: retain, never publish ─────────────────────── + // Mutation: reverting the merge-lane catch to `publish` fires publishEvent. - // The pre-publish fetch THROWS (timeout / auth / socket) — NOT proof that no - // head exists. Publishing the local store here could erase an unseen newer head - // during a transient outage. Fix: `retain` — keep the durable outbox and retry. - // Mutation: reverting the merge-lane catch to `publish` fires publishEvent over - // the unseen head. test(`${label}: failed pre-publish fetch retains the pending edit and retries, never publishing`, async () => { mock.method(relayClient, "fetchEvents", () => Promise.reject(new Error("socket timeout")), @@ -503,17 +461,17 @@ export function runMergeLaneSyncSuite({ assert.equal( publishCalls.length, 0, - "must not publish when the pre-publish fetch failed", + "must not publish when pre-publish fetch failed", ); assert.ok( getPending(manager) !== null, - "a failed fetch must retain the pending edit", + "failed fetch retains pending edit", ); assert.ok( readOutbox("pk-fetchfail", RELAY), - "durable outbox must survive a failed fetch", + "durable outbox survives failed fetch", ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); + assert.ok(fw._hasTimer(), "retry scheduled"); } finally { tauri.restore(); restore(); @@ -521,114 +479,71 @@ export function runMergeLaneSyncSuite({ } }); - // ─── Unreadable head: retain, never max-merge over it (Carl P1) ────────────── + // ─── Unreadable / unsupported head: retain, never publish ────────────────── - // A pre-publish head event exists but cannot be decrypted (transient keychain - // fault, future NIP-44 scheme, malformed/unsupported payload). A max-merge is - // only safe once both operands were actually read, so publishing the local - // store over an uninspectable head would drop its unread entries. Fix: `retain` - // — keep the durable outbox and retry, never publish. Mutation: reverting the - // `!remote` branch to `publish` fires publishEvent and clobbers the unread head. - test(`${label}: unreadable head (decrypt failure) retains the pending edit and retries, never publishing`, async () => { - // A head event exists but its ciphertext is not registered in the echo map, - // so decrypt rejects → decryptAndParse returns null → retain. - mock.method(relayClient, "fetchEvents", () => - Promise.resolve([ - { - pubkey: "pk-undec", - content: "unregistered-cipher", - created_at: 500, - id: "evt-undec", - }, - ]), - ); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); + for (const { title, setupHead, pubkey } of [ + { + title: "unreadable head (decrypt failure)", + pubkey: "pk-undec", + setupHead: (tauri) => { + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-undec", + content: "unregistered-cipher", + created_at: 500, + id: "evt-undec", + }, + ]), + ); + return tauri; + }, + }, + { + title: "unsupported head payload schema", + pubkey: "pk-badver", + setupHead: (tauri) => { + const head = tauri.mintHead({ version: 2, channels: {} }, 500); + mock.method(relayClient, "fetchEvents", () => Promise.resolve([head])); + return tauri; + }, + }, + ]) { + test(`${label}: ${title} retains the pending edit and retries, never publishing`, async () => { + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installEchoTauri(pubkey); + setupHead(tauri); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + try { + const manager = new Manager(pubkey, RELAY); + publish(manager, makeStore({ a: makeEntry(true, 100, 1) })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal(publishCalls.length, 0, `${title}: must not publish`); + assert.ok(getPending(manager) !== null, `${title}: pending retained`); + assert.ok(fw._hasTimer(), "retry scheduled"); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installEchoTauri("pk-undec"); - try { - const manager = new Manager("pk-undec", RELAY); - publish(manager, makeStore({ a: makeEntry(true, 100, 1) })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - publishCalls.length, - 0, - "must not publish over a head we could not read", - ); - assert.ok( - getPending(manager) !== null, - "unreadable head must retain the pending edit", - ); - assert.ok( - readOutbox("pk-undec", RELAY), - "durable outbox must survive an unreadable head", - ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); + } - // A readable head whose decrypted payload carries an unsupported schema version - // (the lane parser rejects it → decryptAndParse returns null). Distinct from a - // decrypt fault: the ciphertext decrypts fine, but we still cannot trust the - // contents, so retain rather than max-merge over it. - test(`${label}: unsupported head payload schema retains the pending edit, never publishing`, async () => { - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installEchoTauri("pk-badver"); - // mintHead stringifies the given object as the head plaintext; a future schema - // version decrypts cleanly but the lane parser returns null. - const head = tauri.mintHead({ version: 2, channels: {} }, 500); - mock.method(relayClient, "fetchEvents", () => Promise.resolve([head])); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - try { - const manager = new Manager("pk-badver", RELAY); - publish(manager, makeStore({ a: makeEntry(true, 100, 1) })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - publishCalls.length, - 0, - "must not publish over a head whose schema we do not support", - ); - assert.ok( - getPending(manager) !== null, - "unsupported schema must retain the pending edit", - ); - assert.ok(fw._hasTimer(), "a retry must be scheduled"); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } - }); + // ─── Timestamp clamp ──────────────────────────────────────────────────────── + // Mutation: removing the clamp lets createdAt = lastRemote+1 (~now+3600). - // ─── Timestamp clamp (Carl P2): a far-future remote head must not push our - // published createdAt past the relay's ±15min future-drift window. - // Mutation test: removing the clamp lets createdAt = lastRemote+1 (~now+3600), - // which exceeds now + MAX_PUBLISH_FUTURE_SECS. test(`${label}: timestamp clamp: published createdAt stays inside the relay future window`, async () => { const nowSecs = Math.floor(Date.now() / 1000); - const farFutureHead = nowSecs + 3_600; // 1h ahead — beyond the ±15min window + const farFutureHead = nowSecs + 3_600; const fw = makeFakeWindow(); const restore = installFakeWindow(fw); const tauri = installEchoTauri("pk-clamp"); - // A readable far-future head: primes lastRemoteCreatedAt to farFutureHead on - // the priming fetch and is max-merged on the pre-publish fetch. It must be - // decryptable — an unreadable head now retains rather than publishes, so the - // clamp is only exercised via a head we actually read. const head = tauri.mintHead(makeStore({}), farFutureHead); mock.method(relayClient, "fetchEvents", () => Promise.resolve([head])); let signedCreatedAt = null; @@ -638,14 +553,14 @@ export function runMergeLaneSyncSuite({ }); try { const manager = new Manager("pk-clamp", RELAY); - await fetchRemote(manager); // prime lastRemoteCreatedAt + await fetchRemote(manager); publish(manager, makeStore({ ch1: makeEntry(true, 100, 1) })); fw._fireTimer(); await new Promise((r) => setTimeout(r, 20)); assert.ok(signedCreatedAt !== null, "publish must have been attempted"); assert.ok( signedCreatedAt <= Math.floor(Date.now() / 1000) + 840, - `createdAt must be clamped inside the future window — got ${signedCreatedAt}`, + `createdAt clamped — got ${signedCreatedAt}`, ); } finally { tauri.restore(); @@ -654,3 +569,18 @@ export function runMergeLaneSyncSuite({ } }); } + +// ─── Authoritative engine invocation (once, against ChannelStarSyncManager) ── +// Each case exercises MergeLaneSyncManager directly; ChannelStarSyncManager is +// the canonical concrete subclass. channelStarsSync.test.mjs and +// channelMutesSync.test.mjs cover per-lane wire contracts separately. +runMergeLaneSyncSuite({ + label: "stars", + Manager: ChannelStarSyncManager, + readOutbox: readChannelStarsOutbox, + watermarkKind: "channel-stars", + makeEntry: (starred, updatedAt, rev) => ({ starred, updatedAt, rev }), + publish: (m, s) => m.publishStars(s), + getPending: (m) => m.getPendingStarStore(), + fetchRemote: (m) => m.fetchRemoteStars(), +}); diff --git a/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs b/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs index 237311a0f3..ebb2498705 100644 --- a/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs +++ b/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs @@ -3,38 +3,23 @@ import test from "node:test"; // Multi-window durable-outbox safety (Carl's CHANGES_REQUESTED, finding P1). // -// All four sidebar-sync lanes persist an unpublished edit so it survives a -// quit/community-switch inside the 2s publish debounce. localStorage offers no -// atomic compare-and-delete or transactional read-modify-write, so neither a -// single shared key nor a per-window key a window OVERWRITES can be reclaimed -// by a peer safely: the value can change between the reclaim decision-read and -// the delete (the recheck race a byte-compare narrows but cannot close). -// // The fix keys the outbox per window AND write-once: -// `::::`, where the nonce is stable per -// window (sessionStorage) and `seq` is a per-window monotonic counter. A window -// NEVER rewrites a key: a new edit writes a NEW key, then deletes its own older -// keys (write-before-delete, so a crash leaves ≥1 record, never zero). Because -// records are immutable, a booting peer that proves a foreign key reclaimable -// against durable relay evidence can delete it with no recheck — nothing can -// have changed at that key since the proof. Resume enumerates ALL windows' -// keys: merge lanes (stars/mutes) fold every record (order-independent); -// whole-blob lanes (sort/sections) replay the max-`queuedAt` record, ties broken -// by key. Reclamation runs AFTER replay so a same-second record the head appears -// to supersede is consumed into pending first. Whole-blob supersession is STRICT -// (`queuedAt` < head `created_at`) so a same-second record is never dropped, and -// the legacy v1 shared key is never deleted by v2 (it is mutable; only replayed). +// `::::`. A window NEVER rewrites a key: +// a new edit writes a NEW key then deletes its own older keys (write-before-delete). +// Because records are immutable, a booting peer that proves a foreign key +// reclaimable against durable relay evidence can delete it with no recheck. +// Resume enumerates ALL windows' keys: merge lanes fold every record; whole-blob +// lanes replay the max-queuedAt record, ties broken by key. Reclamation runs +// AFTER replay so a same-second record appears to supersede is consumed into +// pending first. Whole-blob supersession is STRICT (queuedAt < head created_at) +// so a same-second record is never dropped, and the legacy v1 shared key is +// never deleted by v2. // -// This matrix drives the shared helpers directly through a mock Storage seam — -// no relay, no timers — so each interleaving is deterministic. The seam mocks -// both localStorage (with a read-then-mutate hook to interpose a foreign write -// between the reclaim decision-read and the delete) and sessionStorage (for the -// per-window nonce). Manager- and hook-level behavior is covered by their own -// suites; this file isolates the cross-window storage contract. +// This suite drives the shared helpers through a mock Storage seam — no relay, +// no timers — so each interleaving is deterministic. Manager/hook-level behavior +// is covered by their own suites; this file isolates the cross-window storage +// contract. -// A mock Storage. `onReadMutate(key, afterReads, fn)` runs `fn(map)` right after -// the Nth getItem of `key` returns its captured value — used to simulate a peer -// window rewriting a key in the reclaim recheck gap. function makeStorage(initial = {}) { const map = new Map(Object.entries(initial)); const reads = new Map(); @@ -69,7 +54,6 @@ function makeStorage(initial = {}) { }; } -// Run `fn(localStorage)` with fresh mock local + session storage installed. function withStorage(fn) { const ls = makeStorage(); const ss = makeStorage(); @@ -103,9 +87,6 @@ const PREFIX = { sections: "buzz-channel-sections-outbox.v1", }; -// A foreign window's key: same (pubkey, relay) scope, a different nonce than -// this process's own, plus a zero-padded `seq`. `legacyKey` is the -// pre-per-window shared key (no nonce/seq). const SEQ_WIDTH = 12; const pad = (seq) => String(seq).padStart(SEQ_WIDTH, "0"); const foreignKey = (lane, nonce, seq) => @@ -123,23 +104,15 @@ const sectionStore = (secs, assignments = {}) => ({ assignments, }); -// ── (i) Reclaim never deletes a foreign edit written in the decision→delete gap ─ -// +// ── (i) Reclaim never deletes a foreign edit written in the decision→delete gap ── // Records are write-once: an owner's fresh edit lands on a NEW key, never a -// rewrite of the enumerated one. reclaimOutbox proves the enumerated (stale) -// key reclaimable and deletes it; a peer edit injected during enumeration lives -// under its own key and is evaluated on its own merits (kept when the head does -// not subsume/supersede it). We interpose that fresh write via the read-mutate -// seam to prove GC removes only the proven-stale key and never the fresh one. +// rewrite. reclaimOutbox proves the enumerated (stale) key reclaimable and deletes +// it; a peer edit injected during enumeration lives under its own key. test("(i) stars: reclaim deletes the proven-stale key and keeps a fresh edit written in the gap", () => { withStorage((ls) => { const stale = foreignKey("stars", "peerB", 0); - // Foreign edit the fetched head has already absorbed → decision = reclaim. writeAt(ls, stale, starStore({ a: starEntry(true, 100, 1) }), 100); - // During enumeration (read of the stale key), the peer commits a FRESH edit - // to a NEW key the head does not reflect — the write-once analogue of the - // owner rewriting in the gap. const fresh = foreignKey("stars", "peerB", 1); ls.onReadMutate(stale, 1, (map) => map.set( @@ -150,7 +123,6 @@ test("(i) stars: reclaim deletes the proven-stale key and keeps a fresh edit wri }), ), ); - // Head carries `a` at (100,1) — subsumes the stale value, not the fresh one. stars.reclaimSubsumedStarsOutbox( PK, RELAY, @@ -176,8 +148,6 @@ test("(i) sort: reclaim deletes the proven-stale key and keeps a fresh edit writ JSON.stringify({ store: sortStore({ dms: "recent" }), queuedAt: 500 }), ), ); - // Head created_at 200 strictly supersedes the queuedAt=100 stale record, - // not the interposed queuedAt=500 fresh key. sort.reclaimSupersededSortOutbox(PK, RELAY, 200); assert.ok(!ls.has(stale), "proven-stale key is reclaimed"); assert.ok(ls.has(fresh), "peer's fresh edit under a new key survives"); @@ -188,11 +158,7 @@ test("(i) sort: reclaim deletes the proven-stale key and keeps a fresh edit writ }); }); -// ── (ii) Two windows teardown/remount: every unpublished intent preserved ────── -// -// Windows A and B each persist an edit and quit; a fresh window remounts and -// enumerates both keys. Merge lanes keep BOTH; whole-blob lanes keep the newest -// (an older peer blob is LWW-superseded by definition, the documented residual). +// ── (ii) Two windows teardown/remount: every unpublished intent preserved ─────── test("(ii) stars (merge): both windows' distinct-channel edits resume", () => { withStorage((ls) => { @@ -214,7 +180,7 @@ test("(ii) stars (merge): both windows' distinct-channel edits resume", () => { }); }); -test("(ii) sort (whole-blob): the newest queued window resumes; older is LWW-superseded", () => { +test("(ii) sort (whole-blob): newest queued window resumes; older is LWW-superseded", () => { withStorage((ls) => { writeAt( ls, @@ -235,11 +201,7 @@ test("(ii) sort (whole-blob): the newest queued window resumes; older is LWW-sup }); }); -// ── (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 -// head cannot be proven to have lost LWW. Strict supersession (`queuedAt` < -// head `created_at`) keeps it; a strictly-earlier record is reclaimed. +// ── (iii) Same-second whole-blob: head at t keeps a record queued at t ────────── test("(iii) sort: same-second record kept, strictly-earlier reclaimed", () => { withStorage((ls) => { @@ -270,32 +232,24 @@ test("(iii) sections: same-second record kept, strictly-earlier reclaimed", () = 99, ); sections.reclaimSupersededSectionsOutbox(PK, RELAY, 100); - assert.ok(ls.has(sameSecond), "same-second record (queuedAt == head) kept"); + assert.ok(ls.has(sameSecond), "same-second record kept"); assert.ok(!ls.has(earlier), "strictly-earlier record reclaimed"); }); }); // ── (iv) Legacy v1 shared key: replays, and is NEVER deleted by v2 ───────────── -// -// A pre-per-window build wrote one shared, MUTABLE key. v2 enumerates it as one -// more record (queuedAt 0 for a bare store) so it resumes, but never deletes it -// under any relay gating — a live old-build window may be rewriting it, and -// queuedAt 0 makes supersession meaningless (mixed dev/DMG fleet residual). test("(iv) stars: legacy shared key resumes and is never reclaimed", () => { withStorage((ls) => { - // Legacy bare store (no envelope) from a pre-per-window build. ls.setItem( legacyKey("stars"), JSON.stringify(starStore({ a: starEntry(true, 100, 1) })), ); - const resumed = stars.readChannelStarsOutbox(PK, RELAY); assert.deepEqual( - resumed.channels.a, + stars.readChannelStarsOutbox(PK, RELAY).channels.a, starEntry(true, 100, 1), "legacy entry resumes", ); - // Even a head that fully subsumes it must not delete the mutable v1 key. stars.reclaimSubsumedStarsOutbox( PK, RELAY, @@ -310,7 +264,6 @@ test("(iv) stars: legacy shared key resumes and is never reclaimed", () => { test("(iv) sections: legacy shared key resumes and is never reclaimed", () => { withStorage((ls) => { - // Legacy entry as a {store, queuedAt} envelope from an interim build. writeAt( ls, legacyKey("sections"), @@ -321,7 +274,6 @@ test("(iv) sections: legacy shared key resumes and is never reclaimed", () => { sections.readChannelSectionsOutbox(PK, RELAY).store.sections, [{ id: "s1", name: "One", order: 0 }], ); - // A head strictly past the queued stamp still must not delete the v1 key. sections.reclaimSupersededSectionsOutbox(PK, RELAY, 999); assert.ok( ls.has(legacyKey("sections")), @@ -330,17 +282,11 @@ test("(iv) sections: legacy shared key resumes and is never reclaimed", () => { }); }); -// ── (v) Owner crash between write-new and delete-old → both replay-coalesce ──── -// -// writeOwnOutbox writes the new key BEFORE deleting older own keys. A crash in -// that gap leaves two own records for the same window; replay coalesces them -// (merge fold / whole-blob max) with no loss and no duplicate publish. +// ── (v) Owner crash between write-new and delete-old → both replay-coalesce ───── test("(v) stars: two own records from a write-new/delete-old crash merge-coalesce", () => { withStorage((ls) => { const base = `${PREFIX.stars}:${SCOPE}:${outboxWindowNonce()}`; - // Simulate the crash residue: the pre-crash key (seq 0) plus the freshly - // written key (seq 1) both present. Merge must keep both channels. writeAt( ls, `${base}:${pad(0)}`, @@ -359,46 +305,35 @@ test("(v) stars: two own records from a write-new/delete-old crash merge-coalesc }); }); -test("(v) sort: two own records from a crash resume the newer seq (padded key order)", () => { +test("(v) sort: two own records resume the newer seq (padded key order)", () => { withStorage((ls) => { const base = `${PREFIX.sort}:${SCOPE}:${outboxWindowNonce()}`; - // Same-second seqs crossing a digit boundary: unpadded, "9" > "10" - // lexically and would wrongly resume the OLDER edit. Zero-padding makes the - // higher seq win the whole-blob tiebreak. writeAt(ls, `${base}:${pad(9)}`, sortStore({ dms: "alpha" }), 100); writeAt(ls, `${base}:${pad(10)}`, sortStore({ dms: "recent" }), 100); assert.equal( sort.readChannelSortOutbox(PK, RELAY).store.groups.dms, "recent", - "newer seq resumes despite the digit-boundary crossing", + "newer seq resumes", ); }); }); -// ── (vi) Reload seeds seq above surviving own keys → no key reuse/overwrite ──── -// -// After a reload the sessionStorage nonce survives but the in-memory seq counter -// restarts. A fresh write must allocate a seq ABOVE the max surviving own key so -// it never overwrites (and thus mutates) an existing immutable record. +// ── (vi) Reload seeds seq above surviving own keys → no key reuse/overwrite ───── -test("(vi) reload: a write after surviving own keys allocates a strictly-higher key", () => { +test("(vi) reload: write after surviving own keys allocates a strictly-higher key", () => { withStorage((ls) => { const base = `${PREFIX.stars}:${SCOPE}:${outboxWindowNonce()}`; - // A surviving own key from before the (simulated) reload. writeAt( ls, `${base}:${pad(5)}`, starStore({ a: starEntry(true, 50, 1) }), 50, ); - // First write of the "new session" — seq counter cold, must seed above 5. stars.writeChannelStarsOutbox( PK, starStore({ b: starEntry(true, 100, 1) }), RELAY, ); - // The pre-reload key must NOT have been overwritten; the new write lands on - // a strictly-higher key, and delete-old drops the seq-5 key. const ownKeys = []; for (let i = 0; i < ls.length; i++) { const k = ls.key(i); @@ -407,9 +342,8 @@ test("(vi) reload: a write after surviving own keys allocates a strictly-higher assert.equal(ownKeys.length, 1, "delete-old leaves exactly one own key"); assert.ok( ownKeys[0] > `${base}:${pad(5)}`, - "new key seq is strictly above the surviving key", + "new key seq is strictly above surviving key", ); - // No data loss: the newest edit resumes. assert.deepEqual( stars.readChannelStarsOutbox(PK, RELAY).channels.b, starEntry(true, 100, 1), @@ -417,9 +351,9 @@ test("(vi) reload: a write after surviving own keys allocates a strictly-higher }); }); -// ── (vii) Whole-blob replay tie → deterministic nonce (key) tiebreak ─────────── +// ── (vii) Whole-blob replay tie → deterministic key tiebreak ───────────────── -test("(vii) sort: equal-queuedAt records resolve by key so replay is deterministic", () => { +test("(vii) sort: equal-queuedAt records resolve by key (deterministic)", () => { withStorage((ls) => { writeAt( ls, @@ -433,7 +367,6 @@ test("(vii) sort: equal-queuedAt records resolve by key so replay is determinist sortStore({ forums: "recent" }), 100, ); - // Same queuedAt → the lexicographically-greater key wins (…:zzz:…). assert.equal( sort.readChannelSortOutbox(PK, RELAY).store.groups.forums, "recent", @@ -443,10 +376,8 @@ test("(vii) sort: equal-queuedAt records resolve by key so replay is determinist // ── (viii) Merge-lane replay is order-independent ────────────────────────────── -test("(viii) stars: same-channel records fold to the max entry regardless of key order", () => { +test("(viii) stars: same-channel records fold to max entry regardless of key order", () => { withStorage((ls) => { - // Lower-rev record under a lexicographically-greater key (enumerated later) - // must still lose to the higher-rev record — merge is order-independent. writeAt( ls, foreignKey("stars", "aaa", 0), @@ -459,81 +390,60 @@ test("(viii) stars: same-channel records fold to the max entry regardless of key starStore({ c: starEntry(false, 100, 2) }), 200, ); - const merged = stars.readChannelStarsOutbox(PK, RELAY); assert.deepEqual( - merged.channels.c, + stars.readChannelStarsOutbox(PK, RELAY).channels.c, starEntry(true, 100, 5), - "higher rev wins the tie", + "higher rev wins", ); }); }); -// ── (ix) GC no-op when the head subsumes/supersedes nothing ──────────────────── -// -// The hook calls reclaim only inside the `apply-remote` branch, so a `failed` -// head fetch (or `absent`) never invokes it — that guard is structural in the -// hook. At the storage layer the matching invariant is that a head which -// subsumes/supersedes nothing removes nothing: a foreign edit newer than the -// head is live intent and is kept, so a stale/empty head can never over-collect. +// ── (ix) GC no-op when head subsumes/supersedes nothing ────────────────────── -test("(ix) stars: a head that subsumes nothing reclaims nothing", () => { +test("(ix) stars: head that subsumes nothing reclaims nothing", () => { withStorage((ls) => { const key = foreignKey("stars", "B", 0); writeAt(ls, key, starStore({ a: starEntry(true, 300, 2) }), 300); - // Empty head subsumes no channel → keep everything. stars.reclaimSubsumedStarsOutbox(PK, RELAY, starStore({})); assert.ok(ls.has(key), "unsubsumed foreign edit is kept"); }); }); -test("(ix) sort: a head older than the queued edit supersedes nothing", () => { +test("(ix) sort: head older than queued edit supersedes nothing", () => { withStorage((ls) => { const key = foreignKey("sort", "B", 0); writeAt(ls, key, sortStore({ dms: "recent" }), 300); - // headCreatedAt=0 (absent-equivalent) < queuedAt → keep. sort.reclaimSupersededSortOutbox(PK, RELAY, 0); assert.ok(ls.has(key), "edit queued after the head is kept"); }); }); // ── (x) Legacy whole-blob replay is one-shot and value-sensitive ─────────────── -// -// The legacy v1 key is never deleted (it is mutable), so without a consumption -// marker `resumeWholeBlobOutbox` would return it on EVERY boot and the hook would -// republish the stale blob above the current relay head forever (Thufir pass-2 -// resurrection finding). The per-value marker records the exact legacy raw a -// prior boot replayed: an unchanged legacy blob is skipped, a rewritten one (a -// live old build) is replayed again. The hook transfers the intent into its own -// v2 key (synchronous publish) BEFORE writing the marker, so a crash between the -// two replays the legacy blob once more rather than losing it. +// Without a consumption marker, resumeWholeBlobOutbox would return the legacy blob +// on EVERY boot and the hook would republish the stale blob forever. The per-value +// marker records the exact legacy raw a prior boot replayed. test("(x) sort: retained legacy blob replays once, then is skipped across later boots", () => { withStorage((ls) => { ls.setItem(legacyKey("sort"), JSON.stringify(sortStore({ dms: "recent" }))); - - // Boot 1: the legacy blob resumes and reports itself for consumption. const boot1 = sort.readChannelSortOutbox(PK, RELAY); assert.equal(boot1.store.groups.dms, "recent", "legacy blob resumes"); assert.equal( boot1.legacyRawToConsume, JSON.stringify(sortStore({ dms: "recent" })), - "reports the exact legacy raw to consume", + "reports exact legacy raw", ); - // Hook: publish transfers intent to a v2 key, then marks consumed; model the - // published-and-cleared steady state (own key gone after a successful ACK). sort.markChannelSortLegacyConsumed(PK, RELAY, boot1.legacyRawToConsume); - assert.ok(ls.has(legacyKey("sort")), "legacy key is still never deleted"); - - // Boot 2 and beyond: the unchanged legacy blob is excluded — no resurrection. + assert.ok(ls.has(legacyKey("sort")), "legacy key never deleted"); assert.equal( sort.readChannelSortOutbox(PK, RELAY), null, - "consumed legacy blob is not replayed again", + "consumed blob not replayed again", ); assert.equal( sort.readChannelSortOutbox(PK, RELAY), null, - "still skipped on a third boot", + "still skipped on third boot", ); }); }); @@ -560,8 +470,6 @@ test("(x) sections: a rewritten legacy blob (live old build) is replayed again", null, "consumed blob skipped", ); - - // A live old build rewrites the legacy key with NEW intent (different raw). writeAt( ls, legacyKey("sections"), @@ -572,12 +480,9 @@ test("(x) sections: a rewritten legacy blob (live old build) is replayed again", assert.deepEqual( boot3.store.sections, [{ id: "s2", name: "Two", order: 0 }], - "changed legacy value is replayed", - ); - assert.ok( - boot3.legacyRawToConsume !== null, - "new legacy raw reported for a fresh consumption marker", + "changed legacy value replayed", ); + assert.ok(boot3.legacyRawToConsume !== null); }); }); @@ -585,34 +490,24 @@ test("(x) sort: crash after v2 transfer but before the marker resumes from the v withStorage((ls) => { ls.setItem(legacyKey("sort"), JSON.stringify(sortStore({ dms: "recent" }))); const boot1 = sort.readChannelSortOutbox(PK, RELAY); - // Hook order: publish (synchronous writeOwnOutbox → v2 key) THEN mark. Model - // a crash in that gap: the v2 key exists, the marker was never written. - sort.writeChannelSortOutbox(PK, boot1.store, RELAY); - // No markChannelSortLegacyConsumed — crash before it. - - // Boot 2: marker absent, so the legacy blob (queuedAt 0) is enumerated, but - // the v2 key (queuedAt > 0) wins the whole-blob max — intent is not lost, - // and the winner is not the legacy record so nothing is re-consumed. + sort.writeChannelSortOutbox(PK, boot1.store, RELAY); // transfer to v2; crash before mark const boot2 = sort.readChannelSortOutbox(PK, RELAY); assert.equal(boot2.store.groups.dms, "recent", "intent survives in v2 key"); assert.equal( boot2.legacyRawToConsume, null, - "winner is the v2 key, not the legacy record", + "winner is the v2 key, not legacy", ); }); }); -test("(x) stars (merge lane): a legacy blob the head subsumes needs no replay publish", () => { +test("(x) stars (merge lane): head-subsumed legacy fold needs no replay publish", () => { withStorage((ls) => { ls.setItem( legacyKey("stars"), JSON.stringify(starStore({ a: starEntry(true, 100, 1) })), ); const outbox = stars.readChannelStarsOutbox(PK, RELAY); - // The hook skips the boot replay publish when the found head subsumes the - // fold — so a lingering never-deleted legacy key doesn't re-drive an - // identical publish on every boot. assert.ok( stars.isStarsStoreSubsumedBy( outbox, @@ -620,15 +515,14 @@ test("(x) stars (merge lane): a legacy blob the head subsumes needs no replay pu ), "head-subsumed legacy fold is publish-free", ); - // A head that does NOT yet reflect the legacy click still publishes. assert.ok( !stars.isStarsStoreSubsumedBy(outbox, starStore({})), - "an unsubsumed legacy click still needs a publish", + "unsubsumed legacy click still needs a publish", ); }); }); -// ── Own-key round trip: write, read, clear (single-window baseline) ──────────── +// ── Own-key round trip: write, read, clear ──────────────────────────────────── test("own key: write resumes, clear removes only this window's own keys", () => { withStorage((ls) => { @@ -645,8 +539,7 @@ test("own key: write resumes, clear removes only this window's own keys", () => } return false; }; - assert.ok(hasOwn(), "own edit is written under this window's nonce"); - // A foreign peer key is untouched by an own-key clear. + assert.ok(hasOwn(), "own edit written under this window's nonce"); writeAt( ls, foreignKey("stars", "peer", 0), diff --git a/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs b/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs index 5613c2402a..43de8c13ec 100644 --- a/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs +++ b/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs @@ -1,4 +1,12 @@ // Shared helpers for sidebar sync manager tests. +// +// Exports: +// makeFakeWindow() — single-slot timer fake (whole-blob simple tests) +// installFakeWindow(fw) — install window substitutes, returns restore fn +// makeTimerBed() — multi-slot timer fake for overlapping-publish tests +// installEchoTauri(pubkey) — faithful crypto seam (encrypt/decrypt/sign) +// installTauriMock(payload) — simple one-shot decrypt mock +// makeHookStubs() — shared relay+Tauri stubs for React hook tests export function makeFakeWindow() { const storage = new Map(); @@ -119,6 +127,101 @@ export function installEchoTauri(pubkey) { }; } +// Multi-slot timer fake keyed by delay — for overlapping-publish tests. +// Returns { win, timers, fireDelay, restore }. Must call restore() in finally. +export 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); + if (!entry) throw new Error(`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 hasDelay = (ms) => [...timers.values()].some((t) => t.ms === ms); + const restore = installFakeWindow(win); + return { win, timers, fireDelay, hasDelay, restore }; +} + +// Stub relay + Tauri for React hook tests. +// Returns { stubRelay, stubTauri } functions. +export function makeHookStubs() { + 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); + } + + 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; + }; + } + + return { stubRelay, stubTauri }; +} + export function installTauriMock(goodCipherPayload) { const orig = globalThis.window?.__TAURI_INTERNALS__; if (typeof globalThis.window === "undefined") globalThis.window = {}; diff --git a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs index a61917c8b2..d113034f7b 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs @@ -2,11 +2,11 @@ import assert from "node:assert/strict"; import { after, before, test } from "node:test"; import { JSDOM } from "jsdom"; +import { makeHookStubs } from "./sidebarSyncTestHelpers.mjs"; const dom = new JSDOM("", { url: "http://localhost", }); - before(() => { Object.assign(globalThis, { document: dom.window.document, @@ -15,71 +15,9 @@ before(() => { window: dom.window, }); }); - 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; - }; -} +const { stubRelay, stubTauri } = makeHookStubs(); test("assignChannel refreshes an existing assignment before the next eviction", async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); @@ -90,12 +28,11 @@ test("assignChannel refreshes an existing assignment before the next eviction", const { useChannelSections } = await import("./useChannelSections.ts"); const restoreRelay = stubRelay(relayClient); - const pubkey = "pk-at-capacity"; const relayUrl = "wss://relay.example"; const assignments = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS }, (_, index) => [ - `chan-${String(index).padStart(4, "0")}`, + Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS }, (_, i) => [ + `chan-${String(i).padStart(4, "0")}`, "section-1", ]), ); @@ -110,15 +47,12 @@ test("assignChannel refreshes an existing assignment before the next eviction", assignments, }), ); - try { const { result, unmount } = renderHook(() => useChannelSections(pubkey, relayUrl), ); - act(() => result.current.assignChannel("chan-0000", "section-2")); act(() => result.current.assignChannel("chan-new", "section-1")); - assert.equal(result.current.assignments["chan-0000"], "section-2"); assert.equal(result.current.assignments["chan-new"], "section-1"); assert.equal(result.current.assignments["chan-0001"], undefined); @@ -133,23 +67,9 @@ test("assignChannel refreshes an existing assignment before the next eviction", } }); -// Carl P1.1 regression (sections): a peer-cache storage event arriving while a -// local whole-blob edit is pending must NOT clobber the optimistic edit or -// strand its outbox. The storage handler must defer to the pending edit just as -// `applyRemote` does for relay arrivals. The pending edit's debounced publish -// owns convergence and will publish-or-adopt against the relay. -// -// Scenario: window A creates section A1 (pending in outbox). Window B writes -// B1 (a different store) to the shared cache. A's storage handler fires. Before -// A's debounce fires, the user makes a second edit A2 inside the same debounce -// window. The resulting store must be derived from A1's state (A2 added on top -// of A1), and the outbox must still hold A's intent — not B1's shape. -// -// Mutation: removing the `hasPendingEdit()` guard in the storage handler causes -// setStore to apply B1, so A2 is derived from B1 instead. The outbox record -// still holds A's first edit shape, not the B1-derived A2 — an inconsistency -// the manager cannot resolve. The test detects the replacement: if B1 were -// applied, the final sections array would include B1's section id. +// Carl P1.1 regression: storage event while a local edit is pending must NOT +// clobber the optimistic edit. +// Mutation: removing `hasPendingEdit()` guard in the storage handler causes setStore to apply B1. test("storage event while a local edit is pending is deferred, not applied", async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); const { relayClient } = await import("@/shared/api/relayClient"); @@ -158,7 +78,6 @@ test("storage event while a local edit is pending is deferred, not applied", asy const restoreRelay = stubRelay(relayClient); const restoreTauri = stubTauri("pk-storage-guard", null); - const pubkey = "pk-storage-guard"; const relayUrl = "wss://r.storage-guard"; let hook = null; @@ -168,8 +87,6 @@ test("storage event while a local edit is pending is deferred, not applied", asy await Promise.resolve(); await Promise.resolve(); }); - - // A1: window A creates a section — this becomes the pending edit. await act(async () => { hook.result.current.createSection("A1-Section"); }); @@ -178,8 +95,6 @@ test("storage event while a local edit is pending is deferred, not applied", asy storeAfterA1.some((id) => id.length > 0), "A1 section created", ); - - // Window B writes B1 to the shared scoped cache key. const b1Store = JSON.stringify({ version: 1, sections: [{ id: "b1-section", name: "B1", order: 0 }], @@ -197,31 +112,22 @@ test("storage event while a local edit is pending is deferred, not applied", asy await Promise.resolve(); await Promise.resolve(); }); - - // B1 must NOT have replaced A1's optimistic state. - const storeAfterStorageEvent = hook.result.current.sections.map( - (s) => s.id, - ); assert.ok( - !storeAfterStorageEvent.includes("b1-section"), - "storage event while pending must not apply B1 to A's optimistic state", + !hook.result.current.sections.map((s) => s.id).includes("b1-section"), + "storage event while pending must not apply B1", ); assert.deepEqual( - storeAfterStorageEvent, + hook.result.current.sections.map((s) => s.id), storeAfterA1, - "optimistic state must be unchanged after the deferred storage event", + "optimistic state unchanged after deferred storage event", ); - - // A2: derived from A1's state (since B1 was deferred). await act(async () => { hook.result.current.createSection("A2-Section"); }); - const finalIds = hook.result.current.sections.map((s) => s.id); assert.ok( - !finalIds.includes("b1-section"), - "A2 must be derived from A1 state, not B1 — b1-section must not appear", + !hook.result.current.sections.map((s) => s.id).includes("b1-section"), + "A2 derived from A1 state — b1-section must not appear", ); - hook.unmount(); } finally { cleanup(); @@ -230,11 +136,9 @@ test("storage event while a local edit is pending is deferred, not applied", asy } }); -// Fix 2 regression: a live remote arriving while a local edit is pending must -// NOT overwrite the optimistic edit or strand its durable outbox. The pending -// edit's own debounced publish owns convergence (publish-or-adopt). Reverting -// applyRemote's hasPendingEdit guard makes the live event clobber the UI and -// leave the outbox replay-eligible. +// Fix 2 regression: live remote while a local edit is pending must NOT overwrite +// the optimistic edit or strand its durable outbox. +// Mutation: reverting applyRemote's hasPendingEdit guard clobbers the UI. 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"); @@ -252,10 +156,8 @@ test("live remote while a local edit is pending defers to the pending edit", asy assignments: {}, }), ); - const pubkey = "pk-live-pending"; const relayUrl = "wss://r.live"; - let hook = null; try { await act(async () => { @@ -265,8 +167,6 @@ test("live remote while a local edit is pending defers to the pending edit", asy await Promise.resolve(); }); 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.createSection("Local"); }); @@ -275,8 +175,6 @@ test("live remote while a local edit is pending defers to the pending edit", asy "local edit persisted to outbox", ); const localSectionIds = hook.result.current.sections.map((s) => s.id); - - // A remote live event arrives while the edit is still pending. await act(async () => { live.cb({ id: "remote-event", @@ -290,15 +188,14 @@ test("live remote while a local edit is pending defers to the pending edit", asy await Promise.resolve(); await Promise.resolve(); }); - assert.deepEqual( hook.result.current.sections.map((s) => s.id), localSectionIds, - "pending local edit must NOT be overwritten by the live remote", + "pending local edit NOT overwritten by live remote", ); assert.ok( readChannelSectionsOutbox(pubkey, relayUrl), - "outbox for the pending edit must survive the live remote", + "outbox for pending edit survives live remote", ); hook.unmount(); } finally { @@ -309,9 +206,8 @@ test("live remote while a local edit is pending defers to the pending edit", asy }); // Fix 3 regression: 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 store must win. -// Reverting applyRemote's `>=` back to `<=` converges on the larger id instead. +// winner (lowest id wins). +// Mutation: reverting applyRemote's `>=` back to `<=` converges on the larger 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"); @@ -319,7 +215,6 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn 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({ @@ -328,7 +223,6 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn assignments: {}, }); }); - const pubkey = "pk-tie"; const relayUrl = "wss://r.tie"; let hook = null; @@ -340,14 +234,13 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn await Promise.resolve(); }); assert.ok(live.cb, "live subscription installed"); - const deliver = async (id) => { await act(async () => { live.cb({ id, pubkey, created_at: 1000, - content: id, // decrypt echoes this into the section id + content: id, kind: 30078, tags: [["d", "channel-sections"]], sig: "s", @@ -356,14 +249,12 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn await Promise.resolve(); }); }; - - await deliver("bbbb"); // larger id first - await deliver("aaaa"); // lower id — relay canonical winner - + await deliver("bbbb"); + await deliver("aaaa"); assert.deepEqual( hook.result.current.sections.map((s) => s.id), ["aaaa"], - "lower event id must win the equal-timestamp tie-break", + "lower event id wins tie-break", ); hook.unmount(); } finally { @@ -373,15 +264,9 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn } }); -// Carl P1-sections regression: on reconnect the hook must wake the EXISTING -// pending edit (retryPendingPublish) rather than re-queue it via -// publishSections(). 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 (the losing local section dropped) and NOTHING -// published. Reverting the reconnect handler to publishSections(pending) resets -// the baseline and publishes the stale edit. +// Carl P1-sections: on reconnect the hook must wake the EXISTING pending edit +// (retryPendingPublish) rather than re-queue it via publishSections(). +// Mutation: reverting reconnect handler to publishSortPrefs(pending) resets baseline. 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"); @@ -389,7 +274,6 @@ test("reconnect adopts a remote that advanced while the edit was pending, never const reconnect = {}; const publishCalls = []; - // A mutable head: bumped to 200 right before reconnect fires. let head = { pubkey: "pk-sec-recon", content: "remote-cipher", @@ -399,7 +283,7 @@ test("reconnect adopts a remote that advanced while the edit was pending, never const origFetch = relayClient.fetchEvents; relayClient.fetchEvents = async () => [head]; const restoreRelay = stubRelay(relayClient, { reconnect, publishCalls }); - relayClient.fetchEvents = async () => [head]; // override stubRelay's fetchEvents + relayClient.fetchEvents = async () => [head]; const restoreTauri = stubTauri("pk-sec-recon", () => JSON.stringify({ version: 1, @@ -407,7 +291,6 @@ test("reconnect adopts a remote that advanced while the edit was pending, never assignments: {}, }), ); - const pubkey = "pk-sec-recon"; const relayUrl = "wss://r.recon"; let hook = null; @@ -419,7 +302,6 @@ test("reconnect adopts a remote that advanced while the edit was pending, never await Promise.resolve(); }); assert.ok(reconnect.cb, "reconnect handler installed"); - await act(async () => { hook.result.current.createSection("Local"); }); @@ -427,8 +309,6 @@ test("reconnect adopts a remote that advanced while the edit was pending, never hook.result.current.sections.some((s) => s.name === "Local"), "optimistic local section applied", ); - - // Remote advances to 200 (peer won LWW), then reconnect fires. head = { pubkey: "pk-sec-recon", content: "remote-cipher", @@ -442,16 +322,15 @@ test("reconnect adopts a remote that advanced while the edit was pending, never await Promise.resolve(); await Promise.resolve(); }); - assert.equal( publishCalls.length, 0, - "must adopt the advanced remote on reconnect, never publish", + "must adopt advanced remote on reconnect, never publish", ); assert.deepEqual( hook.result.current.sections.map((s) => s.id), ["remote"], - "the losing local edit must be adopted away by the advanced remote", + "losing local edit adopted away", ); hook.unmount(); } finally { @@ -462,13 +341,9 @@ test("reconnect adopts a remote that advanced while the edit was pending, never } }); -// Thufir pass-3 regression (sections lane): a legacy replay whose durability -// transfer into this window's own v2 key fails (quota) must NOT write the -// consumed marker. Otherwise the marker permanently suppresses the only copy of -// the legacy edit on every later boot — silent loss of exactly the durability -// this PR guarantees. The hook now gates markLegacyConsumed on the `durable` -// flag publishSections() returns; removing that guard writes the marker -// unconditionally and this test goes red. +// Thufir pass-3: a legacy replay whose v2 transfer fails (quota) must NOT write +// the consumed marker. +// Mutation: writing the marker unconditionally loses the legacy blob. 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"); @@ -479,7 +354,6 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed const { normalizeRelayUrl } = await import("@/shared/lib/normalizeRelayUrl"); const origLocalStorage = window.localStorage; - const pubkey = "pk-sec-quota"; const relayUrl = "wss://r.sec-quota"; const scope = `${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; @@ -493,8 +367,6 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed }, queuedAt: 0, }); - - // 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), @@ -509,36 +381,27 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed }, key: (i) => [...map.keys()][i] ?? null, }; - const restoreRelay = stubRelay(relayClient); const restoreTauri = stubTauri(pubkey, null); - let hook = null; try { 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))}`, "1700000000", ); - await act(async () => { hook = renderHook(() => useChannelSections(pubkey, relayUrl)); for (let i = 0; i < 4; i++) await Promise.resolve(); }); - - const markerWritten = [...map.keys()].some((k) => - k.includes("-legacy-consumed:"), - ); assert.equal( - markerWritten, + [...map.keys()].some((k) => k.includes("-legacy-consumed:")), false, - "consumed marker must not be written after a failed v2 transfer", + "consumed marker must not be written after failed v2 transfer", ); - const resumed = readChannelSectionsOutbox(pubkey, relayUrl); assert.ok(resumed !== null, "legacy blob must remain replayable"); assert.equal( @@ -548,7 +411,7 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed ); assert.ok( resumed.legacyRawToConsume !== null, - "legacy record still reports itself for consumption", + "legacy record reports itself for consumption", ); hook.unmount(); } finally { diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs b/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs index 61e989f56b..b247d357c3 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs @@ -2,11 +2,11 @@ import assert from "node:assert/strict"; import { after, before, test } from "node:test"; import { JSDOM } from "jsdom"; +import { makeHookStubs } from "./sidebarSyncTestHelpers.mjs"; const dom = new JSDOM("", { url: "http://localhost", }); - before(() => { Object.assign(globalThis, { document: dom.window.document, @@ -15,76 +15,13 @@ before(() => { window: dom.window, }); }); - after(() => dom.window.close()); -// ── Shared test helpers ─────────────────────────────────────────────────────── +const { stubRelay, stubTauri } = makeHookStubs(); -/** 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. -// 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. +// Carl P1.1 regression: storage event while a local sort edit is pending must +// NOT clobber the optimistic edit. +// Mutation: removing `hasPendingEdit()` guard lets B1 apply, so A2 derives from B1. test("storage event while a local sort edit is pending is deferred, not applied", async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); const { relayClient } = await import("@/shared/api/relayClient"); @@ -95,7 +32,6 @@ test("storage event while a local sort edit is pending is deferred, not applied" const restoreRelay = stubRelay(relayClient); const restoreTauri = stubTauri("pk-sort-sg", null); - const pubkey = "pk-sort-sg"; const relayUrl = "wss://r.sort-sg"; let hook = null; @@ -105,8 +41,6 @@ test("storage event while a local sort edit is pending is deferred, not applied" await Promise.resolve(); await Promise.resolve(); }); - - // A1: set "channels" to "alpha" — becomes the pending edit. await act(async () => { hook.result.current.setSortModeFor("channels", "alpha"); }); @@ -115,8 +49,6 @@ test("storage event while a local sort edit is pending is deferred, not applied" "alpha", "A1: channels sort set to alpha", ); - - // Window B writes "recent" for "channels" to the shared cache. const b1Store = JSON.stringify({ version: 1, groups: { channels: "recent" }, @@ -133,23 +65,19 @@ test("storage event while a local sort edit is pending is deferred, not applied" await Promise.resolve(); await Promise.resolve(); }); - assert.equal( hook.result.current.sortModeFor("channels"), "alpha", - "storage event while pending must not apply B1 (recent) over A1 (alpha)", + "storage event while pending must not apply B1", ); - - // A2: derived from A1 state (channels stays alpha). await act(async () => { hook.result.current.setSortModeFor("starred", "recent"); }); assert.equal( hook.result.current.sortModeFor("channels"), "alpha", - "A2 must be derived from A1 state — channels must remain alpha", + "A2 derived from A1 — channels remains alpha", ); - hook.unmount(); } finally { cleanup(); @@ -158,8 +86,8 @@ test("storage event while a local sort edit is pending is deferred, not applied" } }); -// 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. +// Carl P1-sort: 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"); @@ -174,7 +102,6 @@ test("live remote while a local edit is pending defers to the pending edit", asy 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; @@ -186,7 +113,6 @@ test("live remote while a local edit is pending defers to the pending edit", asy await Promise.resolve(); }); assert.ok(live.cb, "live subscription installed"); - await act(async () => { hook.result.current.setSortModeFor("channels", "recent"); }); @@ -199,7 +125,6 @@ test("live remote while a local edit is pending defers to the pending edit", asy "recent", "optimistic local edit applied", ); - await act(async () => { live.cb({ id: "remote-event", @@ -213,20 +138,19 @@ test("live remote while a local edit is pending defers to the pending edit", asy await Promise.resolve(); await Promise.resolve(); }); - assert.equal( hook.result.current.sortModeFor("channels"), "recent", - "pending local edit must NOT be overwritten", + "pending local edit NOT overwritten", ); assert.equal( hook.result.current.sortModeFor("remote-group"), "alpha", - "remote store must not have been applied over the pending edit", + "remote store not applied over pending edit", ); assert.ok( readChannelSortOutbox(pubkey, relayUrl), - "outbox for the pending edit must survive the live remote", + "outbox survives live remote", ); hook.unmount(); } finally { @@ -237,7 +161,7 @@ test("live remote while a local edit is pending defers to the pending edit", asy }); // 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. +// Mutation: reverting applyRemote's `>=` back to `<=` 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"); @@ -247,12 +171,10 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn 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"; let hook = null; @@ -264,7 +186,6 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn await Promise.resolve(); }); assert.ok(live.cb, "live subscription installed"); - const deliver = async (id) => { await act(async () => { live.cb({ @@ -280,19 +201,17 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn await Promise.resolve(); }); }; - await deliver("bbbb"); await deliver("aaaa"); - assert.equal( hook.result.current.sortModeFor("aaaa"), "recent", - "lower event id (relay canonical winner) must be applied", + "lower event id (relay canonical winner) applied", ); assert.equal( hook.result.current.sortModeFor("bbbb"), "alpha", - "larger id's store must be superseded by the lower-id whole-blob winner", + "larger id's store superseded", ); hook.unmount(); } finally { @@ -302,10 +221,9 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn } }); -// Carl P1-sort regression: on reconnect the hook must wake the EXISTING pending -// edit (retryPendingPublish) rather than re-queue it via publishSortPrefs(). -// Mutation: reverting the reconnect handler to publishSortPrefs(pending) resets -// the baseline and publishes the stale edit over the advanced remote. +// Carl P1-sort: on reconnect the hook must wake the EXISTING pending edit +// (retryPendingPublish) rather than re-queue it via publishSortPrefs(). +// Mutation: reverting to publishSortPrefs(pending) resets the baseline. 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"); @@ -324,11 +242,10 @@ test("reconnect adopts a remote that advanced while the edit was pending, never const origFetch = relayClient.fetchEvents; relayClient.fetchEvents = async () => [head]; const restoreRelay = stubRelay(relayClient, { reconnect, publishCalls }); - relayClient.fetchEvents = async () => [head]; // override stubRelay's fetchEvents + relayClient.fetchEvents = async () => [head]; const restoreTauri = stubTauri("pk-sort-recon", () => JSON.stringify({ version: 1, groups: { "remote-group": "recent" } }), ); - const pubkey = "pk-sort-recon"; const relayUrl = "wss://r.recon"; let hook = null; @@ -340,11 +257,9 @@ test("reconnect adopts a remote that advanced while the edit was pending, never await Promise.resolve(); }); assert.ok(reconnect.cb, "reconnect handler installed"); - await act(async () => { hook.result.current.setSortModeFor("channels", "recent"); }); - head = { pubkey: "pk-sort-recon", content: "remote-cipher", @@ -358,21 +273,20 @@ test("reconnect adopts a remote that advanced while the edit was pending, never await Promise.resolve(); await Promise.resolve(); }); - assert.equal( publishCalls.length, 0, - "must adopt the advanced remote on reconnect, never publish", + "must adopt advanced remote on reconnect, never publish", ); assert.equal( hook.result.current.sortModeFor("channels"), "alpha", - "the losing local edit must be adopted away", + "losing local edit adopted away", ); assert.equal( hook.result.current.sortModeFor("remote-group"), "recent", - "the advanced remote store must be adopted", + "advanced remote store adopted", ); hook.unmount(); } finally { @@ -383,9 +297,9 @@ test("reconnect adopts a remote that advanced while the edit was pending, never } }); -// 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. +// Thufir pass-3: legacy blob whose v2 transfer fails (quota) must NOT write the +// consumed marker. +// Mutation: writing the marker unconditionally loses the legacy blob. 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"); @@ -396,7 +310,6 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed const { normalizeRelayUrl } = await import("@/shared/lib/normalizeRelayUrl"); const origLocalStorage = window.localStorage; - const pubkey = "pk-quota"; const relayUrl = "wss://r.quota"; const scope = `${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; @@ -406,7 +319,6 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed store: { version: 1, groups: { "legacy-group": "recent" } }, queuedAt: 0, }); - const map = new Map([[legacyKey, legacyRaw]]); const throwingStorage = { getItem: (k) => (map.has(k) ? map.get(k) : null), @@ -421,10 +333,8 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed }, key: (i) => [...map.keys()][i] ?? null, }; - const restoreRelay = stubRelay(relayClient); const restoreTauri = stubTauri(pubkey, null); - let hook = null; try { Object.defineProperty(window, "localStorage", { @@ -435,7 +345,6 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed `buzz-sync-watermark.v1:channel-sort:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`, "1700000000", ); - await act(async () => { hook = renderHook(() => useChannelSortPreference(pubkey, relayUrl)); await Promise.resolve(); @@ -443,25 +352,21 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed await Promise.resolve(); await Promise.resolve(); }); - - const markerWritten = [...map.keys()].some((k) => - k.includes("-legacy-consumed:"), - ); assert.equal( - markerWritten, + [...map.keys()].some((k) => k.includes("-legacy-consumed:")), false, - "consumed marker must not be written after a failed v2 transfer", + "consumed marker must not be written after failed v2 transfer", ); 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", + "exact legacy intent survives", ); assert.ok( resumed.legacyRawToConsume !== null, - "legacy record still reports itself for consumption", + "legacy record reports itself for consumption", ); hook.unmount(); } finally { diff --git a/desktop/src/features/sidebar/lib/wholeBlobSync.shared.test.mjs b/desktop/src/features/sidebar/lib/wholeBlobSync.shared.test.mjs index ee56570509..9f9da162fe 100644 --- a/desktop/src/features/sidebar/lib/wholeBlobSync.shared.test.mjs +++ b/desktop/src/features/sidebar/lib/wholeBlobSync.shared.test.mjs @@ -1,11 +1,6 @@ -// Authoritative whole-blob sync suite, run directly against -// ChannelSectionSyncManager. -// -// 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. channelSortSync.test.mjs covers the same engine via -// its compact adapter invocation. +// Authoritative whole-blob sync suite — runs directly against +// ChannelSectionSyncManager. Covers the 15 structural invariants common to +// whole-blob lanes. Lane-specific tests stay in the lane files. import assert from "node:assert/strict"; import test, { mock } from "node:test"; @@ -16,6 +11,7 @@ import { readChannelSectionsOutbox } from "./channelSectionsStorage.ts"; import { makeFakeWindow, installFakeWindow, + makeTimerBed, installTauriMock, installEchoTauri, } from "./sidebarSyncTestHelpers.mjs"; @@ -35,49 +31,11 @@ const decryptPayload = JSON.stringify( ); const emptyDecryptPayload = JSON.stringify(makeStore([])); -// 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() ──────────────────────────────────────────────────────────── +// ─── 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(); - }); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); const restore = installFakeWindow(fw); try { @@ -86,7 +44,6 @@ test("destroy: cancels pending publish without flushing to the relay", () => { 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(); @@ -129,11 +86,11 @@ test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolv } }); -// ─── Boot seed-publish guard (revert-fix regression suite) ──────────────── +// ─── Boot seed-publish guard ────────────────────────────────────────────────── for (const { title, setupFetch, setupWatermark, pubkey, assertPending } of [ { - title: "fetch failed (error) does not trigger seed-publish via bootstrap", + title: "fetch error does not trigger seed-publish", setupFetch: () => mock.method(relayClient, "fetchEvents", () => Promise.reject(new Error("relay timeout")), @@ -141,8 +98,7 @@ for (const { title, setupFetch, setupWatermark, pubkey, assertPending } of [ assertPending: (m) => assert.equal(m.getPendingStore(), null), }, { - title: - "absent fetch with prior watermark blocks seed-publish via bootstrap", + title: "absent fetch with prior watermark blocks seed-publish", setupFetch: () => mock.method(relayClient, "fetchEvents", () => Promise.resolve([])), setupWatermark: (fw) => @@ -154,8 +110,7 @@ for (const { title, setupFetch, setupWatermark, pubkey, assertPending } of [ assertPending: (m) => assert.equal(m.getPendingStore(), null), }, { - title: - "absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", + title: "absent fetch with zero watermark seeds (first-sync preserved)", setupFetch: () => mock.method(relayClient, "fetchEvents", () => Promise.resolve([])), pubkey: "pk-fresh", @@ -181,7 +136,7 @@ for (const { title, setupFetch, setupWatermark, pubkey, assertPending } of [ }); } -// ─── Adopt-winner / local-winner ───────────────────────────────────────── +// ─── Adopt-winner / local-winner ───────────────────────────────────────────── test("adopt-winner: newer remote head at pre-publish adopts remote and skips publish", async () => { mock.method(relayClient, "fetchEvents", () => @@ -209,27 +164,26 @@ test("adopt-winner: newer remote head at pre-publish adopts remote and skips pub manager.publishSections(nonEmptyStore()); assert.ok( readChannelSectionsOutbox("pk-lww", RELAY) !== null, - "edit must be persisted to the durable outbox", + "edit must be in 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", + "must not publish when remote 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(manager.getPendingStore(), null); assert.equal( readChannelSectionsOutbox("pk-lww", RELAY), null, - "outbox must be cleared on adopt", + "outbox cleared on adopt", ); manager.destroy(); } finally { @@ -260,7 +214,7 @@ test("adopt-winner: local edit at/ahead of head publishes and clears outbox", as assert.equal( readChannelSectionsOutbox("pk-win", RELAY), null, - "outbox must be cleared once the edit is confirmed retained", + "outbox cleared after confirmed publish", ); manager.destroy(); } finally { @@ -270,7 +224,7 @@ test("adopt-winner: local edit at/ahead of head publishes and clears outbox", as } }); -// ─── Timestamp clamp ────────────────────────────────────────────────────── +// ─── Timestamp clamp ───────────────────────────────────────────────────────── test("timestamp clamp: published createdAt stays inside the relay future window", async () => { const nowSecs = Math.floor(Date.now() / 1000); @@ -304,7 +258,7 @@ test("timestamp clamp: published createdAt stays inside the relay future window" 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}`, + `createdAt must be clamped — got ${signedCreatedAt}`, ); manager.destroy(); } finally { @@ -314,96 +268,62 @@ test("timestamp clamp: published createdAt stays inside the relay future window" } }); -// ─── Unreadable head: retain, never overwrite ───────────────────────────── +// ─── Retain / retry on bad head ─────────────────────────────────────────────── -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(); +for (const { title, pubkey, content, tauri: tauriPayload } of [ + { + title: "unreadable head (decrypt failure)", + pubkey: "pk-undec", + content: "bad-cipher", + tauri: installTauriMock.bind(null, "{}"), + }, + { + title: "failed pre-publish fetch", + pubkey: "pk-fetchfail", + content: null, + tauri: installTauriMock.bind(null, "{}"), + }, +]) { + test(`${title} retains the pending edit and retries, never publishing`, async () => { + if (content === null) { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("socket timeout")), + ); + } else { + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([{ pubkey, content, created_at: 500, id: "evt" }]), + ); + } + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const t = tauriPayload(); + try { + const manager = new ChannelSectionSyncManager(pubkey, RELAY); + manager.publishSections(nonEmptyStore()); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal(publishCalls.length, 0, "must not publish"); + assert.ok(manager.getPendingStore() !== null, "pending edit retained"); + assert.ok( + readChannelSectionsOutbox(pubkey, RELAY) !== null, + "durable outbox intact", + ); + assert.ok(fw._hasTimer(), "retry scheduled"); + manager.destroy(); + } finally { + t.restore(); + restore(); + mock.reset(); + } }); - 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 ───────────────────────────────────────────────── +// ─── Overlapping publishes ──────────────────────────────────────────────────── test("overlapping publishes: older completion does not erase a newer queued edit", async () => { mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); @@ -411,11 +331,10 @@ test("overlapping publishes: older completion does not erase a newer queued edit let publishCalls = 0; mock.method(relayClient, "publishEvent", () => { publishCalls++; - if (publishCalls === 1) { + if (publishCalls === 1) return new Promise((res) => { releaseFirst = res; }); - } return Promise.resolve(); }); const { timers, fireDelay, restore } = makeTimerBed(); @@ -427,32 +346,29 @@ test("overlapping publishes: older completion does not erase a newer queued edit 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", + "B is pending", ); 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", + "older completion leaves B pending", ); assert.ok( readChannelSectionsOutbox("pk-overlap", RELAY) !== null, - "older completion must leave B's outbox intact", + "older completion leaves B outbox intact", ); assert.ok( [...timers.values()].some((t) => t.ms === 2000), - "B's debounce timer must survive so it still publishes", + "B debounce timer survives", ); manager.destroy(); } finally { @@ -462,7 +378,7 @@ test("overlapping publishes: older completion does not erase a newer queued edit } }); -// ─── Live remote during debounce is adopted at pre-publish ──────────────── +// ─── Live remote during debounce ────────────────────────────────────────────── test("live remote during debounce is adopted at pre-publish, not overwritten", async () => { let liveCallback = null; @@ -509,9 +425,9 @@ test("live remote during debounce is adopted at pre-publish, not overwritten", a assert.equal( publishCalls.length, 0, - "must not publish over a remote that became head during the debounce", + "must not publish over a remote that became head during debounce", ); - assert.equal(adopted.length, 1, "the newer remote must be adopted"); + assert.equal(adopted.length, 1, "newer remote must be adopted"); manager.destroy(); } finally { tauri.restore(); @@ -520,7 +436,7 @@ test("live remote during debounce is adopted at pre-publish, not overwritten", a } }); -// ─── Same-second collision: second edit queued during confirmation survives ─ +// ─── Same-second collision ──────────────────────────────────────────────────── test("same-second collision: second edit queued during confirmation survives", async () => { let fetchCalls = 0; @@ -554,20 +470,16 @@ test("same-second collision: second edit queued during confirmation survives", a 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, @@ -576,27 +488,21 @@ test("same-second collision: second edit queued during confirmation survives", a assert.notEqual( manager.getPendingStore(), null, - "edit-2 must still be pending after the stale-gen adopt", + "edit-2 still pending after 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(adopted.length, 0, "edit-2 must not be adopted away"); assert.equal( manager.getPendingStore(), null, - "edit-2 clears via its own confirmed publish", + "edit-2 clears via confirmed publish", ); assert.equal( readChannelSectionsOutbox("pk-collide2", RELAY), null, - "edit-2's durable outbox is cleared on confirmation", + "edit-2 outbox cleared", ); manager.destroy(); } finally { @@ -606,8 +512,6 @@ test("same-second collision: second edit queued during confirmation survives", a } }); -// ─── 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; @@ -616,49 +520,38 @@ test("same-second collision: loser adopts the winner and its next edit survives" 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); + return Promise.resolve(fetchCalls === 1 ? [] : storedHead); }); const winnerStore = makeStore([{ id: "peer", name: "Peer", order: 0 }]); mock.method(relayClient, "publishEvent", (event) => { publishCalls++; - if (publishCalls === 1) { + 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", - ); + assert.equal(publishCalls, 2, "edit after adopt must publish successfully"); manager.destroy(); } finally { tauri.restore(); @@ -667,7 +560,7 @@ test("same-second collision: loser adopts the winner and its next edit survives" } }); -// ─── live-sub: undecryptable event advances watermark before decrypt ─────── +// ─── live-sub: undecryptable event advances watermark ──────────────────────── test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { let liveCallback = null; @@ -687,10 +580,7 @@ test("revert-fix: undecryptable live event advances watermark before decrypt att "watermark starts absent", ); await manager.subscribeToSections(() => {}); - assert.ok( - liveCallback !== null, - "subscribeLive must have captured the callback", - ); + assert.ok(liveCallback !== null, "subscribeLive captured the callback"); liveCallback({ pubkey: "pk-live", content: "!bad-cipher!", @@ -704,7 +594,7 @@ test("revert-fix: undecryptable live event advances watermark before decrypt att `buzz-sync-watermark.v1:${watermarkLane}:pk-live:${RELAY_KEY}`, ) ?? "0", ) >= 1700005555, - "live undecryptable event must advance the watermark before decrypt is attempted", + "live undecryptable event must advance watermark before decrypt", ); manager.destroy(); } finally {