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//, 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"); }); });