// Pure / injectable-logic tests for Daily Digest episode synthesis. The // relay round-trip and FS cache write-back aren't exercised here (a fake // provider stands in, save:false skips disk); this nails prompt shaping, // the operator-string scrub backstop, and the get-or-generate cache gate. import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { buildOverviewPrompt, scrubOperatorStrings, synthesizeEpisodeOverview, getOrCreateEpisodeOverview, selectDigestEpisodes, scopeForUser, nextDigestWatermark, } from "../daily-digest.js"; import { renderDigestEmail } from "../email-template.js"; const record = (over = {}) => ({ id: "1700000000000-abc", title: "How Markets Work", type: "podcast", chunks: [ { title: "Supply & demand", summary: "Prices clear where the two curves meet." }, { title: "Information", summary: "Asymmetry distorts outcomes." }, ], ...over, }); // A provider stub recording calls, returning a fixed analyze result. const fakeProvider = (text, sink = {}) => ({ async analyzeText(args) { sink.calls = (sink.calls || 0) + 1; sink.lastArgs = args; return { text }; }, }); describe("buildOverviewPrompt", () => { test("includes title, type, and each topic's title + summary", () => { const p = buildOverviewPrompt(record()); assert.match(p, /"How Markets Work"/); assert.match(p, /podcast episode/); assert.match(p, /- Supply & demand: Prices clear/); assert.match(p, /- Information: Asymmetry distorts/); assert.match(p, /1–2 paragraph/); }); test("a topic with no summary still contributes its title", () => { const p = buildOverviewPrompt( record({ chunks: [{ title: "Loose ends" }] }), ); assert.match(p, /- Loose ends/); assert.doesNotMatch(p, /Loose ends:/); // no trailing colon when summary absent }); test("null-safe: missing fields fall back", () => { const p = buildOverviewPrompt({}); assert.match(p, /"Untitled"/); assert.match(p, /recording/); // unknown type }); }); describe("scrubOperatorStrings", () => { test("removes operator/infra tokens", () => { const out = scrubOperatorStrings( "Routed via Spark Control and the vLLM box on Parakeet.", ); assert.doesNotMatch(out, /spark control/i); assert.doesNotMatch(out, /vllm/i); assert.doesNotMatch(out, /parakeet/i); }); test("strips LAN hosts and private IPs, keeps public/content data", () => { assert.doesNotMatch( scrubOperatorStrings("see http://immense-voyage.local/admin"), /\.local/, ); assert.doesNotMatch(scrubOperatorStrings("host 192.168.1.42 here"), /192\.168/); // A public dotted quad in content is the user's data, not a leak. assert.match(scrubOperatorStrings("DNS is 8.8.8.8"), /8\.8\.8\.8/); }); test("leaves ordinary prose intact", () => { const clean = "The episode covers supply, demand, and information costs."; assert.equal(scrubOperatorStrings(clean), clean); }); test("null-safe", () => { assert.equal(scrubOperatorStrings(null), ""); assert.equal(scrubOperatorStrings(""), ""); }); }); describe("synthesizeEpisodeOverview", () => { test("scrubs the model result and passes a stable per-episode jobId", async () => { const sink = {}; const out = await synthesizeEpisodeOverview(record(), { provider: fakeProvider("A clear overview from Spark Control.", sink), }); assert.doesNotMatch(out, /spark control/i); assert.equal(sink.calls, 1); assert.equal(sink.lastArgs.jobId, "digest-1700000000000-abc"); }); test("throws when there are no topics", async () => { await assert.rejects( () => synthesizeEpisodeOverview(record({ chunks: [] }), { provider: fakeProvider("x") }), /no topic summaries/, ); }); test("throws when the model returns nothing usable", async () => { await assert.rejects( () => synthesizeEpisodeOverview(record(), { provider: fakeProvider(" ") }), /empty synthesis result/, ); }); }); describe("getOrCreateEpisodeOverview", () => { test("cache hit returns stored overview without calling the provider", async () => { const sink = {}; const res = await getOrCreateEpisodeOverview({ record: record({ digestOverview: "Already done." }), provider: fakeProvider("fresh", sink), save: false, }); assert.equal(res.cached, true); assert.equal(res.overview, "Already done."); assert.equal(sink.calls || 0, 0); }); test("cache miss synthesizes (save:false skips disk)", async () => { const sink = {}; const res = await getOrCreateEpisodeOverview({ record: record(), provider: fakeProvider("A fresh overview.", sink), save: false, }); assert.equal(res.cached, false); assert.equal(res.overview, "A fresh overview."); assert.equal(sink.calls, 1); }); }); describe("selectDigestEpisodes", () => { const at = (iso, id) => ({ id, title: id, type: "youtube", url: "", createdAt: iso }); const sessions = [ at("2026-06-14T00:00:00.000Z", "old"), at("2026-06-15T09:00:00.000Z", "a"), at("2026-06-15T10:00:00.000Z", "b"), ]; const watermark = new Date("2026-06-15T08:00:00.000Z").getTime(); test("keeps only recaps created after the watermark, oldest first", () => { const { episodes, total, overflow } = selectDigestEpisodes(sessions, watermark); assert.deepEqual(episodes.map((e) => e.id), ["a", "b"]); assert.equal(total, 2); assert.equal(overflow, 0); }); test("caps the list and reports the overflow", () => { const many = Array.from({ length: 13 }, (_, i) => at(`2026-06-15T1${i % 10}:00:00.000Z`, `e${i}`), ); const { episodes, overflow, total } = selectDigestEpisodes(many, 0, 10); assert.equal(episodes.length, 10); assert.equal(overflow, 3); assert.equal(total, 13); }); test("empty / malformed-date inputs", () => { assert.deepEqual(selectDigestEpisodes([], watermark).episodes, []); assert.deepEqual(selectDigestEpisodes(null, watermark).episodes, []); assert.deepEqual( selectDigestEpisodes([at("not-a-date", "x")], 0).episodes, [], ); }); test("null watermark treats everything as fresh", () => { assert.equal(selectDigestEpisodes(sessions, null).total, 3); }); }); describe("nextDigestWatermark", () => { const t = (iso) => new Date(iso).getTime(); const e1 = "2026-06-15T09:00:00.000Z"; const e2 = "2026-06-15T10:00:00.000Z"; const e3 = "2026-06-15T11:00:00.000Z"; test("all sent → newest sent createdAt", () => { assert.equal(nextDigestWatermark([e1, e2, e3], []), t(e3)); }); test("never advances past the oldest failure (so it's retried)", () => { // sent e1 & e3, e2 failed → watermark just before e2, NOT now/e3. assert.equal(nextDigestWatermark([e1, e3], [e2]), t(e2) - 1); }); test("failures newer than everything sent don't pull the watermark back", () => { assert.equal(nextDigestWatermark([e1, e2], [e3]), t(e2)); }); test("nothing sent → null (caller must not advance)", () => { assert.equal(nextDigestWatermark([], [e1]), null); assert.equal(nextDigestWatermark(null, null), null); }); }); describe("scopeForUser", () => { test("admin keeps the owner scope; everyone else is their id", () => { assert.equal(scopeForUser({ id: "u1", is_admin: 1 }), "owner"); assert.equal(scopeForUser({ id: "u1", is_admin: 0 }), "u1"); }); }); describe("renderDigestEmail", () => { const episodes = [ { title: "First", type: "podcast", url: "https://x/1", overview: "Ov one." }, { title: "Second", type: "youtube", url: "", overview: "Ov two." }, ]; test("subject reflects count; body carries overviews + unsubscribe link", () => { const m = renderDigestEmail({ episodes, overflowCount: 3, manageUrl: "https://recaps.cc/", unsubscribeUrl: "https://recaps.cc/api/digest/unsubscribe?token=tok", }); assert.match(m.subject, /2 new recaps/); assert.match(m.html, /Ov one\./); assert.match(m.html, /unsubscribe\?token=tok/); assert.match(m.html, /3 more/); assert.match(m.text, /Unsubscribe: https:\/\/recaps\.cc/); }); test("singular subject for one recap", () => { const m = renderDigestEmail({ episodes: [episodes[0]], manageUrl: "https://recaps.cc/", unsubscribeUrl: "https://recaps.cc/u", }); assert.match(m.subject, /1 new recap\b/); }); });