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
This commit is contained in:
@@ -4,10 +4,11 @@ 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-pro"]);
|
||||
assert.ok(PRICING["gemini-3-flash-preview"]);
|
||||
assert.ok(PRICING["gemini-2.5-flash"]);
|
||||
assert.ok(PRICING["gemini-3.1-flash-lite"]);
|
||||
});
|
||||
|
||||
test("has a 'default' fallback row", () => {
|
||||
@@ -66,7 +67,7 @@ describe("calcCost", () => {
|
||||
});
|
||||
|
||||
test("formats >$0.01 totals as $X.XXXX", () => {
|
||||
const cost = calcCost("gemini-3-pro-preview", {
|
||||
const cost = calcCost("gemini-3.1-pro-preview", {
|
||||
promptTokenCount: 1_000_000,
|
||||
candidatesTokenCount: 0,
|
||||
thoughtsTokenCount: 0,
|
||||
|
||||
+21
-11
@@ -43,8 +43,14 @@ describe("initHistory + getHistoryDir", () => {
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -56,7 +62,7 @@ describe("saveToHistory", () => {
|
||||
);
|
||||
assert.match(id, /^\d+-videoId123$/);
|
||||
|
||||
const raw = await fs.readFile(path.join(historyDir, `${id}.json`), "utf-8");
|
||||
const raw = await fs.readFile(scopeFile(id), "utf-8");
|
||||
const record = JSON.parse(raw);
|
||||
assert.equal(record.id, id);
|
||||
assert.equal(record.videoId, "videoId123");
|
||||
@@ -74,6 +80,7 @@ describe("saveToHistory", () => {
|
||||
|
||||
test("falls back to 'Untitled' when title is empty", async () => {
|
||||
const id = await history.saveToHistory(
|
||||
SCOPE,
|
||||
"noTitleX",
|
||||
"url",
|
||||
"",
|
||||
@@ -83,20 +90,20 @@ describe("saveToHistory", () => {
|
||||
"",
|
||||
"youtube"
|
||||
);
|
||||
const raw = await fs.readFile(path.join(historyDir, `${id}.json`), "utf-8");
|
||||
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("vid", "url", "t", [], [], [], "", null);
|
||||
const raw = await fs.readFile(path.join(historyDir, `${id}.json`), "utf-8");
|
||||
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(longGuid, longGuid, "ep", [], [], [], "", "podcast");
|
||||
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}$/);
|
||||
@@ -104,9 +111,10 @@ describe("saveToHistory", () => {
|
||||
});
|
||||
|
||||
describe("loadMeta + saveMeta", () => {
|
||||
// loadMeta/saveMeta are scope-aware (history/<scope>/_meta.json).
|
||||
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();
|
||||
// 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));
|
||||
});
|
||||
@@ -116,14 +124,16 @@ describe("loadMeta + saveMeta", () => {
|
||||
folders: [{ id: "f1", name: "Bitcoin podcasts", collapsed: false, items: ["s1", "s2"] }],
|
||||
uncategorized: ["s3"],
|
||||
};
|
||||
await history.saveMeta(original);
|
||||
const loaded = await history.loadMeta();
|
||||
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 () => {
|
||||
await fs.writeFile(path.join(historyDir, "_meta.json"), "{ this is not json");
|
||||
const loaded = await history.loadMeta();
|
||||
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, []);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Tests for the ephemeral sessions the subscription background processor
|
||||
// mints to run /api/process AS each item's owner (per-tenant subscriptions,
|
||||
// step 4). The mechanism reuses the real sessions table, so verifying the
|
||||
// row it writes is valid + non-expired (and gets cleaned up) is enough to
|
||||
// trust the existing cookie → tenant-auth → req.user chain.
|
||||
|
||||
import { test, describe, before, after } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { initDb, getDb, closeDb } from "../db.js";
|
||||
import {
|
||||
mintInternalSession,
|
||||
deleteInternalSession,
|
||||
adminUserId,
|
||||
} from "../auth-routes.js";
|
||||
|
||||
let dataDir;
|
||||
|
||||
function makeUser({ id, email, isAdmin = 0 }) {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO users (id, email, created_at, synthetic_install_id, is_admin)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(id, email, Date.now(), `inst-${id}`, isAdmin);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
dataDir = mkdtempSync(path.join(tmpdir(), "recap-sess-"));
|
||||
await initDb({ dataDir });
|
||||
});
|
||||
|
||||
after(() => {
|
||||
closeDb();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("mintInternalSession / deleteInternalSession", () => {
|
||||
test("creates a valid, non-expired session row for the user", () => {
|
||||
makeUser({ id: "u-tenant", email: "tenant@example.com" });
|
||||
const token = mintInternalSession("u-tenant");
|
||||
assert.ok(typeof token === "string" && token.length > 20);
|
||||
|
||||
// Looks up exactly like tenant-auth does: by id, must be unexpired.
|
||||
const row = getDb()
|
||||
.prepare("SELECT * FROM sessions WHERE id = ? AND expires_at > ?")
|
||||
.get(token, Date.now());
|
||||
assert.ok(row, "session row exists and is not expired");
|
||||
assert.equal(row.user_id, "u-tenant");
|
||||
assert.ok(row.expires_at > Date.now(), "expires in the future");
|
||||
});
|
||||
|
||||
test("deleteInternalSession removes the row (no lingering identity)", () => {
|
||||
makeUser({ id: "u-temp", email: "temp@example.com" });
|
||||
const token = mintInternalSession("u-temp");
|
||||
assert.ok(getDb().prepare("SELECT 1 FROM sessions WHERE id = ?").get(token));
|
||||
deleteInternalSession(token);
|
||||
assert.equal(
|
||||
getDb().prepare("SELECT 1 FROM sessions WHERE id = ?").get(token),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("deleteInternalSession tolerates null / unknown tokens", () => {
|
||||
// Should not throw.
|
||||
deleteInternalSession(null);
|
||||
deleteInternalSession("does-not-exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adminUserId", () => {
|
||||
test("returns the operator (is_admin = 1) user id", () => {
|
||||
makeUser({ id: "u-admin", email: "admin@example.com", isAdmin: 1 });
|
||||
assert.equal(adminUserId(), "u-admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// Pure-logic tests for the expiry-reminder cadence: which reminder kind
|
||||
// (if any) applies to a relay subscription row right now. The send/dedup
|
||||
// path hits SQLite + SMTP and isn't unit-tested here; this nails the
|
||||
// decision that drives it.
|
||||
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { reminderKindFor } from "../subscription-reminders.js";
|
||||
|
||||
const sub = (days_left, expired = false) => ({
|
||||
tier: "pro",
|
||||
expires_at: "2026-07-01T00:00:00.000Z",
|
||||
expired,
|
||||
days_left,
|
||||
});
|
||||
|
||||
describe("reminderKindFor", () => {
|
||||
test("upcoming: smallest crossed threshold wins", () => {
|
||||
assert.equal(reminderKindFor(sub(8)), null); // beyond 7d
|
||||
assert.equal(reminderKindFor(sub(7)), "upcoming_7d");
|
||||
assert.equal(reminderKindFor(sub(5)), "upcoming_7d");
|
||||
assert.equal(reminderKindFor(sub(2)), "upcoming_7d");
|
||||
assert.equal(reminderKindFor(sub(1)), "upcoming_1d");
|
||||
assert.equal(reminderKindFor(sub(0)), "upcoming_1d"); // expires today, not yet lapsed
|
||||
});
|
||||
|
||||
test("lapsed: only within the lapsed window", () => {
|
||||
assert.equal(reminderKindFor(sub(0, true)), "lapsed");
|
||||
assert.equal(reminderKindFor(sub(-1, true)), "lapsed");
|
||||
assert.equal(reminderKindFor(sub(-3, true)), "lapsed");
|
||||
assert.equal(reminderKindFor(sub(-4, true)), null); // aged out (window=3)
|
||||
});
|
||||
|
||||
test("null-safe / malformed input", () => {
|
||||
assert.equal(reminderKindFor(null), null);
|
||||
assert.equal(reminderKindFor({}), null);
|
||||
assert.equal(reminderKindFor({ expired: false }), null); // no days_left
|
||||
assert.equal(reminderKindFor({ days_left: "soon" }), null);
|
||||
});
|
||||
|
||||
test("respects custom thresholds", () => {
|
||||
const upcoming = [
|
||||
{ days: 14, kind: "upcoming_14d" },
|
||||
{ days: 3, kind: "upcoming_3d" },
|
||||
];
|
||||
assert.equal(reminderKindFor(sub(14), { upcoming }), "upcoming_14d");
|
||||
assert.equal(reminderKindFor(sub(3), { upcoming }), "upcoming_3d");
|
||||
assert.equal(reminderKindFor(sub(15), { upcoming }), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import { test, describe } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { initHistory, getScopeHistoryDir, getHistoryDir } from "../history.js";
|
||||
import {
|
||||
getProcessedVideoIds,
|
||||
isKnownVideo,
|
||||
loadSubscriptions,
|
||||
saveSubscriptions,
|
||||
loadSkipList,
|
||||
addToSkipList,
|
||||
loadSeenList,
|
||||
addToSeenList,
|
||||
loadAutoQueue,
|
||||
saveAutoQueue,
|
||||
mutateAutoQueue,
|
||||
listSubscriptionScopes,
|
||||
migrateGlobalSubscriptionsToOwner,
|
||||
} from "../subscriptions.js";
|
||||
|
||||
// ── isKnownVideo (pure dedup predicate) ──────────────────────────────────
|
||||
describe("isKnownVideo", () => {
|
||||
const sets = {
|
||||
processedIds: new Set(["P1"]),
|
||||
queuedIds: new Set(["Q1"]),
|
||||
skippedIds: new Set(["S1"]),
|
||||
seenIds: new Set(["N1"]),
|
||||
};
|
||||
|
||||
test("true when the id is in the library (processed)", () => {
|
||||
assert.equal(isKnownVideo("P1", sets), true);
|
||||
});
|
||||
test("true when the id is already queued", () => {
|
||||
assert.equal(isKnownVideo("Q1", sets), true);
|
||||
});
|
||||
test("true when the id was declined (skip list)", () => {
|
||||
assert.equal(isKnownVideo("S1", sets), true);
|
||||
});
|
||||
test("true when the id was offered before (seen list)", () => {
|
||||
assert.equal(isKnownVideo("N1", sets), true);
|
||||
});
|
||||
test("false for a genuinely new id", () => {
|
||||
assert.equal(isKnownVideo("NEW", sets), false);
|
||||
});
|
||||
test("returns a real boolean, not a Set/undefined", () => {
|
||||
assert.equal(typeof isKnownVideo("NEW", sets), "boolean");
|
||||
assert.equal(typeof isKnownVideo("P1", sets), "boolean");
|
||||
});
|
||||
test("tolerates missing/partial set bags", () => {
|
||||
assert.equal(isKnownVideo("X", {}), false);
|
||||
assert.equal(isKnownVideo("X", undefined), false);
|
||||
assert.equal(isKnownVideo("X", { processedIds: new Set(["X"]) }), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getProcessedVideoIds (scope-aware library scan) ──────────────────────
|
||||
// This is the function whose wrong-directory bug caused the auto-queue to
|
||||
// re-offer already-summarized videos: it must scan history/<scope>/, NOT
|
||||
// the top-level history dir, and must not leak ids across scopes.
|
||||
describe("getProcessedVideoIds", () => {
|
||||
function freshDataDir() {
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "recap-subs-"));
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
test("scans the scope's library dir and collects videoIds", async () => {
|
||||
const dataDir = freshDataDir();
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const ownerDir = getScopeHistoryDir("owner");
|
||||
mkdirSync(ownerDir, { recursive: true });
|
||||
writeFileSync(path.join(ownerDir, "a.json"), JSON.stringify({ videoId: "VID_A" }));
|
||||
writeFileSync(path.join(ownerDir, "b.json"), JSON.stringify({ videoId: "VID_B" }));
|
||||
|
||||
const ids = await getProcessedVideoIds("owner");
|
||||
assert.equal(ids.size, 2);
|
||||
assert.ok(ids.has("VID_A"));
|
||||
assert.ok(ids.has("VID_B"));
|
||||
});
|
||||
|
||||
test("does NOT leak ids across scopes (per-tenant isolation)", async () => {
|
||||
const dataDir = freshDataDir();
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const ownerDir = getScopeHistoryDir("owner");
|
||||
const tenantDir = getScopeHistoryDir("tenant123");
|
||||
mkdirSync(ownerDir, { recursive: true });
|
||||
mkdirSync(tenantDir, { recursive: true });
|
||||
writeFileSync(path.join(ownerDir, "a.json"), JSON.stringify({ videoId: "OWNER_VID" }));
|
||||
writeFileSync(path.join(tenantDir, "c.json"), JSON.stringify({ videoId: "TENANT_VID" }));
|
||||
|
||||
const ownerIds = await getProcessedVideoIds("owner");
|
||||
assert.ok(ownerIds.has("OWNER_VID"));
|
||||
assert.equal(ownerIds.has("TENANT_VID"), false);
|
||||
});
|
||||
|
||||
test("ignores _meta.json and non-JSON / malformed files", async () => {
|
||||
const dataDir = freshDataDir();
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const ownerDir = getScopeHistoryDir("owner");
|
||||
mkdirSync(ownerDir, { recursive: true });
|
||||
writeFileSync(path.join(ownerDir, "good.json"), JSON.stringify({ videoId: "GOOD" }));
|
||||
writeFileSync(path.join(ownerDir, "_meta.json"), JSON.stringify({ folders: [], uncategorized: [] }));
|
||||
writeFileSync(path.join(ownerDir, "notes.txt"), "videoId: NOPE");
|
||||
writeFileSync(path.join(ownerDir, "broken.json"), "{ not valid json");
|
||||
writeFileSync(path.join(ownerDir, "nofield.json"), JSON.stringify({ title: "no videoId here" }));
|
||||
|
||||
const ids = await getProcessedVideoIds("owner");
|
||||
assert.deepEqual([...ids], ["GOOD"]);
|
||||
});
|
||||
|
||||
test("returns an empty set for a scope with no library yet", async () => {
|
||||
const dataDir = freshDataDir();
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const ids = await getProcessedVideoIds("never-summarized");
|
||||
assert.equal(ids.size, 0);
|
||||
});
|
||||
|
||||
test("defaults to the owner scope when none is passed", async () => {
|
||||
const dataDir = freshDataDir();
|
||||
await initHistory({ dataDir, mode: "single" });
|
||||
const ownerDir = getScopeHistoryDir("owner");
|
||||
mkdirSync(ownerDir, { recursive: true });
|
||||
writeFileSync(path.join(ownerDir, "a.json"), JSON.stringify({ videoId: "DEFAULT_OWNER" }));
|
||||
|
||||
const ids = await getProcessedVideoIds(); // no arg → "owner"
|
||||
assert.ok(ids.has("DEFAULT_OWNER"));
|
||||
});
|
||||
|
||||
// Regression: after the 0.2.147 migration the subscription sidecar files
|
||||
// live inside the scope dir. They must NOT be treated as session records
|
||||
// (that produced the phantom "Invalid Date · undefined topics" library
|
||||
// entry). Verify the ROOT_SIDECARS filter skips them even if one happens
|
||||
// to carry a top-level videoId.
|
||||
test("skips subscription sidecar files when scanning the library", async () => {
|
||||
const dataDir = freshDataDir();
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const ownerDir = getScopeHistoryDir("owner");
|
||||
mkdirSync(ownerDir, { recursive: true });
|
||||
writeFileSync(path.join(ownerDir, "real.json"), JSON.stringify({ videoId: "REAL" }));
|
||||
for (const f of ["subscriptions.json", "auto-queue.json", "skip-list.json", "seen-list.json"]) {
|
||||
// Even with a (bogus) top-level videoId, a sidecar must be ignored.
|
||||
writeFileSync(path.join(ownerDir, f), JSON.stringify({ videoId: "SIDECAR_" + f, items: [] }));
|
||||
}
|
||||
const ids = await getProcessedVideoIds("owner");
|
||||
assert.deepEqual([...ids], ["REAL"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Per-scope storage (subscriptions / skip / seen / auto-queue) ─────────
|
||||
describe("per-scope storage", () => {
|
||||
function fresh() {
|
||||
return mkdtempSync(path.join(tmpdir(), "recap-store-"));
|
||||
}
|
||||
|
||||
test("subscriptions round-trip and stay isolated per scope", async () => {
|
||||
await initHistory({ dataDir: fresh(), mode: "multi" });
|
||||
assert.deepEqual(await loadSubscriptions("owner"), []); // empty default
|
||||
await saveSubscriptions("owner", [{ id: "s1", url: "u1" }]);
|
||||
await saveSubscriptions("tenantA", [{ id: "s2", url: "u2" }]);
|
||||
assert.equal((await loadSubscriptions("owner")).length, 1);
|
||||
assert.equal((await loadSubscriptions("owner"))[0].id, "s1");
|
||||
assert.equal((await loadSubscriptions("tenantA"))[0].id, "s2");
|
||||
});
|
||||
|
||||
test("skip + seen lists are per-scope Sets", async () => {
|
||||
await initHistory({ dataDir: fresh(), mode: "multi" });
|
||||
await addToSkipList("owner", "VID1");
|
||||
await addToSkipList("owner", "VID2");
|
||||
await addToSeenList("owner", ["S1", "S2"]);
|
||||
const skip = await loadSkipList("owner");
|
||||
const seen = await loadSeenList("owner");
|
||||
assert.ok(skip.has("VID1") && skip.has("VID2"));
|
||||
assert.ok(seen.has("S1") && seen.has("S2"));
|
||||
assert.equal((await loadSkipList("tenantA")).size, 0); // isolated
|
||||
});
|
||||
|
||||
test("auto-queue save/load round-trips", async () => {
|
||||
await initHistory({ dataDir: fresh(), mode: "multi" });
|
||||
await saveAutoQueue("owner", [{ id: "a", status: "pending" }]);
|
||||
const q = await loadAutoQueue("owner");
|
||||
assert.equal(q.length, 1);
|
||||
assert.equal(q[0].status, "pending");
|
||||
});
|
||||
|
||||
test("mutateAutoQueue serializes concurrent read-modify-writes (no lost updates)", async () => {
|
||||
await initHistory({ dataDir: fresh(), mode: "multi" });
|
||||
await saveAutoQueue("owner", []);
|
||||
// Fire 20 concurrent appends. Under a naive load→mutate→save these would
|
||||
// clobber each other; mutateAutoQueue must serialize them.
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, (_, i) =>
|
||||
mutateAutoQueue("owner", (items) => {
|
||||
items.push({ id: `item-${i}` });
|
||||
}),
|
||||
),
|
||||
);
|
||||
const q = await loadAutoQueue("owner");
|
||||
assert.equal(q.length, 20, "all 20 concurrent appends survived");
|
||||
assert.equal(new Set(q.map((x) => x.id)).size, 20, "no duplicates/drops");
|
||||
});
|
||||
|
||||
test("mutateAutoQueue can replace the array (filter/remove)", async () => {
|
||||
await initHistory({ dataDir: fresh(), mode: "multi" });
|
||||
await saveAutoQueue("owner", [{ id: "keep" }, { id: "drop" }]);
|
||||
await mutateAutoQueue("owner", (items) => items.filter((x) => x.id !== "drop"));
|
||||
const q = await loadAutoQueue("owner");
|
||||
assert.deepEqual(q.map((x) => x.id), ["keep"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Auto-queue endpoint mutation patterns ────────────────────────────────
|
||||
// The /api/auto-queue/* handlers run these exact callbacks through
|
||||
// mutateAutoQueue. Verify the logic directly (the HTTP layer just adds the
|
||||
// license gate + scope resolution, covered elsewhere).
|
||||
describe("auto-queue endpoint mutation logic", () => {
|
||||
async function seed(items) {
|
||||
await initHistory({ dataDir: mkdtempSync(path.join(tmpdir(), "recap-ep-")), mode: "multi" });
|
||||
await saveAutoQueue("owner", items);
|
||||
}
|
||||
|
||||
test("approve: pending → approved; rejects non-pending", async () => {
|
||||
await seed([{ id: "x", status: "pending" }, { id: "y", status: "completed" }]);
|
||||
// approve x
|
||||
let item = null, badStatus = null;
|
||||
await mutateAutoQueue("owner", (items) => {
|
||||
const it = items.find((q) => q.id === "x");
|
||||
if (!it) return;
|
||||
if (it.status !== "pending") { badStatus = it.status; return; }
|
||||
it.status = "approved";
|
||||
item = { ...it };
|
||||
});
|
||||
assert.equal(badStatus, null);
|
||||
assert.equal(item.status, "approved");
|
||||
assert.equal((await loadAutoQueue("owner")).find((q) => q.id === "x").status, "approved");
|
||||
// approving y (completed) is rejected
|
||||
badStatus = null;
|
||||
await mutateAutoQueue("owner", (items) => {
|
||||
const it = items.find((q) => q.id === "y");
|
||||
if (it && it.status !== "pending") badStatus = it.status;
|
||||
});
|
||||
assert.equal(badStatus, "completed");
|
||||
});
|
||||
|
||||
test("skip: captures videoId then removes the item", async () => {
|
||||
await seed([{ id: "x", videoId: "VID_X", status: "pending" }, { id: "z", status: "pending" }]);
|
||||
let videoId = null;
|
||||
await mutateAutoQueue("owner", (items) => {
|
||||
const it = items.find((q) => q.id === "x");
|
||||
if (it && it.videoId) videoId = it.videoId;
|
||||
return items.filter((q) => q.id !== "x");
|
||||
});
|
||||
assert.equal(videoId, "VID_X");
|
||||
const q = await loadAutoQueue("owner");
|
||||
assert.deepEqual(q.map((i) => i.id), ["z"]);
|
||||
});
|
||||
|
||||
test("clear-finished: drops completed + failed, keeps active", async () => {
|
||||
await seed([
|
||||
{ id: "a", status: "pending" },
|
||||
{ id: "b", status: "completed" },
|
||||
{ id: "c", status: "failed" },
|
||||
{ id: "d", status: "approved" },
|
||||
]);
|
||||
let removed = 0;
|
||||
await mutateAutoQueue("owner", (items) => {
|
||||
const before = items.length;
|
||||
const kept = items.filter((q) => !["completed", "failed"].includes(q.status));
|
||||
removed = before - kept.length;
|
||||
return kept;
|
||||
});
|
||||
assert.equal(removed, 2);
|
||||
assert.deepEqual((await loadAutoQueue("owner")).map((i) => i.id).sort(), ["a", "d"]);
|
||||
});
|
||||
|
||||
test("delete-by-subscription: removes that sub's items only", async () => {
|
||||
await seed([
|
||||
{ id: "a", subscriptionId: "sub1", status: "pending" },
|
||||
{ id: "b", subscriptionId: "sub2", status: "pending" },
|
||||
{ id: "c", subscriptionId: "sub1", status: "approved" },
|
||||
]);
|
||||
await mutateAutoQueue("owner", (items) => items.filter((q) => q.subscriptionId !== "sub1"));
|
||||
assert.deepEqual((await loadAutoQueue("owner")).map((i) => i.id), ["b"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scope enumeration ────────────────────────────────────────────────────
|
||||
describe("listSubscriptionScopes", () => {
|
||||
test("always includes owner; adds scopes with non-empty subscriptions", async () => {
|
||||
await initHistory({ dataDir: mkdtempSync(path.join(tmpdir(), "recap-enum-")), mode: "multi" });
|
||||
await saveSubscriptions("tenantA", [{ id: "s", url: "u" }]);
|
||||
await saveSubscriptions("tenantB", []); // empty → excluded
|
||||
const scopes = await listSubscriptionScopes();
|
||||
assert.ok(scopes.includes("owner"));
|
||||
assert.ok(scopes.includes("tenantA"));
|
||||
assert.ok(!scopes.includes("tenantB"));
|
||||
});
|
||||
|
||||
test("returns just owner when nobody has subscriptions", async () => {
|
||||
await initHistory({ dataDir: mkdtempSync(path.join(tmpdir(), "recap-enum2-")), mode: "multi" });
|
||||
const scopes = await listSubscriptionScopes();
|
||||
assert.deepEqual(scopes, ["owner"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Migration: history-root globals → owner scope ────────────────────────
|
||||
describe("migrateGlobalSubscriptionsToOwner", () => {
|
||||
test("moves root files into owner/, idempotently, without clobbering", async () => {
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "recap-mig-"));
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const root = getHistoryDir();
|
||||
mkdirSync(root, { recursive: true });
|
||||
// Legacy global files at the history root.
|
||||
writeFileSync(path.join(root, "subscriptions.json"), JSON.stringify({ subscriptions: [{ id: "s1" }] }));
|
||||
writeFileSync(path.join(root, "auto-queue.json"), JSON.stringify({ items: [{ id: "q1" }] }));
|
||||
|
||||
const moved = await migrateGlobalSubscriptionsToOwner();
|
||||
assert.equal(moved, 2);
|
||||
// Now readable via the owner scope.
|
||||
assert.equal((await loadSubscriptions("owner"))[0].id, "s1");
|
||||
assert.equal((await loadAutoQueue("owner"))[0].id, "q1");
|
||||
// Source files gone.
|
||||
assert.equal(existsSync(path.join(root, "subscriptions.json")), false);
|
||||
// Re-running is a no-op (nothing left to move).
|
||||
assert.equal(await migrateGlobalSubscriptionsToOwner(), 0);
|
||||
});
|
||||
|
||||
test("does not clobber an existing owner-scope file", async () => {
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "recap-mig2-"));
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const root = getHistoryDir();
|
||||
const ownerDir = getScopeHistoryDir("owner");
|
||||
mkdirSync(ownerDir, { recursive: true });
|
||||
// Owner already has subscriptions; a stale root file must NOT overwrite.
|
||||
writeFileSync(path.join(ownerDir, "subscriptions.json"), JSON.stringify({ subscriptions: [{ id: "KEEP" }] }));
|
||||
writeFileSync(path.join(root, "subscriptions.json"), JSON.stringify({ subscriptions: [{ id: "STALE" }] }));
|
||||
|
||||
await migrateGlobalSubscriptionsToOwner();
|
||||
assert.equal((await loadSubscriptions("owner"))[0].id, "KEEP");
|
||||
});
|
||||
});
|
||||
@@ -207,7 +207,11 @@ describe("retryGemini", () => {
|
||||
},
|
||||
{ retries: 2, delayMs: 1, label: "test", log: (msg) => logs.push(msg) }
|
||||
).catch(() => {});
|
||||
assert.equal(logs.length, 1);
|
||||
// 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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user