diff --git a/server/package.json b/server/package.json index 6516136..7899a22 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/test/gemini-helpers.test.js b/server/test/gemini-helpers.test.js new file mode 100644 index 0000000..e834b4a --- /dev/null +++ b/server/test/gemini-helpers.test.js @@ -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":/); + }); +}); diff --git a/server/test/history.test.js b/server/test/history.test.js new file mode 100644 index 0000000..4e502c5 --- /dev/null +++ b/server/test/history.test.js @@ -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, []); + }); +}); diff --git a/server/test/license.test.js b/server/test/license.test.js new file mode 100644 index 0000000..fe84e2c --- /dev/null +++ b/server/test/license.test.js @@ -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")); + }); +}); diff --git a/server/test/util.test.js b/server/test/util.test.js new file mode 100644 index 0000000..c416793 --- /dev/null +++ b/server/test/util.test.js @@ -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"); + }); +});