Add unit tests for util / gemini-helpers / license / history modules

69 tests across 16 suites, ~120 ms total. Uses node:test (built into
Node 20+) — no new dependency, no Docker rebuild churn. Run with:

  cd server && npm test

Coverage:
  • util.js               extractVideoId, formatTime,
                          parseTimestampedTranscript, safeText,
                          retryGemini (incl. 503 retry, network-error
                          retry, non-retryable passthrough), sendEvent
  • gemini-helpers.js     PRICING table integrity, calcCost (model-
                          specific rates, default fallback, missing
                          fields, sub-cent ¢ formatting, totalTokens
                          precedence), buildAnalysisPrompt
  • license.js            checkLicense (no key, malformed, fallback to
                          startos-config.json, license.txt priority),
                          activate (bad-format throw, file write),
                          deactivate (file removal, idempotent),
                          publicView (no raw key leak, sorted
                          entitlements, ISO dates), has()
  • history.js            initHistory + getHistoryDir, saveToHistory
                          (id shape, defaults, podcast guid encoding),
                          loadMeta + saveMeta round-trip, corrupt-file
                          tolerance

Tests that need module-private file paths (license, history) use a
mkdtemp'd tmp dir as DATA_DIR + dynamic import() so each suite starts
clean. No test mocks the filesystem — they read/write real files
inside the tmp dir, matching production behavior.

