diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs index 0071d4d0e9..7b084b898a 100644 --- a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs @@ -378,19 +378,47 @@ test("finding 3 easy branch: a fresh click beats an evicted high-rev entry at an // here as the hard branch (not disguised as safety). test("finding 3 hard branch: equal-second evicted click (rev 1) loses to observed remote (rev 100)", () => { const NOW = 777; - // Remount click on the evicted channel: empty high-water → rev 1, updatedAt=NOW. - const click = S(E(true, NOW, 1)); - // The previously observed remote for the same channel at the same second, - // rev 100 (it may precede the remount — not genuinely concurrent). - const remote = S(E(false, NOW, 100)); - const merged = mergeStores(click, remote); + const TARGET = "aaa-target"; // lexicographically small → evicted by the id tiebreak + + // >500 entries all at the CURRENT second (NOW). With MAX+1 equal-updatedAt + // entries, boundMuteStore sorts ascending by (updatedAt, id) and keeps the + // highest MAX, so the lowest id is evicted — TARGET, NOT because it is old. + const channels = { [TARGET]: E(true, NOW, 7) }; + for (let i = 0; i < MAX_CHANNEL_MUTE_ENTRIES; i++) { + channels[`z-${String(i).padStart(3, "0")}`] = E(true, NOW, 0); + } + const bounded = boundMuteStore({ version: 1, channels }); + assert.equal( + Object.keys(bounded.channels).length, + MAX_CHANNEL_MUTE_ENTRIES, + "bound trims to the cap", + ); + assert.equal( + bounded.channels[TARGET], + undefined, + "TARGET evicted by the id tiebreak at equal updatedAt", + ); + + // Remount in the same second: TARGET's rev high-water is gone with the entry, + // so a fresh click mints rev 1 at updatedAt=NOW. + const click = { version: 1, channels: { [TARGET]: E(true, NOW, 1) } }; + // The previously observed remote for TARGET at the same second, rev 100 (it + // may precede the remount — not genuinely concurrent). + const remote = { version: 1, channels: { [TARGET]: E(false, NOW, 100) } }; + + // equal updatedAt → rev decides → 100 > 1: the click LOSES. Documented + // deterministic residual, proven here through real eviction+remount, not a + // pre-shrunk tuple. Deterministic in both merge orders — a lost click, never + // a divergence. assert.deepEqual( - merged.channels.c, + mergeStores(click, remote).channels[TARGET], E(false, NOW, 100), "equal updatedAt → higher rev wins deterministically (documented residual)", ); - // Deterministic either merge order — a lost click, never a divergence. - assert.deepEqual(mergeStores(remote, click).channels.c, E(false, NOW, 100)); + assert.deepEqual( + mergeStores(remote, click).channels[TARGET], + E(false, NOW, 100), + ); }); // ── mutedChannelIdsFromStore ──────────────────────────────────────────────── diff --git a/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs index 8239b9e8eb..bbdf9061d1 100644 --- a/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs @@ -378,19 +378,47 @@ test("finding 3 easy branch: a fresh click beats an evicted high-rev entry at an // here as the hard branch (not disguised as safety). test("finding 3 hard branch: equal-second evicted click (rev 1) loses to observed remote (rev 100)", () => { const NOW = 777; - // Remount click on the evicted channel: empty high-water → rev 1, updatedAt=NOW. - const click = S(E(true, NOW, 1)); - // The previously observed remote for the same channel at the same second, - // rev 100 (it may precede the remount — not genuinely concurrent). - const remote = S(E(false, NOW, 100)); - const merged = mergeStores(click, remote); + const TARGET = "aaa-target"; // lexicographically small → evicted by the id tiebreak + + // >500 entries all at the CURRENT second (NOW). With MAX+1 equal-updatedAt + // entries, boundStarStore sorts ascending by (updatedAt, id) and keeps the + // highest MAX, so the lowest id is evicted — TARGET, NOT because it is old. + const channels = { [TARGET]: E(true, NOW, 7) }; + for (let i = 0; i < MAX_CHANNEL_STAR_ENTRIES; i++) { + channels[`z-${String(i).padStart(3, "0")}`] = E(true, NOW, 0); + } + const bounded = boundStarStore({ version: 1, channels }); + assert.equal( + Object.keys(bounded.channels).length, + MAX_CHANNEL_STAR_ENTRIES, + "bound trims to the cap", + ); + assert.equal( + bounded.channels[TARGET], + undefined, + "TARGET evicted by the id tiebreak at equal updatedAt", + ); + + // Remount in the same second: TARGET's rev high-water is gone with the entry, + // so a fresh click mints rev 1 at updatedAt=NOW. + const click = { version: 1, channels: { [TARGET]: E(true, NOW, 1) } }; + // The previously observed remote for TARGET at the same second, rev 100 (it + // may precede the remount — not genuinely concurrent). + const remote = { version: 1, channels: { [TARGET]: E(false, NOW, 100) } }; + + // equal updatedAt → rev decides → 100 > 1: the click LOSES. Documented + // deterministic residual, proven here through real eviction+remount, not a + // pre-shrunk tuple. Deterministic in both merge orders — a lost click, never + // a divergence. assert.deepEqual( - merged.channels.c, + mergeStores(click, remote).channels[TARGET], E(false, NOW, 100), "equal updatedAt → higher rev wins deterministically (documented residual)", ); - // Deterministic either merge order — a lost click, never a divergence. - assert.deepEqual(mergeStores(remote, click).channels.c, E(false, NOW, 100)); + assert.deepEqual( + mergeStores(remote, click).channels[TARGET], + E(false, NOW, 100), + ); }); // ── starredChannelIdsFromStore ──────────────────────────────────────────────── diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.test.mjs b/desktop/src/features/sidebar/lib/useChannelMutes.test.mjs index c06466a8f7..b54dd0c274 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelMutes.test.mjs @@ -327,6 +327,69 @@ test("empty-store click survives a later higher-rev head with an older updatedAt } }); +// Unobserved-future residual (accepted mixed-fleet case, MINOR-2 contrast to the +// fast-clock fix): a click mints at wall-clock t with an empty high-water; a +// genuinely UNOBSERVED opposite-value head then arrives at t+300 and wins on the +// primary updatedAt key. The logical-monotonic stamp only defends against state +// the replica already observed — a future head it never saw before clicking is +// not covered, exactly as under today's shipped LWW. +test("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 { useChannelMutes } = await import("./useChannelMutes.ts"); + + const live = {}; + const restore = stubRelay(relayClient, { live }); + const origTauri = window.__TAURI_INTERNALS__; + const origDateNow = Date.now; + Date.now = () => 1000 * 1_000; // click at updatedAt 1000 (t) + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + // fetchEvents is stubbed empty, so bootstrap/pre-publish never decrypt; + // the only decrypt is the live head — the genuinely unobserved future + // entry at updatedAt 1300, delivered after the empty-high-water click. + if (cmd === "nip44_decrypt_from_self") + return Promise.resolve( + mutePayload({ shared: { muted: false, updatedAt: 1300, rev: 1 } }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + const pubkey = "pk-unobserved-future"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelMutes(pubkey, "wss://r")); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + await act(async () => hook.result.current.muteChannel("shared")); // empty store → rev 1 @ 1000 + await act(async () => { + // Genuinely unobserved head at t+300 (updatedAt 1300), opposite value. + live.cb({ + id: "unobserved-future", + pubkey, + created_at: 1300, + content: "cipher", + kind: 30078, + tags: [["d", "channel-mutes"]], + sig: "s", + }); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + assert.equal( + hook.result.current.mutedChannelIds.has("shared"), + false, + "unobserved future head wins on primary updatedAt (accepted residual)", + ); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + window.__TAURI_INTERNALS__ = origTauri; + restore(); + } +}); + // Cross-window storage: a peer window's write is observed into the high-water // and max-merged, so a following click sees the peer's rev and no edit is lost. test("cross-window storage event is observed and max-merged", async () => { diff --git a/desktop/src/features/sidebar/lib/useChannelStars.test.mjs b/desktop/src/features/sidebar/lib/useChannelStars.test.mjs index 4317279ef5..579434473c 100644 --- a/desktop/src/features/sidebar/lib/useChannelStars.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelStars.test.mjs @@ -327,6 +327,68 @@ test("empty-store click survives a later higher-rev head with an older updatedAt } }); +// Unobserved-future residual (accepted mixed-fleet case, MINOR-2 contrast to the +// fast-clock fix): a click mints at wall-clock t with an empty high-water; a +// genuinely UNOBSERVED opposite-value head then arrives at t+300 and wins on the +// primary updatedAt key. The logical-monotonic stamp only defends against state +// the replica already observed — a future head it never saw before clicking is +// not covered, exactly as under today's shipped LWW. +test("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 { useChannelStars } = await import("./useChannelStars.ts"); + + const live = {}; + const restore = stubRelay(relayClient, { live }); + const origTauri = window.__TAURI_INTERNALS__; + const origDateNow = Date.now; + Date.now = () => 1000 * 1_000; // click at updatedAt 1000 (t) + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + // fetchEvents is stubbed empty, so bootstrap/pre-publish never decrypt; + // the only decrypt is the live head — the genuinely unobserved future + // entry at updatedAt 1300, delivered after the empty-high-water click. + if (cmd === "nip44_decrypt_from_self") + return Promise.resolve( + starPayload({ shared: { starred: false, updatedAt: 1300, rev: 1 } }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + const pubkey = "pk-unobserved-future"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelStars(pubkey, "wss://r")); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + await act(async () => hook.result.current.starChannel("shared")); // empty store → rev 1 @ 1000 + await act(async () => { + // Genuinely unobserved head at t+300 (updatedAt 1300), opposite value. + live.cb({ + id: "unobserved-future", + pubkey, + created_at: 1300, + content: "cipher", + kind: 30078, + tags: [["d", "channel-stars"]], + sig: "s", + }); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + assert.equal( + hook.result.current.starredChannelIds.has("shared"), + false, + "unobserved future head wins on primary updatedAt (accepted residual)", + ); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + window.__TAURI_INTERNALS__ = origTauri; + restore(); + } +}); // Cross-window storage: a peer window's write is observed into the high-water // and max-merged, so a following click sees the peer's rev and no edit is lost. test("cross-window storage event is observed and max-merged", async () => {