// 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", () => { // saveToHistory is scope-aware: it takes a `scope` first and writes under // history//. Tests use the "owner" scope (single-mode / operator). const SCOPE = "owner"; const scopeFile = (id) => path.join(historyDir, SCOPE, `${id}.json`); test("returns an id and writes a file with the expected shape", async () => { const id = await history.saveToHistory( SCOPE, "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(scopeFile(id), "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( SCOPE, "noTitleX", "url", "", [], [], [], "", "youtube" ); const raw = await fs.readFile(scopeFile(id), "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(SCOPE, "vid", "url", "t", [], [], [], "", null); const raw = await fs.readFile(scopeFile(id), "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(SCOPE, 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", () => { // loadMeta/saveMeta are scope-aware (history//_meta.json). test("loadMeta returns default empty shape when file missing", async () => { // A scope that has never been written → default shape. const meta = await history.loadMeta("never-written-scope"); 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("owner", original); const loaded = await history.loadMeta("owner"); assert.deepEqual(loaded, original); }); test("loadMeta returns default when _meta.json is corrupt", async () => { const dir = path.join(historyDir, "corrupt-scope"); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path.join(dir, "_meta.json"), "{ this is not json"); const loaded = await history.loadMeta("corrupt-scope"); assert.deepEqual(loaded.folders, []); assert.deepEqual(loaded.uncategorized, []); }); }); describe("safeFilename", () => { // Exported so callers writing user content to disk (e.g. library import) // can share the one guard instead of rolling their own. test("returns valid ids unchanged", () => { for (const id of ["abc123", "a_b-c", "VIDEOid123", "0"]) { assert.equal(history.safeFilename(id), id); } }); test("rejects traversal, separators, and other unsafe chars", () => { for (const bad of ["../../evil", "..", "a/b", "a\\b", "a.json", "foo bar", "", "a:b"]) { assert.throws(() => history.safeFilename(bad), /invalid_session_id/); } }); test("rejects non-strings", () => { assert.throws(() => history.safeFilename(123), /invalid_session_id/); assert.throws(() => history.safeFilename(null), /invalid_session_id/); }); });