| 1 | // Tests for web/static/search.js (#586) — the pure matchers and the |
| 2 | // SearchIndex with an injected decrypt, so everything runs in Node. |
| 3 | import assert from "assert"; |
| 4 | import { matchText, snippetFor, SearchIndex } from "../web/static/search.js"; |
| 5 | |
| 6 | function test(name, fn) { |
| 7 | return Promise.resolve() |
| 8 | .then(fn) |
| 9 | .then(() => console.log(`PASS ${name}`)) |
| 10 | .catch((e) => { |
| 11 | console.error(`FAIL ${name}`); |
| 12 | console.error(e); |
| 13 | process.exit(1); |
| 14 | }); |
| 15 | } |
| 16 | |
| 17 | await test("matchText: case-insensitive AND across terms", () => { |
| 18 | assert.ok(matchText("We ship on Friday", "ship friday")); |
| 19 | assert.ok(matchText("We ship on Friday", "SHIP")); |
| 20 | assert.ok(!matchText("We ship on Friday", "ship monday")); |
| 21 | assert.ok(!matchText("", "x")); |
| 22 | assert.ok(!matchText("hello", "")); |
| 23 | assert.ok(!matchText("hello", " "), "whitespace-only query matches nothing"); |
| 24 | }); |
| 25 | |
| 26 | await test("matchText: unicode text matches plainly", () => { |
| 27 | assert.ok(matchText("Cámara está lista 🎉", "cámara lista")); |
| 28 | }); |
| 29 | |
| 30 | await test("snippetFor: short text passes through; long text windows around the hit", () => { |
| 31 | assert.strictEqual(snippetFor("short one", "one"), "short one"); |
| 32 | const long = "aaa ".repeat(40) + "NEEDLE in the middle " + "bbb ".repeat(40); |
| 33 | const snip = snippetFor(long, "needle", 60); |
| 34 | assert.ok(snip.includes("NEEDLE"), "hit stays in the window"); |
| 35 | assert.ok(snip.length <= 62, "bounded (+ellipses)"); |
| 36 | assert.ok(snip.startsWith("…") && snip.endsWith("…"), "cut edges ellipsised"); |
| 37 | }); |
| 38 | |
| 39 | const fakeDecrypt = async (row) => { |
| 40 | // Rows carry their "plaintext" in-blob for the test: "name|text" or "SKIP". |
| 41 | if (row.blob === "SKIP") return null; |
| 42 | const [name, text] = row.blob.split("|"); |
| 43 | return { name, text, ts: row.created_at * 1000 }; |
| 44 | }; |
| 45 | |
| 46 | await test("SearchIndex: incremental ensure — re-ensuring decrypts each row once", async () => { |
| 47 | let decrypts = 0; |
| 48 | const idx = new SearchIndex(async (r) => { |
| 49 | decrypts++; |
| 50 | return fakeDecrypt(r); |
| 51 | }); |
| 52 | await idx.ensureThread("t1", [ |
| 53 | { id: 1, created_at: 10, blob: "Ana|the launch plan" }, |
| 54 | { id: 2, created_at: 11, blob: "Bo|lunch at noon" }, |
| 55 | ]); |
| 56 | assert.strictEqual(decrypts, 2); |
| 57 | await idx.ensureThread("t1", [ |
| 58 | { id: 1, created_at: 10, blob: "Ana|the launch plan" }, |
| 59 | { id: 2, created_at: 11, blob: "Bo|lunch at noon" }, |
| 60 | { id: 3, created_at: 12, blob: "Ana|launch is a go" }, |
| 61 | ]); |
| 62 | assert.strictEqual(decrypts, 3, "already-seen rows skipped"); |
| 63 | const hits = idx.query("launch"); |
| 64 | assert.strictEqual(hits.length, 2); |
| 65 | assert.strictEqual(hits[0].id, 3, "newest first"); |
| 66 | }); |
| 67 | |
| 68 | await test("SearchIndex: OLDER rows arriving later (the backfill pattern) still index", async () => { |
| 69 | // The recent window indexes first; a backfill walk then delivers strictly |
| 70 | // older pages. A high-water mark would silently drop them — the regression |
| 71 | // this test pins (found live by the #586 E2E drive). |
| 72 | const idx = new SearchIndex(fakeDecrypt); |
| 73 | await idx.ensureThread("t1", [{ id: 100, created_at: 100, blob: "Ana|recent chatter" }]); |
| 74 | await idx.ensureThread("t1", [ |
| 75 | { id: 5, created_at: 5, blob: "Bo|the ancient treasure" }, |
| 76 | { id: 6, created_at: 6, blob: "Ana|more history" }, |
| 77 | ]); |
| 78 | const hits = idx.query("ancient treasure"); |
| 79 | assert.strictEqual(hits.length, 1, "backfilled row is searchable"); |
| 80 | assert.strictEqual(hits[0].id, 5); |
| 81 | }); |
| 82 | |
| 83 | await test("SearchIndex: tombstone rows evict their target from the index", async () => { |
| 84 | const idx = new SearchIndex(fakeDecrypt); |
| 85 | await idx.ensureThread("t1", [ |
| 86 | { id: 1, created_at: 10, blob: "Ana|delete me later" }, |
| 87 | { id: 2, created_at: 11, blob: "Bo|keep me" }, |
| 88 | ]); |
| 89 | assert.strictEqual(idx.query("delete me").length, 1); |
| 90 | await idx.ensureThread("t1", [{ id: 3, created_at: 12, blob: "", tombstone_of: 1 }]); |
| 91 | assert.strictEqual(idx.query("delete me").length, 0, "unsent message stops matching"); |
| 92 | assert.strictEqual(idx.query("keep me").length, 1); |
| 93 | }); |
| 94 | |
| 95 | await test("SearchIndex: undecryptable rows skipped; drops work; cross-thread query + limit", async () => { |
| 96 | const idx = new SearchIndex(fakeDecrypt); |
| 97 | await idx.ensureThread("a", [ |
| 98 | { id: 5, created_at: 1, blob: "Ana|target in a" }, |
| 99 | { id: 6, created_at: 2, blob: "SKIP" }, |
| 100 | ]); |
| 101 | await idx.ensureThread("b", [{ id: 9, created_at: 3, blob: "Bo|target in b" }]); |
| 102 | let hits = idx.query("target"); |
| 103 | assert.deepStrictEqual(hits.map((h) => h.tid), ["b", "a"], "newest id first across threads"); |
| 104 | idx.dropMessage("b", 9); |
| 105 | assert.strictEqual(idx.query("target").length, 1, "live delete evicts"); |
| 106 | idx.dropThread("a"); |
| 107 | assert.strictEqual(idx.query("target").length, 0, "leaving a thread evicts it"); |
| 108 | // limit honoured |
| 109 | const many = new SearchIndex(fakeDecrypt); |
| 110 | await many.ensureThread("m", Array.from({ length: 60 }, (_, i) => ({ id: i + 1, created_at: i, blob: `N|hit ${i}` }))); |
| 111 | assert.strictEqual(many.query("hit", 50).length, 50); |
| 112 | }); |
| 113 | |
| 114 | console.log("\nALL PASSED"); |
| 115 | |