fix(sidebar): correct test bugs from consolidation pass

Six categories of test bugs introduced during the compression pass:

1. Unbound restore() in finally blocks (makeTimerBed pattern) — fixed
   to t.restore() in mergeLaneSync.shared.test.mjs.

2. Stale key-ID snapshot in mergeLaneHook v2 own-key test — writeOwnOutbox
   replaces the bootstrap key on click; test now enumerates current keys.

3. Missing assertionsAfterRestore in mergeLaneStorage destructuring — fixed.

4. Legacy-delete no-op: second readChannelStarsStore call moved from
   assertions (mock still active) to assertionsAfterRestore.

5. channelStarsSync / channelMutesSync writeOutbox arg order — production
   signature is (pubkey, store, relayUrl); args 2-3 were swapped.

6. cancelPendingPublish does not clear pendingStore (by design); removed
   incorrect getPendingStore() === null assertions in typed-API tests.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-31 15:29:24 -04:00
parent f0d34dc608
commit 0f98440bea
14 changed files with 1769 additions and 1869 deletions

View File

@@ -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");
});

View File

@@ -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();
}
});

View File

@@ -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.

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();
}
});

View File

@@ -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("<!doctype html><html><body></body></html>", {
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);
}
});
}

View File

@@ -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(),
});

View File

