Files
recap/server/test/daily-digest.test.js
T
Keysat b4fa5d7be8 Add opt-in Daily Digest (daily email of last 24h of library recaps)
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.
2026-06-15 19:50:48 -05:00

249 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, /12 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/);
});
});