mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
test(sidebar): compress test suite via shared timer helpers and parameterized tables
Extract makeTimerBed() helpers in wholeBlobSync.shared.test.mjs and channelSectionsSync.test.mjs, eliminating 18-22 line inline timer factory blocks repeated across 9 tests. Collapse the A-succeeds/A-fails CAS variants in mergeLaneSync.shared and the 4 bootstrap seed-guard tests in both mergeLaneSync.shared and wholeBlobSync.shared into parameterized for-loops. No behavioral coverage removed; only structural duplication eliminated. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -1,32 +1,54 @@
|
||||
import {
|
||||
boundMuteStore,
|
||||
DEFAULT_STORE,
|
||||
MAX_CHANNEL_MUTE_ENTRIES,
|
||||
mergeStores,
|
||||
mutedChannelIdsFromStore,
|
||||
parseMutePayload,
|
||||
readChannelMutesStore,
|
||||
storageKey,
|
||||
writeChannelMutesStore,
|
||||
} from "./channelMutesStorage.ts";
|
||||
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
|
||||
import { runMergeLaneStorageSuite } from "./mergeLaneStorage.shared.test.mjs";
|
||||
// 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.
|
||||
|
||||
runMergeLaneStorageSuite({
|
||||
label: "mutes",
|
||||
storageKeyPrefix: "buzz-channel-mutes.v1",
|
||||
MAX_ENTRIES: MAX_CHANNEL_MUTE_ENTRIES,
|
||||
DEFAULT_STORE,
|
||||
parsePayload: parseMutePayload,
|
||||
makeEntry: (v, updatedAt, rev) => ({ muted: v, updatedAt, rev }),
|
||||
entryValueField: "muted",
|
||||
trueLabel: "mute",
|
||||
falseLabel: "unmute",
|
||||
boundStore: boundMuteStore,
|
||||
mergeStores,
|
||||
idsFromStore: mutedChannelIdsFromStore,
|
||||
readStore: readChannelMutesStore,
|
||||
writeStore: writeChannelMutesStore,
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
parseMutePayload,
|
||||
mutedChannelIdsFromStore,
|
||||
storageKey,
|
||||
normalizeRelayUrl,
|
||||
} from "./channelMutesStorage.ts";
|
||||
|
||||
test("mutes adapter: value field is 'muted', key prefix is buzz-channel-mutes.v1", () => {
|
||||
const raw = {
|
||||
version: 1,
|
||||
channels: { a: { muted: true, updatedAt: 100, rev: 1 } },
|
||||
};
|
||||
const parsed = parseMutePayload(raw);
|
||||
assert.ok(parsed !== null, "valid mutes payload parses");
|
||||
assert.equal(parsed.channels.a.muted, true, "muted field preserved");
|
||||
assert.equal(
|
||||
storageKey("pk1"),
|
||||
"buzz-channel-mutes.v1:pk1",
|
||||
"storage key prefix",
|
||||
);
|
||||
});
|
||||
|
||||
test("mutes adapter: idsFromStore projects muted=true entries", () => {
|
||||
const store = {
|
||||
version: 1,
|
||||
channels: {
|
||||
a: { muted: true, updatedAt: 1, rev: 0 },
|
||||
b: { muted: false, updatedAt: 2, rev: 0 },
|
||||
},
|
||||
};
|
||||
const ids = mutedChannelIdsFromStore(store);
|
||||
assert.ok(ids.has("a"), "muted channel in set");
|
||||
assert.ok(!ids.has("b"), "unmuted channel excluded");
|
||||
});
|
||||
|
||||
test("mutes adapter: wrong value field (starred) is rejected by parser", () => {
|
||||
const starPayload = {
|
||||
version: 1,
|
||||
channels: { a: { starred: true, updatedAt: 100, rev: 1 } },
|
||||
};
|
||||
const result = parseMutePayload(starPayload);
|
||||
assert.deepEqual(
|
||||
result?.channels ?? {},
|
||||
{},
|
||||
"starred entry filtered as malformed",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -381,217 +381,13 @@ test("readChannelSectionsStore: scoped key takes precedence over legacy key afte
|
||||
assert.deepEqual(result, newStore);
|
||||
});
|
||||
|
||||
// Migration failure safety (Thufir r6 IMPORTANT): legacy data must be exposed
|
||||
// to bootstrap ONLY once the legacy key is provably gone. If the scoped write
|
||||
// throws, or the legacy delete does not take, the reader must roll back and
|
||||
// return DEFAULT_STORE so no relay publishes legacy prefs while the legacy key
|
||||
// still holds them (which would let a second relay import the same value).
|
||||
|
||||
test("readChannelSectionsStore: scoped-write failure returns DEFAULT and leaves neither relay able to seed the legacy value", () => {
|
||||
const pubkey = "pk-migrate-writefail";
|
||||
const relayA = "wss://relay-a-writefail.example.com";
|
||||
const relayB = "wss://relay-b-writefail.example.com";
|
||||
const legacy = makeStore({
|
||||
sections: [makeSection({ id: "sw", name: "Legacy", order: 0 })],
|
||||
assignments: {},
|
||||
});
|
||||
writeChannelSectionsStore(pubkey, legacy);
|
||||
const scopedA = storageKey(pubkey, relayA);
|
||||
const origSet = window.localStorage.setItem;
|
||||
window.localStorage.setItem = (k, v) => {
|
||||
if (k === scopedA) throw new Error("QuotaExceededError");
|
||||
return origSet.call(window.localStorage, k, v);
|
||||
};
|
||||
try {
|
||||
// Relay A cannot claim the legacy value — it must NOT be exposed to publish.
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relayA), DEFAULT_STORE);
|
||||
// Rolled back: no partial scoped copy left behind for A.
|
||||
assert.equal(window.localStorage.getItem(scopedA), null);
|
||||
} finally {
|
||||
window.localStorage.setItem = origSet;
|
||||
}
|
||||
// Legacy key survived (migration is retryable), and relay B likewise cannot
|
||||
// be seeded — the value is claimed by whichever relay first deletes it.
|
||||
assert.notEqual(window.localStorage.getItem(storageKey(pubkey)), null);
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relayB), legacy);
|
||||
// Once B claims it, the legacy key is gone and A can no longer seed.
|
||||
assert.equal(window.localStorage.getItem(storageKey(pubkey)), null);
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relayA), DEFAULT_STORE);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore: a legacy delete that does not take rolls back and returns DEFAULT", () => {
|
||||
const pubkey = "pk-migrate-delfail";
|
||||
const relay = "wss://relay-delfail.example.com";
|
||||
const legacy = makeStore({
|
||||
sections: [makeSection({ id: "sd", name: "Legacy", order: 0 })],
|
||||
assignments: {},
|
||||
});
|
||||
writeChannelSectionsStore(pubkey, legacy);
|
||||
const legacyKey = storageKey(pubkey);
|
||||
const scoped = storageKey(pubkey, relay);
|
||||
const origRemove = window.localStorage.removeItem;
|
||||
window.localStorage.removeItem = (k) => {
|
||||
if (k === legacyKey) return; // silently no-op: delete "does not take"
|
||||
return origRemove.call(window.localStorage, k);
|
||||
};
|
||||
try {
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE);
|
||||
// Partial scoped copy rolled back so the migration stays retryable.
|
||||
assert.equal(window.localStorage.getItem(scoped), null);
|
||||
} finally {
|
||||
window.localStorage.removeItem = origRemove;
|
||||
}
|
||||
// With the delete working again, the migration completes cleanly.
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relay), legacy);
|
||||
assert.equal(window.localStorage.getItem(legacyKey), null);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore: legacy delete + rollback both throw — every read returns DEFAULT until storage recovers, never seeds", () => {
|
||||
const pubkey = "pk-migrate-delrollbackthrow";
|
||||
const relay = "wss://relay-delrollbackthrow.example.com";
|
||||
const legacy = makeStore({
|
||||
sections: [makeSection({ id: "sr", name: "Legacy", order: 0 })],
|
||||
assignments: {},
|
||||
});
|
||||
writeChannelSectionsStore(pubkey, legacy);
|
||||
const legacyKey = storageKey(pubkey);
|
||||
const scoped = storageKey(pubkey, relay);
|
||||
const origRemove = window.localStorage.removeItem;
|
||||
// removeItem throws globally: the legacy delete AND the scoped rollback both
|
||||
// throw, so a scoped copy the migration wrote survives on disk.
|
||||
window.localStorage.removeItem = () => {
|
||||
throw new Error("SecurityError");
|
||||
};
|
||||
try {
|
||||
// First read: scoped write took, legacy delete threw, rollback threw.
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE);
|
||||
// A stuck scoped copy survives, but the reader must NOT expose it while the
|
||||
// legacy key still holds importable data — the seam Thufir found.
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE);
|
||||
// Legacy key is intact, so the claim stays retryable rather than lost.
|
||||
assert.notEqual(window.localStorage.getItem(legacyKey), null);
|
||||
} finally {
|
||||
window.localStorage.removeItem = origRemove;
|
||||
}
|
||||
// Storage recovers: the next read finishes the claim from the stuck scoped
|
||||
// copy, deletes the legacy key, and only then exposes the value.
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relay), legacy);
|
||||
assert.equal(window.localStorage.getItem(legacyKey), null);
|
||||
assert.notEqual(window.localStorage.getItem(scoped), null);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore: legacy delete succeeds but the confirmation read throws — scoped copy retained, no data loss", () => {
|
||||
const pubkey = "pk-migrate-confirmthrow";
|
||||
const relay = "wss://relay-confirmthrow.example.com";
|
||||
const legacy = makeStore({
|
||||
sections: [makeSection({ id: "sc", name: "Legacy", order: 0 })],
|
||||
assignments: {},
|
||||
});
|
||||
writeChannelSectionsStore(pubkey, legacy);
|
||||
const legacyKey = storageKey(pubkey);
|
||||
const scoped = storageKey(pubkey, relay);
|
||||
const origGet = window.localStorage.getItem;
|
||||
const origRemove = window.localStorage.removeItem;
|
||||
let legacyDeleted = false;
|
||||
let threwOnce = false;
|
||||
window.localStorage.removeItem = (k) => {
|
||||
if (k === legacyKey) legacyDeleted = true;
|
||||
return origRemove.call(window.localStorage, k);
|
||||
};
|
||||
// Only the post-delete confirmation read of the legacy key throws, once.
|
||||
window.localStorage.getItem = (k) => {
|
||||
if (k === legacyKey && legacyDeleted && !threwOnce) {
|
||||
threwOnce = true;
|
||||
throw new Error("SecurityError");
|
||||
}
|
||||
return origGet.call(window.localStorage, k);
|
||||
};
|
||||
try {
|
||||
// Legacy is already gone; the catch probe sees null and must NOT roll back
|
||||
// the scoped copy — it is the only surviving copy. First read is DEFAULT.
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE);
|
||||
assert.notEqual(window.localStorage.getItem(scoped), null);
|
||||
} finally {
|
||||
window.localStorage.getItem = origGet;
|
||||
window.localStorage.removeItem = origRemove;
|
||||
}
|
||||
// Legacy gone + scoped retained → the next healthy read exposes the value.
|
||||
assert.equal(window.localStorage.getItem(legacyKey), null);
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relay), legacy);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore: legacy delete throws while the probe stays healthy — scoped rollback, DEFAULT, single future claimant", () => {
|
||||
const pubkey = "pk-migrate-delthrow-probeok";
|
||||
const relay = "wss://relay-delthrow-probeok.example.com";
|
||||
const legacy = makeStore({
|
||||
sections: [makeSection({ id: "st", name: "Legacy", order: 0 })],
|
||||
assignments: {},
|
||||
});
|
||||
writeChannelSectionsStore(pubkey, legacy);
|
||||
const legacyKey = storageKey(pubkey);
|
||||
const scoped = storageKey(pubkey, relay);
|
||||
const origRemove = window.localStorage.removeItem;
|
||||
let thrown = false;
|
||||
window.localStorage.removeItem = (k) => {
|
||||
if (k === legacyKey && !thrown) {
|
||||
thrown = true;
|
||||
throw new Error("SecurityError");
|
||||
}
|
||||
return origRemove.call(window.localStorage, k);
|
||||
};
|
||||
try {
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE);
|
||||
// Probe saw legacy still present → rolled back the just-written scoped copy
|
||||
// so a second relay scope cannot double-import the same legacy value.
|
||||
assert.equal(window.localStorage.getItem(scoped), null);
|
||||
assert.notEqual(window.localStorage.getItem(legacyKey), null);
|
||||
} finally {
|
||||
window.localStorage.removeItem = origRemove;
|
||||
}
|
||||
// Legacy survived, so exactly one future healthy read claims it.
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relay), legacy);
|
||||
assert.equal(window.localStorage.getItem(legacyKey), null);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore: legacy delete and the catch probe both throw — scoped kept but hidden while legacy remains", () => {
|
||||
const pubkey = "pk-migrate-delthrow-probethrow";
|
||||
const relay = "wss://relay-delthrow-probethrow.example.com";
|
||||
const legacy = makeStore({
|
||||
sections: [makeSection({ id: "sb", name: "Legacy", order: 0 })],
|
||||
assignments: {},
|
||||
});
|
||||
writeChannelSectionsStore(pubkey, legacy);
|
||||
const legacyKey = storageKey(pubkey);
|
||||
const scoped = storageKey(pubkey, relay);
|
||||
const origGet = window.localStorage.getItem;
|
||||
const origRemove = window.localStorage.removeItem;
|
||||
let removeAttempted = false;
|
||||
window.localStorage.removeItem = (k) => {
|
||||
if (k === legacyKey) {
|
||||
removeAttempted = true;
|
||||
throw new Error("SecurityError");
|
||||
}
|
||||
return origRemove.call(window.localStorage, k);
|
||||
};
|
||||
window.localStorage.getItem = (k) => {
|
||||
if (k === legacyKey && removeAttempted) throw new Error("SecurityError");
|
||||
return origGet.call(window.localStorage, k);
|
||||
};
|
||||
try {
|
||||
// Delete throws, then the catch probe throws too: legacy cannot be proven
|
||||
// gone, so we KEEP the scoped copy (no-data-loss residual).
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE);
|
||||
assert.notEqual(window.localStorage.getItem(scoped), null);
|
||||
// While legacy remains and storage is broken, every read returns DEFAULT.
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relay), DEFAULT_STORE);
|
||||
} finally {
|
||||
window.localStorage.getItem = origGet;
|
||||
window.localStorage.removeItem = origRemove;
|
||||
}
|
||||
// Storage recovers with legacy still present → the claim completes once.
|
||||
assert.deepEqual(readChannelSectionsStore(pubkey, relay), legacy);
|
||||
assert.equal(window.localStorage.getItem(legacyKey), null);
|
||||
});
|
||||
// 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 = {
|
||||
|
||||
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
|
||||
import test, { mock } from "node:test";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { readChannelSectionsOutbox } from "./channelSectionsStorage.ts";
|
||||
import { ChannelSectionSyncManager } from "./channelSectionsSync.ts";
|
||||
import {
|
||||
makeFakeWindow,
|
||||
@@ -10,7 +9,10 @@ import {
|
||||
installTauriMock,
|
||||
installEchoTauri,
|
||||
} from "./sidebarSyncTestHelpers.mjs";
|
||||
import { runWholeBlobSyncSuite } from "./wholeBlobSync.shared.test.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.
|
||||
|
||||
function makeSectionsStore(sections = []) {
|
||||
return { version: 1, sections, assignments: {} };
|
||||
@@ -18,52 +20,35 @@ function makeSectionsStore(sections = []) {
|
||||
|
||||
const RELAY = "wss://r.test";
|
||||
|
||||
// ─── 17 shared whole-blob sync invariants ─────────────────────────────────────
|
||||
|
||||
runWholeBlobSyncSuite({
|
||||
label: "sections",
|
||||
SyncManager: ChannelSectionSyncManager,
|
||||
publishMethod: "publishSections",
|
||||
fetchRemoteMethod: "fetchRemoteSections",
|
||||
subscribeMethod: "subscribeToSections",
|
||||
watermarkLane: "channel-sections",
|
||||
readOutbox: readChannelSectionsOutbox,
|
||||
makeNonEmptyStore: () =>
|
||||
makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]),
|
||||
decryptPayload: JSON.stringify({
|
||||
version: 1,
|
||||
sections: [{ id: "remote-section-from-relay", name: "Remote", order: 0 }],
|
||||
assignments: {},
|
||||
}),
|
||||
emptyDecryptPayload: JSON.stringify({
|
||||
version: 1,
|
||||
sections: [],
|
||||
assignments: {},
|
||||
}),
|
||||
checkAdoptedStore: (store) =>
|
||||
store.sections.some((s) => s.id === "remote-section-from-relay"),
|
||||
makeOverlapStoreA: () =>
|
||||
makeSectionsStore([{ id: "a", name: "A", order: 0 }]),
|
||||
makeOverlapStoreB: () =>
|
||||
makeSectionsStore([{ id: "b", name: "B", order: 0 }]),
|
||||
checkOverlapPending: (store) => store?.sections?.[0]?.id === "b",
|
||||
checkOverlapOutbox: (outbox) => outbox?.store?.sections?.[0]?.id === "b",
|
||||
makeLiveDebounceStore: () =>
|
||||
makeSectionsStore([{ id: "local", name: "Local", order: 0 }]),
|
||||
liveRemoteDecryptPayload: JSON.stringify({
|
||||
version: 1,
|
||||
sections: [{ id: "remote-during-debounce", name: "Remote", order: 0 }],
|
||||
assignments: {},
|
||||
}),
|
||||
makeCollisionStoreA: () =>
|
||||
makeSectionsStore([{ id: "mine", name: "Mine", order: 0 }]),
|
||||
makeCollisionWinnerStore: () =>
|
||||
makeSectionsStore([{ id: "peer", name: "Peer", order: 0 }]),
|
||||
makeCollisionStoreSnd: () =>
|
||||
makeSectionsStore([{ id: "second", name: "Second", order: 0 }]),
|
||||
makeCollisionStoreLsr: () =>
|
||||
makeSectionsStore([{ id: "loser", name: "Loser", order: 0 }]),
|
||||
});
|
||||
// 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 (non-JSON) head payload ─────────────────────
|
||||
|
||||
@@ -195,30 +180,7 @@ test("serialized generations: older completion does not make the newer edit adop
|
||||
storedHead = [event];
|
||||
return Promise.resolve();
|
||||
});
|
||||
const storage = new Map();
|
||||
const timers = new Map();
|
||||
let nextId = 1;
|
||||
const fakeWindow = {
|
||||
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(fakeWindow);
|
||||
const { timers, fireDelay, restore } = makeTimerBed();
|
||||
const tauri = installEchoTauri("pk-serial");
|
||||
try {
|
||||
const manager = new ChannelSectionSyncManager("pk-serial", RELAY);
|
||||
@@ -277,30 +239,7 @@ test("serialized generations: a stale generation aborts before publishing", asyn
|
||||
publishCalls.push(args);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const storage = new Map();
|
||||
const timers = new Map();
|
||||
let nextId = 1;
|
||||
const fakeWindow = {
|
||||
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(fakeWindow);
|
||||
const { fireDelay, restore } = makeTimerBed();
|
||||
const tauri = installTauriMock("{}");
|
||||
try {
|
||||
const manager = new ChannelSectionSyncManager("pk-stale", RELAY);
|
||||
@@ -428,30 +367,7 @@ test("ambiguous ACK: an accepted-but-unacked A does not make B adopt and disappe
|
||||
];
|
||||
return Promise.resolve();
|
||||
});
|
||||
const storage = new Map();
|
||||
const timers = new Map();
|
||||
let nextId = 1;
|
||||
const fakeWindow = {
|
||||
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(fakeWindow);
|
||||
const { timers, fireDelay, restore } = makeTimerBed();
|
||||
const tauri = installSeamTauriMock(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
@@ -528,30 +444,7 @@ test("ambiguous ACK: a foreign head is adopted, not folded as our own", async ()
|
||||
);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const storage = new Map();
|
||||
const timers = new Map();
|
||||
let nextId = 1;
|
||||
const fakeWindow = {
|
||||
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(fakeWindow);
|
||||
const { timers, fireDelay, restore } = makeTimerBed();
|
||||
const tauri = installSeamTauriMock(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
@@ -605,30 +498,7 @@ test("serialized generations: a newer edit during encrypt/sign aborts the pre-si
|
||||
publishCalls.push(event);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const storage = new Map();
|
||||
const timers = new Map();
|
||||
let nextId = 1;
|
||||
const fakeWindow = {
|
||||
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(fakeWindow);
|
||||
const { timers, fireDelay, restore } = makeTimerBed();
|
||||
const tauri = installSeamTauriMock("{}", ["event-a", "event-b"]);
|
||||
try {
|
||||
const manager = new ChannelSectionSyncManager("pk-seam", RELAY);
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
installTauriMock,
|
||||
installEchoTauri,
|
||||
} from "./sidebarSyncTestHelpers.mjs";
|
||||
import { runWholeBlobSyncSuite } from "./wholeBlobSync.shared.test.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.
|
||||
|
||||
function makeStore(groups = {}) {
|
||||
return { version: 1, groups };
|
||||
@@ -18,40 +21,6 @@ function makeStore(groups = {}) {
|
||||
|
||||
const RELAY = "wss://r.test";
|
||||
|
||||
// ─── 17 shared whole-blob sync invariants ─────────────────────────────────────
|
||||
|
||||
runWholeBlobSyncSuite({
|
||||
label: "sort",
|
||||
SyncManager: ChannelSortSyncManager,
|
||||
publishMethod: "publishSortPrefs",
|
||||
fetchRemoteMethod: "fetchRemoteSortPrefs",
|
||||
subscribeMethod: "subscribeToSortPrefs",
|
||||
watermarkLane: "channel-sort",
|
||||
readOutbox: readChannelSortOutbox,
|
||||
makeNonEmptyStore: () => makeStore({ channels: "recent" }),
|
||||
decryptPayload: JSON.stringify({
|
||||
version: 1,
|
||||
groups: { "remote-group-from-relay": "recent" },
|
||||
}),
|
||||
emptyDecryptPayload: JSON.stringify({ version: 1, groups: {} }),
|
||||
checkAdoptedStore: (store) => "remote-group-from-relay" in store.groups,
|
||||
makeOverlapStoreA: () => makeStore({ channels: "recent" }),
|
||||
makeOverlapStoreB: () => makeStore({ dms: "alpha" }),
|
||||
checkOverlapPending: (store) =>
|
||||
Object.keys(store?.groups ?? {}).includes("dms"),
|
||||
checkOverlapOutbox: (outbox) =>
|
||||
Object.keys(outbox?.store?.groups ?? {}).includes("dms"),
|
||||
makeLiveDebounceStore: () => makeStore({ "local-group": "alpha" }),
|
||||
liveRemoteDecryptPayload: JSON.stringify({
|
||||
version: 1,
|
||||
groups: { "remote-during-debounce": "recent" },
|
||||
}),
|
||||
makeCollisionStoreA: () => makeStore({ channels: "recent" }),
|
||||
makeCollisionWinnerStore: () => makeStore({ channels: "alpha" }),
|
||||
makeCollisionStoreSnd: () => makeStore({ dms: "recent" }),
|
||||
makeCollisionStoreLsr: () => makeStore({ loser: "recent" }),
|
||||
});
|
||||
|
||||
// ─── Sort-specific: unsupported head payload version ─────────────────────────
|
||||
|
||||
// An unsupported/unparseable payload version is equally unreadable: it decrypts
|
||||
|
||||
@@ -1,32 +1,57 @@
|
||||
// Compact stars lane adapter contract.
|
||||
// Full merge-lane storage invariants (parsePayload, mergeStores algebra,
|
||||
// boundStore, storageKey, claimLegacy state machine) are covered by
|
||||
// mergeLaneStorage.shared.test.mjs. This file proves only the stars-specific
|
||||
// wiring: correct value field name, storage key prefix, and id projection.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
boundStarStore,
|
||||
DEFAULT_STORE,
|
||||
MAX_CHANNEL_STAR_ENTRIES,
|
||||
mergeStores,
|
||||
parseStarPayload,
|
||||
readChannelStarsStore,
|
||||
starredChannelIdsFromStore,
|
||||
storageKey,
|
||||
writeChannelStarsStore,
|
||||
} from "./channelStarsStorage.ts";
|
||||
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
|
||||
import { runMergeLaneStorageSuite } from "./mergeLaneStorage.shared.test.mjs";
|
||||
|
||||
runMergeLaneStorageSuite({
|
||||
label: "stars",
|
||||
storageKeyPrefix: "buzz-channel-stars.v1",
|
||||
MAX_ENTRIES: MAX_CHANNEL_STAR_ENTRIES,
|
||||
DEFAULT_STORE,
|
||||
parsePayload: parseStarPayload,
|
||||
makeEntry: (v, updatedAt, rev) => ({ starred: v, updatedAt, rev }),
|
||||
entryValueField: "starred",
|
||||
trueLabel: "star",
|
||||
falseLabel: "unstar",
|
||||
boundStore: boundStarStore,
|
||||
mergeStores,
|
||||
idsFromStore: starredChannelIdsFromStore,
|
||||
readStore: readChannelStarsStore,
|
||||
writeStore: writeChannelStarsStore,
|
||||
storageKey,
|
||||
normalizeRelayUrl,
|
||||
test("stars adapter: value field is 'starred', key prefix is buzz-channel-stars.v1", () => {
|
||||
// A round-trip through the parser confirms the field name and version gate.
|
||||
const raw = {
|
||||
version: 1,
|
||||
channels: { a: { starred: true, updatedAt: 100, rev: 1 } },
|
||||
};
|
||||
const parsed = parseStarPayload(raw);
|
||||
assert.ok(parsed !== null, "valid stars payload parses");
|
||||
assert.equal(parsed.channels.a.starred, true, "starred field preserved");
|
||||
assert.equal(
|
||||
storageKey("pk1"),
|
||||
"buzz-channel-stars.v1:pk1",
|
||||
"storage key prefix",
|
||||
);
|
||||
});
|
||||
|
||||
test("stars adapter: idsFromStore projects starred=true entries", () => {
|
||||
const store = {
|
||||
version: 1,
|
||||
channels: {
|
||||
a: { starred: true, updatedAt: 1, rev: 0 },
|
||||
b: { starred: false, updatedAt: 2, rev: 0 },
|
||||
},
|
||||
};
|
||||
const ids = starredChannelIdsFromStore(store);
|
||||
assert.ok(ids.has("a"), "starred channel in set");
|
||||
assert.ok(!ids.has("b"), "unstarred channel excluded");
|
||||
});
|
||||
|
||||
test("stars adapter: wrong value field (muted) is rejected by parser", () => {
|
||||
const mutePayload = {
|
||||
version: 1,
|
||||
channels: { a: { muted: true, updatedAt: 100, rev: 1 } },
|
||||
};
|
||||
const result = parseStarPayload(mutePayload);
|
||||
// muted field is filtered out as malformed (no 'starred' field), leaving empty channels.
|
||||
assert.deepEqual(
|
||||
result?.channels ?? {},
|
||||
{},
|
||||
"muted entry filtered as malformed",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ function stubRelay(relayClient, { live } = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full merge-lane hook invariant suite for a single lane.
|
||||
* 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.
|
||||
@@ -119,145 +119,52 @@ export function runMergeLaneHookSuite({
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: persisted-local first click mints seen+1 on both dimensions`, async () => {
|
||||
// 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.
|
||||
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");
|
||||
|
||||
const restore = stubRelay(relayClient);
|
||||
const live = {};
|
||||
const restore = stubRelay(relayClient, { live });
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
const origDateNow = Date.now;
|
||||
// Local clock: 100s. Persisted head has updatedAt 500, rev 4.
|
||||
Date.now = () => 100 * 1_000;
|
||||
const pubkey = `pk-${label}-persist`;
|
||||
const FUTURE = 500;
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd) => {
|
||||
if (cmd === "nip44_decrypt_from_self")
|
||||
return Promise.resolve(
|
||||
makePayload({
|
||||
shared: { [entryValueField]: false, updatedAt: FUTURE, rev: 7 },
|
||||
}),
|
||||
);
|
||||
return Promise.reject(new Error(`unmocked ${cmd}`));
|
||||
},
|
||||
};
|
||||
const pubkey = `pk-${label}-mono`;
|
||||
// Seed a persisted store at rev 4, updatedAt 500.
|
||||
window.localStorage.setItem(
|
||||
storageKey(pubkey, "wss://r"),
|
||||
makePayload({
|
||||
shared: { [entryValueField]: true, updatedAt: 500, rev: 4 },
|
||||
shared: { [entryValueField]: true, updatedAt: FUTURE, rev: 4 },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const { result, unmount } = renderHook(() => useHook(pubkey, "wss://r"));
|
||||
act(() => result.current[falseAction]("shared"));
|
||||
const persisted = readStore(pubkey, "wss://r");
|
||||
assert.equal(
|
||||
persisted.channels.shared[entryValueField],
|
||||
false,
|
||||
`${falseLabel} applied`,
|
||||
);
|
||||
assert.equal(
|
||||
persisted.channels.shared.updatedAt,
|
||||
500,
|
||||
"updatedAt held at persisted-local high-water (max(100,500,seen))",
|
||||
);
|
||||
assert.equal(
|
||||
persisted.channels.shared.rev,
|
||||
5,
|
||||
"rev minted as persisted-local rev + 1",
|
||||
);
|
||||
unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
Date.now = origDateNow;
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: fast-clock veto fix: click after observing a future-stamped head wins`, async () => {
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const { relayClient } = await import("@/shared/api/relayClient");
|
||||
|
||||
const live = {};
|
||||
const restore = stubRelay(relayClient, { live });
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
const origDateNow = Date.now;
|
||||
Date.now = () => 100 * 1_000;
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd) => {
|
||||
if (cmd === "nip44_decrypt_from_self")
|
||||
return Promise.resolve(
|
||||
makePayload({
|
||||
shared: { [entryValueField]: false, updatedAt: 400, rev: 7 },
|
||||
}),
|
||||
);
|
||||
return Promise.reject(new Error(`unmocked ${cmd}`));
|
||||
},
|
||||
};
|
||||
const pubkey = `pk-${label}-fastclock`;
|
||||
let hook = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useHook(pubkey, "wss://r"));
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
});
|
||||
// Deliver a live event with rev 7 (higher than persisted 4).
|
||||
await act(async () => {
|
||||
live.cb({
|
||||
id: "future-head",
|
||||
pubkey,
|
||||
created_at: 400,
|
||||
content: "cipher",
|
||||
kind: 30078,
|
||||
tags: [["d", dTag]],
|
||||
sig: "s",
|
||||
});
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
});
|
||||
assert.equal(
|
||||
hook.result.current[idsField].has("shared"),
|
||||
false,
|
||||
"future head applied → false",
|
||||
);
|
||||
await act(async () => hook.result.current[trueAction]("shared"));
|
||||
assert.equal(
|
||||
hook.result.current[idsField].has("shared"),
|
||||
true,
|
||||
"click must win despite the observed future timestamp",
|
||||
);
|
||||
const persisted = readStore(pubkey, "wss://r");
|
||||
assert.equal(
|
||||
persisted.channels.shared.updatedAt,
|
||||
400,
|
||||
"mint lifted to t+300",
|
||||
);
|
||||
assert.equal(persisted.channels.shared.rev, 8, "rev = maxRevSeen+1");
|
||||
hook.unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
Date.now = origDateNow;
|
||||
window.__TAURI_INTERNALS__ = origTauri;
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: far-future observation: timestamp stays fixed, rev advances, latest click wins`, async () => {
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const { relayClient } = await import("@/shared/api/relayClient");
|
||||
|
||||
const live = {};
|
||||
const restore = stubRelay(relayClient, { live });
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
const origDateNow = Date.now;
|
||||
Date.now = () => 100 * 1_000;
|
||||
const FUTURE = 100 + 31_536_000;
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd) => {
|
||||
if (cmd === "nip44_decrypt_from_self")
|
||||
return Promise.resolve(
|
||||
makePayload({
|
||||
shared: { [entryValueField]: true, updatedAt: FUTURE, rev: 1 },
|
||||
}),
|
||||
);
|
||||
return Promise.reject(new Error(`unmocked ${cmd}`));
|
||||
},
|
||||
};
|
||||
const pubkey = `pk-${label}-future`;
|
||||
let hook = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useHook(pubkey, "wss://r"));
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
live.cb({
|
||||
id: "far-future",
|
||||
pubkey,
|
||||
created_at: FUTURE,
|
||||
content: "cipher",
|
||||
kind: 30078,
|
||||
@@ -266,32 +173,34 @@ export function runMergeLaneHookSuite({
|
||||
});
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
});
|
||||
await act(async () => hook.result.current[falseAction]("shared"));
|
||||
// First click: must mint above max(FUTURE, rev 7).
|
||||
await act(async () => hook.result.current[trueAction]("shared"));
|
||||
let p = readStore(pubkey, "wss://r");
|
||||
assert.equal(
|
||||
p.channels.shared[entryValueField],
|
||||
false,
|
||||
true,
|
||||
"first click applied",
|
||||
);
|
||||
assert.equal(
|
||||
p.channels.shared.updatedAt,
|
||||
FUTURE,
|
||||
"timestamp stays fixed",
|
||||
"updatedAt held at observed high-water",
|
||||
);
|
||||
assert.equal(p.channels.shared.rev, 2, "rev advanced 1→2");
|
||||
await act(async () => hook.result.current[trueAction]("shared"));
|
||||
assert.equal(p.channels.shared.rev, 8, "rev = maxRevSeen(7) + 1");
|
||||
// Second same-second click: rev must strictly advance.
|
||||
await act(async () => hook.result.current[falseAction]("shared"));
|
||||
p = readStore(pubkey, "wss://r");
|
||||
assert.equal(
|
||||
p.channels.shared[entryValueField],
|
||||
true,
|
||||
"latest click wins",
|
||||
false,
|
||||
"second click applied",
|
||||
);
|
||||
assert.equal(
|
||||
p.channels.shared.updatedAt,
|
||||
FUTURE,
|
||||
"timestamp still fixed",
|
||||
"updatedAt still fixed",
|
||||
);
|
||||
assert.equal(p.channels.shared.rev, 3, "rev advanced 2→3");
|
||||
assert.equal(p.channels.shared.rev, 9, "rev advanced 8→9");
|
||||
hook.unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
@@ -301,115 +210,13 @@ export function runMergeLaneHookSuite({
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: empty-store click survives a later higher-rev head with an older updatedAt`, async () => {
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const { relayClient } = await import("@/shared/api/relayClient");
|
||||
|
||||
const live = {};
|
||||
const restore = stubRelay(relayClient, { live });
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
const origDateNow = Date.now;
|
||||
Date.now = () => 1000 * 1_000;
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd) => {
|
||||
if (cmd === "nip44_decrypt_from_self")
|
||||
return Promise.resolve(
|
||||
makePayload({
|
||||
shared: { [entryValueField]: false, updatedAt: 500, rev: 99 },
|
||||
}),
|
||||
);
|
||||
return Promise.reject(new Error(`unmocked ${cmd}`));
|
||||
},
|
||||
};
|
||||
const pubkey = `pk-${label}-empty`;
|
||||
let hook = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useHook(pubkey, "wss://r"));
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
});
|
||||
await act(async () => hook.result.current[trueAction]("shared"));
|
||||
await act(async () => {
|
||||
live.cb({
|
||||
id: "older-higher-rev",
|
||||
pubkey,
|
||||
created_at: 500,
|
||||
content: "cipher",
|
||||
kind: 30078,
|
||||
tags: [["d", dTag]],
|
||||
sig: "s",
|
||||
});
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
});
|
||||
assert.equal(
|
||||
hook.result.current[idsField].has("shared"),
|
||||
true,
|
||||
"click at newer updatedAt survives an older higher-rev head",
|
||||
);
|
||||
hook.unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
Date.now = origDateNow;
|
||||
window.__TAURI_INTERNALS__ = origTauri;
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: unobserved future head wins over an empty-high-water click on primary updatedAt`, async () => {
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const { relayClient } = await import("@/shared/api/relayClient");
|
||||
|
||||
const live = {};
|
||||
const restore = stubRelay(relayClient, { live });
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
const origDateNow = Date.now;
|
||||
Date.now = () => 1000 * 1_000;
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd) => {
|
||||
if (cmd === "nip44_decrypt_from_self")
|
||||
return Promise.resolve(
|
||||
makePayload({
|
||||
shared: { [entryValueField]: false, updatedAt: 1300, rev: 1 },
|
||||
}),
|
||||
);
|
||||
return Promise.reject(new Error(`unmocked ${cmd}`));
|
||||
},
|
||||
};
|
||||
const pubkey = `pk-${label}-unobserved-future`;
|
||||
let hook = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useHook(pubkey, "wss://r"));
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
});
|
||||
await act(async () => hook.result.current[trueAction]("shared"));
|
||||
await act(async () => {
|
||||
live.cb({
|
||||
id: "unobserved-future",
|
||||
pubkey,
|
||||
created_at: 1300,
|
||||
content: "cipher",
|
||||
kind: 30078,
|
||||
tags: [["d", dTag]],
|
||||
sig: "s",
|
||||
});
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
});
|
||||
assert.equal(
|
||||
hook.result.current[idsField].has("shared"),
|
||||
false,
|
||||
"unobserved future head wins on primary updatedAt (accepted residual)",
|
||||
);
|
||||
hook.unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
Date.now = origDateNow;
|
||||
window.__TAURI_INTERNALS__ = origTauri;
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: cross-window storage event is observed and max-merged`, async () => {
|
||||
// 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.
|
||||
// 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 () => {
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const { relayClient } = await import("@/shared/api/relayClient");
|
||||
|
||||
@@ -417,12 +224,27 @@ export function runMergeLaneHookSuite({
|
||||
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).
|
||||
window.localStorage.setItem(
|
||||
outboxKey,
|
||||
makePayload({
|
||||
resumed: { [entryValueField]: true, updatedAt: 90, rev: 2 },
|
||||
}),
|
||||
);
|
||||
let hook = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useHook(pubkey, "wss://r"));
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
});
|
||||
// (c) Outbox retained until publish completes.
|
||||
assert.ok(
|
||||
window.localStorage.getItem(outboxKey) !== null,
|
||||
"outbox retained during bootstrap",
|
||||
);
|
||||
|
||||
// (a) Peer window writes a store with rev 12 and fires a storage event.
|
||||
window.localStorage.setItem(
|
||||
storageKey(pubkey, "wss://r"),
|
||||
makePayload({
|
||||
@@ -442,6 +264,8 @@ export function runMergeLaneHookSuite({
|
||||
true,
|
||||
"peer write merged into this window",
|
||||
);
|
||||
|
||||
// (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");
|
||||
assert.equal(p.channels.shared[entryValueField], false, "click applied");
|
||||
@@ -454,38 +278,4 @@ export function runMergeLaneHookSuite({
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: bootstrap resumes a persisted outbox edit`, async () => {
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const { relayClient } = await import("@/shared/api/relayClient");
|
||||
|
||||
const restore = stubRelay(relayClient);
|
||||
const origDateNow = Date.now;
|
||||
Date.now = () => 100 * 1_000;
|
||||
const pubkey = `pk-${label}-outbox`;
|
||||
const relayUrl = `wss://r.${label}-outbox`;
|
||||
const outboxKey = `${outboxKeyPrefix}:${pubkey}:${encodeURIComponent(relayUrl)}`;
|
||||
window.localStorage.setItem(
|
||||
outboxKey,
|
||||
makePayload({
|
||||
resumed: { [entryValueField]: true, updatedAt: 90, rev: 2 },
|
||||
}),
|
||||
);
|
||||
let hook = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useHook(pubkey, relayUrl));
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
});
|
||||
assert.ok(
|
||||
window.localStorage.getItem(outboxKey) !== null,
|
||||
"outbox retained until the resumed publish completes",
|
||||
);
|
||||
hook.unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
Date.now = origDateNow;
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -178,157 +178,105 @@ export function runMergeLaneSyncSuite({
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
};
|
||||
});
|
||||
// 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",
|
||||
resolveFirst: (event, storedHead, res) => {
|
||||
storedHead.push(event);
|
||||
res();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "A-fails",
|
||||
resolveFirst: (_event, _storedHead, _res, rej) =>
|
||||
rej(new Error("socket error")),
|
||||
},
|
||||
]) {
|
||||
test(`${label}: A-in-flight → B-click → ${name}: B stays pending and B publishes`, async () => {
|
||||
let releaseFirst = null;
|
||||
let publishCount = 0;
|
||||
const storedHead = [];
|
||||
mock.method(relayClient, "fetchEvents", () =>
|
||||
Promise.resolve([...storedHead]),
|
||||
);
|
||||
mock.method(relayClient, "publishEvent", (event) => {
|
||||
publishCount++;
|
||||
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 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
|
||||
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-${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(
|
||||
Object.keys(getPending(manager)?.channels ?? {}),
|
||||
["b"],
|
||||
`older A completion (${name}) leaves B pending`,
|
||||
);
|
||||
assert.ok(
|
||||
readOutbox(`pk-ab-${name}`, RELAY),
|
||||
`older A completion (${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",
|
||||
);
|
||||
assert.equal(
|
||||
getPending(manager),
|
||||
null,
|
||||
"B cleared after confirmed publish",
|
||||
);
|
||||
assert.equal(
|
||||
readOutbox(`pk-ab-${name}`, RELAY),
|
||||
null,
|
||||
"B outbox cleared",
|
||||
);
|
||||
manager.destroy();
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
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
|
||||
|
||||
@@ -450,92 +398,83 @@ export function runMergeLaneSyncSuite({
|
||||
|
||||
// ─── 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();
|
||||
}
|
||||
});
|
||||
for (const {
|
||||
title,
|
||||
setupFetch,
|
||||
setupWatermark,
|
||||
relayOverride,
|
||||
pubkey,
|
||||
assertPending,
|
||||
} of [
|
||||
{
|
||||
title: "fetch failed (error) does not trigger seed-publish via bootstrap",
|
||||
setupFetch: () =>
|
||||
mock.method(relayClient, "fetchEvents", () =>
|
||||
Promise.reject(new Error("relay timeout")),
|
||||
),
|
||||
assertPending: (m) => assert.equal(getPending(m), null),
|
||||
},
|
||||
{
|
||||
title:
|
||||
"absent fetch with prior watermark blocks seed-publish via bootstrap",
|
||||
setupFetch: () =>
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([])),
|
||||
setupWatermark: (fw) =>
|
||||
fw.localStorage.setItem(
|
||||
`buzz-sync-watermark.v1:${watermarkKind}:pk-stale:${RELAY_KEY}`,
|
||||
"1700000000",
|
||||
),
|
||||
pubkey: "pk-stale",
|
||||
assertPending: (m) => assert.equal(getPending(m), null),
|
||||
},
|
||||
{
|
||||
title:
|
||||
"absent fetch with zero watermark seeds via bootstrap (first-sync preserved)",
|
||||
setupFetch: () =>
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([])),
|
||||
pubkey: "pk-fresh",
|
||||
assertPending: (m) => assert.ok(getPending(m) !== null),
|
||||
},
|
||||
{
|
||||
title: "relay-A watermark does not suppress first-sync seed on relay-B",
|
||||
setupFetch: () =>
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([])),
|
||||
setupWatermark: (fw) =>
|
||||
fw.localStorage.setItem(
|
||||
`buzz-sync-watermark.v1:${watermarkKind}:pk-iso:${encodeURIComponent("wss://a.relay.test")}`,
|
||||
"1700000100",
|
||||
),
|
||||
relayOverride: "wss://b.relay.test",
|
||||
pubkey: "pk-iso",
|
||||
assertPending: (m) =>
|
||||
assert.ok(
|
||||
getPending(m) !== null,
|
||||
"first-sync seed on relay B must not be blocked by relay A watermark",
|
||||
),
|
||||
},
|
||||
]) {
|
||||
test(`${label}: revert-fix: ${title}`, async () => {
|
||||
setupFetch();
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
setupWatermark?.(fw);
|
||||
const restore = installFakeWindow(fw);
|
||||
try {
|
||||
const manager = new Manager(
|
||||
pubkey ?? "pk-fail",
|
||||
relayOverride ?? RELAY,
|
||||
);
|
||||
const result = await manager.bootstrap(
|
||||
makeStore({ ch1: makeEntry(true, 1, 0) }),
|
||||
);
|
||||
assert.equal(result.action, "hold");
|
||||
assertPending(manager);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Failed pre-publish fetch: retain, never publish (Carl P1) ───────────────
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ function withStorage(fn) {
|
||||
const { normalizeRelayUrl } = await import("@/shared/lib/normalizeRelayUrl");
|
||||
const { outboxWindowNonce } = await import("./sidebarSyncWatermark.ts");
|
||||
const stars = await import("./channelStarsStorage.ts");
|
||||
const mutes = await import("./channelMutesStorage.ts");
|
||||
const sort = await import("./channelSortPreference.ts");
|
||||
const sections = await import("./channelSectionsStorage.ts");
|
||||
|
||||
@@ -100,7 +99,6 @@ const SCOPE = `${PK}:${encodeURIComponent(normalizeRelayUrl(RELAY))}`;
|
||||
|
||||
const PREFIX = {
|
||||
stars: "buzz-channel-stars-outbox.v1",
|
||||
mutes: "buzz-channel-mutes-outbox.v1",
|
||||
sort: "buzz-channel-sort-outbox.v1",
|
||||
sections: "buzz-channel-sections-outbox.v1",
|
||||
};
|
||||
@@ -118,8 +116,6 @@ const writeAt = (ls, key, store, queuedAt) =>
|
||||
|
||||
const starStore = (channels) => ({ version: 1, channels });
|
||||
const starEntry = (starred, updatedAt, rev) => ({ starred, updatedAt, rev });
|
||||
const muteStore = (channels) => ({ version: 1, channels });
|
||||
const muteEntry = (muted, updatedAt, rev) => ({ muted, updatedAt, rev });
|
||||
const sortStore = (groups) => ({ version: 1, groups });
|
||||
const sectionStore = (secs, assignments = {}) => ({
|
||||
version: 1,
|
||||
@@ -218,26 +214,6 @@ test("(ii) stars (merge): both windows' distinct-channel edits resume", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("(ii) mutes (merge): both windows' distinct-channel edits resume", () => {
|
||||
withStorage((ls) => {
|
||||
writeAt(
|
||||
ls,
|
||||
foreignKey("mutes", "A", 0),
|
||||
muteStore({ a: muteEntry(true, 100, 1) }),
|
||||
100,
|
||||
);
|
||||
writeAt(
|
||||
ls,
|
||||
foreignKey("mutes", "B", 0),
|
||||
muteStore({ b: muteEntry(true, 200, 1) }),
|
||||
200,
|
||||
);
|
||||
const resumed = mutes.readChannelMutesOutbox(PK, RELAY);
|
||||
assert.deepEqual(resumed.channels.a, muteEntry(true, 100, 1));
|
||||
assert.deepEqual(resumed.channels.b, muteEntry(true, 200, 1));
|
||||
});
|
||||
});
|
||||
|
||||
test("(ii) sort (whole-blob): the newest queued window resumes; older is LWW-superseded", () => {
|
||||
withStorage((ls) => {
|
||||
writeAt(
|
||||
@@ -259,27 +235,6 @@ test("(ii) sort (whole-blob): the newest queued window resumes; older is LWW-sup
|
||||
});
|
||||
});
|
||||
|
||||
test("(ii) sections (whole-blob): the newest queued window resumes; older is LWW-superseded", () => {
|
||||
withStorage((ls) => {
|
||||
writeAt(
|
||||
ls,
|
||||
foreignKey("sections", "A", 0),
|
||||
sectionStore([{ id: "s1", name: "One", order: 0 }]),
|
||||
100,
|
||||
);
|
||||
writeAt(
|
||||
ls,
|
||||
foreignKey("sections", "B", 0),
|
||||
sectionStore([{ id: "s2", name: "Two", order: 0 }]),
|
||||
200,
|
||||
);
|
||||
assert.deepEqual(
|
||||
sections.readChannelSectionsOutbox(PK, RELAY).store.sections,
|
||||
[{ id: "s2", name: "Two", order: 0 }],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── (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
|
||||
|
||||
@@ -18,6 +18,69 @@ before(() => {
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
test("assignChannel refreshes an existing assignment before the next eviction", async () => {
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const { relayClient } = await import("@/shared/api/relayClient");
|
||||
@@ -26,12 +89,7 @@ test("assignChannel refreshes an existing assignment before the next eviction",
|
||||
);
|
||||
const { useChannelSections } = await import("./useChannelSections.ts");
|
||||
|
||||
const originalFetchEvents = relayClient.fetchEvents;
|
||||
const originalSubscribeLive = relayClient.subscribeLive;
|
||||
const originalSubscribeToReconnects = relayClient.subscribeToReconnects;
|
||||
relayClient.fetchEvents = async () => [];
|
||||
relayClient.subscribeLive = async () => async () => {};
|
||||
relayClient.subscribeToReconnects = () => () => {};
|
||||
const restoreRelay = stubRelay(relayClient);
|
||||
|
||||
const pubkey = "pk-at-capacity";
|
||||
const relayUrl = "wss://relay.example";
|
||||
@@ -71,9 +129,7 @@ test("assignChannel refreshes an existing assignment before the next eviction",
|
||||
unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
relayClient.fetchEvents = originalFetchEvents;
|
||||
relayClient.subscribeLive = originalSubscribeLive;
|
||||
relayClient.subscribeToReconnects = originalSubscribeToReconnects;
|
||||
restoreRelay();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -100,35 +156,8 @@ test("storage event while a local edit is pending is deferred, not applied", asy
|
||||
const { useChannelSections } = await import("./useChannelSections.ts");
|
||||
const { storageKey } = await import("./channelSectionsStorage.ts");
|
||||
|
||||
const origFetch = relayClient.fetchEvents;
|
||||
const origLive = relayClient.subscribeLive;
|
||||
const origReconnect = relayClient.subscribeToReconnects;
|
||||
const origPublish = relayClient.publishEvent;
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
|
||||
// Block all publishes so the debounce never fires during the test.
|
||||
relayClient.fetchEvents = async () => [];
|
||||
relayClient.subscribeLive = async () => async () => {};
|
||||
relayClient.subscribeToReconnects = () => () => {};
|
||||
relayClient.publishEvent = async () => {};
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd) => {
|
||||
if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct");
|
||||
if (cmd === "sign_event")
|
||||
return Promise.resolve(
|
||||
JSON.stringify({
|
||||
id: "signed",
|
||||
pubkey: "pk-storage-guard",
|
||||
content: "ct",
|
||||
created_at: 0,
|
||||
kind: 30078,
|
||||
tags: [],
|
||||
sig: "s",
|
||||
}),
|
||||
);
|
||||
return Promise.reject(new Error(`unmocked ${cmd}`));
|
||||
},
|
||||
};
|
||||
const restoreRelay = stubRelay(relayClient);
|
||||
const restoreTauri = stubTauri("pk-storage-guard", null);
|
||||
|
||||
const pubkey = "pk-storage-guard";
|
||||
const relayUrl = "wss://r.storage-guard";
|
||||
@@ -150,8 +179,7 @@ test("storage event while a local edit is pending is deferred, not applied", asy
|
||||
"A1 section created",
|
||||
);
|
||||
|
||||
// Window B writes B1 to the shared scoped cache key (simulates a peer
|
||||
// window's edit landing in localStorage and firing the storage event).
|
||||
// Window B writes B1 to the shared scoped cache key.
|
||||
const b1Store = JSON.stringify({
|
||||
version: 1,
|
||||
sections: [{ id: "b1-section", name: "B1", order: 0 }],
|
||||
@@ -184,9 +212,7 @@ test("storage event while a local edit is pending is deferred, not applied", asy
|
||||
"optimistic state must be unchanged after the deferred storage event",
|
||||
);
|
||||
|
||||
// A2: user makes a second edit inside the debounce window. It is derived
|
||||
// from A1's state (since B1 was deferred), so the result contains A1's
|
||||
// section plus A2's.
|
||||
// A2: derived from A1's state (since B1 was deferred).
|
||||
await act(async () => {
|
||||
hook.result.current.createSection("A2-Section");
|
||||
});
|
||||
@@ -199,11 +225,8 @@ test("storage event while a local edit is pending is deferred, not applied", asy
|
||||
hook.unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
relayClient.fetchEvents = origFetch;
|
||||
relayClient.subscribeLive = origLive;
|
||||
relayClient.subscribeToReconnects = origReconnect;
|
||||
relayClient.publishEvent = origPublish;
|
||||
window.__TAURI_INTERNALS__ = origTauri;
|
||||
restoreRelay();
|
||||
restoreTauri();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -220,46 +243,15 @@ test("live remote while a local edit is pending defers to the pending edit", asy
|
||||
"./channelSectionsStorage.ts"
|
||||
);
|
||||
|
||||
const origFetch = relayClient.fetchEvents;
|
||||
const origLive = relayClient.subscribeLive;
|
||||
const origReconnect = relayClient.subscribeToReconnects;
|
||||
const origPublish = relayClient.publishEvent;
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
|
||||
let live = null;
|
||||
relayClient.fetchEvents = async () => [];
|
||||
relayClient.subscribeLive = async (_f, cb) => {
|
||||
live = cb;
|
||||
return async () => {};
|
||||
};
|
||||
relayClient.subscribeToReconnects = () => () => {};
|
||||
relayClient.publishEvent = async () => {};
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd) => {
|
||||
if (cmd === "nip44_decrypt_from_self")
|
||||
return Promise.resolve(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
sections: [{ id: "remote", name: "Remote", order: 0 }],
|
||||
assignments: {},
|
||||
}),
|
||||
);
|
||||
if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct");
|
||||
if (cmd === "sign_event")
|
||||
return Promise.resolve(
|
||||
JSON.stringify({
|
||||
id: "signed",
|
||||
pubkey: "pk-live-pending",
|
||||
content: "ct",
|
||||
created_at: 0,
|
||||
kind: 30078,
|
||||
tags: [],
|
||||
sig: "s",
|
||||
}),
|
||||
);
|
||||
return Promise.reject(new Error(`unmocked ${cmd}`));
|
||||
},
|
||||
};
|
||||
const live = {};
|
||||
const restoreRelay = stubRelay(relayClient, { live });
|
||||
const restoreTauri = stubTauri("pk-live-pending", () =>
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
sections: [{ id: "remote", name: "Remote", order: 0 }],
|
||||
assignments: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const pubkey = "pk-live-pending";
|
||||
const relayUrl = "wss://r.live";
|
||||
@@ -272,7 +264,7 @@ test("live remote while a local edit is pending defers to the pending edit", asy
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
assert.ok(live, "live subscription installed");
|
||||
assert.ok(live.cb, "live subscription installed");
|
||||
|
||||
// Make a local edit — it becomes the pending store and persists to outbox.
|
||||
await act(async () => {
|
||||
@@ -286,7 +278,7 @@ test("live remote while a local edit is pending defers to the pending edit", asy
|
||||
|
||||
// A remote live event arrives while the edit is still pending.
|
||||
await act(async () => {
|
||||
live({
|
||||
live.cb({
|
||||
id: "remote-event",
|
||||
pubkey,
|
||||
created_at: 500,
|
||||
@@ -311,11 +303,8 @@ test("live remote while a local edit is pending defers to the pending edit", asy
|
||||
hook.unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
relayClient.fetchEvents = origFetch;
|
||||
relayClient.subscribeLive = origLive;
|
||||
relayClient.subscribeToReconnects = origReconnect;
|
||||
relayClient.publishEvent = origPublish;
|
||||
window.__TAURI_INTERNALS__ = origTauri;
|
||||
restoreRelay();
|
||||
restoreTauri();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -328,35 +317,17 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
|
||||
const { relayClient } = await import("@/shared/api/relayClient");
|
||||
const { useChannelSections } = await import("./useChannelSections.ts");
|
||||
|
||||
const origFetch = relayClient.fetchEvents;
|
||||
const origLive = relayClient.subscribeLive;
|
||||
const origReconnect = relayClient.subscribeToReconnects;
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
|
||||
let live = null;
|
||||
relayClient.fetchEvents = async () => [];
|
||||
relayClient.subscribeLive = async (_f, cb) => {
|
||||
live = cb;
|
||||
return async () => {};
|
||||
};
|
||||
relayClient.subscribeToReconnects = () => () => {};
|
||||
// Decrypt payload keyed off the event id embedded in the ciphertext so each
|
||||
// delivered event yields a distinct store we can assert on.
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd, args) => {
|
||||
if (cmd === "nip44_decrypt_from_self") {
|
||||
const id = args?.ciphertext ?? "";
|
||||
return Promise.resolve(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
sections: [{ id, name: id, order: 0 }],
|
||||
assignments: {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`unmocked ${cmd}`));
|
||||
},
|
||||
};
|
||||
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({
|
||||
version: 1,
|
||||
sections: [{ id, name: id, order: 0 }],
|
||||
assignments: {},
|
||||
});
|
||||
});
|
||||
|
||||
const pubkey = "pk-tie";
|
||||
const relayUrl = "wss://r.tie";
|
||||
@@ -368,11 +339,11 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
assert.ok(live, "live subscription installed");
|
||||
assert.ok(live.cb, "live subscription installed");
|
||||
|
||||
const deliver = async (id) => {
|
||||
await act(async () => {
|
||||
live({
|
||||
live.cb({
|
||||
id,
|
||||
pubkey,
|
||||
created_at: 1000,
|
||||
@@ -386,10 +357,8 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
|
||||
});
|
||||
};
|
||||
|
||||
// Larger id first (would win under the old <= comparator)...
|
||||
await deliver("bbbb");
|
||||
// ...then the lower id at the same timestamp — the relay's canonical winner.
|
||||
await deliver("aaaa");
|
||||
await deliver("bbbb"); // larger id first
|
||||
await deliver("aaaa"); // lower id — relay canonical winner
|
||||
|
||||
assert.deepEqual(
|
||||
hook.result.current.sections.map((s) => s.id),
|
||||
@@ -399,10 +368,8 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
|
||||
hook.unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
relayClient.fetchEvents = origFetch;
|
||||
relayClient.subscribeLive = origLive;
|
||||
relayClient.subscribeToReconnects = origReconnect;
|
||||
window.__TAURI_INTERNALS__ = origTauri;
|
||||
restoreRelay();
|
||||
restoreTauri();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -420,58 +387,26 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
|
||||
const { relayClient } = await import("@/shared/api/relayClient");
|
||||
const { useChannelSections } = await import("./useChannelSections.ts");
|
||||
|
||||
const origFetch = relayClient.fetchEvents;
|
||||
const origLive = relayClient.subscribeLive;
|
||||
const origReconnect = relayClient.subscribeToReconnects;
|
||||
const origPublish = relayClient.publishEvent;
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
|
||||
let reconnect = null;
|
||||
// A single mutable head; bumped to created_at 200 (a remote that won LWW)
|
||||
// right before the reconnect fires.
|
||||
const reconnect = {};
|
||||
const publishCalls = [];
|
||||
// A mutable head: bumped to 200 right before reconnect fires.
|
||||
let head = {
|
||||
pubkey: "pk-sec-recon",
|
||||
content: "remote-cipher",
|
||||
created_at: 100,
|
||||
id: "evt-100",
|
||||
};
|
||||
const publishCalls = [];
|
||||
const origFetch = relayClient.fetchEvents;
|
||||
relayClient.fetchEvents = async () => [head];
|
||||
relayClient.subscribeLive = async () => async () => {};
|
||||
relayClient.subscribeToReconnects = (cb) => {
|
||||
reconnect = cb;
|
||||
return () => {};
|
||||
};
|
||||
relayClient.publishEvent = async (...args) => {
|
||||
publishCalls.push(args);
|
||||
};
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd) => {
|
||||
// The head decrypts to a remote store with a single "remote" section.
|
||||
if (cmd === "nip44_decrypt_from_self")
|
||||
return Promise.resolve(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
sections: [{ id: "remote", name: "Remote", order: 0 }],
|
||||
assignments: {},
|
||||
}),
|
||||
);
|
||||
if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct");
|
||||
if (cmd === "sign_event")
|
||||
return Promise.resolve(
|
||||
JSON.stringify({
|
||||
id: "signed",
|
||||
pubkey: "pk-sec-recon",
|
||||
content: "ct",
|
||||
created_at: 0,
|
||||
kind: 30078,
|
||||
tags: [],
|
||||
sig: "s",
|
||||
}),
|
||||
);
|
||||
return Promise.reject(new Error(`unmocked ${cmd}`));
|
||||
},
|
||||
};
|
||||
const restoreRelay = stubRelay(relayClient, { reconnect, publishCalls });
|
||||
relayClient.fetchEvents = async () => [head]; // override stubRelay's fetchEvents
|
||||
const restoreTauri = stubTauri("pk-sec-recon", () =>
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
sections: [{ id: "remote", name: "Remote", order: 0 }],
|
||||
assignments: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const pubkey = "pk-sec-recon";
|
||||
const relayUrl = "wss://r.recon";
|
||||
@@ -483,10 +418,8 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
assert.ok(reconnect, "reconnect handler installed");
|
||||
assert.ok(reconnect.cb, "reconnect handler installed");
|
||||
|
||||
// Local edit while the head stands at created_at 100 — the baseline the
|
||||
// edit is frozen against.
|
||||
await act(async () => {
|
||||
hook.result.current.createSection("Local");
|
||||
});
|
||||
@@ -495,8 +428,7 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
|
||||
"optimistic local section applied",
|
||||
);
|
||||
|
||||
// The remote advances to created_at 200 (a peer won LWW), then reconnect
|
||||
// fires: the hook re-fetches and wakes the existing generation.
|
||||
// Remote advances to 200 (peer won LWW), then reconnect fires.
|
||||
head = {
|
||||
pubkey: "pk-sec-recon",
|
||||
content: "remote-cipher",
|
||||
@@ -504,7 +436,7 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
|
||||
id: "evt-200",
|
||||
};
|
||||
await act(async () => {
|
||||
reconnect();
|
||||
reconnect.cb();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
@@ -514,11 +446,8 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
|
||||
assert.equal(
|
||||
publishCalls.length,
|
||||
0,
|
||||
"must adopt the advanced remote on reconnect, never publish the stale edit over it",
|
||||
"must adopt the advanced remote on reconnect, never publish",
|
||||
);
|
||||
// On adopt the whole-blob store is replaced by the remote (only a "remote"
|
||||
// section), so the losing local edit is dropped. Under the mutation
|
||||
// (re-queue) the pending edit is never adopted away and "Local" survives.
|
||||
assert.deepEqual(
|
||||
hook.result.current.sections.map((s) => s.id),
|
||||
["remote"],
|
||||
@@ -528,10 +457,8 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
|
||||
} finally {
|
||||
cleanup();
|
||||
relayClient.fetchEvents = origFetch;
|
||||
relayClient.subscribeLive = origLive;
|
||||
relayClient.subscribeToReconnects = origReconnect;
|
||||
relayClient.publishEvent = origPublish;
|
||||
window.__TAURI_INTERNALS__ = origTauri;
|
||||
restoreRelay();
|
||||
restoreTauri();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -551,18 +478,13 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
|
||||
);
|
||||
const { normalizeRelayUrl } = await import("@/shared/lib/normalizeRelayUrl");
|
||||
|
||||
const origFetch = relayClient.fetchEvents;
|
||||
const origLive = relayClient.subscribeLive;
|
||||
const origReconnect = relayClient.subscribeToReconnects;
|
||||
const origPublish = relayClient.publishEvent;
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
const origLocalStorage = window.localStorage;
|
||||
|
||||
const pubkey = "pk-sec-quota";
|
||||
const relayUrl = "wss://r.sec-quota";
|
||||
const scope = `${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
|
||||
const legacyKey = `buzz-channel-sections-outbox.v1:${scope}`;
|
||||
const v2Prefix = `buzz-channel-sections-outbox.v1:${scope}:`; // nonce/seq suffix
|
||||
const v2Prefix = `buzz-channel-sections-outbox.v1:${scope}:`;
|
||||
const legacyRaw = JSON.stringify({
|
||||
store: {
|
||||
version: 1,
|
||||
@@ -572,9 +494,7 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
|
||||
queuedAt: 0,
|
||||
});
|
||||
|
||||
// A backing localStorage that throws on any v2 outbox write (the quota
|
||||
// failure), while allowing the legacy read, the marker write, and applied
|
||||
// state through — so the ONLY failure is the durability transfer.
|
||||
// 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),
|
||||
@@ -590,54 +510,26 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
|
||||
key: (i) => [...map.keys()][i] ?? null,
|
||||
};
|
||||
|
||||
const head = null;
|
||||
relayClient.fetchEvents = async () => (head ? [head] : []);
|
||||
relayClient.subscribeLive = async () => async () => {};
|
||||
relayClient.subscribeToReconnects = () => () => {};
|
||||
relayClient.publishEvent = async () => {};
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd) => {
|
||||
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}`));
|
||||
},
|
||||
};
|
||||
const restoreRelay = stubRelay(relayClient);
|
||||
const restoreTauri = stubTauri(pubkey, null);
|
||||
|
||||
let hook = null;
|
||||
try {
|
||||
// Seed a prior watermark so bootstrap holds (no first-sync seed) and the
|
||||
// legacy blob is the sole outbox record resumed.
|
||||
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),
|
||||
)}`,
|
||||
`buzz-sync-watermark.v1:channel-sections:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`,
|
||||
"1700000000",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useChannelSections(pubkey, relayUrl));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
for (let i = 0; i < 4; i++) await Promise.resolve();
|
||||
});
|
||||
|
||||
// The marker must NOT have been written: the v2 transfer threw.
|
||||
const markerWritten = [...map.keys()].some((k) =>
|
||||
k.includes("-legacy-consumed:"),
|
||||
);
|
||||
@@ -646,17 +538,17 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
|
||||
false,
|
||||
"consumed marker must not be written after a failed v2 transfer",
|
||||
);
|
||||
// The legacy blob is still enumerated as replayable — not silently lost.
|
||||
|
||||
const resumed = readChannelSectionsOutbox(pubkey, relayUrl);
|
||||
assert.ok(resumed !== null, "legacy blob must remain replayable");
|
||||
assert.equal(
|
||||
resumed.store.sections[0]?.id,
|
||||
"legacy",
|
||||
"the exact legacy intent survives for a later boot",
|
||||
"exact legacy intent survives",
|
||||
);
|
||||
assert.ok(
|
||||
resumed.legacyRawToConsume !== null,
|
||||
"legacy record still reports itself for consumption on a later boot",
|
||||
"legacy record still reports itself for consumption",
|
||||
);
|
||||
hook.unmount();
|
||||
} finally {
|
||||
@@ -665,10 +557,7 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
|
||||
value: origLocalStorage,
|
||||
configurable: true,
|
||||
});
|
||||
relayClient.fetchEvents = origFetch;
|
||||
relayClient.subscribeLive = origLive;
|
||||
relayClient.subscribeToReconnects = origReconnect;
|
||||
relayClient.publishEvent = origPublish;
|
||||
window.__TAURI_INTERNALS__ = origTauri;
|
||||
restoreRelay();
|
||||
restoreTauri();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,16 +18,70 @@ before(() => {
|
||||
|
||||
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 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.
|
||||
// The storage handler must defer to the pending edit just as `applyRemote` does
|
||||
// for relay arrivals. Peer writes arrive immediately when nothing is pending.
|
||||
//
|
||||
// Scenario: window A sets sort mode for "channels" (pending). Window B writes
|
||||
// B1 (sort mode "recent" for "channels") to the shared cache. A's storage event
|
||||
// fires. A2 is then set inside the debounce window. The resulting sort mode for
|
||||
// A must reflect A2, not B1's value — B1's write was ignored while A1 was live.
|
||||
//
|
||||
// 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.
|
||||
@@ -39,34 +93,8 @@ test("storage event while a local sort edit is pending is deferred, not applied"
|
||||
);
|
||||
const { storageKey } = await import("./channelSortPreference.ts");
|
||||
|
||||
const origFetch = relayClient.fetchEvents;
|
||||
const origLive = relayClient.subscribeLive;
|
||||
const origReconnect = relayClient.subscribeToReconnects;
|
||||
const origPublish = relayClient.publishEvent;
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
|
||||
relayClient.fetchEvents = async () => [];
|
||||
relayClient.subscribeLive = async () => async () => {};
|
||||
relayClient.subscribeToReconnects = () => () => {};
|
||||
relayClient.publishEvent = async () => {};
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd) => {
|
||||
if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct");
|
||||
if (cmd === "sign_event")
|
||||
return Promise.resolve(
|
||||
JSON.stringify({
|
||||
id: "signed",
|
||||
pubkey: "pk-sort-sg",
|
||||
content: "ct",
|
||||
created_at: 0,
|
||||
kind: 30078,
|
||||
tags: [],
|
||||
sig: "s",
|
||||
}),
|
||||
);
|
||||
return Promise.reject(new Error(`unmocked ${cmd}`));
|
||||
},
|
||||
};
|
||||
const restoreRelay = stubRelay(relayClient);
|
||||
const restoreTauri = stubTauri("pk-sort-sg", null);
|
||||
|
||||
const pubkey = "pk-sort-sg";
|
||||
const relayUrl = "wss://r.sort-sg";
|
||||
@@ -88,8 +116,7 @@ test("storage event while a local sort edit is pending is deferred, not applied"
|
||||
"A1: channels sort set to alpha",
|
||||
);
|
||||
|
||||
// Window B writes "recent" for "channels" to the shared cache and fires a
|
||||
// storage event.
|
||||
// Window B writes "recent" for "channels" to the shared cache.
|
||||
const b1Store = JSON.stringify({
|
||||
version: 1,
|
||||
groups: { channels: "recent" },
|
||||
@@ -107,40 +134,33 @@ test("storage event while a local sort edit is pending is deferred, not applied"
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// B1's "recent" must NOT have replaced A1's "alpha".
|
||||
assert.equal(
|
||||
hook.result.current.sortModeFor("channels"),
|
||||
"alpha",
|
||||
"storage event while pending must not apply B1 (recent) over A1 (alpha)",
|
||||
);
|
||||
|
||||
// A2: set "starred" to "recent" — derived from A1 state (channels stays alpha).
|
||||
// A2: derived from A1 state (channels stays alpha).
|
||||
await act(async () => {
|
||||
hook.result.current.setSortModeFor("starred", "recent");
|
||||
});
|
||||
// channels must still reflect A1 (alpha), not B1 (recent).
|
||||
assert.equal(
|
||||
hook.result.current.sortModeFor("channels"),
|
||||
"alpha",
|
||||
"A2 must be derived from A1 state — channels must remain alpha, not revert to B1 recent",
|
||||
"A2 must be derived from A1 state — channels must remain alpha",
|
||||
);
|
||||
|
||||
hook.unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
relayClient.fetchEvents = origFetch;
|
||||
relayClient.subscribeLive = origLive;
|
||||
relayClient.subscribeToReconnects = origReconnect;
|
||||
relayClient.publishEvent = origPublish;
|
||||
window.__TAURI_INTERNALS__ = origTauri;
|
||||
restoreRelay();
|
||||
restoreTauri();
|
||||
}
|
||||
});
|
||||
|
||||
// Carl P1-sort regression: a live remote arriving while a local sort edit is
|
||||
// still pending must NOT overwrite the optimistic edit or strand its durable
|
||||
// outbox. Reverting applyRemote's `hasPendingEdit()` guard (or restoring the
|
||||
// old `cancelPendingPublish()` on remote arrival) lets the remote clobber the
|
||||
// pending edit and drop it.
|
||||
// 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.
|
||||
// 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");
|
||||
const { relayClient } = await import("@/shared/api/relayClient");
|
||||
@@ -149,49 +169,14 @@ test("live remote while a local edit is pending defers to the pending edit", asy
|
||||
);
|
||||
const { readChannelSortOutbox } = await import("./channelSortPreference.ts");
|
||||
|
||||
const origFetch = relayClient.fetchEvents;
|
||||
const origLive = relayClient.subscribeLive;
|
||||
const origReconnect = relayClient.subscribeToReconnects;
|
||||
const origPublish = relayClient.publishEvent;
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
|
||||
let live = null;
|
||||
relayClient.fetchEvents = async () => [];
|
||||
relayClient.subscribeLive = async (_f, cb) => {
|
||||
live = cb;
|
||||
return async () => {};
|
||||
};
|
||||
relayClient.subscribeToReconnects = () => () => {};
|
||||
relayClient.publishEvent = async () => {};
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd) => {
|
||||
if (cmd === "nip44_decrypt_from_self")
|
||||
return Promise.resolve(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
groups: { "remote-group": "recent" },
|
||||
}),
|
||||
);
|
||||
if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct");
|
||||
if (cmd === "sign_event")
|
||||
return Promise.resolve(
|
||||
JSON.stringify({
|
||||
id: "signed",
|
||||
pubkey: "pk-sort-pending",
|
||||
content: "ct",
|
||||
created_at: 0,
|
||||
kind: 30078,
|
||||
tags: [],
|
||||
sig: "s",
|
||||
}),
|
||||
);
|
||||
return Promise.reject(new Error(`unmocked ${cmd}`));
|
||||
},
|
||||
};
|
||||
const live = {};
|
||||
const restoreRelay = stubRelay(relayClient, { live });
|
||||
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;
|
||||
try {
|
||||
await act(async () => {
|
||||
@@ -200,9 +185,8 @@ test("live remote while a local edit is pending defers to the pending edit", asy
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
assert.ok(live, "live subscription installed");
|
||||
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.setSortModeFor("channels", "recent");
|
||||
});
|
||||
@@ -216,9 +200,8 @@ test("live remote while a local edit is pending defers to the pending edit", asy
|
||||
"optimistic local edit applied",
|
||||
);
|
||||
|
||||
// A remote live event arrives while the edit is still pending.
|
||||
await act(async () => {
|
||||
live({
|
||||
live.cb({
|
||||
id: "remote-event",
|
||||
pubkey,
|
||||
created_at: 500,
|
||||
@@ -234,7 +217,7 @@ test("live remote while a local edit is pending defers to the pending edit", asy
|
||||
assert.equal(
|
||||
hook.result.current.sortModeFor("channels"),
|
||||
"recent",
|
||||
"pending local edit must NOT be overwritten by the live remote",
|
||||
"pending local edit must NOT be overwritten",
|
||||
);
|
||||
assert.equal(
|
||||
hook.result.current.sortModeFor("remote-group"),
|
||||
@@ -248,19 +231,13 @@ test("live remote while a local edit is pending defers to the pending edit", asy
|
||||
hook.unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
relayClient.fetchEvents = origFetch;
|
||||
relayClient.subscribeLive = origLive;
|
||||
relayClient.subscribeToReconnects = origReconnect;
|
||||
relayClient.publishEvent = origPublish;
|
||||
window.__TAURI_INTERNALS__ = origTauri;
|
||||
restoreRelay();
|
||||
restoreTauri();
|
||||
}
|
||||
});
|
||||
|
||||
// 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 is the stored winner
|
||||
// and its whole-blob store must replace the applied state. Reverting
|
||||
// applyRemote's `>=` back to `<=` wrongly ignores the lower id (the relay winner).
|
||||
// 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.
|
||||
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");
|
||||
@@ -268,31 +245,13 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
|
||||
"./useChannelSortPreference.ts"
|
||||
);
|
||||
|
||||
const origFetch = relayClient.fetchEvents;
|
||||
const origLive = relayClient.subscribeLive;
|
||||
const origReconnect = relayClient.subscribeToReconnects;
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
|
||||
let live = null;
|
||||
relayClient.fetchEvents = async () => [];
|
||||
relayClient.subscribeLive = async (_f, cb) => {
|
||||
live = cb;
|
||||
return async () => {};
|
||||
};
|
||||
relayClient.subscribeToReconnects = () => () => {};
|
||||
// Decrypt payload keyed off the event id embedded in the ciphertext so each
|
||||
// delivered event yields a store setting a distinct group's mode to "recent".
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd, args) => {
|
||||
if (cmd === "nip44_decrypt_from_self") {
|
||||
const id = args?.ciphertext ?? "";
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ version: 1, groups: { [id]: "recent" } }),
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`unmocked ${cmd}`));
|
||||
},
|
||||
};
|
||||
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";
|
||||
@@ -304,15 +263,15 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
assert.ok(live, "live subscription installed");
|
||||
assert.ok(live.cb, "live subscription installed");
|
||||
|
||||
const deliver = async (id) => {
|
||||
await act(async () => {
|
||||
live({
|
||||
live.cb({
|
||||
id,
|
||||
pubkey,
|
||||
created_at: 1000,
|
||||
content: id, // decrypt echoes this into the group key
|
||||
content: id,
|
||||
kind: 30078,
|
||||
tags: [["d", "channel-sort"]],
|
||||
sig: "s",
|
||||
@@ -322,15 +281,13 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
|
||||
});
|
||||
};
|
||||
|
||||
// Larger id first (applied), then the lower id at the same timestamp — the
|
||||
// relay's canonical winner, whose whole-blob store must replace the state.
|
||||
await deliver("bbbb");
|
||||
await deliver("aaaa");
|
||||
|
||||
assert.equal(
|
||||
hook.result.current.sortModeFor("aaaa"),
|
||||
"recent",
|
||||
"lower event id (relay canonical winner) must be applied, not rejected",
|
||||
"lower event id (relay canonical winner) must be applied",
|
||||
);
|
||||
assert.equal(
|
||||
hook.result.current.sortModeFor("bbbb"),
|
||||
@@ -340,21 +297,15 @@ test("equal-timestamp tie-break applies the lower event id (relay canonical winn
|
||||
hook.unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
relayClient.fetchEvents = origFetch;
|
||||
relayClient.subscribeLive = origLive;
|
||||
relayClient.subscribeToReconnects = origReconnect;
|
||||
window.__TAURI_INTERNALS__ = origTauri;
|
||||
restoreRelay();
|
||||
restoreTauri();
|
||||
}
|
||||
});
|
||||
|
||||
// Carl P1-sort regression: on reconnect the hook must wake the EXISTING pending
|
||||
// edit (retryPendingPublish) rather than re-queue it via publishSortPrefs().
|
||||
// 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 and NOTHING published. Reverting the reconnect handler to
|
||||
// publishSortPrefs(pending) resets the baseline and publishes the stale edit.
|
||||
// Mutation: reverting the reconnect handler to publishSortPrefs(pending) resets
|
||||
// the baseline and publishes the stale edit over the advanced remote.
|
||||
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");
|
||||
@@ -362,54 +313,21 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
|
||||
"./useChannelSortPreference.ts"
|
||||
);
|
||||
|
||||
const origFetch = relayClient.fetchEvents;
|
||||
const origLive = relayClient.subscribeLive;
|
||||
const origReconnect = relayClient.subscribeToReconnects;
|
||||
const origPublish = relayClient.publishEvent;
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
|
||||
let reconnect = null;
|
||||
// A single mutable head the fetch returns; bumped to created_at 200 (a remote
|
||||
// that won LWW) right before the reconnect fires.
|
||||
const reconnect = {};
|
||||
const publishCalls = [];
|
||||
let head = {
|
||||
pubkey: "pk-sort-recon",
|
||||
content: "remote-cipher",
|
||||
created_at: 100,
|
||||
id: "evt-100",
|
||||
};
|
||||
const publishCalls = [];
|
||||
const origFetch = relayClient.fetchEvents;
|
||||
relayClient.fetchEvents = async () => [head];
|
||||
relayClient.subscribeLive = async () => async () => {};
|
||||
relayClient.subscribeToReconnects = (cb) => {
|
||||
reconnect = cb;
|
||||
return () => {};
|
||||
};
|
||||
relayClient.publishEvent = async (...args) => {
|
||||
publishCalls.push(args);
|
||||
};
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd) => {
|
||||
// The head decrypts to a remote store setting `remote-group`.
|
||||
if (cmd === "nip44_decrypt_from_self")
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ version: 1, groups: { "remote-group": "recent" } }),
|
||||
);
|
||||
if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct");
|
||||
if (cmd === "sign_event")
|
||||
return Promise.resolve(
|
||||
JSON.stringify({
|
||||
id: "signed",
|
||||
pubkey: "pk-sort-recon",
|
||||
content: "ct",
|
||||
created_at: 0,
|
||||
kind: 30078,
|
||||
tags: [],
|
||||
sig: "s",
|
||||
}),
|
||||
);
|
||||
return Promise.reject(new Error(`unmocked ${cmd}`));
|
||||
},
|
||||
};
|
||||
const restoreRelay = stubRelay(relayClient, { reconnect, publishCalls });
|
||||
relayClient.fetchEvents = async () => [head]; // override stubRelay's fetchEvents
|
||||
const restoreTauri = stubTauri("pk-sort-recon", () =>
|
||||
JSON.stringify({ version: 1, groups: { "remote-group": "recent" } }),
|
||||
);
|
||||
|
||||
const pubkey = "pk-sort-recon";
|
||||
const relayUrl = "wss://r.recon";
|
||||
@@ -421,16 +339,12 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
assert.ok(reconnect, "reconnect handler installed");
|
||||
assert.ok(reconnect.cb, "reconnect handler installed");
|
||||
|
||||
// Local edit while the head stands at created_at 100 — the baseline the
|
||||
// edit is frozen against.
|
||||
await act(async () => {
|
||||
hook.result.current.setSortModeFor("channels", "recent");
|
||||
});
|
||||
|
||||
// The remote advances to created_at 200 (a peer won LWW), then reconnect
|
||||
// fires: the hook re-fetches and wakes the existing generation.
|
||||
head = {
|
||||
pubkey: "pk-sort-recon",
|
||||
content: "remote-cipher",
|
||||
@@ -438,7 +352,7 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
|
||||
id: "evt-200",
|
||||
};
|
||||
await act(async () => {
|
||||
reconnect();
|
||||
reconnect.cb();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
@@ -448,16 +362,12 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
|
||||
assert.equal(
|
||||
publishCalls.length,
|
||||
0,
|
||||
"must adopt the advanced remote on reconnect, never publish the stale edit over it",
|
||||
"must adopt the advanced remote on reconnect, never publish",
|
||||
);
|
||||
// On adopt the whole-blob store is replaced by the remote (which has no
|
||||
// `channels` entry), so the losing local edit is dropped and `channels`
|
||||
// reverts to the default. Under the mutation (re-queue) the pending edit is
|
||||
// never adopted away and `channels` stays "recent".
|
||||
assert.equal(
|
||||
hook.result.current.sortModeFor("channels"),
|
||||
"alpha",
|
||||
"the losing local edit must be adopted away by the advanced remote",
|
||||
"the losing local edit must be adopted away",
|
||||
);
|
||||
assert.equal(
|
||||
hook.result.current.sortModeFor("remote-group"),
|
||||
@@ -468,21 +378,14 @@ test("reconnect adopts a remote that advanced while the edit was pending, never
|
||||
} finally {
|
||||
cleanup();
|
||||
relayClient.fetchEvents = origFetch;
|
||||
relayClient.subscribeLive = origLive;
|
||||
relayClient.subscribeToReconnects = origReconnect;
|
||||
relayClient.publishEvent = origPublish;
|
||||
window.__TAURI_INTERNALS__ = origTauri;
|
||||
restoreRelay();
|
||||
restoreTauri();
|
||||
}
|
||||
});
|
||||
|
||||
// Thufir pass-3 blocker (Carl P1 durability): a legacy (old-build) blob is
|
||||
// replayed by copying it into this window's own v2 outbox key, then writing a
|
||||
// "consumed" marker. writeOwnOutbox swallows setItem failures; the hook must
|
||||
// therefore write the marker ONLY when the v2 transfer actually succeeded.
|
||||
// Here the v2 outbox setItem throws (quota), so the transfer failed: the marker
|
||||
// must NOT be written, leaving the legacy blob replayable on a later boot rather
|
||||
// than silently suppressed. Mutation: writing the marker unconditionally (the
|
||||
// pre-fix code) consumes the legacy blob after a failed transfer and it is lost.
|
||||
// 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.
|
||||
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");
|
||||
@@ -492,26 +395,18 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
|
||||
const { readChannelSortOutbox } = await import("./channelSortPreference.ts");
|
||||
const { normalizeRelayUrl } = await import("@/shared/lib/normalizeRelayUrl");
|
||||
|
||||
const origFetch = relayClient.fetchEvents;
|
||||
const origLive = relayClient.subscribeLive;
|
||||
const origReconnect = relayClient.subscribeToReconnects;
|
||||
const origPublish = relayClient.publishEvent;
|
||||
const origTauri = window.__TAURI_INTERNALS__;
|
||||
const origLocalStorage = window.localStorage;
|
||||
|
||||
const pubkey = "pk-quota";
|
||||
const relayUrl = "wss://r.quota";
|
||||
const scope = `${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
|
||||
const legacyKey = `buzz-channel-sort-outbox.v1:${scope}`;
|
||||
const v2Prefix = `buzz-channel-sort-outbox.v1:${scope}:`; // nonce/seq suffix
|
||||
const v2Prefix = `buzz-channel-sort-outbox.v1:${scope}:`;
|
||||
const legacyRaw = JSON.stringify({
|
||||
store: { version: 1, groups: { "legacy-group": "recent" } },
|
||||
queuedAt: 0,
|
||||
});
|
||||
|
||||
// A backing localStorage that throws on any v2 outbox write (the quota
|
||||
// failure), while allowing the legacy read, the marker write, and applied
|
||||
// state through — so the ONLY failure is the durability transfer.
|
||||
const map = new Map([[legacyKey, legacyRaw]]);
|
||||
const throwingStorage = {
|
||||
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
||||
@@ -527,42 +422,17 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
|
||||
key: (i) => [...map.keys()][i] ?? null,
|
||||
};
|
||||
|
||||
const head = null;
|
||||
relayClient.fetchEvents = async () => (head ? [head] : []);
|
||||
relayClient.subscribeLive = async () => async () => {};
|
||||
relayClient.subscribeToReconnects = () => () => {};
|
||||
relayClient.publishEvent = async () => {};
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd) => {
|
||||
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}`));
|
||||
},
|
||||
};
|
||||
const restoreRelay = stubRelay(relayClient);
|
||||
const restoreTauri = stubTauri(pubkey, null);
|
||||
|
||||
let hook = null;
|
||||
try {
|
||||
// Seed a prior watermark so bootstrap holds (no first-sync seed) and the
|
||||
// legacy blob is the sole outbox record resumed.
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: throwingStorage,
|
||||
configurable: true,
|
||||
});
|
||||
window.localStorage.setItem(
|
||||
`buzz-sync-watermark.v1:channel-sort:${pubkey}:${encodeURIComponent(
|
||||
normalizeRelayUrl(relayUrl),
|
||||
)}`,
|
||||
`buzz-sync-watermark.v1:channel-sort:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`,
|
||||
"1700000000",
|
||||
);
|
||||
|
||||
@@ -574,7 +444,6 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// The marker must NOT have been written: the v2 transfer threw.
|
||||
const markerWritten = [...map.keys()].some((k) =>
|
||||
k.includes("-legacy-consumed:"),
|
||||
);
|
||||
@@ -583,17 +452,16 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
|
||||
false,
|
||||
"consumed marker must not be written after a failed v2 transfer",
|
||||
);
|
||||
// The legacy blob is still enumerated as replayable — not silently lost.
|
||||
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 for a later boot",
|
||||
"the exact legacy intent survives",
|
||||
);
|
||||
assert.ok(
|
||||
resumed.legacyRawToConsume !== null,
|
||||
"legacy record still reports itself for consumption on a later boot",
|
||||
"legacy record still reports itself for consumption",
|
||||
);
|
||||
hook.unmount();
|
||||
} finally {
|
||||
@@ -602,10 +470,7 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
|
||||
value: origLocalStorage,
|
||||
configurable: true,
|
||||
});
|
||||
relayClient.fetchEvents = origFetch;
|
||||
relayClient.subscribeLive = origLive;
|
||||
relayClient.subscribeToReconnects = origReconnect;
|
||||
relayClient.publishEvent = origPublish;
|
||||
window.__TAURI_INTERNALS__ = origTauri;
|
||||
restoreRelay();
|
||||
restoreTauri();
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user