| 1 | // Media, Links & Docs: the pure extractor + in-memory index |
| 2 | // (web/static/threadmedia.js). Node-run: node test/threadmedia.test.mjs |
| 3 | import assert from "node:assert/strict"; |
| 4 | import { extractEntries, bucketByMonth, monthLabel, ThreadMediaIndex, filterEntries } from "../web/static/threadmedia.js"; |
| 5 | |
| 6 | let failures = 0; |
| 7 | async function test(name, fn) { |
| 8 | try { |
| 9 | await fn(); |
| 10 | console.log("PASS " + name); |
| 11 | } catch (e) { |
| 12 | console.log("FAIL " + name + " — " + (e && e.message)); |
| 13 | failures++; |
| 14 | } |
| 15 | } |
| 16 | |
| 17 | const meta = { id: 42, ts: 1754000000000 }; |
| 18 | |
| 19 | await test("classifies image, gif, video as media; other files as docs", () => { |
| 20 | const out = extractEntries({ uid: "u1", name: "Ada", text: "", ts: 1754000000000, attachments: [ |
| 21 | { id: "b1", type: "image/jpeg", w: 100, h: 50 }, |
| 22 | { id: "b2", type: "image/gif" }, |
| 23 | { id: "b3", type: "video/mp4", poster: "p3", dur: 12 }, |
| 24 | { id: "b4", type: "application/pdf", name: "notes.pdf", size: 900 }, |
| 25 | ] }, meta); |
| 26 | assert.equal(out.media.length, 3); |
| 27 | assert.equal(out.docs.length, 1); |
| 28 | assert.equal(out.docs[0].att.name, "notes.pdf"); |
| 29 | assert.equal(out.media[0].sid, 42); |
| 30 | }); |
| 31 | |
| 32 | await test("extracts EVERY link, not just the first", () => { |
| 33 | const out = extractEntries({ uid: "u1", name: "Ada", ts: 1, text: "see https://a.example and https://b.example" }, meta); |
| 34 | assert.deepEqual(out.links.map((l) => l.url), ["https://a.example", "https://b.example"]); |
| 35 | }); |
| 36 | |
| 37 | await test("links inside fenced code blocks don't extract (matches firstLink)", () => { |
| 38 | const out = extractEntries({ uid: "u1", name: "A", ts: 1, text: "```\nhttps://code.example\n```\nhttps://real.example" }, meta); |
| 39 | assert.equal(out.links.length, 1); |
| 40 | assert.equal(out.links[0].url, "https://real.example"); |
| 41 | }); |
| 42 | |
| 43 | await test("app events, reactions, and entry-less messages return null", () => { |
| 44 | assert.equal(extractEntries({ type: "todo.add", text: "https://x.example" }, meta), null); |
| 45 | assert.equal(extractEntries({ uid: "u", name: "A", text: "plain words", ts: 1 }, meta), null); |
| 46 | assert.equal(extractEntries(null, meta), null); |
| 47 | }); |
| 48 | |
| 49 | await test("attachment without a blob id is skipped", () => { |
| 50 | assert.equal(extractEntries({ uid: "u", name: "A", text: "", ts: 1, attachments: [{ type: "image/png" }] }, meta), null); |
| 51 | }); |
| 52 | |
| 53 | await test("bucketByMonth groups consecutive same-month entries, keeps order", () => { |
| 54 | const aug = Date.UTC(2026, 7, 10), jul = Date.UTC(2026, 6, 1); |
| 55 | const buckets = bucketByMonth([ |
| 56 | { sid: 3, ts: aug }, { sid: 2, ts: aug }, { sid: 1, ts: jul }, |
| 57 | ]); |
| 58 | assert.equal(buckets.length, 2); |
| 59 | assert.equal(buckets[0].items.length, 2); |
| 60 | assert.equal(buckets[0].label, monthLabel(aug)); |
| 61 | assert.equal(buckets[1].items[0].sid, 1); |
| 62 | }); |
| 63 | |
| 64 | await test("index folds rows once, sorts newest-first, filters hidden senders at query time", async () => { |
| 65 | const rows = [ |
| 66 | { id: 1, created_at: 100, blob: "x" }, |
| 67 | { id: 2, created_at: 200, blob: "y" }, |
| 68 | { id: 2, created_at: 200, blob: "y" }, // duplicate fold — seen-set dedupes |
| 69 | { id: 3, created_at: 300, blob: "z", tombstone_of: 1 }, // tombstone row — skipped |
| 70 | ]; |
| 71 | const payloads = { |
| 72 | 1: { uid: "muted", name: "M", ts: 100000, text: "", attachments: [{ id: "a", type: "image/png" }] }, |
| 73 | 2: { uid: "ok", name: "O", ts: 200000, text: "", attachments: [{ id: "b", type: "image/png" }] }, |
| 74 | }; |
| 75 | const idx = new ThreadMediaIndex(async (row) => payloads[row.id] || null); |
| 76 | await idx.ensureThread("t1", rows); |
| 77 | await idx.ensureThread("t1", rows); // idempotent |
| 78 | assert.deepEqual(idx.tab("t1", "media", { hiddenUids: new Set() }).map((e) => e.sid), [2, 1]); |
| 79 | assert.deepEqual(idx.tab("t1", "media", { hiddenUids: new Set(["muted"]) }).map((e) => e.sid), [2]); |
| 80 | assert.equal(idx.counts("t1", { hiddenUids: new Set(["muted"]) }).media, 1); |
| 81 | }); |
| 82 | |
| 83 | await test("invalidate drops a message's entries and lets it re-fold (edit path)", async () => { |
| 84 | const payloads = { 5: { uid: "u", name: "A", ts: 1, text: "https://old.example" } }; |
| 85 | const idx = new ThreadMediaIndex(async (row) => payloads[row.id]); |
| 86 | await idx.ensureThread("t1", [{ id: 5, created_at: 1, blob: "x" }]); |
| 87 | assert.equal(idx.tab("t1", "links", { hiddenUids: new Set() })[0].url, "https://old.example"); |
| 88 | payloads[5] = { uid: "u", name: "A", ts: 1, text: "https://new.example" }; |
| 89 | idx.invalidate("t1", 5); |
| 90 | await idx.ensureThread("t1", [{ id: 5, created_at: 1, blob: "x" }]); |
| 91 | assert.deepEqual(idx.tab("t1", "links", { hiddenUids: new Set() }).map((l) => l.url), ["https://new.example"]); |
| 92 | }); |
| 93 | |
| 94 | await test("decrypt failure on one row doesn't poison the rest", async () => { |
| 95 | const idx = new ThreadMediaIndex(async (row) => { |
| 96 | if (row.id === 1) throw new Error("bad key"); |
| 97 | return { uid: "u", name: "A", ts: 1, text: "", attachments: [{ id: "b", type: "image/png" }] }; |
| 98 | }); |
| 99 | await idx.ensureThread("t1", [{ id: 1, created_at: 1, blob: "x" }, { id: 2, created_at: 2, blob: "y" }]); |
| 100 | assert.equal(idx.tab("t1", "media", { hiddenUids: new Set() }).length, 1); |
| 101 | }); |
| 102 | |
| 103 | await test("filterEntries matches sender, URL, fetched unfurl title, and filename — case-insensitively", () => { |
| 104 | const entries = [ |
| 105 | { sid: 1, name: "Ada", url: "https://example.com/Report" }, |
| 106 | { sid: 2, name: "Bob", url: "https://news.example/x" }, |
| 107 | { sid: 3, name: "Cy", att: { id: "d", name: "Budget-2026.xlsx" } }, |
| 108 | { sid: 4, name: "Ada", att: { id: "p", type: "image/png" } }, |
| 109 | ]; |
| 110 | const titles = { "https://news.example/x": "Rain expected Friday" }; |
| 111 | const titleFor = (u) => titles[u] || ""; |
| 112 | const ids = (q) => filterEntries(entries, q, titleFor).map((e) => e.sid); |
| 113 | assert.deepEqual(ids(""), [1, 2, 3, 4], "blank query = everything"); |
| 114 | assert.deepEqual(ids(" "), [1, 2, 3, 4]); |
| 115 | assert.deepEqual(ids("ada"), [1, 4], "sender name, any tab"); |
| 116 | assert.deepEqual(ids("REPORT"), [1], "URL substring, case-insensitive"); |
| 117 | assert.deepEqual(ids("friday"), [2], "an already-fetched unfurl title"); |
| 118 | assert.deepEqual(ids("budget"), [3], "doc filename"); |
| 119 | assert.deepEqual(ids("zzz"), []); |
| 120 | assert.deepEqual(filterEntries(entries, "friday").map((e) => e.sid), [], "no titleFor → titles don't match"); |
| 121 | }); |
| 122 | |
| 123 | process.exit(failures ? 1 : 0); |
| 124 | |