0ae59f3550
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
235 lines
6.9 KiB
JavaScript
235 lines
6.9 KiB
JavaScript
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(() => {});
|
|
// retries: 2 → the loop logs twice: the "failed, retrying in …s" notice
|
|
// before attempt 2's wait, then "Retrying… (attempt 2/2)" at the top of
|
|
// attempt 2. (The test previously expected 1, written before the
|
|
// top-of-attempt retry line existed.)
|
|
assert.equal(logs.length, 2);
|
|
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");
|
|
});
|
|
});
|