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: -// `