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,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, []);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user