Files
recap/server/test/history.test.js
T
Keysat 0ae59f3550 Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host:
- Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool,
  anonymous trial minting with per-IP/-64 caps
- Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite),
  prepaid 30-day periods, expiry-reminder emails
- Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id
- SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single
- StartOS actions/versions through 0.2.155
2026-06-13 14:25:05 -05:00

141 lines
4.9 KiB
JavaScript

// 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/<scope>/. 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/<scope>/_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, []);
});
});