b4fa5d7be8
Multi-mode, off by default. Each new recap is synthesized into a 1-2 paragraph overview via the relay (operator-absorbed) and cached onto the session JSON; a daily 08:00 scan emails opted-in users their fresh recaps, deduped by a per-user watermark that never skips a failed or over-cap recap. One-click tokenized unsubscribe; settings-modal toggle; admin test trigger. Bumps to 0.2.158.
249 lines
8.3 KiB
JavaScript
249 lines
8.3 KiB
JavaScript
// 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/);
|
||
});
|
||
});
|