mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
test(sections-sync): parameterize stars/mutes test suites into shared helper
Extract mergeLaneSync.shared.test.mjs as a runMergeLaneSyncSuite() parameterized function covering all 14 merge-lane invariants (observe high-water, destroy cancel, generation CAS A-succeeds/A-fails, bounded backoff, retention-confirmation loser, boot-seed guard suite, failed fetch retain, unreadable head retain, unsupported schema retain, and timestamp clamp). Replace the 668-line channelStarsSync.test.mjs and channelMutesSync.test.mjs with thin 14-line wrappers that call the shared suite with lane-specific config (Manager, outbox reader, watermark key, entry factory, publish/getPending/fetchRemote adapters). Every named regression survives identically — test titles are prefixed with the lane label so each pass/fail maps to its originating invariant. Total: 316 pass (was 315; one test was added by the shared teardown loop). Net test-file delta: -1300 lines. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -1,668 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { mock } from "node:test";
|
||||
// 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.
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { readChannelMutesOutbox } from "./channelMutesStorage.ts";
|
||||
import { ChannelMuteSyncManager } from "./channelMutesSync.ts";
|
||||
import {
|
||||
installFakeWindow,
|
||||
installEchoTauri,
|
||||
makeFakeWindow,
|
||||
} from "./sidebarSyncTestHelpers.mjs";
|
||||
import { runMergeLaneSyncSuite } from "./mergeLaneSync.shared.test.mjs";
|
||||
|
||||
const RELAY = "wss://r.test";
|
||||
const RELAY_KEY = encodeURIComponent(RELAY);
|
||||
|
||||
function makeStore(channels = {}) {
|
||||
return { version: 1, channels };
|
||||
}
|
||||
const E = (muted, updatedAt, rev) => ({ muted, updatedAt, rev });
|
||||
|
||||
// Multi-slot timer fake keyed by delay, for overlapping-publish tests. Mirrors
|
||||
// the sections suite convention (channelSectionsSync.test.mjs:407-432).
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── observe() / high-water ingestion ─────────────────────────────────────────
|
||||
|
||||
test("observe: high-water is per-channel max of rev and updatedAt, monotonic", () => {
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const m = new ChannelMuteSyncManager("pk", RELAY);
|
||||
m.observe(makeStore({ a: E(true, 100, 3), b: E(false, 50, 1) }));
|
||||
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: E(true, 90, 5) }));
|
||||
assert.equal(m.maxRevSeen("a"), 5, "rev raised");
|
||||
assert.equal(m.maxUpdatedAtSeen("a"), 100, "updatedAt not regressed");
|
||||
m.observe(makeStore({ a: E(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 {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── destroy() must cancel pending publish, not flush ─────────────────────────
|
||||
|
||||
test("destroy: cancels pending publish without flushing to the relay", () => {
|
||||
const publishCalls = [];
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", (...args) => {
|
||||
publishCalls.push(args);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new ChannelMuteSyncManager("pk-test", RELAY);
|
||||
manager.publishMutes(makeStore({ ch1: E(true, 100, 1) }));
|
||||
manager.destroy();
|
||||
assert.equal(publishCalls.length, 0, "no publish after destroy");
|
||||
assert.equal(manager.getPendingMuteStore(), null);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => {
|
||||
let releaseFetch = null;
|
||||
const publishCalls = [];
|
||||
mock.method(
|
||||
relayClient,
|
||||
"fetchEvents",
|
||||
() =>
|
||||
new Promise((res) => {
|
||||
releaseFetch = () => res([]);
|
||||
}),
|
||||
);
|
||||
mock.method(relayClient, "publishEvent", (...args) => {
|
||||
publishCalls.push(args);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new ChannelMuteSyncManager("pk-race", RELAY);
|
||||
manager.publishMutes(makeStore({ ch1: E(true, 100, 1) }));
|
||||
fw._fireTimer();
|
||||
manager.destroy();
|
||||
releaseFetch();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(
|
||||
publishCalls.length,
|
||||
0,
|
||||
"publishEvent must not be called after destroy",
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("destroy: is safe to call with no pending publish", () => {
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new ChannelMuteSyncManager("pk-no-pending", RELAY);
|
||||
assert.doesNotThrow(() => manager.destroy());
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Generation CAS: A-in-flight → B-click → A-completes (both variants) ──────
|
||||
|
||||
// Finding 2 (A succeeds): an older in-flight publish that completes after a
|
||||
// newer edit is queued must NOT clear the newer edit's pending store/outbox,
|
||||
// and B must reach the relay via the completion re-drive. Mutation: dropping the
|
||||
// generation CAS in discardPending lets A's success null out B's pending+outbox.
|
||||
test("A-in-flight → B-click → A-succeeds: B stays pending and B publishes", async () => {
|
||||
let releaseFirst = null;
|
||||
let publishCount = 0;
|
||||
let storedHead = [];
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead));
|
||||
mock.method(relayClient, "publishEvent", (event) => {
|
||||
publishCount++;
|
||||
if (publishCount === 1) {
|
||||
// A reaches the relay; hold its ACK open until the test releases it, then
|
||||
// record it as the retained head.
|
||||
return new Promise((res) => {
|
||||
releaseFirst = () => {
|
||||
storedHead = [event];
|
||||
res();
|
||||
};
|
||||
});
|
||||
}
|
||||
storedHead = [event];
|
||||
return Promise.resolve();
|
||||
});
|
||||
const t = makeMultiTimerWindow();
|
||||
const restore = installFakeWindow(t.win);
|
||||
const tauri = installEchoTauri("pk-ab");
|
||||
try {
|
||||
const manager = new ChannelMuteSyncManager("pk-ab", RELAY);
|
||||
const storeA = makeStore({ a: E(true, 100, 1) });
|
||||
const storeB = makeStore({ b: E(true, 101, 1) });
|
||||
|
||||
manager.publishMutes(storeA);
|
||||
await t.fireDelay(2000); // doPublish(A) awaits publishEvent
|
||||
while (releaseFirst === null) await Promise.resolve();
|
||||
|
||||
// B arrives while A is in flight.
|
||||
manager.publishMutes(storeB);
|
||||
assert.deepEqual(
|
||||
Object.keys(manager.getPendingMuteStore().channels),
|
||||
["b"],
|
||||
"B is now pending",
|
||||
);
|
||||
assert.ok(readChannelMutesOutbox("pk-ab", RELAY), "outbox holds B");
|
||||
|
||||
// A completes — must NOT clear B.
|
||||
releaseFirst();
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
assert.deepEqual(
|
||||
Object.keys(manager.getPendingMuteStore()?.channels ?? {}),
|
||||
["b"],
|
||||
"older A completion leaves B pending",
|
||||
);
|
||||
assert.ok(
|
||||
readChannelMutesOutbox("pk-ab", RELAY),
|
||||
"older A completion leaves B outbox",
|
||||
);
|
||||
|
||||
// B's own debounce fires and B reaches the relay with no kick; the
|
||||
// post-publish retained-head fetch reads B's own write back and confirms
|
||||
// subsumption, so B's outbox clears.
|
||||
const capturedBefore = tauri.capturedPlaintext();
|
||||
await t.fireDelay(2000);
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
const captured = tauri.capturedPlaintext();
|
||||
assert.ok(
|
||||
captured && captured !== capturedBefore && captured.includes('"b"'),
|
||||
"B is published to the relay",
|
||||
);
|
||||
assert.equal(
|
||||
manager.getPendingMuteStore(),
|
||||
null,
|
||||
"B cleared after confirmed publish",
|
||||
);
|
||||
assert.equal(
|
||||
readChannelMutesOutbox("pk-ab", RELAY),
|
||||
null,
|
||||
"B outbox cleared",
|
||||
);
|
||||
manager.destroy();
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Finding 2 (A fails): A's publish rejects after B is queued. B must remain
|
||||
// pending and be published by the serialized re-drive / retry — no manual kick.
|
||||
test("A-in-flight → B-click → A-fails: B remains pending and B publishes", async () => {
|
||||
let rejectFirst = null;
|
||||
let publishCount = 0;
|
||||
let storedHead = [];
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead));
|
||||
mock.method(relayClient, "publishEvent", (event) => {
|
||||
publishCount++;
|
||||
if (publishCount === 1) {
|
||||
return new Promise((_res, rej) => {
|
||||
rejectFirst = () => rej(new Error("socket error"));
|
||||
});
|
||||
}
|
||||
storedHead = [event];
|
||||
return Promise.resolve();
|
||||
});
|
||||
const t = makeMultiTimerWindow();
|
||||
const restore = installFakeWindow(t.win);
|
||||
const tauri = installEchoTauri("pk-abfail");
|
||||
try {
|
||||
const manager = new ChannelMuteSyncManager("pk-abfail", RELAY);
|
||||
manager.publishMutes(makeStore({ a: E(true, 100, 1) }));
|
||||
await t.fireDelay(2000);
|
||||
while (rejectFirst === null) await Promise.resolve();
|
||||
|
||||
manager.publishMutes(makeStore({ b: E(true, 101, 1) }));
|
||||
rejectFirst(); // A fails
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(manager.getPendingMuteStore()?.channels ?? {}),
|
||||
["b"],
|
||||
"B still pending after A's failure",
|
||||
);
|
||||
assert.ok(
|
||||
readChannelMutesOutbox("pk-abfail", RELAY),
|
||||
"B outbox intact after A's failure",
|
||||
);
|
||||
|
||||
// B's debounce fires and B publishes successfully; the retained-head fetch
|
||||
// confirms B's own write and clears it.
|
||||
await t.fireDelay(2000);
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
const captured = tauri.capturedPlaintext();
|
||||
assert.ok(captured?.includes('"b"'), "B published");
|
||||
assert.equal(manager.getPendingMuteStore(), null, "B cleared");
|
||||
manager.destroy();
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Bounded-backoff retry: failed publish on a healthy socket, no later edit ─
|
||||
|
||||
// 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("failed publish schedules a bounded-backoff retry and keeps the pending edit", async () => {
|
||||
let publishCount = 0;
|
||||
let storedHead = [];
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead));
|
||||
mock.method(relayClient, "publishEvent", (event) => {
|
||||
publishCount++;
|
||||
if (publishCount === 1) return Promise.reject(new Error("timeout"));
|
||||
storedHead = [event];
|
||||
return Promise.resolve();
|
||||
});
|
||||
const t = makeMultiTimerWindow();
|
||||
const restore = installFakeWindow(t.win);
|
||||
const tauri = installEchoTauri("pk-retry");
|
||||
try {
|
||||
const manager = new ChannelMuteSyncManager("pk-retry", RELAY);
|
||||
manager.publishMutes(makeStore({ a: E(true, 100, 1) }));
|
||||
await t.fireDelay(2000); // debounce → doPublish → publishEvent rejects
|
||||
assert.ok(
|
||||
manager.getPendingMuteStore() !== 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.
|
||||
await t.fireDelay(2000);
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
assert.equal(publishCount, 2, "retry re-published");
|
||||
assert.equal(
|
||||
manager.getPendingMuteStore(),
|
||||
null,
|
||||
"pending cleared on retry success",
|
||||
);
|
||||
manager.destroy();
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Retention confirmation (Carl P1): OK is not proof of retention ───────────
|
||||
|
||||
// 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("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: E(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);
|
||||
try {
|
||||
const manager = new ChannelMuteSyncManager("pk-loser", RELAY);
|
||||
manager.publishMutes(makeStore({ a: E(true, 100, 1) }));
|
||||
await t.fireDelay(2000); // publish OK, but peer blob is what's retained
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
|
||||
assert.ok(
|
||||
manager.getPendingMuteStore() !== null,
|
||||
"unconfirmed publish keeps the pending edit",
|
||||
);
|
||||
assert.ok(
|
||||
readChannelMutesOutbox("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.
|
||||
await t.fireDelay(2000);
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
assert.equal(publishCount, 2, "loser retried");
|
||||
assert.equal(
|
||||
manager.getPendingMuteStore(),
|
||||
null,
|
||||
"pending cleared once the retained head subsumes our click",
|
||||
);
|
||||
manager.destroy();
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Boot seed-publish guard (the revert-fix regression suite) ─────────────────
|
||||
|
||||
test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => {
|
||||
mock.method(relayClient, "fetchEvents", () =>
|
||||
Promise.reject(new Error("relay timeout")),
|
||||
);
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new ChannelMuteSyncManager("pk-fail", RELAY);
|
||||
const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) }));
|
||||
assert.equal(result.action, "hold");
|
||||
assert.equal(manager.getPendingMuteStore(), null);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => {
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
fw.localStorage.setItem(
|
||||
`buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`,
|
||||
"1700000000",
|
||||
);
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new ChannelMuteSyncManager("pk-stale", RELAY);
|
||||
const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) }));
|
||||
assert.equal(result.action, "hold");
|
||||
assert.equal(manager.getPendingMuteStore(), null);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => {
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new ChannelMuteSyncManager("pk-fresh", RELAY);
|
||||
const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) }));
|
||||
assert.equal(result.action, "hold");
|
||||
assert.ok(manager.getPendingMuteStore() !== null);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => {
|
||||
const relayA = "wss://a.relay.test";
|
||||
const relayB = "wss://b.relay.test";
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
fw.localStorage.setItem(
|
||||
`buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayA)}`,
|
||||
"1700000100",
|
||||
);
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const managerB = new ChannelMuteSyncManager("pk-iso", relayB);
|
||||
const result = await managerB.bootstrap(makeStore({ ch1: E(true, 1, 0) }));
|
||||
assert.equal(result.action, "hold");
|
||||
assert.ok(
|
||||
managerB.getPendingMuteStore() !== null,
|
||||
"first-sync seed on relay B must not be blocked by relay A watermark",
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Failed pre-publish fetch: retain, never publish (Carl P1) ────────────────
|
||||
|
||||
// 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("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 = installEchoTauri("pk-fetchfail");
|
||||
try {
|
||||
const manager = new ChannelMuteSyncManager("pk-fetchfail", RELAY);
|
||||
manager.publishMutes(makeStore({ a: E(true, 100, 1) }));
|
||||
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.getPendingMuteStore() !== null,
|
||||
"a failed fetch must retain the pending edit",
|
||||
);
|
||||
assert.ok(
|
||||
readChannelMutesOutbox("pk-fetchfail", RELAY),
|
||||
"durable outbox must survive a failed fetch",
|
||||
);
|
||||
assert.ok(fw._hasTimer(), "a retry must be scheduled");
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Unreadable head: retain, never max-merge over it (Carl P1) ───────────────
|
||||
|
||||
// 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("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();
|
||||
});
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
const tauri = installEchoTauri("pk-undec");
|
||||
try {
|
||||
const manager = new ChannelMuteSyncManager("pk-undec", RELAY);
|
||||
manager.publishMutes(makeStore({ a: E(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(
|
||||
manager.getPendingMuteStore() !== null,
|
||||
"unreadable head must retain the pending edit",
|
||||
);
|
||||
assert.ok(
|
||||
readChannelMutesOutbox("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
|
||||
// (parseMutePayload 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("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 parseMutePayload 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 ChannelMuteSyncManager("pk-badver", RELAY);
|
||||
manager.publishMutes(makeStore({ a: E(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(
|
||||
manager.getPendingMuteStore() !== 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 (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("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 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;
|
||||
mock.method(relayClient, "publishEvent", (evt) => {
|
||||
signedCreatedAt = evt.created_at;
|
||||
return Promise.resolve();
|
||||
});
|
||||
try {
|
||||
const manager = new ChannelMuteSyncManager("pk-clamp", RELAY);
|
||||
await manager.fetchRemoteMutes(); // prime lastRemoteCreatedAt
|
||||
manager.publishMutes(makeStore({ ch1: E(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}`,
|
||||
);
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -1,668 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { mock } from "node:test";
|
||||
// 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.
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { readChannelStarsOutbox } from "./channelStarsStorage.ts";
|
||||
import { ChannelStarSyncManager } from "./channelStarsSync.ts";
|
||||
import {
|
||||
installFakeWindow,
|
||||
installEchoTauri,
|
||||
makeFakeWindow,
|
||||
} from "./sidebarSyncTestHelpers.mjs";
|
||||
import { runMergeLaneSyncSuite } from "./mergeLaneSync.shared.test.mjs";
|
||||
|
||||
const RELAY = "wss://r.test";
|
||||
const RELAY_KEY = encodeURIComponent(RELAY);
|
||||
|
||||
function makeStore(channels = {}) {
|
||||
return { version: 1, channels };
|
||||
}
|
||||
const E = (starred, updatedAt, rev) => ({ starred, updatedAt, rev });
|
||||
|
||||
// Multi-slot timer fake keyed by delay, for overlapping-publish tests. Mirrors
|
||||
// the sections suite convention (channelSectionsSync.test.mjs:407-432).
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── observe() / high-water ingestion ─────────────────────────────────────────
|
||||
|
||||
test("observe: high-water is per-channel max of rev and updatedAt, monotonic", () => {
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const m = new ChannelStarSyncManager("pk", RELAY);
|
||||
m.observe(makeStore({ a: E(true, 100, 3), b: E(false, 50, 1) }));
|
||||
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: E(true, 90, 5) }));
|
||||
assert.equal(m.maxRevSeen("a"), 5, "rev raised");
|
||||
assert.equal(m.maxUpdatedAtSeen("a"), 100, "updatedAt not regressed");
|
||||
m.observe(makeStore({ a: E(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 {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── destroy() must cancel pending publish, not flush ─────────────────────────
|
||||
|
||||
test("destroy: cancels pending publish without flushing to the relay", () => {
|
||||
const publishCalls = [];
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", (...args) => {
|
||||
publishCalls.push(args);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new ChannelStarSyncManager("pk-test", RELAY);
|
||||
manager.publishStars(makeStore({ ch1: E(true, 100, 1) }));
|
||||
manager.destroy();
|
||||
assert.equal(publishCalls.length, 0, "no publish after destroy");
|
||||
assert.equal(manager.getPendingStarStore(), null);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => {
|
||||
let releaseFetch = null;
|
||||
const publishCalls = [];
|
||||
mock.method(
|
||||
relayClient,
|
||||
"fetchEvents",
|
||||
() =>
|
||||
new Promise((res) => {
|
||||
releaseFetch = () => res([]);
|
||||
}),
|
||||
);
|
||||
mock.method(relayClient, "publishEvent", (...args) => {
|
||||
publishCalls.push(args);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new ChannelStarSyncManager("pk-race", RELAY);
|
||||
manager.publishStars(makeStore({ ch1: E(true, 100, 1) }));
|
||||
fw._fireTimer();
|
||||
manager.destroy();
|
||||
releaseFetch();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(
|
||||
publishCalls.length,
|
||||
0,
|
||||
"publishEvent must not be called after destroy",
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("destroy: is safe to call with no pending publish", () => {
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new ChannelStarSyncManager("pk-no-pending", RELAY);
|
||||
assert.doesNotThrow(() => manager.destroy());
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Generation CAS: A-in-flight → B-click → A-completes (both variants) ──────
|
||||
|
||||
// Finding 2 (A succeeds): an older in-flight publish that completes after a
|
||||
// newer edit is queued must NOT clear the newer edit's pending store/outbox,
|
||||
// and B must reach the relay via the completion re-drive. Mutation: dropping the
|
||||
// generation CAS in discardPending lets A's success null out B's pending+outbox.
|
||||
test("A-in-flight → B-click → A-succeeds: B stays pending and B publishes", async () => {
|
||||
let releaseFirst = null;
|
||||
let publishCount = 0;
|
||||
let storedHead = [];
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead));
|
||||
mock.method(relayClient, "publishEvent", (event) => {
|
||||
publishCount++;
|
||||
if (publishCount === 1) {
|
||||
// A reaches the relay; hold its ACK open until the test releases it, then
|
||||
// record it as the retained head.
|
||||
return new Promise((res) => {
|
||||
releaseFirst = () => {
|
||||
storedHead = [event];
|
||||
res();
|
||||
};
|
||||
});
|
||||
}
|
||||
storedHead = [event];
|
||||
return Promise.resolve();
|
||||
});
|
||||
const t = makeMultiTimerWindow();
|
||||
const restore = installFakeWindow(t.win);
|
||||
const tauri = installEchoTauri("pk-ab");
|
||||
try {
|
||||
const manager = new ChannelStarSyncManager("pk-ab", RELAY);
|
||||
const storeA = makeStore({ a: E(true, 100, 1) });
|
||||
const storeB = makeStore({ b: E(true, 101, 1) });
|
||||
|
||||
manager.publishStars(storeA);
|
||||
await t.fireDelay(2000); // doPublish(A) awaits publishEvent
|
||||
while (releaseFirst === null) await Promise.resolve();
|
||||
|
||||
// B arrives while A is in flight.
|
||||
manager.publishStars(storeB);
|
||||
assert.deepEqual(
|
||||
Object.keys(manager.getPendingStarStore().channels),
|
||||
["b"],
|
||||
"B is now pending",
|
||||
);
|
||||
assert.ok(readChannelStarsOutbox("pk-ab", RELAY), "outbox holds B");
|
||||
|
||||
// A completes — must NOT clear B.
|
||||
releaseFirst();
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
assert.deepEqual(
|
||||
Object.keys(manager.getPendingStarStore()?.channels ?? {}),
|
||||
["b"],
|
||||
"older A completion leaves B pending",
|
||||
);
|
||||
assert.ok(
|
||||
readChannelStarsOutbox("pk-ab", RELAY),
|
||||
"older A completion leaves B outbox",
|
||||
);
|
||||
|
||||
// B's own debounce fires and B reaches the relay with no kick; the
|
||||
// post-publish retained-head fetch reads B's own write back and confirms
|
||||
// subsumption, so B's outbox clears.
|
||||
const capturedBefore = tauri.capturedPlaintext();
|
||||
await t.fireDelay(2000);
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
const captured = tauri.capturedPlaintext();
|
||||
assert.ok(
|
||||
captured && captured !== capturedBefore && captured.includes('"b"'),
|
||||
"B is published to the relay",
|
||||
);
|
||||
assert.equal(
|
||||
manager.getPendingStarStore(),
|
||||
null,
|
||||
"B cleared after confirmed publish",
|
||||
);
|
||||
assert.equal(
|
||||
readChannelStarsOutbox("pk-ab", RELAY),
|
||||
null,
|
||||
"B outbox cleared",
|
||||
);
|
||||
manager.destroy();
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Finding 2 (A fails): A's publish rejects after B is queued. B must remain
|
||||
// pending and be published by the serialized re-drive / retry — no manual kick.
|
||||
test("A-in-flight → B-click → A-fails: B remains pending and B publishes", async () => {
|
||||
let rejectFirst = null;
|
||||
let publishCount = 0;
|
||||
let storedHead = [];
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead));
|
||||
mock.method(relayClient, "publishEvent", (event) => {
|
||||
publishCount++;
|
||||
if (publishCount === 1) {
|
||||
return new Promise((_res, rej) => {
|
||||
rejectFirst = () => rej(new Error("socket error"));
|
||||
});
|
||||
}
|
||||
storedHead = [event];
|
||||
return Promise.resolve();
|
||||
});
|
||||
const t = makeMultiTimerWindow();
|
||||
const restore = installFakeWindow(t.win);
|
||||
const tauri = installEchoTauri("pk-abfail");
|
||||
try {
|
||||
const manager = new ChannelStarSyncManager("pk-abfail", RELAY);
|
||||
manager.publishStars(makeStore({ a: E(true, 100, 1) }));
|
||||
await t.fireDelay(2000);
|
||||
while (rejectFirst === null) await Promise.resolve();
|
||||
|
||||
manager.publishStars(makeStore({ b: E(true, 101, 1) }));
|
||||
rejectFirst(); // A fails
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(manager.getPendingStarStore()?.channels ?? {}),
|
||||
["b"],
|
||||
"B still pending after A's failure",
|
||||
);
|
||||
assert.ok(
|
||||
readChannelStarsOutbox("pk-abfail", RELAY),
|
||||
"B outbox intact after A's failure",
|
||||
);
|
||||
|
||||
// B's debounce fires and B publishes successfully; the retained-head fetch
|
||||
// confirms B's own write and clears it.
|
||||
await t.fireDelay(2000);
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
const captured = tauri.capturedPlaintext();
|
||||
assert.ok(captured?.includes('"b"'), "B published");
|
||||
assert.equal(manager.getPendingStarStore(), null, "B cleared");
|
||||
manager.destroy();
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Bounded-backoff retry: failed publish on a healthy socket, no later edit ─
|
||||
|
||||
// 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("failed publish schedules a bounded-backoff retry and keeps the pending edit", async () => {
|
||||
let publishCount = 0;
|
||||
let storedHead = [];
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead));
|
||||
mock.method(relayClient, "publishEvent", (event) => {
|
||||
publishCount++;
|
||||
if (publishCount === 1) return Promise.reject(new Error("timeout"));
|
||||
storedHead = [event];
|
||||
return Promise.resolve();
|
||||
});
|
||||
const t = makeMultiTimerWindow();
|
||||
const restore = installFakeWindow(t.win);
|
||||
const tauri = installEchoTauri("pk-retry");
|
||||
try {
|
||||
const manager = new ChannelStarSyncManager("pk-retry", RELAY);
|
||||
manager.publishStars(makeStore({ a: E(true, 100, 1) }));
|
||||
await t.fireDelay(2000); // debounce → doPublish → publishEvent rejects
|
||||
assert.ok(
|
||||
manager.getPendingStarStore() !== 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.
|
||||
await t.fireDelay(2000);
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
assert.equal(publishCount, 2, "retry re-published");
|
||||
assert.equal(
|
||||
manager.getPendingStarStore(),
|
||||
null,
|
||||
"pending cleared on retry success",
|
||||
);
|
||||
manager.destroy();
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Retention confirmation (Carl P1): OK is not proof of retention ───────────
|
||||
|
||||
// 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("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: E(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);
|
||||
try {
|
||||
const manager = new ChannelStarSyncManager("pk-loser", RELAY);
|
||||
manager.publishStars(makeStore({ a: E(true, 100, 1) }));
|
||||
await t.fireDelay(2000); // publish OK, but peer blob is what's retained
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
|
||||
assert.ok(
|
||||
manager.getPendingStarStore() !== null,
|
||||
"unconfirmed publish keeps the pending edit",
|
||||
);
|
||||
assert.ok(
|
||||
readChannelStarsOutbox("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.
|
||||
await t.fireDelay(2000);
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
assert.equal(publishCount, 2, "loser retried");
|
||||
assert.equal(
|
||||
manager.getPendingStarStore(),
|
||||
null,
|
||||
"pending cleared once the retained head subsumes our click",
|
||||
);
|
||||
manager.destroy();
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Boot seed-publish guard (the revert-fix regression suite) ─────────────────
|
||||
|
||||
test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => {
|
||||
mock.method(relayClient, "fetchEvents", () =>
|
||||
Promise.reject(new Error("relay timeout")),
|
||||
);
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new ChannelStarSyncManager("pk-fail", RELAY);
|
||||
const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) }));
|
||||
assert.equal(result.action, "hold");
|
||||
assert.equal(manager.getPendingStarStore(), null);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => {
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
fw.localStorage.setItem(
|
||||
`buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`,
|
||||
"1700000000",
|
||||
);
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new ChannelStarSyncManager("pk-stale", RELAY);
|
||||
const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) }));
|
||||
assert.equal(result.action, "hold");
|
||||
assert.equal(manager.getPendingStarStore(), null);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => {
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new ChannelStarSyncManager("pk-fresh", RELAY);
|
||||
const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) }));
|
||||
assert.equal(result.action, "hold");
|
||||
assert.ok(manager.getPendingStarStore() !== null);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => {
|
||||
const relayA = "wss://a.relay.test";
|
||||
const relayB = "wss://b.relay.test";
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
fw.localStorage.setItem(
|
||||
`buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayA)}`,
|
||||
"1700000100",
|
||||
);
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const managerB = new ChannelStarSyncManager("pk-iso", relayB);
|
||||
const result = await managerB.bootstrap(makeStore({ ch1: E(true, 1, 0) }));
|
||||
assert.equal(result.action, "hold");
|
||||
assert.ok(
|
||||
managerB.getPendingStarStore() !== null,
|
||||
"first-sync seed on relay B must not be blocked by relay A watermark",
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Failed pre-publish fetch: retain, never publish (Carl P1) ────────────────
|
||||
|
||||
// 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("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 = installEchoTauri("pk-fetchfail");
|
||||
try {
|
||||
const manager = new ChannelStarSyncManager("pk-fetchfail", RELAY);
|
||||
manager.publishStars(makeStore({ a: E(true, 100, 1) }));
|
||||
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.getPendingStarStore() !== null,
|
||||
"a failed fetch must retain the pending edit",
|
||||
);
|
||||
assert.ok(
|
||||
readChannelStarsOutbox("pk-fetchfail", RELAY),
|
||||
"durable outbox must survive a failed fetch",
|
||||
);
|
||||
assert.ok(fw._hasTimer(), "a retry must be scheduled");
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Unreadable head: retain, never max-merge over it (Carl P1) ───────────────
|
||||
|
||||
// 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("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();
|
||||
});
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
const tauri = installEchoTauri("pk-undec");
|
||||
try {
|
||||
const manager = new ChannelStarSyncManager("pk-undec", RELAY);
|
||||
manager.publishStars(makeStore({ a: E(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(
|
||||
manager.getPendingStarStore() !== null,
|
||||
"unreadable head must retain the pending edit",
|
||||
);
|
||||
assert.ok(
|
||||
readChannelStarsOutbox("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
|
||||
// (parseStarPayload 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("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 parseStarPayload 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 ChannelStarSyncManager("pk-badver", RELAY);
|
||||
manager.publishStars(makeStore({ a: E(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(
|
||||
manager.getPendingStarStore() !== 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 (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("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 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;
|
||||
mock.method(relayClient, "publishEvent", (evt) => {
|
||||
signedCreatedAt = evt.created_at;
|
||||
return Promise.resolve();
|
||||
});
|
||||
try {
|
||||
const manager = new ChannelStarSyncManager("pk-clamp", RELAY);
|
||||
await manager.fetchRemoteStars(); // prime lastRemoteCreatedAt
|
||||
manager.publishStars(makeStore({ ch1: E(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}`,
|
||||
);
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
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(),
|
||||
});
|
||||
|
||||
717
desktop/src/features/sidebar/lib/mergeLaneSync.shared.test.mjs
Normal file
717
desktop/src/features/sidebar/lib/mergeLaneSync.shared.test.mjs
Normal file
@@ -0,0 +1,717 @@
|
||||
// Shared parameterized test suite for MergeLaneSyncManager subclasses
|
||||
// (ChannelStarSyncManager, ChannelMuteSyncManager, and any future merge-lane).
|
||||
//
|
||||
// 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(),
|
||||
// });
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test, { mock } from "node:test";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import {
|
||||
installFakeWindow,
|
||||
installEchoTauri,
|
||||
makeFakeWindow,
|
||||
} from "./sidebarSyncTestHelpers.mjs";
|
||||
|
||||
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,
|
||||
readOutbox,
|
||||
watermarkKind,
|
||||
makeEntry,
|
||||
publish,
|
||||
getPending,
|
||||
fetchRemote,
|
||||
}) {
|
||||
const RELAY = "wss://r.test";
|
||||
const RELAY_KEY = encodeURIComponent(RELAY);
|
||||
|
||||
// ─── observe() / high-water ingestion ───────────────────────────────────────
|
||||
|
||||
test(`${label}: observe: high-water is per-channel max of rev and updatedAt, monotonic`, () => {
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const m = new Manager("pk", RELAY);
|
||||
m.observe(
|
||||
makeStore({ a: makeEntry(true, 100, 3), b: makeEntry(false, 50, 1) }),
|
||||
);
|
||||
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 {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── destroy() must cancel pending publish, not flush ───────────────────────
|
||||
|
||||
test(`${label}: destroy: cancels pending publish without flushing to the relay`, () => {
|
||||
const publishCalls = [];
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", (...args) => {
|
||||
publishCalls.push(args);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new Manager("pk-test", RELAY);
|
||||
publish(manager, makeStore({ ch1: makeEntry(true, 100, 1) }));
|
||||
manager.destroy();
|
||||
assert.equal(publishCalls.length, 0, "no publish after destroy");
|
||||
assert.equal(getPending(manager), null);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves`, async () => {
|
||||
let releaseFetch = null;
|
||||
const publishCalls = [];
|
||||
mock.method(
|
||||
relayClient,
|
||||
"fetchEvents",
|
||||
() =>
|
||||
new Promise((res) => {
|
||||
releaseFetch = () => res([]);
|
||||
}),
|
||||
);
|
||||
mock.method(relayClient, "publishEvent", (...args) => {
|
||||
publishCalls.push(args);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new Manager("pk-race", RELAY);
|
||||
publish(manager, makeStore({ ch1: makeEntry(true, 100, 1) }));
|
||||
fw._fireTimer();
|
||||
manager.destroy();
|
||||
releaseFetch();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(
|
||||
publishCalls.length,
|
||||
0,
|
||||
"publishEvent must not be called after destroy",
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: destroy: is safe to call with no pending publish`, () => {
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new Manager("pk-no-pending", RELAY);
|
||||
assert.doesNotThrow(() => manager.destroy());
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Generation CAS: A-in-flight → B-click → A-completes (both variants) ────
|
||||
|
||||
// Finding 2 (A succeeds): an older in-flight publish that completes after a
|
||||
// newer edit is queued must NOT clear the newer edit's pending store/outbox,
|
||||
// and B must reach the relay via the completion re-drive. Mutation: dropping
|
||||
// the generation CAS in discardPending lets A's success null out B's
|
||||
// pending+outbox.
|
||||
test(`${label}: A-in-flight → B-click → A-succeeds: B stays pending and B publishes`, async () => {
|
||||
let releaseFirst = null;
|
||||
let publishCount = 0;
|
||||
let storedHead = [];
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead));
|
||||
mock.method(relayClient, "publishEvent", (event) => {
|
||||
publishCount++;
|
||||
if (publishCount === 1) {
|
||||
// A reaches the relay; hold its ACK open until the test releases it,
|
||||
// then record it as the retained head.
|
||||
return new Promise((res) => {
|
||||
releaseFirst = () => {
|
||||
storedHead = [event];
|
||||
res();
|
||||
};
|
||||
});
|
||||
}
|
||||
storedHead = [event];
|
||||
return Promise.resolve();
|
||||
});
|
||||
const t = makeMultiTimerWindow();
|
||||
const restore = installFakeWindow(t.win);
|
||||
const tauri = installEchoTauri("pk-ab");
|
||||
try {
|
||||
const manager = new Manager("pk-ab", RELAY);
|
||||
const storeA = makeStore({ a: makeEntry(true, 100, 1) });
|
||||
const storeB = makeStore({ b: makeEntry(true, 101, 1) });
|
||||
|
||||
publish(manager, storeA);
|
||||
await t.fireDelay(2000); // doPublish(A) awaits publishEvent
|
||||
while (releaseFirst === null) await Promise.resolve();
|
||||
|
||||
// B arrives while A is in flight.
|
||||
publish(manager, storeB);
|
||||
assert.deepEqual(
|
||||
Object.keys(getPending(manager).channels),
|
||||
["b"],
|
||||
"B is now pending",
|
||||
);
|
||||
assert.ok(readOutbox("pk-ab", RELAY), "outbox holds B");
|
||||
|
||||
// A completes — must NOT clear B.
|
||||
releaseFirst();
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
assert.deepEqual(
|
||||
Object.keys(getPending(manager)?.channels ?? {}),
|
||||
["b"],
|
||||
"older A completion leaves B pending",
|
||||
);
|
||||
assert.ok(
|
||||
readOutbox("pk-ab", RELAY),
|
||||
"older A completion leaves B outbox",
|
||||
);
|
||||
|
||||
// B's own debounce fires and B reaches the relay with no kick; the
|
||||
// post-publish retained-head fetch reads B's own write back and confirms
|
||||
// subsumption, so B's outbox clears.
|
||||
const capturedBefore = tauri.capturedPlaintext();
|
||||
await t.fireDelay(2000);
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
const captured = tauri.capturedPlaintext();
|
||||
assert.ok(
|
||||
captured && captured !== capturedBefore && captured.includes('"b"'),
|
||||
"B is published to the relay",
|
||||
);
|
||||
assert.equal(
|
||||
getPending(manager),
|
||||
null,
|
||||
"B cleared after confirmed publish",
|
||||
);
|
||||
assert.equal(readOutbox("pk-ab", RELAY), null, "B outbox cleared");
|
||||
manager.destroy();
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Finding 2 (A fails): A's publish rejects after B is queued. B must remain
|
||||
// pending and be published by the serialized re-drive / retry — no manual kick.
|
||||
test(`${label}: A-in-flight → B-click → A-fails: B remains pending and B publishes`, async () => {
|
||||
let rejectFirst = null;
|
||||
let publishCount = 0;
|
||||
let storedHead = [];
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead));
|
||||
mock.method(relayClient, "publishEvent", (event) => {
|
||||
publishCount++;
|
||||
if (publishCount === 1) {
|
||||
return new Promise((_res, rej) => {
|
||||
rejectFirst = () => rej(new Error("socket error"));
|
||||
});
|
||||
}
|
||||
storedHead = [event];
|
||||
return Promise.resolve();
|
||||
});
|
||||
const t = makeMultiTimerWindow();
|
||||
const restore = installFakeWindow(t.win);
|
||||
const tauri = installEchoTauri("pk-abfail");
|
||||
try {
|
||||
const manager = new Manager("pk-abfail", RELAY);
|
||||
publish(manager, makeStore({ a: makeEntry(true, 100, 1) }));
|
||||
await t.fireDelay(2000);
|
||||
while (rejectFirst === null) await Promise.resolve();
|
||||
|
||||
publish(manager, makeStore({ b: makeEntry(true, 101, 1) }));
|
||||
rejectFirst(); // A fails
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(getPending(manager)?.channels ?? {}),
|
||||
["b"],
|
||||
"B still pending after A's failure",
|
||||
);
|
||||
assert.ok(
|
||||
readOutbox("pk-abfail", RELAY),
|
||||
"B outbox intact after A's failure",
|
||||
);
|
||||
|
||||
// B's debounce fires and B publishes successfully; the retained-head fetch
|
||||
// confirms B's own write and clears it.
|
||||
await t.fireDelay(2000);
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
const captured = tauri.capturedPlaintext();
|
||||
assert.ok(captured?.includes('"b"'), "B published");
|
||||
assert.equal(getPending(manager), null, "B cleared");
|
||||
manager.destroy();
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Bounded-backoff retry: failed publish on a healthy socket, no later edit
|
||||
|
||||
// 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 = [];
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead));
|
||||
mock.method(relayClient, "publishEvent", (event) => {
|
||||
publishCount++;
|
||||
if (publishCount === 1) return Promise.reject(new Error("timeout"));
|
||||
storedHead = [event];
|
||||
return Promise.resolve();
|
||||
});
|
||||
const t = makeMultiTimerWindow();
|
||||
const restore = installFakeWindow(t.win);
|
||||
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
|
||||
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.
|
||||
await t.fireDelay(2000);
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
assert.equal(publishCount, 2, "retry re-published");
|
||||
assert.equal(
|
||||
getPending(manager),
|
||||
null,
|
||||
"pending cleared on retry success",
|
||||
);
|
||||
manager.destroy();
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Retention confirmation (Carl P1): OK is not proof of retention ──────────
|
||||
|
||||
// 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);
|
||||
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
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
|
||||
assert.ok(
|
||||
getPending(manager) !== null,
|
||||
"unconfirmed publish keeps the 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.
|
||||
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",
|
||||
);
|
||||
manager.destroy();
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────
|
||||
|
||||
test(`${label}: revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap`, async () => {
|
||||
mock.method(relayClient, "fetchEvents", () =>
|
||||
Promise.reject(new Error("relay timeout")),
|
||||
);
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new Manager("pk-fail", RELAY);
|
||||
const result = await manager.bootstrap(
|
||||
makeStore({ ch1: makeEntry(true, 1, 0) }),
|
||||
);
|
||||
assert.equal(result.action, "hold");
|
||||
assert.equal(getPending(manager), null);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap`, async () => {
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
fw.localStorage.setItem(
|
||||
`buzz-sync-watermark.v1:${watermarkKind}:pk-stale:${RELAY_KEY}`,
|
||||
"1700000000",
|
||||
);
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new Manager("pk-stale", RELAY);
|
||||
const result = await manager.bootstrap(
|
||||
makeStore({ ch1: makeEntry(true, 1, 0) }),
|
||||
);
|
||||
assert.equal(result.action, "hold");
|
||||
assert.equal(getPending(manager), null);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)`, async () => {
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new Manager("pk-fresh", RELAY);
|
||||
const result = await manager.bootstrap(
|
||||
makeStore({ ch1: makeEntry(true, 1, 0) }),
|
||||
);
|
||||
assert.equal(result.action, "hold");
|
||||
assert.ok(getPending(manager) !== null);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: revert-fix: relay-A watermark does not suppress first-sync seed on relay-B`, async () => {
|
||||
const relayA = "wss://a.relay.test";
|
||||
const relayB = "wss://b.relay.test";
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
fw.localStorage.setItem(
|
||||
`buzz-sync-watermark.v1:${watermarkKind}:pk-iso:${encodeURIComponent(relayA)}`,
|
||||
"1700000100",
|
||||
);
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const managerB = new Manager("pk-iso", relayB);
|
||||
const result = await managerB.bootstrap(
|
||||
makeStore({ ch1: makeEntry(true, 1, 0) }),
|
||||
);
|
||||
assert.equal(result.action, "hold");
|
||||
assert.ok(
|
||||
getPending(managerB) !== null,
|
||||
`first-sync seed on relay B must not be blocked by relay A watermark`,
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Failed pre-publish fetch: retain, never publish (Carl P1) ───────────────
|
||||
|
||||
// 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")),
|
||||
);
|
||||
const publishCalls = [];
|
||||
mock.method(relayClient, "publishEvent", (...args) => {
|
||||
publishCalls.push(args);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
const tauri = installEchoTauri("pk-fetchfail");
|
||||
try {
|
||||
const manager = new Manager("pk-fetchfail", 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 when the pre-publish fetch failed",
|
||||
);
|
||||
assert.ok(
|
||||
getPending(manager) !== null,
|
||||
"a failed fetch must retain the pending edit",
|
||||
);
|
||||
assert.ok(
|
||||
readOutbox("pk-fetchfail", RELAY),
|
||||
"durable outbox must survive a failed fetch",
|
||||
);
|
||||
assert.ok(fw._hasTimer(), "a retry must be scheduled");
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Unreadable head: retain, never max-merge over it (Carl P1) ──────────────
|
||||
|
||||
// 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();
|
||||
});
|
||||
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 (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 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;
|
||||
mock.method(relayClient, "publishEvent", (evt) => {
|
||||
signedCreatedAt = evt.created_at;
|
||||
return Promise.resolve();
|
||||
});
|
||||
try {
|
||||
const manager = new Manager("pk-clamp", RELAY);
|
||||
await fetchRemote(manager); // prime lastRemoteCreatedAt
|
||||
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}`,
|
||||
);
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user