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:
@@ -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