Deliberately not yet covered (need an Express app harness or external
binaries): license-middleware (gate behavior), config (live-reload
poll), audio (ffmpeg/ffprobe), ytdlp (yt-dlp + git), cookies (state
mutation routes), the /api/process pipeline. Worth a follow-up after
the current refactor settles.
This commit is contained in:
Keysat
2026-05-09 10:36:12 -05:00
parent fe07580a12
commit a09ad9c429
5 changed files with 673 additions and 1 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
"dev": "node --watch index.js",
"test": "node --test --test-reporter=spec 'test/**/*.test.js'"
},
"dependencies": {
"@google/genai": "^1.41.0",
+131
View File
@@ -0,0 +1,131 @@
import { test, describe } from "node:test";
import { strict as assert } from "node:assert";
import { PRICING, calcCost, buildAnalysisPrompt } from "../gemini-helpers.js";
describe("PRICING table", () => {
test("includes all current production model slugs", () => {
assert.ok(PRICING["gemini-3-flash-preview"]);
assert.ok(PRICING["gemini-3-pro-preview"]);
assert.ok(PRICING["gemini-3.1-pro-preview"]);
assert.ok(PRICING["gemini-2.5-flash"]);
});
test("has a 'default' fallback row", () => {
assert.ok(PRICING["default"]);
assert.equal(typeof PRICING["default"].input, "number");
});
test("each row has input/output/thinking rates", () => {
for (const [model, rates] of Object.entries(PRICING)) {
assert.equal(typeof rates.input, "number", `${model}.input`);
assert.equal(typeof rates.output, "number", `${model}.output`);
assert.equal(typeof rates.thinking, "number", `${model}.thinking`);
}
});
});
describe("calcCost", () => {
test("uses model-specific rates when known", () => {
const cost = calcCost("gemini-3-flash-preview", {
promptTokenCount: 1_000_000,
candidatesTokenCount: 1_000_000,
thoughtsTokenCount: 0,
});
assert.equal(cost.inputTokens, 1_000_000);
assert.equal(cost.outputTokens, 1_000_000);
// 1M tokens × $0.50 input + 1M × $3.00 output = $3.50
assert.equal(parseFloat(cost.totalCost).toFixed(2), "3.50");
});
test("falls back to 'default' rates for unknown model", () => {
const cost = calcCost("invented-model-x", {
promptTokenCount: 1_000_000,
candidatesTokenCount: 0,
thoughtsTokenCount: 0,
});
// default input rate = $1.00 per 1M
assert.equal(parseFloat(cost.totalCost).toFixed(2), "1.00");
});
test("treats missing usage fields as zero", () => {
const cost = calcCost("gemini-3-flash-preview", {});
assert.equal(cost.inputTokens, 0);
assert.equal(cost.outputTokens, 0);
assert.equal(cost.thinkingTokens, 0);
assert.equal(parseFloat(cost.totalCost), 0);
});
test("formats sub-cent totals as ¢", () => {
const cost = calcCost("gemini-2.5-flash", {
promptTokenCount: 1000,
candidatesTokenCount: 1000,
thoughtsTokenCount: 0,
});
// tiny — display in ¢
assert.match(cost.totalCostDisplay, /¢$/);
});
test("formats >$0.01 totals as $X.XXXX", () => {
const cost = calcCost("gemini-3-pro-preview", {
promptTokenCount: 1_000_000,
candidatesTokenCount: 0,
thoughtsTokenCount: 0,
});
// $2.00
assert.match(cost.totalCostDisplay, /^\$\d+\.\d{4}$/);
});
test("totalTokens prefers usage.totalTokenCount when provided", () => {
const cost = calcCost("gemini-3-flash-preview", {
promptTokenCount: 100,
candidatesTokenCount: 50,
thoughtsTokenCount: 25,
totalTokenCount: 200, // server-reported total may include hidden tokens
});
assert.equal(cost.totalTokens, 200);
});
test("totalTokens falls back to sum when totalTokenCount missing", () => {
const cost = calcCost("gemini-3-flash-preview", {
promptTokenCount: 100,
candidatesTokenCount: 50,
thoughtsTokenCount: 25,
});
assert.equal(cost.totalTokens, 175);
});
});
describe("buildAnalysisPrompt", () => {
test("numbers entries with timestamp + offset", () => {
const entries = [
{ offset: 0, text: "Welcome", duration: 5 },
{ offset: 30, text: "Topic two", duration: 60 },
];
const prompt = buildAnalysisPrompt(entries);
assert.match(prompt, /\[0\] \(0:00\) Welcome/);
assert.match(prompt, /\[1\] \(0:30\) Topic two/);
});
test("substitutes the last segment index in the constraint", () => {
const entries = [{ offset: 0, text: "x", duration: 1 }];
const prompt = buildAnalysisPrompt(entries);
assert.match(prompt, /from 0 to 0 must belong/);
});
test("handles long-running content (hours)", () => {
const entries = [
{ offset: 0, text: "start", duration: 1 },
{ offset: 3661, text: "later", duration: 1 },
];
const prompt = buildAnalysisPrompt(entries);
assert.match(prompt, /\(1:01:01\) later/);
});
test("emits a JSON-output instruction", () => {
const prompt = buildAnalysisPrompt([{ offset: 0, text: "x", duration: 1 }]);
assert.match(prompt, /Respond with ONLY valid JSON/);
assert.match(prompt, /"sections":/);
assert.match(prompt, /"startIndex":/);
assert.match(prompt, /"endIndex":/);
});
});
+130
View File
@@ -0,0 +1,130 @@
// Tests for server/history.js — file-backed session storage + meta.
//
// Each test mounts an isolated tmp dir as DATA_DIR and re-inits the
// history module. We don't import the routes layer here — those need
// an Express app and are covered better by an integration test later.
import { test, describe, before, after } from "node:test";
import { strict as assert } from "node:assert";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
let tmpDir;
let historyDir;
let history;
before(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "recap-history-test-"));
historyDir = path.join(tmpDir, "history");
history = await import("../history.js");
await history.initHistory({ dataDir: tmpDir });
});
after(async () => {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
describe("initHistory + getHistoryDir", () => {
test("creates the history directory", async () => {
const stat = await fs.stat(historyDir);
assert.ok(stat.isDirectory());
});
test("getHistoryDir returns the configured path", () => {
assert.equal(history.getHistoryDir(), historyDir);
});
test("init is idempotent", async () => {
// Should not throw or wipe data.
await history.initHistory({ dataDir: tmpDir });
await history.initHistory({ dataDir: tmpDir });
});
});
describe("saveToHistory", () => {
test("returns an id and writes a file with the expected shape", async () => {
const id = await history.saveToHistory(
"videoId123",
"https://youtu.be/videoId123",
"My title",
[{ title: "Topic 1" }],
[{ offset: 0, text: "hello" }],
[{ message: "started" }],
"20260101",
"youtube"
);
assert.match(id, /^\d+-videoId123$/);
const raw = await fs.readFile(path.join(historyDir, `${id}.json`), "utf-8");
const record = JSON.parse(raw);
assert.equal(record.id, id);
assert.equal(record.videoId, "videoId123");
assert.equal(record.url, "https://youtu.be/videoId123");
assert.equal(record.title, "My title");
assert.equal(record.type, "youtube");
assert.equal(record.topicCount, 1);
assert.equal(record.segmentCount, 1);
assert.equal(record.uploadDate, "20260101");
assert.deepEqual(record.chunks, [{ title: "Topic 1" }]);
assert.deepEqual(record.entries, [{ offset: 0, text: "hello" }]);
assert.deepEqual(record.logs, [{ message: "started" }]);
assert.ok(record.createdAt);
});
test("falls back to 'Untitled' when title is empty", async () => {
const id = await history.saveToHistory(
"noTitleX",
"url",
"",
[],
[],
[],
"",
"youtube"
);
const raw = await fs.readFile(path.join(historyDir, `${id}.json`), "utf-8");
const record = JSON.parse(raw);
assert.equal(record.title, "Untitled");
});
test("defaults type to 'youtube' when not specified", async () => {
const id = await history.saveToHistory("vid", "url", "t", [], [], [], "", null);
const raw = await fs.readFile(path.join(historyDir, `${id}.json`), "utf-8");
assert.equal(JSON.parse(raw).type, "youtube");
});
test("encodes long podcast guids into a base64-truncated id suffix", async () => {
const longGuid = "https://example.com/podcasts/feed.xml#episode-uuid-very-long-string";
const id = await history.saveToHistory(longGuid, longGuid, "ep", [], [], [], "", "podcast");
// suffix should be 16 base64 chars, not the raw URL
assert.ok(!id.includes("https"));
assert.match(id, /^\d+-[A-Za-z0-9_-]{16}$/);
});
});
describe("loadMeta + saveMeta", () => {
test("loadMeta returns default empty shape when file missing", async () => {
// Use a fresh sub-history to ensure no prior _meta.json
const meta = await history.loadMeta();
assert.ok(Array.isArray(meta.folders));
assert.ok(Array.isArray(meta.uncategorized));
});
test("saveMeta + loadMeta round-trips", async () => {
const original = {
folders: [{ id: "f1", name: "Bitcoin podcasts", collapsed: false, items: ["s1", "s2"] }],
uncategorized: ["s3"],
};
await history.saveMeta(original);
const loaded = await history.loadMeta();
assert.deepEqual(loaded, original);
});
test("loadMeta returns default when _meta.json is corrupt", async () => {
await fs.writeFile(path.join(historyDir, "_meta.json"), "{ this is not json");
const loaded = await history.loadMeta();
assert.deepEqual(loaded.folders, []);
assert.deepEqual(loaded.uncategorized, []);
});
});
+180
View File
@@ -0,0 +1,180 @@
// Tests for server/license.js — the offline Ed25519 verifier wrapper.
//
// license.js reads its paths (LICENSE_PATH, STARTOS_CONFIG_PATH) at module
// load time from env vars. To isolate test fixtures, we set RECAP_LICENSE_
// KEY_PATH + DATA_DIR to a tmp dir BEFORE importing the module — which is
// why this file uses dynamic import().
import { test, describe, before, after } from "node:test";
import { strict as assert } from "node:assert";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
let tmpDir;
let licensePath;
let licenseModule;
before(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "recap-license-test-"));
licensePath = path.join(tmpDir, "license.txt");
process.env.DATA_DIR = tmpDir;
process.env.RECAP_LICENSE_KEY_PATH = licensePath;
// Make sure config dir exists so the fallback read in readLicenseString
// doesn't fail on a missing parent.
await fs.mkdir(path.join(tmpDir, "config"), { recursive: true });
// Dynamic import after env vars are in place.
licenseModule = await import("../license.js");
});
after(async () => {
delete process.env.RECAP_LICENSE_KEY_PATH;
delete process.env.DATA_DIR;
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
describe("checkLicense", () => {
test("returns 'unlicensed' when no key file exists", async () => {
await fs.unlink(licensePath).catch(() => {});
const state = licenseModule.checkLicense();
assert.equal(state.state, "unlicensed");
assert.equal(state.reason, null);
assert.deepEqual([...state.entitlements], []);
});
test("returns 'invalid' for a malformed key", async () => {
await fs.writeFile(licensePath, "garbage-not-a-license");
const state = licenseModule.checkLicense();
assert.equal(state.state, "invalid");
assert.ok(state.reason, "should have a reason");
});
test("returns 'invalid' for a LIC1 prefix without valid base32 body", async () => {
await fs.writeFile(licensePath, "LIC1-not-real-key");
const state = licenseModule.checkLicense();
assert.equal(state.state, "invalid");
});
test("falls back to startos-config.json when license.txt is missing", async () => {
await fs.unlink(licensePath).catch(() => {});
const cfgPath = path.join(tmpDir, "config", "startos-config.json");
await fs.writeFile(cfgPath, JSON.stringify({ recap_license_key: "LIC1-bad-but-detected" }));
const state = licenseModule.checkLicense();
// Will be 'invalid' (bad key) but NOT 'unlicensed' — proves the fallback ran.
assert.equal(state.state, "invalid");
await fs.unlink(cfgPath).catch(() => {});
});
test("license.txt takes priority over startos-config.json", async () => {
await fs.writeFile(licensePath, "LIC1-from-license-txt");
const cfgPath = path.join(tmpDir, "config", "startos-config.json");
await fs.writeFile(cfgPath, JSON.stringify({ recap_license_key: "LIC1-from-config" }));
const state = licenseModule.checkLicense();
// Both are bad, but the reason should reflect license.txt's content,
// not the config's. We can't easily distinguish without a real key,
// but we can confirm it returns 'invalid' (no precedence-related crash).
assert.equal(state.state, "invalid");
await fs.unlink(cfgPath).catch(() => {});
await fs.unlink(licensePath).catch(() => {});
});
});
describe("activate / deactivate", () => {
test("activate rejects keys missing the LIC1- prefix", () => {
assert.throws(() => licenseModule.activate("not-a-license"), { code: "bad_format" });
assert.throws(() => licenseModule.activate(""), { code: "bad_format" });
assert.throws(() => licenseModule.activate(null), { code: "bad_format" });
});
test("activate writes a LIC1-prefixed string to license.txt", async () => {
const fake = "LIC1-fake-but-shaped-correctly";
const state = licenseModule.activate(fake);
const written = (await fs.readFile(licensePath, "utf-8")).trim();
assert.equal(written, fake);
// Returned state will be 'invalid' (signature fails) — not our concern here.
assert.ok(["invalid", "licensed"].includes(state.state));
});
test("deactivate removes license.txt and the state sidecar", async () => {
await fs.writeFile(licensePath, "LIC1-anything");
licenseModule.deactivate();
await assert.rejects(fs.access(licensePath), /ENOENT/);
const state = licenseModule.checkLicense();
assert.equal(state.state, "unlicensed");
});
test("deactivate is idempotent on a clean slate", () => {
// No file → should not throw
licenseModule.deactivate();
licenseModule.deactivate();
});
});
describe("publicView", () => {
test("never includes the raw license key", () => {
const state = licenseModule.checkLicense();
const view = licenseModule.publicView(state);
// The public view should not contain anything that looks like a license string
const flat = JSON.stringify(view);
assert.ok(!flat.includes("LIC1-"));
});
test("converts entitlements Set to a sorted array", () => {
const view = licenseModule.publicView({
state: "licensed",
reason: null,
licenseId: "abc",
entitlements: new Set(["library", "core", "history"]),
expiresAt: null,
isTrial: false,
});
assert.deepEqual(view.entitlements, ["core", "history", "library"]);
});
test("ISO-formats expiresAt and graceUntil", () => {
const expiresAt = new Date("2027-01-01T00:00:00.000Z");
const view = licenseModule.publicView({
state: "licensed",
reason: null,
licenseId: null,
entitlements: new Set(),
expiresAt,
isTrial: false,
});
assert.equal(view.expiresAt, "2027-01-01T00:00:00.000Z");
});
test("always exposes productSlug = 'recap'", () => {
const view = licenseModule.publicView({
state: "unlicensed",
reason: null,
licenseId: null,
entitlements: new Set(),
expiresAt: null,
isTrial: false,
});
assert.equal(view.productSlug, "recap");
});
});
describe("has(state, entitlement)", () => {
test("returns true when entitlement present", () => {
assert.equal(
licenseModule.has({ entitlements: new Set(["core"]) }, "core"),
true
);
});
test("returns false when entitlement absent", () => {
assert.equal(
licenseModule.has({ entitlements: new Set(["core"]) }, "subscriptions"),
false
);
});
test("returns falsy on null state", () => {
assert.ok(!licenseModule.has(null, "core"));
assert.ok(!licenseModule.has(undefined, "core"));
assert.ok(!licenseModule.has({}, "core"));
});
});
+230
View File
@@ -0,0 +1,230 @@
import { test, describe } from "node:test";
import { strict as assert } from "node:assert";
import {
sendEvent,
extractVideoId,
formatTime,
parseTimestampedTranscript,
safeText,
retryGemini,
} from "../util.js";
describe("extractVideoId", () => {
test("extracts from standard watch URL", () => {
assert.equal(extractVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), "dQw4w9WgXcQ");
});
test("extracts from youtu.be short URL", () => {
assert.equal(extractVideoId("https://youtu.be/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
});
test("extracts from /embed/ URL", () => {
assert.equal(extractVideoId("https://www.youtube.com/embed/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
});
test("accepts a bare 11-character id", () => {
assert.equal(extractVideoId("dQw4w9WgXcQ"), "dQw4w9WgXcQ");
});
test("returns null for non-YouTube URL", () => {
assert.equal(extractVideoId("https://example.com/video"), null);
});
test("returns null for empty / null input", () => {
assert.equal(extractVideoId(""), null);
assert.equal(extractVideoId(null), null);
assert.equal(extractVideoId(undefined), null);
});
test("returns null for too-short id", () => {
assert.equal(extractVideoId("dQw4w9W"), null);
});
});
describe("formatTime", () => {
test("formats sub-minute as M:SS", () => {
assert.equal(formatTime(0), "0:00");
assert.equal(formatTime(5), "0:05");
assert.equal(formatTime(59), "0:59");
});
test("formats minutes as M:SS", () => {
assert.equal(formatTime(60), "1:00");
assert.equal(formatTime(125), "2:05");
assert.equal(formatTime(3599), "59:59");
});
test("formats hours+ as H:MM:SS", () => {
assert.equal(formatTime(3600), "1:00:00");
assert.equal(formatTime(3661), "1:01:01");
assert.equal(formatTime(7325), "2:02:05");
});
test("floors fractional seconds", () => {
assert.equal(formatTime(60.9), "1:00");
});
});
describe("parseTimestampedTranscript", () => {
test("parses [M:SS] format", () => {
const entries = parseTimestampedTranscript(
"[0:00] Welcome\n[0:05] Today we're talking about\n[0:30] Topic two"
);
assert.equal(entries.length, 3);
assert.deepEqual(entries[0], { text: "Welcome", offset: 0, duration: 5 });
assert.equal(entries[1].offset, 5);
assert.equal(entries[1].duration, 25);
// Last entry gets default 15s duration
assert.equal(entries[2].offset, 30);
assert.equal(entries[2].duration, 15);
});
test("parses H:MM:SS format", () => {
const entries = parseTimestampedTranscript("[1:02:30] Hello");
assert.equal(entries[0].offset, 1 * 3600 + 2 * 60 + 30);
});
test("parses (M:SS) format", () => {
const entries = parseTimestampedTranscript("(0:00) Welcome");
assert.equal(entries.length, 1);
assert.equal(entries[0].text, "Welcome");
});
test("strips markdown bold markers", () => {
const entries = parseTimestampedTranscript("**[0:00]** Hello world");
assert.equal(entries[0].text, "Hello world");
});
test("ignores blank lines", () => {
const entries = parseTimestampedTranscript("\n[0:00] one\n\n[0:05] two\n");
assert.equal(entries.length, 2);
});
test("returns empty array for unparseable input", () => {
assert.deepEqual(parseTimestampedTranscript(""), []);
assert.deepEqual(parseTimestampedTranscript("no timestamps here"), []);
});
});
describe("safeText", () => {
test("returns .text when accessible", () => {
assert.equal(safeText({ text: "hello" }), "hello");
});
test("falls back to candidates[0].content.parts when .text throws", () => {
const fake = {
get text() { throw new Error("nope"); },
candidates: [{ content: { parts: [{ text: "from " }, { text: "parts" }] } }],
};
assert.equal(safeText(fake), "from parts");
});
test("returns empty string when neither path yields text", () => {
assert.equal(safeText({}), "");
assert.equal(safeText({ candidates: [] }), "");
});
test("does not throw on null / weird shapes", () => {
assert.equal(safeText({ candidates: [{ content: null }] }), "");
});
});
describe("retryGemini", () => {
test("returns successful result on first try", async () => {
const result = await retryGemini(async () => "ok", { retries: 3, delayMs: 1 });
assert.equal(result, "ok");
});
test("retries on 503 and eventually succeeds", async () => {
let attempts = 0;
const result = await retryGemini(
async () => {
attempts++;
if (attempts < 3) {
const err = new Error("temporary");
err.status = 503;
throw err;
}
return "ok";
},
{ retries: 5, delayMs: 1 }
);
assert.equal(result, "ok");
assert.equal(attempts, 3);
});
test("retries on network error patterns", async () => {
let attempts = 0;
const result = await retryGemini(
async () => {
attempts++;
if (attempts < 2) throw new Error("fetch failed");
return "ok";
},
{ retries: 3, delayMs: 1 }
);
assert.equal(result, "ok");
assert.equal(attempts, 2);
});
test("does NOT retry on non-retryable errors", async () => {
let attempts = 0;
await assert.rejects(
retryGemini(
async () => {
attempts++;
throw new Error("bad request: invalid argument");
},
{ retries: 5, delayMs: 1 }
)
);
assert.equal(attempts, 1);
});
test("throws after exhausting retries", async () => {
let attempts = 0;
await assert.rejects(
retryGemini(
async () => {
attempts++;
const err = new Error("still 503");
err.status = 503;
throw err;
},
{ retries: 3, delayMs: 1 }
)
);
assert.equal(attempts, 3);
});
test("calls log function on retries", async () => {
const logs = [];
await retryGemini(
async () => {
const err = new Error("503");
err.status = 503;
throw err;
},
{ retries: 2, delayMs: 1, label: "test", log: (msg) => logs.push(msg) }
).catch(() => {});
assert.equal(logs.length, 1);
assert.match(logs[0], /test/);
assert.match(logs[0], /retrying/);
});
});
describe("sendEvent", () => {
test("writes a properly formatted SSE frame", () => {
let written = "";
const fakeRes = { write: (chunk) => { written += chunk; } };
sendEvent(fakeRes, "result", { foo: "bar" });
assert.equal(written, 'event: result\ndata: {"foo":"bar"}\n\n');
});
test("handles primitive data", () => {
let written = "";
const fakeRes = { write: (chunk) => { written += chunk; } };
sendEvent(fakeRes, "ping", null);
assert.equal(written, "event: ping\ndata: null\n\n");
});
});