// Tests for web/static/search.js (#586) — the pure matchers and the // SearchIndex with an injected decrypt, so everything runs in Node. import assert from "assert"; import { matchText, snippetFor, SearchIndex } from "../web/static/search.js"; function test(name, fn) { return Promise.resolve() .then(fn) .then(() => console.log(`PASS ${name}`)) .catch((e) => { console.error(`FAIL ${name}`); console.error(e); process.exit(1); }); } await test("matchText: case-insensitive AND across terms", () => { assert.ok(matchText("We ship on Friday", "ship friday")); assert.ok(matchText("We ship on Friday", "SHIP")); assert.ok(!matchText("We ship on Friday", "ship monday")); assert.ok(!matchText("", "x")); assert.ok(!matchText("hello", "")); assert.ok(!matchText("hello", " "), "whitespace-only query matches nothing"); }); await test("matchText: unicode text matches plainly", () => { assert.ok(matchText("Cámara está lista 🎉", "cámara lista")); }); await test("snippetFor: short text passes through; long text windows around the hit", () => { assert.strictEqual(snippetFor("short one", "one"), "short one"); const long = "aaa ".repeat(40) + "NEEDLE in the middle " + "bbb ".repeat(40); const snip = snippetFor(long, "needle", 60); assert.ok(snip.includes("NEEDLE"), "hit stays in the window"); assert.ok(snip.length <= 62, "bounded (+ellipses)"); assert.ok(snip.startsWith("…") && snip.endsWith("…"), "cut edges ellipsised"); }); const fakeDecrypt = async (row) => { // Rows carry their "plaintext" in-blob for the test: "name|text" or "SKIP". if (row.blob === "SKIP") return null; const [name, text] = row.blob.split("|"); return { name, text, ts: row.created_at * 1000 }; }; await test("SearchIndex: incremental ensure — re-ensuring decrypts each row once", async () => { let decrypts = 0; const idx = new SearchIndex(async (r) => { decrypts++; return fakeDecrypt(r); }); await idx.ensureThread("t1", [ { id: 1, created_at: 10, blob: "Ana|the launch plan" }, { id: 2, created_at: 11, blob: "Bo|lunch at noon" }, ]); assert.strictEqual(decrypts, 2); await idx.ensureThread("t1", [ { id: 1, created_at: 10, blob: "Ana|the launch plan" }, { id: 2, created_at: 11, blob: "Bo|lunch at noon" }, { id: 3, created_at: 12, blob: "Ana|launch is a go" }, ]); assert.strictEqual(decrypts, 3, "already-seen rows skipped"); const hits = idx.query("launch"); assert.strictEqual(hits.length, 2); assert.strictEqual(hits[0].id, 3, "newest first"); }); await test("SearchIndex: OLDER rows arriving later (the backfill pattern) still index", async () => { // The recent window indexes first; a backfill walk then delivers strictly // older pages. A high-water mark would silently drop them — the regression // this test pins (found live by the #586 E2E drive). const idx = new SearchIndex(fakeDecrypt); await idx.ensureThread("t1", [{ id: 100, created_at: 100, blob: "Ana|recent chatter" }]); await idx.ensureThread("t1", [ { id: 5, created_at: 5, blob: "Bo|the ancient treasure" }, { id: 6, created_at: 6, blob: "Ana|more history" }, ]); const hits = idx.query("ancient treasure"); assert.strictEqual(hits.length, 1, "backfilled row is searchable"); assert.strictEqual(hits[0].id, 5); }); await test("SearchIndex: tombstone rows evict their target from the index", async () => { const idx = new SearchIndex(fakeDecrypt); await idx.ensureThread("t1", [ { id: 1, created_at: 10, blob: "Ana|delete me later" }, { id: 2, created_at: 11, blob: "Bo|keep me" }, ]); assert.strictEqual(idx.query("delete me").length, 1); await idx.ensureThread("t1", [{ id: 3, created_at: 12, blob: "", tombstone_of: 1 }]); assert.strictEqual(idx.query("delete me").length, 0, "unsent message stops matching"); assert.strictEqual(idx.query("keep me").length, 1); }); await test("SearchIndex: undecryptable rows skipped; drops work; cross-thread query + limit", async () => { const idx = new SearchIndex(fakeDecrypt); await idx.ensureThread("a", [ { id: 5, created_at: 1, blob: "Ana|target in a" }, { id: 6, created_at: 2, blob: "SKIP" }, ]); await idx.ensureThread("b", [{ id: 9, created_at: 3, blob: "Bo|target in b" }]); let hits = idx.query("target"); assert.deepStrictEqual(hits.map((h) => h.tid), ["b", "a"], "newest id first across threads"); idx.dropMessage("b", 9); assert.strictEqual(idx.query("target").length, 1, "live delete evicts"); idx.dropThread("a"); assert.strictEqual(idx.query("target").length, 0, "leaving a thread evicts it"); // limit honoured const many = new SearchIndex(fakeDecrypt); await many.ensureThread("m", Array.from({ length: 60 }, (_, i) => ({ id: i + 1, created_at: i, blob: `N|hit ${i}` }))); assert.strictEqual(many.query("hit", 50).length, 50); }); console.log("\nALL PASSED");