| 1 | // Pure-logic tests for the local message store (web/static/store.js, #457). |
| 2 | // The IndexedDB plumbing can't run in Node; these pin the decision logic the |
| 3 | // plumbing and shell.js rely on: when a snapshot is trusted, how tombstones in |
| 4 | // a fetched page translate to store writes, and which cached threads must be |
| 5 | // pruned against a live thread list. |
| 6 | import assert from "assert"; |
| 7 | import { SNAP_VERSION, snapshotUsable, partitionRows, staleThreadIds, backfillCursor, runBackfill } from "../web/static/store.js"; |
| 8 | |
| 9 | function test(name, fn) { |
| 10 | try { |
| 11 | fn(); |
| 12 | console.log(`PASS ${name}`); |
| 13 | } catch (e) { |
| 14 | console.error(`FAIL ${name}`); |
| 15 | console.error(e); |
| 16 | process.exit(1); |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | const goodSnap = () => ({ |
| 21 | v: SNAP_VERSION, |
| 22 | ts: 1, |
| 23 | threads: { threads: [{ id: "t1" }] }, |
| 24 | roster: { roster: [{ user_id: "u1" }] }, |
| 25 | }); |
| 26 | |
| 27 | test("snapshotUsable accepts a well-formed snapshot (optional parts absent)", () => { |
| 28 | assert.strictEqual(snapshotUsable(goodSnap()), true); |
| 29 | }); |
| 30 | |
| 31 | test("snapshotUsable rejects null, wrong version, and missing core responses", () => { |
| 32 | assert.strictEqual(snapshotUsable(null), false); |
| 33 | assert.strictEqual(snapshotUsable({}), false); |
| 34 | assert.strictEqual(snapshotUsable({ ...goodSnap(), v: SNAP_VERSION + 1 }), false, "future format must not hydrate"); |
| 35 | assert.strictEqual(snapshotUsable({ ...goodSnap(), threads: null }), false); |
| 36 | assert.strictEqual(snapshotUsable({ ...goodSnap(), threads: { threads: "nope" } }), false); |
| 37 | assert.strictEqual(snapshotUsable({ ...goodSnap(), roster: {} }), false); |
| 38 | }); |
| 39 | |
| 40 | test("snapshotUsable accepts empty-but-present lists (a brand-new space)", () => { |
| 41 | assert.strictEqual(snapshotUsable({ ...goodSnap(), threads: { threads: [] }, roster: { roster: [] } }), true); |
| 42 | }); |
| 43 | |
| 44 | test("partitionRows keeps every row and lists tombstoned targets to drop", () => { |
| 45 | const rows = [ |
| 46 | { id: 10, blob: "aaa" }, |
| 47 | { id: 11, blob: "", tombstone_of: 7 }, |
| 48 | { id: 12, blob: "bbb" }, |
| 49 | ]; |
| 50 | const { puts, unsend } = partitionRows(rows); |
| 51 | assert.strictEqual(puts.length, 3, "tombstone rows are stored too — they keep the delete durable locally"); |
| 52 | assert.deepStrictEqual(unsend, [7], "the unsent message's ciphertext must drop"); |
| 53 | }); |
| 54 | |
| 55 | test("partitionRows tolerates junk: null rows, id-less rows, empty input", () => { |
| 56 | assert.deepStrictEqual(partitionRows(null), { puts: [], unsend: [] }); |
| 57 | const { puts, unsend } = partitionRows([null, {}, { id: 0 }, { id: 5, blob: "x" }]); |
| 58 | assert.strictEqual(puts.length, 1); |
| 59 | assert.deepStrictEqual(unsend, []); |
| 60 | }); |
| 61 | |
| 62 | test("staleThreadIds prunes cached threads missing from the live list", () => { |
| 63 | const live = { threads: [{ id: "a" }, { id: "b" }] }; |
| 64 | assert.deepStrictEqual(staleThreadIds(["a", "b", "gone"], live), ["gone"]); |
| 65 | assert.deepStrictEqual(staleThreadIds(["a", "b"], live), []); |
| 66 | }); |
| 67 | |
| 68 | test("staleThreadIds never prunes on a malformed live list (an error body must not wipe the cache)", () => { |
| 69 | assert.deepStrictEqual(staleThreadIds(["a", "b"], null), []); |
| 70 | assert.deepStrictEqual(staleThreadIds(["a", "b"], {}), []); |
| 71 | assert.deepStrictEqual(staleThreadIds(["a", "b"], { error: "boom" }), []); |
| 72 | // An EMPTY live list is well-formed — a member with no threads keeps no rows. |
| 73 | assert.deepStrictEqual(staleThreadIds(["a"], { threads: [] }), ["a"]); |
| 74 | }); |
| 75 | |
| 76 | test("backfillCursor: resumes from the persisted watermark, not from scratch", () => { |
| 77 | assert.strictEqual(backfillCursor({ oldest: 42, complete: false }, 900), 42, "interrupted walk continues at its mark"); |
| 78 | assert.strictEqual(backfillCursor(null, 900), 900, "first walk starts at the oldest stored row"); |
| 79 | assert.strictEqual(backfillCursor(null, 0), 0, "empty store → seed a recent page first"); |
| 80 | }); |
| 81 | |
| 82 | test("backfillCursor: a completed thread is never re-walked", () => { |
| 83 | assert.strictEqual(backfillCursor({ oldest: 1, complete: true }, 900), -1); |
| 84 | assert.strictEqual(backfillCursor({ oldest: 0, complete: true }, 0), -1, "empty-thread completion holds too"); |
| 85 | }); |
| 86 | |
| 87 | // runBackfill is async but runs here: in Node the IDB plumbing degrades to its |
| 88 | // best-effort fallbacks (no mark, no stored rows, writes no-op), which is |
| 89 | // exactly the fresh-walk shape the stall guard needs. |
| 90 | await (async () => { |
| 91 | const name = "runBackfill: a page that fails to advance the cursor stops the walk (never spins)"; |
| 92 | try { |
| 93 | // A buggy server ignores `before` and replays the same non-empty page |
| 94 | // forever. The walk's only termination used to be an empty page — this |
| 95 | // would loop for good; the monotonic-cursor guard must bail instead. |
| 96 | let calls = 0; |
| 97 | const page = [{ id: 500, created_at: 1, blob: "x" }, { id: 501, created_at: 2, blob: "y" }]; |
| 98 | const r = await runBackfill("t1", async () => { |
| 99 | calls++; |
| 100 | assert.ok(calls < 10, "walk did not stop on a non-advancing page"); |
| 101 | return page; |
| 102 | }); |
| 103 | assert.deepStrictEqual(r, { complete: false }, "a stalled walk reports incomplete, not complete"); |
| 104 | assert.strictEqual(calls, 2, "one seed page + one non-advancing page, then stop"); |
| 105 | console.log(`PASS ${name}`); |
| 106 | } catch (e) { |
| 107 | console.error(`FAIL ${name}`); |
| 108 | console.error(e); |
| 109 | process.exit(1); |
| 110 | } |
| 111 | })(); |
| 112 | |
| 113 | console.log("\nALL PASSED"); |
| 114 | |