@@ -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:
// `<prefix>:<pubkey>:<relay>:<nonce>:<seq>`, where the nonce is stable per
// window (sessionStorage) and `seq` is a per-window monotonic counter. A window
// NEVER rewrites a key: a new edit writes a NEW key, then deletes its own older
// keys (write-before-delete, so a crash leaves ≥1 record, never zero). Because
// records are immutable, a booting peer that proves a foreign key reclaimable
// against durable relay evidence can delete it with no recheck — nothing can
// have changed at that key since the proof. Resume enumerates ALL windows'
// keys: merge lanes (stars/mutes) fold every record (order-independent);
// whole-blob lanes (sort/sections) replay the max-`queuedAt` record, ties broken
// by key. Reclamation runs AFTER replay so a same-second record the head appears
// to supersede is consumed into pending first. Whole-blob supersession is STRICT
// (`queuedAt` < head `created_at`) so a same-second record is never dropped, and
// the legacy v1 shared key is never deleted by v2 (it is mutable; only replayed).
// `<prefix>:<pubkey>:<relay>:<nonce>:<seq>`. A window NEVER rewrites a key:
// a new edit writes a NEW key then deletes its own older keys (write-before-delete).
// Because records are immutable, a booting peer that proves a foreign key
// reclaimable against durable relay evidence can delete it with no recheck.
// Resume enumerates ALL windows' keys: merge lanes fold every record; whole-blob
// lanes replay the max-queuedAt record, ties broken by key. Reclamation runs
// AFTER replay so a same-second record appears to supersede is consumed into
// pending first. Whole-blob supersession is STRICT (queuedAt < head created_at)
// so a same-second record is never dropped, and the legacy v1 shared key is
// never deleted by v2.
//
// This matrix drives the shared helpers directly through a mock Storage seam —
// no relay, no timers — so each interleaving is deterministic. The seam mocks
// both localStorage (with a read-then-mutate hook to interpose a foreign write
// between the reclaim decision-read and the delete) and sessionStorage (for the
// per-window nonce). Manager- and hook-level behavior is covered by their own
// suites; this file isolates the cross-window storage contract.
// This suite drives the shared helpers through a mock Storage seam — no relay,
// no timers — so each interleaving is deterministic. Manager/hook-level behavior
// is covered by their own suites; this file isolates the cross-window storage
// contract.
// A mock Storage. `onReadMutate(key, afterReads, fn)` runs `fn(map)` right after
// the Nth getItem of `key` returns its captured value — used to simulate a peer
// window rewriting a key in the reclaim recheck gap.
function makeStorage(initial = {}) {
const map = new Map(Object.entries(initial));
const reads = new Map();
@@ -69,7 +54,6 @@ function makeStorage(initial = {}) {
};
}
// Run `fn(localStorage)` with fresh mock local + session storage installed.
function withStorage(fn) {
const ls = makeStorage();
const ss = makeStorage();
@@ -103,9 +87,6 @@ const PREFIX = {
sections: "buzz-channel-sections-outbox.v1",
};
// A foreign window's key: same (pubkey, relay) scope, a different nonce than
// this process's own, plus a zero-padded `seq`. `legacyKey` is the
// pre-per-window shared key (no nonce/seq).
const SEQ_WIDTH = 12;
const pad = (seq) => String(seq).padStart(SEQ_WIDTH, "0");
const foreignKey = (lane, nonce, seq) =>
@@ -123,23 +104,15 @@ const sectionStore = (secs, assignments = {}) => ({
assignments,
});
// ── (i) Reclaim never deletes a foreign edit written in the decision→delete gap ─
//
// ── (i) Reclaim never deletes a foreign edit written in the decision→delete gap ──
// Records are write-once: an owner's fresh edit lands on a NEW key, never a
// rewrite of the enumerated one. reclaimOutbox proves the enumerated (stale)
// key reclaimable and deletes it; a peer edit injected during enumeration lives
// under its own key and is evaluated on its own merits (kept when the head does
// not subsume/supersede it). We interpose that fresh write via the read-mutate
// seam to prove GC removes only the proven-stale key and never the fresh one.
// rewrite. reclaimOutbox proves the enumerated (stale) key reclaimable and deletes
// it; a peer edit injected during enumeration lives under its own key.
test("(i) stars: reclaim deletes the proven-stale key and keeps a fresh edit written in the gap", () => {
withStorage((ls) => {
const stale = foreignKey("stars", "peerB", 0);
// Foreign edit the fetched head has already absorbed → decision = reclaim.
writeAt(ls, stale, starStore({ a: starEntry(true, 100, 1) }), 100);
// During enumeration (read of the stale key), the peer commits a FRESH edit
// to a NEW key the head does not reflect — the write-once analogue of the
// owner rewriting in the gap.
const fresh = foreignKey("stars", "peerB", 1);
ls.onReadMutate(stale, 1, (map) =>
map.set(
@@ -150,7 +123,6 @@ test("(i) stars: reclaim deletes the proven-stale key and keeps a fresh edit wri
}),
),
);
// Head carries `a` at (100,1) — subsumes the stale value, not the fresh one.
stars.reclaimSubsumedStarsOutbox(
PK,
RELAY,
@@ -176,8 +148,6 @@ test("(i) sort: reclaim deletes the proven-stale key and keeps a fresh edit writ
JSON.stringify({ store: sortStore({ dms: "recent" }), queuedAt: 500 }),
),
);
// Head created_at 200 strictly supersedes the queuedAt=100 stale record,
// not the interposed queuedAt=500 fresh key.
sort.reclaimSupersededSortOutbox(PK, RELAY, 200);
assert.ok(!ls.has(stale), "proven-stale key is reclaimed");
assert.ok(ls.has(fresh), "peer's fresh edit under a new key survives");
@@ -188,11 +158,7 @@ test("(i) sort: reclaim deletes the proven-stale key and keeps a fresh edit writ
});
});
// ── (ii) Two windows teardown/remount: every unpublished intent preserved ──────
//
// Windows A and B each persist an edit and quit; a fresh window remounts and
// enumerates both keys. Merge lanes keep BOTH; whole-blob lanes keep the newest
// (an older peer blob is LWW-superseded by definition, the documented residual).
// ── (ii) Two windows teardown/remount: every unpublished intent preserved ───────
test("(ii) stars (merge): both windows' distinct-channel edits resume", () => {
withStorage((ls) => {
@@ -214,7 +180,7 @@ test("(ii) stars (merge): both windows' distinct-channel edits resume", () => {
});
});
test("(ii) sort (whole-blob): the newest queued window resumes; older is LWW-superseded", () => {
test("(ii) sort (whole-blob): newest queued window resumes; older is LWW-superseded", () => {
withStorage((ls) => {
writeAt(
ls,
@@ -235,11 +201,7 @@ test("(ii) sort (whole-blob): the newest queued window resumes; older is LWW-sup
});
});
// ── (iii) Same-second whole-blob: head at t keeps a record queued at t ─────────
//
// One-second clock granularity means a record queued in the same second as the
// head cannot be proven to have lost LWW. Strict supersession (`queuedAt` <
// head `created_at`) keeps it; a strictly-earlier record is reclaimed.
// ── (iii) Same-second whole-blob: head at t keeps a record queued at t ──────────
test("(iii) sort: same-second record kept, strictly-earlier reclaimed", () => {
withStorage((ls) => {
@@ -270,32 +232,24 @@ test("(iii) sections: same-second record kept, strictly-earlier reclaimed", () =
99,
);
sections.reclaimSupersededSectionsOutbox(PK, RELAY, 100);
assert.ok(ls.has(sameSecond), "same-second record (queuedAt == head) kept");
assert.ok(ls.has(sameSecond), "same-second record kept");
assert.ok(!ls.has(earlier), "strictly-earlier record reclaimed");
});
});
// ── (iv) Legacy v1 shared key: replays, and is NEVER deleted by v2 ─────────────
//
// A pre-per-window build wrote one shared, MUTABLE key. v2 enumerates it as one
// more record (queuedAt 0 for a bare store) so it resumes, but never deletes it
// under any relay gating — a live old-build window may be rewriting it, and
// queuedAt 0 makes supersession meaningless (mixed dev/DMG fleet residual).
test("(iv) stars: legacy shared key resumes and is never reclaimed", () => {
withStorage((ls) => {
// Legacy bare store (no envelope) from a pre-per-window build.
ls.setItem(
legacyKey("stars"),
JSON.stringify(starStore({ a: starEntry(true, 100, 1) })),
);
const resumed = stars.readChannelStarsOutbox(PK, RELAY);
assert.deepEqual(
resumed.channels.a,
stars.readChannelStarsOutbox(PK, RELAY).channels.a,
starEntry(true, 100, 1),
"legacy entry resumes",
);
// Even a head that fully subsumes it must not delete the mutable v1 key.
stars.reclaimSubsumedStarsOutbox(
PK,
RELAY,
@@ -310,7 +264,6 @@ test("(iv) stars: legacy shared key resumes and is never reclaimed", () => {
test("(iv) sections: legacy shared key resumes and is never reclaimed", () => {
withStorage((ls) => {
// Legacy entry as a {store, queuedAt} envelope from an interim build.
writeAt(
ls,
legacyKey("sections"),
@@ -321,7 +274,6 @@ test("(iv) sections: legacy shared key resumes and is never reclaimed", () => {
sections.readChannelSectionsOutbox(PK, RELAY).store.sections,
[{ id: "s1", name: "One", order: 0 }],
);
// A head strictly past the queued stamp still must not delete the v1 key.
sections.reclaimSupersededSectionsOutbox(PK, RELAY, 999);
assert.ok(
ls.has(legacyKey("sections")),
@@ -330,17 +282,11 @@ test("(iv) sections: legacy shared key resumes and is never reclaimed", () => {
});
});
// ── (v) Owner crash between write-new and delete-old → both replay-coalesce ────
//
// writeOwnOutbox writes the new key BEFORE deleting older own keys. A crash in
// that gap leaves two own records for the same window; replay coalesces them
// (merge fold / whole-blob max) with no loss and no duplicate publish.
// ── (v) Owner crash between write-new and delete-old → both replay-coalesce ─────
test("(v) stars: two own records from a write-new/delete-old crash merge-coalesce", () => {
withStorage((ls) => {
const base = `${PREFIX.stars}:${SCOPE}:${outboxWindowNonce()}`;
// Simulate the crash residue: the pre-crash key (seq 0) plus the freshly
// written key (seq 1) both present. Merge must keep both channels.
writeAt(
ls,
`${base}:${pad(0)}`,
@@ -359,46 +305,35 @@ test("(v) stars: two own records from a write-new/delete-old crash merge-coalesc
});
});
test("(v) sort: two own records from a crash resume the newer seq (padded key order)", () => {
test("(v) sort: two own records resume the newer seq (padded key order)", () => {
withStorage((ls) => {
const base = `${PREFIX.sort}:${SCOPE}:${outboxWindowNonce()}`;
// Same-second seqs crossing a digit boundary: unpadded, "9" > "10"
// lexically and would wrongly resume the OLDER edit. Zero-padding makes the
// higher seq win the whole-blob tiebreak.
writeAt(ls, `${base}:${pad(9)}`, sortStore({ dms: "alpha" }), 100);
writeAt(ls, `${base}:${pad(10)}`, sortStore({ dms: "recent" }), 100);
assert.equal(
sort.readChannelSortOutbox(PK, RELAY).store.groups.dms,
"recent",
"newer seq resumes despite the digit-boundary crossing",
"newer seq resumes",
);
});
});
// ── (vi) Reload seeds seq above surviving own keys → no key reuse/overwrite ────
//
// After a reload the sessionStorage nonce survives but the in-memory seq counter
// restarts. A fresh write must allocate a seq ABOVE the max surviving own key so
// it never overwrites (and thus mutates) an existing immutable record.
// ── (vi) Reload seeds seq above surviving own keys → no key reuse/overwrite ─────
test("(vi) reload: a write after surviving own keys allocates a strictly-higher key", () => {
test("(vi) reload: write after surviving own keys allocates a strictly-higher key", () => {
withStorage((ls) => {
const base = `${PREFIX.stars}:${SCOPE}:${outboxWindowNonce()}`;
// A surviving own key from before the (simulated) reload.
writeAt(
ls,
`${base}:${pad(5)}`,
starStore({ a: starEntry(true, 50, 1) }),
50,
);
// First write of the "new session" — seq counter cold, must seed above 5.
stars.writeChannelStarsOutbox(
PK,
starStore({ b: starEntry(true, 100, 1) }),
RELAY,
);
// The pre-reload key must NOT have been overwritten; the new write lands on
// a strictly-higher key, and delete-old drops the seq-5 key.
const ownKeys = [];
for (let i = 0; i < ls.length; i++) {
const k = ls.key(i);
@@ -407,9 +342,8 @@ test("(vi) reload: a write after surviving own keys allocates a strictly-higher
assert.equal(ownKeys.length, 1, "delete-old leaves exactly one own key");
assert.ok(
ownKeys[0] > `${base}:${pad(5)}`,
"new key seq is strictly above the surviving key",
"new key seq is strictly above surviving key",
);
// No data loss: the newest edit resumes.
assert.deepEqual(
stars.readChannelStarsOutbox(PK, RELAY).channels.b,
starEntry(true, 100, 1),
@@ -417,9 +351,9 @@ test("(vi) reload: a write after surviving own keys allocates a strictly-higher
});
});
// ── (vii) Whole-blob replay tie → deterministic nonce (key) tiebreak ───────────
// ── (vii) Whole-blob replay tie → deterministic key tiebreak ─────────────────
test("(vii) sort: equal-queuedAt records resolve by key so replay is deterministic", () => {
test("(vii) sort: equal-queuedAt records resolve by key (deterministic)", () => {
withStorage((ls) => {
writeAt(
ls,
@@ -433,7 +367,6 @@ test("(vii) sort: equal-queuedAt records resolve by key so replay is determinist
sortStore({ forums: "recent" }),
100,
);
// Same queuedAt → the lexicographically-greater key wins (…:zzz:…).
assert.equal(
sort.readChannelSortOutbox(PK, RELAY).store.groups.forums,
"recent",
@@ -443,10 +376,8 @@ test("(vii) sort: equal-queuedAt records resolve by key so replay is determinist
// ── (viii) Merge-lane replay is order-independent ──────────────────────────────
test("(viii) stars: same-channel records fold to the max entry regardless of key order", () => {
test("(viii) stars: same-channel records fold to max entry regardless of key order", () => {
withStorage((ls) => {
// Lower-rev record under a lexicographically-greater key (enumerated later)
// must still lose to the higher-rev record — merge is order-independent.
writeAt(
ls,
foreignKey("stars", "aaa", 0),
@@ -459,81 +390,60 @@ test("(viii) stars: same-channel records fold to the max entry regardless of key
starStore({ c: starEntry(false, 100, 2) }),
200,
);
const merged = stars.readChannelStarsOutbox(PK, RELAY);
assert.deepEqual(
merged.channels.c,
stars.readChannelStarsOutbox(PK, RELAY).channels.c,
starEntry(true, 100, 5),
"higher rev wins the tie",
"higher rev wins",
);
});
});
// ── (ix) GC no-op when the head subsumes/supersedes nothing ────────────────────
//
// The hook calls reclaim only inside the `apply-remote` branch, so a `failed`
// head fetch (or `absent`) never invokes it — that guard is structural in the
// hook. At the storage layer the matching invariant is that a head which
// subsumes/supersedes nothing removes nothing: a foreign edit newer than the
// head is live intent and is kept, so a stale/empty head can never over-collect.
// ── (ix) GC no-op when head subsumes/supersedes nothing ──────────────────────
test("(ix) stars: a head that subsumes nothing reclaims nothing", () => {
test("(ix) stars: head that subsumes nothing reclaims nothing", () => {
withStorage((ls) => {
const key = foreignKey("stars", "B", 0);
writeAt(ls, key, starStore({ a: starEntry(true, 300, 2) }), 300);
// Empty head subsumes no channel → keep everything.
stars.reclaimSubsumedStarsOutbox(PK, RELAY, starStore({}));
assert.ok(ls.has(key), "unsubsumed foreign edit is kept");
});
});
test("(ix) sort: a head older than the queued edit supersedes nothing", () => {
test("(ix) sort: head older than queued edit supersedes nothing", () => {
withStorage((ls) => {
const key = foreignKey("sort", "B", 0);
writeAt(ls, key, sortStore({ dms: "recent" }), 300);
// headCreatedAt=0 (absent-equivalent) < queuedAt → keep.
sort.reclaimSupersededSortOutbox(PK, RELAY, 0);
assert.ok(ls.has(key), "edit queued after the head is kept");
});
});
// ── (x) Legacy whole-blob replay is one-shot and value-sensitive ───────────────
//
// The legacy v1 key is never deleted (it is mutable), so without a consumption
// marker `resumeWholeBlobOutbox` would return it on EVERY boot and the hook would
// republish the stale blob above the current relay head forever (Thufir pass-2
// resurrection finding). The per-value marker records the exact legacy raw a
// prior boot replayed: an unchanged legacy blob is skipped, a rewritten one (a
// live old build) is replayed again. The hook transfers the intent into its own
// v2 key (synchronous publish) BEFORE writing the marker, so a crash between the
// two replays the legacy blob once more rather than losing it.
// Without a consumption marker, resumeWholeBlobOutbox would return the legacy blob
// on EVERY boot and the hook would republish the stale blob forever. The per-value
// marker records the exact legacy raw a prior boot replayed.
test("(x) sort: retained legacy blob replays once, then is skipped across later boots", () => {
withStorage((ls) => {
ls.setItem(legacyKey("sort"), JSON.stringify(sortStore({ dms: "recent" })));
// Boot 1: the legacy blob resumes and reports itself for consumption.
const boot1 = sort.readChannelSortOutbox(PK, RELAY);
assert.equal(boot1.store.groups.dms, "recent", "legacy blob resumes");
assert.equal(
boot1.legacyRawToConsume,
JSON.stringify(sortStore({ dms: "recent" })),
"reports the exact legacy raw to consume",
"reports exact legacy raw",
);
// Hook: publish transfers intent to a v2 key, then marks consumed; model the
// published-and-cleared steady state (own key gone after a successful ACK).
sort.markChannelSortLegacyConsumed(PK, RELAY, boot1.legacyRawToConsume);
assert.ok(ls.has(legacyKey("sort")), "legacy key is still never deleted");
// Boot 2 and beyond: the unchanged legacy blob is excluded — no resurrection.
assert.ok(ls.has(legacyKey("sort")), "legacy key never deleted");
assert.equal(
sort.readChannelSortOutbox(PK, RELAY),
null,
"consumed legacy blob is not replayed again",
"consumed blob not replayed again",
);
assert.equal(
sort.readChannelSortOutbox(PK, RELAY),
null,
"still skipped on a third boot",
"still skipped on third boot",
);
});
});
@@ -560,8 +470,6 @@ test("(x) sections: a rewritten legacy blob (live old build) is replayed again",
null,
"consumed blob skipped",
);
// A live old build rewrites the legacy key with NEW intent (different raw).
writeAt(
ls,
legacyKey("sections"),
@@ -572,12 +480,9 @@ test("(x) sections: a rewritten legacy blob (live old build) is replayed again",
assert.deepEqual(
boot3.store.sections,
[{ id: "s2", name: "Two", order: 0 }],
"changed legacy value is replayed",
);
assert.ok(
boot3.legacyRawToConsume !== null,
"new legacy raw reported for a fresh consumption marker",
"changed legacy value replayed",
);
assert.ok(boot3.legacyRawToConsume !== null);
});
});
@@ -585,34 +490,24 @@ test("(x) sort: crash after v2 transfer but before the marker resumes from the v
withStorage((ls) => {
ls.setItem(legacyKey("sort"), JSON.stringify(sortStore({ dms: "recent" })));
const boot1 = sort.readChannelSortOutbox(PK, RELAY);
// Hook order: publish (synchronous writeOwnOutbox → v2 key) THEN mark. Model
// a crash in that gap: the v2 key exists, the marker was never written.
sort.writeChannelSortOutbox(PK, boot1.store, RELAY);
// No markChannelSortLegacyConsumed — crash before it.
// Boot 2: marker absent, so the legacy blob (queuedAt 0) is enumerated, but
// the v2 key (queuedAt > 0) wins the whole-blob max — intent is not lost,
// and the winner is not the legacy record so nothing is re-consumed.
sort.writeChannelSortOutbox(PK, boot1.store, RELAY); // transfer to v2; crash before mark
const boot2 = sort.readChannelSortOutbox(PK, RELAY);
assert.equal(boot2.store.groups.dms, "recent", "intent survives in v2 key");
assert.equal(
boot2.legacyRawToConsume,
null,
"winner is the v2 key, not the legacy record",
"winner is the v2 key, not legacy",
);
});
});
test("(x) stars (merge lane): a legacy blob the head subsumes needs no replay publish", () => {
test("(x) stars (merge lane): head-subsumed legacy fold needs no replay publish", () => {
withStorage((ls) => {
ls.setItem(
legacyKey("stars"),
JSON.stringify(starStore({ a: starEntry(true, 100, 1) })),
);
const outbox = stars.readChannelStarsOutbox(PK, RELAY);
// The hook skips the boot replay publish when the found head subsumes the
// fold — so a lingering never-deleted legacy key doesn't re-drive an
// identical publish on every boot.
assert.ok(
stars.isStarsStoreSubsumedBy(
outbox,
@@ -620,15 +515,14 @@ test("(x) stars (merge lane): a legacy blob the head subsumes needs no replay pu
),
"head-subsumed legacy fold is publish-free",
);
// A head that does NOT yet reflect the legacy click still publishes.
assert.ok(
!stars.isStarsStoreSubsumedBy(outbox, starStore({})),
"an unsubsumed legacy click still needs a publish",
"unsubsumed legacy click still needs a publish",
);
});
});
// ── Own-key round trip: write, read, clear (single-window baseline) ────────────
// ── Own-key round trip: write, read, clear ────────────────────────────────────
test("own key: write resumes, clear removes only this window's own keys", () => {
withStorage((ls) => {
@@ -645,8 +539,7 @@ test("own key: write resumes, clear removes only this window's own keys", () =>
}
return false;
};
assert.ok(hasOwn(), "own edit is written under this window's nonce");
// A foreign peer key is untouched by an own-key clear.
assert.ok(hasOwn(), "own edit written under this window's nonce");
writeAt(
ls,
foreignKey("stars", "peer", 0),

View File

@@ -1,4 +1,12 @@
// Shared helpers for sidebar sync manager tests.
//
// Exports:
// makeFakeWindow() — single-slot timer fake (whole-blob simple tests)
// installFakeWindow(fw) — install window substitutes, returns restore fn
// makeTimerBed() — multi-slot timer fake for overlapping-publish tests
// installEchoTauri(pubkey) — faithful crypto seam (encrypt/decrypt/sign)
// installTauriMock(payload) — simple one-shot decrypt mock
// makeHookStubs() — shared relay+Tauri stubs for React hook tests
export function makeFakeWindow() {
const storage = new Map();
@@ -119,6 +127,101 @@ export function installEchoTauri(pubkey) {
};
}
// Multi-slot timer fake keyed by delay — for overlapping-publish tests.
// Returns { win, timers, fireDelay, restore }. Must call restore() in finally.
export function makeTimerBed() {
const storage = new Map();
const timers = new Map();
let nextId = 1;
const win = {
localStorage: {
getItem: (k) => storage.get(k) ?? null,
setItem: (k, v) => storage.set(k, v),
removeItem: (k) => storage.delete(k),
get length() {
return storage.size;
},
key: (i) => [...storage.keys()][i] ?? null,
},
setTimeout: (fn, ms) => {
const id = nextId++;
timers.set(id, { fn, ms });
return id;
},
clearTimeout: (id) => timers.delete(id),
};
const fireDelay = async (ms) => {
const entry = [...timers.entries()].find(([, v]) => v.ms === ms);
if (!entry) throw new Error(`expected a timer scheduled at ${ms}ms`);
timers.delete(entry[0]);
entry[1].fn();
for (let i = 0; i < 100; i++) await Promise.resolve();
};
const hasDelay = (ms) => [...timers.values()].some((t) => t.ms === ms);
const restore = installFakeWindow(win);
return { win, timers, fireDelay, hasDelay, restore };
}
// Stub relay + Tauri for React hook tests.
// Returns { stubRelay, stubTauri } functions.
export function makeHookStubs() {
function stubRelay(relayClient, { live, reconnect, publishCalls } = {}) {
const orig = {
fetchEvents: relayClient.fetchEvents,
subscribeLive: relayClient.subscribeLive,
subscribeToReconnects: relayClient.subscribeToReconnects,
publishEvent: relayClient.publishEvent,
};
relayClient.fetchEvents = async () => [];
relayClient.subscribeLive = async (_f, cb) => {
if (live) live.cb = cb;
return async () => {};
};
relayClient.subscribeToReconnects = (cb) => {
if (reconnect) reconnect.cb = cb;
return () => {};
};
relayClient.publishEvent = async (...args) => {
if (publishCalls) publishCalls.push(args);
};
return () => Object.assign(relayClient, orig);
}
function stubTauri(pubkey, decryptPayload) {
const orig = window.__TAURI_INTERNALS__;
window.__TAURI_INTERNALS__ = {
invoke: (cmd, args) => {
if (cmd === "nip44_decrypt_from_self") {
const payload =
typeof decryptPayload === "function"
? decryptPayload(args)
: decryptPayload;
return Promise.resolve(payload);
}
if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct");
if (cmd === "sign_event")
return Promise.resolve(
JSON.stringify({
id: "signed",
pubkey,
content: "ct",
created_at: 0,
kind: 30078,
tags: [],
sig: "s",
}),
);
return Promise.reject(new Error(`unmocked ${cmd}`));
},
};
return () => {
window.__TAURI_INTERNALS__ = orig;
};
}
return { stubRelay, stubTauri };
}
export function installTauriMock(goodCipherPayload) {
const orig = globalThis.window?.__TAURI_INTERNALS__;
if (typeof globalThis.window === "undefined") globalThis.window = {};

View File

@@ -2,11 +2,11 @@ import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { JSDOM } from "jsdom";
import { makeHookStubs } from "./sidebarSyncTestHelpers.mjs";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
@@ -15,71 +15,9 @@ before(() => {
window: dom.window,
});
});
after(() => dom.window.close());
// ── Shared test helpers ───────────────────────────────────────────────────────
/** Install relay no-ops and return a restore function. */
function stubRelay(relayClient, { live, reconnect, publishCalls } = {}) {
const orig = {
fetchEvents: relayClient.fetchEvents,
subscribeLive: relayClient.subscribeLive,
subscribeToReconnects: relayClient.subscribeToReconnects,
publishEvent: relayClient.publishEvent,
};
relayClient.fetchEvents = async () => [];
relayClient.subscribeLive = async (_f, cb) => {
if (live) live.cb = cb;
return async () => {};
};
relayClient.subscribeToReconnects = (cb) => {
if (reconnect) reconnect.cb = cb;
return () => {};
};
relayClient.publishEvent = async (...args) => {
if (publishCalls) publishCalls.push(args);
};
return () => Object.assign(relayClient, orig);
}
/**
* Install a Tauri mock that:
* - encrypt returns "ct"
* - sign returns a minimal event with the given pubkey
* - decrypt returns `decryptPayload` (a JSON string or callback)
*/
function stubTauri(pubkey, decryptPayload) {
const orig = window.__TAURI_INTERNALS__;
window.__TAURI_INTERNALS__ = {
invoke: (cmd, args) => {
if (cmd === "nip44_decrypt_from_self") {
const payload =
typeof decryptPayload === "function"
? decryptPayload(args)
: decryptPayload;
return Promise.resolve(payload);
}
if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct");
if (cmd === "sign_event")
return Promise.resolve(
JSON.stringify({
id: "signed",
pubkey,
content: "ct",
created_at: 0,
kind: 30078,
tags: [],
sig: "s",
}),
);
return Promise.reject(new Error(`unmocked ${cmd}`));
},
};
return () => {
window.__TAURI_INTERNALS__ = orig;
};
}
const { stubRelay, stubTauri } = makeHookStubs();
test("assignChannel refreshes an existing assignment before the next eviction", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
@@ -90,12 +28,11 @@ test("assignChannel refreshes an existing assignment before the next eviction",
const { useChannelSections } = await import("./useChannelSections.ts");
const restoreRelay = stubRelay(relayClient);
const pubkey = "pk-at-capacity";
const relayUrl = "wss://relay.example";
const assignments = Object.fromEntries(
Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS }, (_, index) => [
`chan-${String(index).padStart(4, "0")}`,
Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS }, (_, i) => [
`chan-${String(i).padStart(4, "0")}`,
"section-1",
]),
);
@@ -110,15 +47,12 @@ test("assignChannel refreshes an existing assignment before the next eviction",
assignments,
}),
);
try {
const { result, unmount } = renderHook(() =>
useChannelSections(pubkey, relayUrl),
);
act(() => result.current.assignChannel("chan-0000", "section-2"));
act(() => result.current.assignChannel("chan-new", "section-1"));
assert.equal(result.current.assignments["chan-0000"], "section-2");
assert.equal(result.current.assignments["chan-new"], "section-1");
assert.equal(result.current.assignments["chan-0001"], undefined);
@@ -133,23 +67,9 @@ test("assignChannel refreshes an existing assignment before the next eviction",
}
});
// Carl P1.1 regression (sections): a peer-cache storage event arriving while a
// local whole-blob edit is pending must NOT clobber the optimistic edit or
// strand its outbox. The storage handler must defer to the pending edit just as
// `applyRemote` does for relay arrivals. The pending edit's debounced publish
// owns convergence and will publish-or-adopt against the relay.
//
// Scenario: window A creates section A1 (pending in outbox). Window B writes
// B1 (a different store) to the shared cache. A's storage handler fires. Before
// A's debounce fires, the user makes a second edit A2 inside the same debounce
// window. The resulting store must be derived from A1's state (A2 added on top
// of A1), and the outbox must still hold A's intent — not B1's shape.
//
// Mutation: removing the `hasPendingEdit()` guard in the storage handler causes
// setStore to apply B1, so A2 is derived from B1 instead. The outbox record
// still holds A's first edit shape, not the B1-derived A2 — an inconsistency
// the manager cannot resolve. The test detects the replacement: if B1 were
// applied, the final sections array would include B1's section id.
// Carl P1.1 regression: storage event while a local edit is pending must NOT
// clobber the optimistic edit.
// Mutation: removing `hasPendingEdit()` guard in the storage handler causes setStore to apply B1.
test("storage event while a local edit is pending is deferred, not applied", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
@@ -158,7 +78,6 @@ test("storage event while a local edit is pending is deferred, not applied", asy
const restoreRelay = stubRelay(relayClient);
const restoreTauri = stubTauri("pk-storage-guard", null);
const pubkey = "pk-storage-guard";
const relayUrl = "wss://r.storage-guard";
let hook = null;
@@ -168,8 +87,6 @@ test("storage event while a local edit is pending is deferred, not applied", asy
await Promise.resolve();
await Promise.resolve();
});
// A1: window A creates a section — this becomes the pending edit.
await act(async () => {
hook.result.current.createSection("A1-Section");
});
@@ -178,8 +95,6 @@ test("storage event while a local edit is pending is deferred, not applied", asy
storeAfterA1.some((id) => id.length > 0),
"A1 section created",
);
// Window B writes B1 to the shared scoped cache key.
const b1Store = JSON.stringify({
version: 1,
sections: [{ id: "b1-section", name: "B1", order: 0 }],
@@ -197,31 +112,22 @@ test("storage event while a local edit is pending is deferred, not applied", asy
await Promise.resolve();
await Promise.resolve();
});
// B1 must NOT have replaced A1's optimistic state.
const storeAfterStorageEvent = hook.result.current.sections.map(
(s) => s.id,
);
assert.ok(
!storeAfterStorageEvent.includes("b1-section"),
"storage event while pending must not apply B1 to A's optimistic state",
!hook.result.current.sections.map((s) => s.id).includes("b1-section"),
"storage event while pending must not apply B1",
);
assert.deepEqual(
storeAfterStorageEvent,
hook.result.current.sections.map((s) => s.id),
storeAfterA1,
"optimistic state must be unchanged after the deferred storage event",
"optimistic state unchanged after deferred storage event",
);
// A2: derived from A1's state (since B1 was deferred).
await act(async () => {
hook.result.current.createSection("A2-Section");
});
const finalIds = hook.result.current.sections.map((s) => s.id);
assert.ok(
!finalIds.includes("b1-section"),
"A2 must be derived from A1 state, not B1 — b1-section must not appear",
!hook.result.current.sections.map((s) => s.id).includes("b1-section"),
"A2 derived from A1 state — b1-section must not appear",
);
hook.unmount();
} finally {
cleanup();
@@ -230,11 +136,9 @@ test("storage event while a local edit is pending is deferred, not applied", asy
}
});
// Fix 2 regression: a live remote arriving while a local edit is pending must
// NOT overwrite the optimistic edit or strand its durable outbox. The pending
// edit's own debounced publish owns convergence (publish-or-adopt). Reverting
// applyRemote's hasPendingEdit guard makes the live event clobber the UI and
// leave the outbox replay-eligible.
// Fix 2 regression: live remote while a local edit is pending must NOT overwrite
// the optimistic edit or strand its durable outbox.
// Mutation: reverting applyRemote's hasPendingEdit guard clobbers the UI.
test("live remote while a local edit is pending defers to the pending edit", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
@@ -252,10 +156,8 @@ test("live remote while a local edit is pending defers to the pending edit", asy
assignments: {},
}),
);
const pubkey = "pk-live-pending";
const relayUrl = "wss://r.live";
let hook = null;
try {
await act(async () => {
@@ -265,8 +167,6 @@ test("live remote while a local edit is pending defers to the pending edit", asy
await Promise.resolve();
});
assert.ok(live.cb, "live subscription installed");
// Make a local edit — it becomes the pending store and persists to outbox.
await act(async () => {
hook.result.current.createSection("Local");
});
@@ -275,8 +175,6 @@ test("live remote while a local edit is pending defers to the pending edit", asy
"local edit persisted to outbox",
);
const localSectionIds = hook.result.current.sections.map((s) => s.id);
// A remote live event arrives while the edit is still pending.
await act(async () => {
live.cb({
id: "remote-event",
@@ -290,15 +188,14 @@ test("live remote while a local edit is pending defers to the pending edit", asy
await Promise.resolve();
await Promise.resolve();
});
assert.deepEqual(
hook.result.current.sections.map((s) => s.id),
localSectionIds,
"pending local edit must NOT be overwritten by the live remote",
"pending local edit NOT overwritten by live remote",
);
assert.ok(
readChannelSectionsOutbox(pubkey, relayUrl),
"outbox for the pending edit must survive the live remote",
"outbox for pending edit survives live remote",
);
hook.unmount();
} finally {
@@ -309,9 +206,8 @@ test("live remote while a local edit is pending defers to the pending edit", asy
});
// Fix 3 regression: equal-timestamp tie-break must match the relay's canonical
// winner (`created_at DESC, id ASC` → LOWEST id wins). Deliver the larger id
// first, then the lower id at the same timestamp; the lower-id store must win.
// Reverting applyRemote's `>=` back to `<=` converges on the larger id instead.
// winner (lowest id wins).
// Mutation: reverting applyRemote's `>=` back to `<=` converges on the larger id.
test("equal-timestamp tie-break applies the lower event id (relay canonical winner)", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
@@ -319,7 +215,6 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
const live = {};
const restoreRelay = stubRelay(relayClient, { live });
// Decrypt payload keyed off the event id so each event yields a distinct store.
const restoreTauri = stubTauri("pk-tie", (args) => {
const id = args?.ciphertext ?? "";
return JSON.stringify({
@@ -328,7 +223,6 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
assignments: {},
});
});
const pubkey = "pk-tie";
const relayUrl = "wss://r.tie";
let hook = null;
@@ -340,14 +234,13 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
await Promise.resolve();
});
assert.ok(live.cb, "live subscription installed");
const deliver = async (id) => {
await act(async () => {
live.cb({
id,
pubkey,
created_at: 1000,
content: id, // decrypt echoes this into the section id
content: id,
kind: 30078,
tags: [["d", "channel-sections"]],
sig: "s",
@@ -356,14 +249,12 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
await Promise.resolve();
});
};
await deliver("bbbb"); // larger id first
await deliver("aaaa"); // lower id — relay canonical winner
await deliver("bbbb");
await deliver("aaaa");
assert.deepEqual(
hook.result.current.sections.map((s) => s.id),
["aaaa"],
"lower event id must win the equal-timestamp tie-break",
"lower event id wins tie-break",
);
hook.unmount();
} finally {
@@ -373,15 +264,9 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
}
});
// Carl P1-sections regression: on reconnect the hook must wake the EXISTING
// pending edit (retryPendingPublish) rather than re-queue it via
// publishSections(). A re-queue bumps the generation and resets the frozen
// publishBaseline to the just-fetched head, so a remote that won whole-blob LWW
// while the edit was pending would be published over instead of adopted. Here
// the head advances (100 → 200) while a local edit is pending; on reconnect the
// advanced remote must be adopted (the losing local section dropped) and NOTHING
// published. Reverting the reconnect handler to publishSections(pending) resets
// the baseline and publishes the stale edit.
// Carl P1-sections: on reconnect the hook must wake the EXISTING pending edit
// (retryPendingPublish) rather than re-queue it via publishSections().
// Mutation: reverting reconnect handler to publishSortPrefs(pending) resets baseline.
test("reconnect adopts a remote that advanced while the edit was pending, never publishing over it", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
@@ -389,7 +274,6 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
const reconnect = {};
const publishCalls = [];
// A mutable head: bumped to 200 right before reconnect fires.
let head = {
pubkey: "pk-sec-recon",
content: "remote-cipher",
@@ -399,7 +283,7 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
const origFetch = relayClient.fetchEvents;
relayClient.fetchEvents = async () => [head];
const restoreRelay = stubRelay(relayClient, { reconnect, publishCalls });
relayClient.fetchEvents = async () => [head]; // override stubRelay's fetchEvents
relayClient.fetchEvents = async () => [head];
const restoreTauri = stubTauri("pk-sec-recon", () =>
JSON.stringify({
version: 1,
@@ -407,7 +291,6 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
assignments: {},
}),
);
const pubkey = "pk-sec-recon";
const relayUrl = "wss://r.recon";
let hook = null;
@@ -419,7 +302,6 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
await Promise.resolve();
});
assert.ok(reconnect.cb, "reconnect handler installed");
await act(async () => {
hook.result.current.createSection("Local");
});
@@ -427,8 +309,6 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
hook.result.current.sections.some((s) => s.name === "Local"),
"optimistic local section applied",
);
// Remote advances to 200 (peer won LWW), then reconnect fires.
head = {
pubkey: "pk-sec-recon",
content: "remote-cipher",
@@ -442,16 +322,15 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
await Promise.resolve();
await Promise.resolve();
});
assert.equal(
publishCalls.length,
0,
"must adopt the advanced remote on reconnect, never publish",
"must adopt advanced remote on reconnect, never publish",
);
assert.deepEqual(
hook.result.current.sections.map((s) => s.id),
["remote"],
"the losing local edit must be adopted away by the advanced remote",
"losing local edit adopted away",
);
hook.unmount();
} finally {
@@ -462,13 +341,9 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
}
});
// Thufir pass-3 regression (sections lane): a legacy replay whose durability
// transfer into this window's own v2 key fails (quota) must NOT write the
// consumed marker. Otherwise the marker permanently suppresses the only copy of
// the legacy edit on every later boot — silent loss of exactly the durability
// this PR guarantees. The hook now gates markLegacyConsumed on the `durable`
// flag publishSections() returns; removing that guard writes the marker
// unconditionally and this test goes red.
// Thufir pass-3: a legacy replay whose v2 transfer fails (quota) must NOT write
// the consumed marker.
// Mutation: writing the marker unconditionally loses the legacy blob.
test("legacy replay whose v2 transfer fails (quota) does not write the consumed marker", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
@@ -479,7 +354,6 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
const { normalizeRelayUrl } = await import("@/shared/lib/normalizeRelayUrl");
const origLocalStorage = window.localStorage;
const pubkey = "pk-sec-quota";
const relayUrl = "wss://r.sec-quota";
const scope = `${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
@@ -493,8 +367,6 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
},
queuedAt: 0,
});
// localStorage that throws on any v2 outbox write, allowing everything else.
const map = new Map([[legacyKey, legacyRaw]]);
const throwingStorage = {
getItem: (k) => (map.has(k) ? map.get(k) : null),
@@ -509,36 +381,27 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
},
key: (i) => [...map.keys()][i] ?? null,
};
const restoreRelay = stubRelay(relayClient);
const restoreTauri = stubTauri(pubkey, null);
let hook = null;
try {
Object.defineProperty(window, "localStorage", {
value: throwingStorage,
configurable: true,
});
// Seed watermark so bootstrap holds (no first-sync seed).
window.localStorage.setItem(
`buzz-sync-watermark.v1:channel-sections:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`,
"1700000000",
);
await act(async () => {
hook = renderHook(() => useChannelSections(pubkey, relayUrl));
for (let i = 0; i < 4; i++) await Promise.resolve();
});
const markerWritten = [...map.keys()].some((k) =>
k.includes("-legacy-consumed:"),
);
assert.equal(
markerWritten,
[...map.keys()].some((k) => k.includes("-legacy-consumed:")),
false,
"consumed marker must not be written after a failed v2 transfer",
"consumed marker must not be written after failed v2 transfer",
);
const resumed = readChannelSectionsOutbox(pubkey, relayUrl);
assert.ok(resumed !== null, "legacy blob must remain replayable");
assert.equal(
@@ -548,7 +411,7 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
);
assert.ok(
resumed.legacyRawToConsume !== null,
"legacy record still reports itself for consumption",
"legacy record reports itself for consumption",
);
hook.unmount();
} finally {

View File

@@ -2,11 +2,11 @@ import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { JSDOM } from "jsdom";
import { makeHookStubs } from "./sidebarSyncTestHelpers.mjs";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
@@ -15,76 +15,13 @@ before(() => {
window: dom.window,
});
});
after(() => dom.window.close());
// ── Shared test helpers ───────────────────────────────────────────────────────
const { stubRelay, stubTauri } = makeHookStubs();
/** Install relay no-ops and return a restore function. */
function stubRelay(relayClient, { live, reconnect, publishCalls } = {}) {
const orig = {
fetchEvents: relayClient.fetchEvents,
subscribeLive: relayClient.subscribeLive,
subscribeToReconnects: relayClient.subscribeToReconnects,
publishEvent: relayClient.publishEvent,
};
relayClient.fetchEvents = async () => [];
relayClient.subscribeLive = async (_f, cb) => {
if (live) live.cb = cb;
return async () => {};
};
relayClient.subscribeToReconnects = (cb) => {
if (reconnect) reconnect.cb = cb;
return () => {};
};
relayClient.publishEvent = async (...args) => {
if (publishCalls) publishCalls.push(args);
};
return () => Object.assign(relayClient, orig);
}
/**
* Install a Tauri mock for sort tests.
* - encrypt returns "ct" / sign returns a minimal event
* - decrypt returns `decryptPayload` (JSON string or callback)
*/
function stubTauri(pubkey, decryptPayload) {
const orig = window.__TAURI_INTERNALS__;
window.__TAURI_INTERNALS__ = {
invoke: (cmd, args) => {
if (cmd === "nip44_decrypt_from_self") {
const payload =
typeof decryptPayload === "function"
? decryptPayload(args)
: decryptPayload;
return Promise.resolve(payload);
}
if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct");
if (cmd === "sign_event")
return Promise.resolve(
JSON.stringify({
id: "signed",
pubkey,
content: "ct",
created_at: 0,
kind: 30078,
tags: [],
sig: "s",
}),
);
return Promise.reject(new Error(`unmocked ${cmd}`));
},
};
return () => {
window.__TAURI_INTERNALS__ = orig;
};
}
// Carl P1.1 regression (sort): a peer-cache storage event arriving while a
// local whole-blob sort edit is pending must NOT clobber the optimistic edit.
// Mutation: removing the `hasPendingEdit()` guard in the storage handler lets
// B1 apply, so A2's `setStore` updater derives from B1, and the visible mode
// reverts to B1's value.
// Carl P1.1 regression: storage event while a local sort edit is pending must
// NOT clobber the optimistic edit.
// Mutation: removing `hasPendingEdit()` guard lets B1 apply, so A2 derives from B1.
test("storage event while a local sort edit is pending is deferred, not applied", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
@@ -95,7 +32,6 @@ test("storage event while a local sort edit is pending is deferred, not applied"
const restoreRelay = stubRelay(relayClient);
const restoreTauri = stubTauri("pk-sort-sg", null);
const pubkey = "pk-sort-sg";
const relayUrl = "wss://r.sort-sg";
let hook = null;
@@ -105,8 +41,6 @@ test("storage event while a local sort edit is pending is deferred, not applied"
await Promise.resolve();
await Promise.resolve();
});
// A1: set "channels" to "alpha" — becomes the pending edit.
await act(async () => {
hook.result.current.setSortModeFor("channels", "alpha");
});
@@ -115,8 +49,6 @@ test("storage event while a local sort edit is pending is deferred, not applied"
"alpha",
"A1: channels sort set to alpha",
);
// Window B writes "recent" for "channels" to the shared cache.
const b1Store = JSON.stringify({
version: 1,
groups: { channels: "recent" },
@@ -133,23 +65,19 @@ test("storage event while a local sort edit is pending is deferred, not applied"
await Promise.resolve();
await Promise.resolve();
});
assert.equal(
hook.result.current.sortModeFor("channels"),
"alpha",
"storage event while pending must not apply B1 (recent) over A1 (alpha)",
"storage event while pending must not apply B1",
);
// A2: derived from A1 state (channels stays alpha).
await act(async () => {
hook.result.current.setSortModeFor("starred", "recent");
});
assert.equal(
hook.result.current.sortModeFor("channels"),
"alpha",
"A2 must be derived from A1 state — channels must remain alpha",
"A2 derived from A1 — channels remains alpha",
);
hook.unmount();
} finally {
cleanup();
@@ -158,8 +86,8 @@ test("storage event while a local sort edit is pending is deferred, not applied"
}
});
// Carl P1-sort regression: a live remote while a local sort edit is pending
// must NOT overwrite the optimistic edit or strand its durable outbox.
// Carl P1-sort: live remote while a local sort edit is pending must NOT overwrite
// the optimistic edit or strand its durable outbox.
// Mutation: reverting applyRemote's hasPendingEdit() guard lets the remote clobber.
test("live remote while a local edit is pending defers to the pending edit", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
@@ -174,7 +102,6 @@ test("live remote while a local edit is pending defers to the pending edit", asy
const restoreTauri = stubTauri("pk-sort-pending", () =>
JSON.stringify({ version: 1, groups: { "remote-group": "recent" } }),
);
const pubkey = "pk-sort-pending";
const relayUrl = "wss://r.live";
let hook = null;
@@ -186,7 +113,6 @@ test("live remote while a local edit is pending defers to the pending edit", asy
await Promise.resolve();
});
assert.ok(live.cb, "live subscription installed");
await act(async () => {
hook.result.current.setSortModeFor("channels", "recent");
});
@@ -199,7 +125,6 @@ test("live remote while a local edit is pending defers to the pending edit", asy
"recent",
"optimistic local edit applied",
);
await act(async () => {
live.cb({
id: "remote-event",
@@ -213,20 +138,19 @@ test("live remote while a local edit is pending defers to the pending edit", asy
await Promise.resolve();
await Promise.resolve();
});
assert.equal(
hook.result.current.sortModeFor("channels"),
"recent",
"pending local edit must NOT be overwritten",
"pending local edit NOT overwritten",
);
assert.equal(
hook.result.current.sortModeFor("remote-group"),
"alpha",
"remote store must not have been applied over the pending edit",
"remote store not applied over pending edit",
);
assert.ok(
readChannelSortOutbox(pubkey, relayUrl),
"outbox for the pending edit must survive the live remote",
"outbox survives live remote",
);
hook.unmount();
} finally {
@@ -237,7 +161,7 @@ test("live remote while a local edit is pending defers to the pending edit", asy
});
// Equal-timestamp tie-break must match the relay's canonical winner (lowest id wins).
// Mutation: reverting applyRemote's `>=` back to `<=` wrongly ignores the lower id.
// Mutation: reverting applyRemote's `>=` back to `<=` ignores the lower id.
test("equal-timestamp tie-break applies the lower event id (relay canonical winner)", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
@@ -247,12 +171,10 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
const live = {};
const restoreRelay = stubRelay(relayClient, { live });
// Decrypt echoes ciphertext as the group key so each event yields a distinct store.
const restoreTauri = stubTauri("pk-sort-tie", (args) => {
const id = args?.ciphertext ?? "";
return JSON.stringify({ version: 1, groups: { [id]: "recent" } });
});
const pubkey = "pk-sort-tie";
const relayUrl = "wss://r.tie";
let hook = null;
@@ -264,7 +186,6 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
await Promise.resolve();
});
assert.ok(live.cb, "live subscription installed");
const deliver = async (id) => {
await act(async () => {
live.cb({
@@ -280,19 +201,17 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
await Promise.resolve();
});
};
await deliver("bbbb");
await deliver("aaaa");
assert.equal(
hook.result.current.sortModeFor("aaaa"),
"recent",
"lower event id (relay canonical winner) must be applied",
"lower event id (relay canonical winner) applied",
);
assert.equal(
hook.result.current.sortModeFor("bbbb"),
"alpha",
"larger id's store must be superseded by the lower-id whole-blob winner",
"larger id's store superseded",
);
hook.unmount();
} finally {
@@ -302,10 +221,9 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
}
});
// Carl P1-sort regression: on reconnect the hook must wake the EXISTING pending
// edit (retryPendingPublish) rather than re-queue it via publishSortPrefs().
// Mutation: reverting the reconnect handler to publishSortPrefs(pending) resets
// the baseline and publishes the stale edit over the advanced remote.
// Carl P1-sort: on reconnect the hook must wake the EXISTING pending edit
// (retryPendingPublish) rather than re-queue it via publishSortPrefs().
// Mutation: reverting to publishSortPrefs(pending) resets the baseline.
test("reconnect adopts a remote that advanced while the edit was pending, never publishing over it", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
@@ -324,11 +242,10 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
const origFetch = relayClient.fetchEvents;
relayClient.fetchEvents = async () => [head];
const restoreRelay = stubRelay(relayClient, { reconnect, publishCalls });
relayClient.fetchEvents = async () => [head]; // override stubRelay's fetchEvents
relayClient.fetchEvents = async () => [head];
const restoreTauri = stubTauri("pk-sort-recon", () =>
JSON.stringify({ version: 1, groups: { "remote-group": "recent" } }),
);
const pubkey = "pk-sort-recon";
const relayUrl = "wss://r.recon";
let hook = null;
@@ -340,11 +257,9 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
await Promise.resolve();
});
assert.ok(reconnect.cb, "reconnect handler installed");
await act(async () => {
hook.result.current.setSortModeFor("channels", "recent");
});
head = {
pubkey: "pk-sort-recon",
content: "remote-cipher",
@@ -358,21 +273,20 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
await Promise.resolve();
await Promise.resolve();
});
assert.equal(
publishCalls.length,
0,
"must adopt the advanced remote on reconnect, never publish",
"must adopt advanced remote on reconnect, never publish",
);
assert.equal(
hook.result.current.sortModeFor("channels"),
"alpha",
"the losing local edit must be adopted away",
"losing local edit adopted away",
);
assert.equal(
hook.result.current.sortModeFor("remote-group"),
"recent",
"the advanced remote store must be adopted",
"advanced remote store adopted",
);
hook.unmount();
} finally {
@@ -383,9 +297,9 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
}
});
// Thufir pass-3 blocker: a legacy blob whose v2 transfer fails (quota) must NOT
// write the consumed marker. Mutation: writing the marker unconditionally
// consumes the legacy blob after a failed transfer and it is lost.
// Thufir pass-3: legacy blob whose v2 transfer fails (quota) must NOT write the
// consumed marker.
// Mutation: writing the marker unconditionally loses the legacy blob.
test("legacy replay whose v2 transfer fails (quota) does not write the consumed marker", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
@@ -396,7 +310,6 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
const { normalizeRelayUrl } = await import("@/shared/lib/normalizeRelayUrl");
const origLocalStorage = window.localStorage;
const pubkey = "pk-quota";
const relayUrl = "wss://r.quota";
const scope = `${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
@@ -406,7 +319,6 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
store: { version: 1, groups: { "legacy-group": "recent" } },
queuedAt: 0,
});
const map = new Map([[legacyKey, legacyRaw]]);
const throwingStorage = {
getItem: (k) => (map.has(k) ? map.get(k) : null),
@@ -421,10 +333,8 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
},
key: (i) => [...map.keys()][i] ?? null,
};
const restoreRelay = stubRelay(relayClient);
const restoreTauri = stubTauri(pubkey, null);
let hook = null;
try {
Object.defineProperty(window, "localStorage", {
@@ -435,7 +345,6 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
`buzz-sync-watermark.v1:channel-sort:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`,
"1700000000",
);
await act(async () => {
hook = renderHook(() => useChannelSortPreference(pubkey, relayUrl));
await Promise.resolve();
@@ -443,25 +352,21 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
await Promise.resolve();
await Promise.resolve();
});
const markerWritten = [...map.keys()].some((k) =>
k.includes("-legacy-consumed:"),
);
assert.equal(
markerWritten,
[...map.keys()].some((k) => k.includes("-legacy-consumed:")),
false,
"consumed marker must not be written after a failed v2 transfer",
"consumed marker must not be written after failed v2 transfer",
);
const resumed = readChannelSortOutbox(pubkey, relayUrl);
assert.ok(resumed !== null, "legacy blob must remain replayable");
assert.equal(
resumed.store.groups["legacy-group"],
"recent",
"the exact legacy intent survives",
"exact legacy intent survives",
);
assert.ok(
resumed.legacyRawToConsume !== null,
"legacy record still reports itself for consumption",
"legacy record reports itself for consumption",
);
hook.unmount();
} finally {

View File

@@ -1,11 +1,6 @@
// Authoritative whole-blob sync suite, run directly against
// ChannelSectionSyncManager.
//
// Covers the 15 structural invariants common to whole-blob lanes. Lane-specific
// tests (serialized generations, ambiguous ACK, malformed payload,
// unsupported-version, reconnect adopts, durable outbox, failed publish) stay
// in the lane test files. channelSortSync.test.mjs covers the same engine via
// its compact adapter invocation.
// Authoritative whole-blob sync suite — runs directly against
// ChannelSectionSyncManager. Covers the 15 structural invariants common to
// whole-blob lanes. Lane-specific tests stay in the lane files.
import assert from "node:assert/strict";
import test, { mock } from "node:test";
@@ -16,6 +11,7 @@ import { readChannelSectionsOutbox } from "./channelSectionsStorage.ts";
import {
makeFakeWindow,
installFakeWindow,
makeTimerBed,
installTauriMock,
installEchoTauri,
} from "./sidebarSyncTestHelpers.mjs";
@@ -35,49 +31,11 @@ const decryptPayload = JSON.stringify(
);
const emptyDecryptPayload = JSON.stringify(makeStore([]));
// Multi-slot timer fake: each setTimeout is keyed by delay so overlapping
// timers can be fired independently.
function makeTimerBed() {
const storage = new Map();
const timers = new Map();
let nextId = 1;
const win = {
localStorage: {
getItem: (k) => storage.get(k) ?? null,
setItem: (k, v) => storage.set(k, v),
removeItem: (k) => storage.delete(k),
get length() {
return storage.size;
},
key: (i) => [...storage.keys()][i] ?? null,
},
setTimeout: (fn, ms) => {
const id = nextId++;
timers.set(id, { fn, ms });
return id;
},
clearTimeout: (id) => timers.delete(id),
};
const fireDelay = async (ms) => {
const entry = [...timers.entries()].find(([, v]) => v.ms === ms);
assert.ok(entry, `expected a timer scheduled at ${ms}ms`);
timers.delete(entry[0]);
entry[1].fn();
for (let i = 0; i < 100; i++) await Promise.resolve();
};
const restore = installFakeWindow(win);
return { win, timers, fireDelay, restore };
}
// ─── destroy() ────────────────────────────────────────────────────────────
// ─── destroy() ───────────────────────────────────────────────────────────────
test("destroy: cancels pending publish without flushing to the relay", () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
const publishCalls = [];
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
@@ -86,7 +44,6 @@ test("destroy: cancels pending publish without flushing to the relay", () => {
assert.ok(fw._hasTimer(), "debounce timer should be set");
manager.destroy();
assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy");
assert.equal(publishCalls.length, 0);
assert.equal(manager.getPendingStore(), null);
} finally {
restore();
@@ -129,11 +86,11 @@ test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolv
}
});
// ─── Boot seed-publish guard (revert-fix regression suite) ────────────────
// ─── Boot seed-publish guard ──────────────────────────────────────────────────
for (const { title, setupFetch, setupWatermark, pubkey, assertPending } of [
{
title: "fetch failed (error) does not trigger seed-publish via bootstrap",
title: "fetch error does not trigger seed-publish",
setupFetch: () =>
mock.method(relayClient, "fetchEvents", () =>
Promise.reject(new Error("relay timeout")),
@@ -141,8 +98,7 @@ for (const { title, setupFetch, setupWatermark, pubkey, assertPending } of [
assertPending: (m) => assert.equal(m.getPendingStore(), null),
},
{
title:
"absent fetch with prior watermark blocks seed-publish via bootstrap",
title: "absent fetch with prior watermark blocks seed-publish",
setupFetch: () =>
mock.method(relayClient, "fetchEvents", () => Promise.resolve([])),
setupWatermark: (fw) =>
@@ -154,8 +110,7 @@ for (const { title, setupFetch, setupWatermark, pubkey, assertPending } of [
assertPending: (m) => assert.equal(m.getPendingStore(), null),
},
{
title:
"absent fetch with zero watermark seeds via bootstrap (first-sync preserved)",
title: "absent fetch with zero watermark seeds (first-sync preserved)",
setupFetch: () =>
mock.method(relayClient, "fetchEvents", () => Promise.resolve([])),
pubkey: "pk-fresh",
@@ -181,7 +136,7 @@ for (const { title, setupFetch, setupWatermark, pubkey, assertPending } of [
});
}
// ─── Adopt-winner / local-winner ─────────────────────────────────────────
// ─── Adopt-winner / local-winner ─────────────────────────────────────────────
test("adopt-winner: newer remote head at pre-publish adopts remote and skips publish", async () => {
mock.method(relayClient, "fetchEvents", () =>
@@ -209,27 +164,26 @@ test("adopt-winner: newer remote head at pre-publish adopts remote and skips pub
manager.publishSections(nonEmptyStore());
assert.ok(
readChannelSectionsOutbox("pk-lww", RELAY) !== null,
"edit must be persisted to the durable outbox",
"edit must be in durable outbox",
);
fw._fireTimer();
await new Promise((r) => setTimeout(r, 20));
assert.equal(
publishCalls.length,
0,
"must not publish when a newer remote head wins LWW",
"must not publish when remote wins LWW",
);
assert.equal(adopted.length, 1, "adopt sink must receive the remote");
assert.ok(
adopted[0].store.sections.some(
(s) => s.id === "remote-section-from-relay",
),
"adopted store must be the remote content",
);
assert.equal(manager.getPendingStore(), null, "pending must be cleared");
assert.equal(manager.getPendingStore(), null);
assert.equal(
readChannelSectionsOutbox("pk-lww", RELAY),
null,
"outbox must be cleared on adopt",
"outbox cleared on adopt",
);
manager.destroy();
} finally {
@@ -260,7 +214,7 @@ test("adopt-winner: local edit at/ahead of head publishes and clears outbox", as
assert.equal(
readChannelSectionsOutbox("pk-win", RELAY),
null,
"outbox must be cleared once the edit is confirmed retained",
"outbox cleared after confirmed publish",
);
manager.destroy();
} finally {
@@ -270,7 +224,7 @@ test("adopt-winner: local edit at/ahead of head publishes and clears outbox", as
}
});
// ─── Timestamp clamp ──────────────────────────────────────────────────────
// ─── Timestamp clamp ─────────────────────────────────────────────────────────
test("timestamp clamp: published createdAt stays inside the relay future window", async () => {
const nowSecs = Math.floor(Date.now() / 1000);
@@ -304,7 +258,7 @@ test("timestamp clamp: published createdAt stays inside the relay future window"
assert.ok(signedCreatedAt !== null, "publish must have been attempted");
assert.ok(
signedCreatedAt <= Math.floor(Date.now() / 1000) + 840,
`createdAt must be clamped inside the future window — got ${signedCreatedAt}`,
`createdAt must be clamped — got ${signedCreatedAt}`,
);
manager.destroy();
} finally {
@@ -314,96 +268,62 @@ test("timestamp clamp: published createdAt stays inside the relay future window"
}
});
// ─── Unreadable head: retain, never overwrite ─────────────────────────────
// ─── Retain / retry on bad head ───────────────────────────────────────────────
test("unreadable head (decrypt failure) retains the pending edit and retries, never publishing", async () => {
mock.method(relayClient, "fetchEvents", () =>
Promise.resolve([
{
pubkey: "pk-undec",
content: "bad-cipher",
created_at: 500,
id: "evt-undec",
},
]),
);
const publishCalls = [];
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
for (const { title, pubkey, content, tauri: tauriPayload } of [
{
title: "unreadable head (decrypt failure)",
pubkey: "pk-undec",
content: "bad-cipher",
tauri: installTauriMock.bind(null, "{}"),
},
{
title: "failed pre-publish fetch",
pubkey: "pk-fetchfail",
content: null,
tauri: installTauriMock.bind(null, "{}"),
},
]) {
test(`${title} retains the pending edit and retries, never publishing`, async () => {
if (content === null) {
mock.method(relayClient, "fetchEvents", () =>
Promise.reject(new Error("socket timeout")),
);
} else {
mock.method(relayClient, "fetchEvents", () =>
Promise.resolve([{ pubkey, content, created_at: 500, id: "evt" }]),
);
}
const publishCalls = [];
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
const t = tauriPayload();
try {
const manager = new ChannelSectionSyncManager(pubkey, RELAY);
manager.publishSections(nonEmptyStore());
fw._fireTimer();
await new Promise((r) => setTimeout(r, 20));
assert.equal(publishCalls.length, 0, "must not publish");
assert.ok(manager.getPendingStore() !== null, "pending edit retained");
assert.ok(
readChannelSectionsOutbox(pubkey, RELAY) !== null,
"durable outbox intact",
);
assert.ok(fw._hasTimer(), "retry scheduled");
manager.destroy();
} finally {
t.restore();
restore();
mock.reset();
}
});
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
const tauri = installTauriMock("{}");
try {
const manager = new ChannelSectionSyncManager("pk-undec", RELAY);
manager.publishSections(nonEmptyStore());
fw._fireTimer();
await new Promise((r) => setTimeout(r, 20));
assert.equal(
publishCalls.length,
0,
"must not publish over a head we could not read",
);
assert.ok(
manager.getPendingStore() !== null,
"unreadable head must retain the pending edit",
);
assert.ok(
readChannelSectionsOutbox("pk-undec", RELAY) !== null,
"durable outbox must survive an unreadable head",
);
assert.ok(fw._hasTimer(), "a retry must be scheduled");
manager.destroy();
} finally {
tauri.restore();
restore();
mock.reset();
}
});
}
// ─── Failed pre-publish fetch: retain, never publish ──────────────────────
test("failed pre-publish fetch retains the pending edit and retries, never publishing", async () => {
mock.method(relayClient, "fetchEvents", () =>
Promise.reject(new Error("socket timeout")),
);
const publishCalls = [];
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
const tauri = installTauriMock("{}");
try {
const manager = new ChannelSectionSyncManager("pk-fetchfail", RELAY);
manager.publishSections(nonEmptyStore());
fw._fireTimer();
await new Promise((r) => setTimeout(r, 20));
assert.equal(
publishCalls.length,
0,
"must not publish when the pre-publish fetch failed",
);
assert.ok(
manager.getPendingStore() !== null,
"a failed fetch must retain the pending edit",
);
assert.ok(
readChannelSectionsOutbox("pk-fetchfail", RELAY) !== null,
"durable outbox must survive a failed fetch",
);
assert.ok(fw._hasTimer(), "a retry must be scheduled");
manager.destroy();
} finally {
tauri.restore();
restore();
mock.reset();
}
});
// ─── Overlapping publishes ─────────────────────────────────────────────────
// ─── Overlapping publishes ────────────────────────────────────────────────────
test("overlapping publishes: older completion does not erase a newer queued edit", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
@@ -411,11 +331,10 @@ test("overlapping publishes: older completion does not erase a newer queued edit
let publishCalls = 0;
mock.method(relayClient, "publishEvent", () => {
publishCalls++;
if (publishCalls === 1) {
if (publishCalls === 1)
return new Promise((res) => {
releaseFirst = res;
});
}
return Promise.resolve();
});
const { timers, fireDelay, restore } = makeTimerBed();
@@ -427,32 +346,29 @@ test("overlapping publishes: older completion does not erase a newer queued edit
manager.publishSections(storeA);
await fireDelay(2000);
while (releaseFirst === null) await Promise.resolve();
manager.publishSections(storeB);
assert.ok(
manager.getPendingStore()?.sections?.[0]?.id === "b",
"B is now the pending edit",
"B is pending",
);
assert.ok(
readChannelSectionsOutbox("pk-overlap", RELAY)?.store?.sections?.[0]
?.id === "b",
"outbox holds B",
);
releaseFirst();
for (let i = 0; i < 10; i++) await Promise.resolve();
assert.ok(
manager.getPendingStore()?.sections?.[0]?.id === "b",
"older completion must leave B pending",
"older completion leaves B pending",
);
assert.ok(
readChannelSectionsOutbox("pk-overlap", RELAY) !== null,
"older completion must leave B's outbox intact",
"older completion leaves B outbox intact",
);
assert.ok(
[...timers.values()].some((t) => t.ms === 2000),
"B's debounce timer must survive so it still publishes",
"B debounce timer survives",
);
manager.destroy();
} finally {
@@ -462,7 +378,7 @@ test("overlapping publishes: older completion does not erase a newer queued edit
}
});
// ─── Live remote during debounce is adopted at pre-publish ────────────────
// ─── Live remote during debounce ──────────────────────────────────────────────
test("live remote during debounce is adopted at pre-publish, not overwritten", async () => {
let liveCallback = null;
@@ -509,9 +425,9 @@ test("live remote during debounce is adopted at pre-publish, not overwritten", a
assert.equal(
publishCalls.length,
0,
"must not publish over a remote that became head during the debounce",
"must not publish over a remote that became head during debounce",
);
assert.equal(adopted.length, 1, "the newer remote must be adopted");
assert.equal(adopted.length, 1, "newer remote must be adopted");
manager.destroy();
} finally {
tauri.restore();
@@ -520,7 +436,7 @@ test("live remote during debounce is adopted at pre-publish, not overwritten", a
}
});
// ─── Same-second collision: second edit queued during confirmation survives ─
// ─── Same-second collision ────────────────────────────────────────────────────
test("same-second collision: second edit queued during confirmation survives", async () => {
let fetchCalls = 0;
@@ -554,20 +470,16 @@ test("same-second collision: second edit queued during confirmation survives", a
const manager = new ChannelSectionSyncManager("pk-collide2", RELAY);
const adopted = [];
manager.setOnRemoteAdopted((r) => adopted.push(r));
manager.publishSections(
makeStore([{ id: "mine", name: "Mine", order: 0 }]),
);
await fireDelay(2000);
while (releaseConfirmation === null) await Promise.resolve();
manager.publishSections(
makeStore([{ id: "second", name: "Second", order: 0 }]),
);
releaseConfirmation();
for (let i = 0; i < 100; i++) await Promise.resolve();
assert.equal(
adopted.length,
0,
@@ -576,27 +488,21 @@ test("same-second collision: second edit queued during confirmation survives", a
assert.notEqual(
manager.getPendingStore(),
null,
"edit-2 must still be pending after the stale-gen adopt",
"edit-2 still pending after stale-gen adopt",
);
await fireDelay(2000);
for (let i = 0; i < 100; i++) await Promise.resolve();
assert.equal(publishCalls, 2, "edit-2 publishes above the peer winner");
assert.equal(
adopted.length,
0,
"edit-2 must not be adopted away — no phantom-baseline poisoning",
);
assert.equal(adopted.length, 0, "edit-2 must not be adopted away");
assert.equal(
manager.getPendingStore(),
null,
"edit-2 clears via its own confirmed publish",
"edit-2 clears via confirmed publish",
);
assert.equal(
readChannelSectionsOutbox("pk-collide2", RELAY),
null,
"edit-2's durable outbox is cleared on confirmation",
"edit-2 outbox cleared",
);
manager.destroy();
} finally {
@@ -606,8 +512,6 @@ test("same-second collision: second edit queued during confirmation survives", a
}
});
// ─── Same-second collision: loser adopts the winner and its next edit survives ─
test("same-second collision: loser adopts the winner and its next edit survives", async () => {
let fetchCalls = 0;
let publishCalls = 0;
@@ -616,49 +520,38 @@ test("same-second collision: loser adopts the winner and its next edit survives"
const tauri = installEchoTauri("pk-loser");
mock.method(relayClient, "fetchEvents", () => {
fetchCalls++;
if (fetchCalls === 1) return Promise.resolve([]);
if (fetchCalls === 2) return Promise.resolve(storedHead);
return Promise.resolve(storedHead);
return Promise.resolve(fetchCalls === 1 ? [] : storedHead);
});
const winnerStore = makeStore([{ id: "peer", name: "Peer", order: 0 }]);
mock.method(relayClient, "publishEvent", (event) => {
publishCalls++;
if (publishCalls === 1) {
if (publishCalls === 1)
storedHead = [
tauri.mintHead(winnerStore, event.created_at, "0-peer-winner"),
];
}
return Promise.resolve();
});
try {
const manager = new ChannelSectionSyncManager("pk-loser", RELAY);
const adopted = [];
manager.setOnRemoteAdopted((r) => adopted.push(r));
manager.publishSections(
makeStore([{ id: "loser", name: "Loser", order: 0 }]),
);
await fireDelay(2000);
for (let i = 0; i < 100; i++) await Promise.resolve();
assert.equal(adopted.length, 1, "loser must adopt the peer winner");
assert.equal(
manager.getPendingStore(),
null,
"pending cleared after adopt",
);
manager.publishSections(
makeStore([{ id: "mine", name: "Mine", order: 0 }]),
);
await fireDelay(2000);
for (let i = 0; i < 100; i++) await Promise.resolve();
assert.equal(
publishCalls,
2,
"the edit after the adopt must publish successfully",
);
assert.equal(publishCalls, 2, "edit after adopt must publish successfully");
manager.destroy();
} finally {
tauri.restore();
@@ -667,7 +560,7 @@ test("same-second collision: loser adopts the winner and its next edit survives"
}
});
// ─── live-sub: undecryptable event advances watermark before decrypt ───────
// ─── live-sub: undecryptable event advances watermark ────────────────────────
test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => {
let liveCallback = null;
@@ -687,10 +580,7 @@ test("revert-fix: undecryptable live event advances watermark before decrypt att
"watermark starts absent",
);
await manager.subscribeToSections(() => {});
assert.ok(
liveCallback !== null,
"subscribeLive must have captured the callback",
);
assert.ok(liveCallback !== null, "subscribeLive captured the callback");
liveCallback({
pubkey: "pk-live",
content: "!bad-cipher!",
@@ -704,7 +594,7 @@ test("revert-fix: undecryptable live event advances watermark before decrypt att
`buzz-sync-watermark.v1:${watermarkLane}:pk-live:${RELAY_KEY}`,
) ?? "0",
) >= 1700005555,
"live undecryptable event must advance the watermark before decrypt is attempted",
"live undecryptable event must advance watermark before decrypt",
);
manager.destroy();
} finally {