mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
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:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user