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:
+2
-1
@@ -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",
|
||||
|
||||
@@ -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":/);
|
||||
});
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user