Files
recap/server/test/gemini-helpers.test.js
T
Keysat a09ad9c429 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.
2026-05-09 10:36:12 -05:00

132 lines
4.4 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.
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":/);
});
});