import { test } from "node:test"; import assert from "node:assert/strict"; import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { Paths } from "../lib/paths"; import type { Availability } from "../lib/availability"; import { excludedIds, verifyBeforeClean } from "./verifyBeforeClean"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test common/controller/verifyBeforeClean.test.ts // // Covers the tiers that need no network plus both failure policies — the two // cases that decide whether an irreversible delete happens on incomplete // information, and the ones an e2e run can't easily provoke. Tier B/C happy // paths are covered end-to-end in editor/e2e/pre-clean-availability.spec.ts. // getSettings() reads getPaths().settingsFile; point it at a path that does not // exist so these tests can never read the developer's real settings.json. process.env.SETTINGS_FILE = path.join(tmpdir(), "ttb-vbc-no-such-settings.json"); async function withPaths(fn: (paths: Paths) => Promise): Promise { const dir = await mkdtemp(path.join(tmpdir(), "ttb-vbc-")); const paths = { channelsDir: path.join(dir, "channels"), // Deliberately nonexistent: any yt-dlp spawn fails instantly, so a test // that expects "no probe" cannot pass by accident. ytdlpBin: path.join(dir, "no-such-ytdlp"), } as Paths; try { await fn(paths); } finally { await rm(dir, { recursive: true, force: true }); } } async function seedConfig( paths: Paths, slug: string, config: Record, ): Promise { const dir = path.join(paths.channelsDir, slug); await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, "config.json"), JSON.stringify(config)); } async function seedVideo( paths: Paths, slug: string, id: string, opts: { availability?: Availability; doNotCleanNote?: string } = {}, ): Promise { const dir = path.join(paths.channelsDir, slug, "data", id); await mkdir(dir, { recursive: true }); if (opts.availability) { await writeFile( path.join(dir, "availability.json"), JSON.stringify({ checkedAt: new Date().toISOString(), availability: opts.availability, }), ); } if (opts.doNotCleanNote !== undefined) { await writeFile( path.join(dir, "do-not-clean.json"), JSON.stringify({ setAt: new Date().toISOString(), note: opts.doNotCleanNote, }), ); } return dir; } async function readNote(videoDir: string): Promise { const raw = await readFile(path.join(videoDir, "do-not-clean.json"), "utf8"); return (JSON.parse(raw) as { note?: string }).note; } test("tier A pins an already-known-gone video without probing the source", async () => { await withPaths(async (paths) => { await seedConfig(paths, "ch", { handling: "youtube", url: "https://www.youtube.com/@ch/videos", }); const dir = await seedVideo(paths, "ch", "gone1", { availability: "deleted", }); const verdicts = await verifyBeforeClean({ channelSlug: "ch", paths, candidateIds: ["gone1"], onLog: () => {}, }); // Resolved from the cached sidecar, so the run returns before tier B — with // a bogus ytdlpBin, any spawn would have surfaced as unverified instead. assert.equal(verdicts.pinned.get("gone1"), "deleted"); assert.equal(verdicts.unverified.size, 0); assert.equal(verdicts.alreadyGone.size, 0); assert.ok((await readNote(dir))?.includes("pre-clean check")); assert.deepEqual([...excludedIds(verdicts)], ["gone1"]); }); }); test("a hand-written do-not-clean note survives a re-pin", async () => { await withPaths(async (paths) => { await seedConfig(paths, "ch", { handling: "youtube", url: "https://www.youtube.com/@ch/videos", }); const dir = await seedVideo(paths, "ch", "gone2", { availability: "private", doNotCleanNote: "keep this, I need it", }); const verdicts = await verifyBeforeClean({ channelSlug: "ch", paths, candidateIds: ["gone2"], onLog: () => {}, }); assert.equal(verdicts.pinned.size, 0); assert.ok(verdicts.alreadyGone.has("gone2")); assert.equal(await readNote(dir), "keep this, I need it"); // Still excluded from the delete — protected, just not newly pinned. assert.ok(excludedIds(verdicts).has("gone2")); }); }); test("a channel with no URL fails OPEN: candidates stay cleanable", async () => { await withPaths(async (paths) => { // No `url` — structurally unverifiable, and no setting toggle should be // needed to clean an imported or hand-built archive. await seedConfig(paths, "ch", { handling: "transcribe" }); await seedVideo(paths, "ch", "live1"); const verdicts = await verifyBeforeClean({ channelSlug: "ch", paths, candidateIds: ["live1"], onLog: () => {}, }); assert.equal(verdicts.pinned.size, 0); assert.equal(verdicts.unverified.size, 0); assert.equal(excludedIds(verdicts).size, 0); }); }); test("an unreachable source fails CLOSED: nothing is cleared to delete", async () => { await withPaths(async (paths) => { // A URL is configured, so this channel IS checkable — and yt-dlp is absent, // so we get no answer. That must never read as "still available". await seedConfig(paths, "ch", { handling: "youtube", url: "https://www.youtube.com/@ch/videos", }); await seedVideo(paths, "ch", "live1"); await seedVideo(paths, "ch", "live2"); const verdicts = await verifyBeforeClean({ channelSlug: "ch", paths, candidateIds: ["live1", "live2"], onLog: () => {}, }); assert.equal(verdicts.pinned.size, 0); // No marker is written for an inconclusive result — the next sweep retries. assert.equal(verdicts.unverified.size, 2); assert.equal(excludedIds(verdicts).size, 2); }); }); // A RATE-LIMITED CONFIRMATION (release 10, L2 review). The tier C check now // stops at its first rate-limited probe. The suspects it never reached still // carry whatever availability.json they had — here a months-old `public` — // and judged on that they would be CLEARED FOR DELETE. They must come back // unverified instead. A temp yt-dlp script answers the flat listing (one // listed video, so the rest are suspects) and each probe per `probe`. async function withFakeSource( probe: string, fn: (paths: Paths, probes: () => Promise) => Promise, ): Promise { const dir = await mkdtemp(path.join(tmpdir(), "ttb-vbc-src-")); const probesFile = path.join(dir, "probes"); const bin = path.join(dir, "fake-ytdlp.sh"); await writeFile( bin, [ "#!/bin/sh", 'for a in "$@"; do last="$a"; done', 'case " $* " in', ' *" --flat-playlist "*) echo "https://www.youtube.com/watch?v=listed00001"; exit 0;;', "esac", `echo "$last" >> '${probesFile}'`, probe, "", ].join("\n"), ); await chmod(bin, 0o755); const paths = { channelsDir: path.join(dir, "channels"), ytdlpBin: bin, autoQueueStateFile: path.join(dir, ".auto-queue", "state.json"), } as Paths; try { await fn(paths, async () => (await readFile(probesFile, "utf8").catch(() => "")) .split("\n") .filter(Boolean), ); } finally { await rm(dir, { recursive: true, force: true }); } } async function seedProbeable( paths: Paths, id: string, opts: { noUrl?: boolean } = {}, ): Promise { const dir = path.join(paths.channelsDir, "ch", "data", id); await mkdir(dir, { recursive: true }); await writeFile( path.join(dir, "metadata.info.json"), JSON.stringify( opts.noUrl ? { id } : { id, webpage_url: `https://www.youtube.com/watch?v=${id}` }, ), ); // A STALE verdict from long ago: exactly what must not clear a delete. await writeFile( path.join(dir, "availability.json"), JSON.stringify({ checkedAt: "2026-01-01T00:00:00.000Z", availability: "public" }), ); } const SUSPECTS = ["suspect0001", "suspect0002", "suspect0003"]; test("a rate-limited confirmation leaves every suspect it did not probe unverified: nothing is cleaned on an old record", async () => { await withFakeSource( `echo "ERROR: [youtube] x: This content isn't available, try again later." >&2; exit 1`, async (paths, probes) => { await seedConfig(paths, "ch", { handling: "youtube", url: "https://www.youtube.com/@ch/videos", }); await seedProbeable(paths, "listed00001"); for (const id of SUSPECTS) await seedProbeable(paths, id); const verdicts = await verifyBeforeClean({ channelSlug: "ch", paths, candidateIds: ["listed00001", ...SUSPECTS], onLog: () => {}, }); // ONE probe: the check stopped at the soft block. assert.equal((await probes()).length, 1); // Every suspect is protected: the probed one read `error`, the other two // were never asked and are NOT judged on their stale `public`. assert.deepEqual([...verdicts.unverified].sort(), SUSPECTS); assert.equal(verdicts.pinned.size, 0); const excluded = excludedIds(verdicts); for (const id of SUSPECTS) assert.ok(excluded.has(id), id); // The listed video is still listed: cleanable as before. assert.equal(excluded.has("listed00001"), false); // …and the platform cooldown was recorded, once, under youtube. const state = JSON.parse( await readFile(paths.autoQueueStateFile, "utf8"), ) as { download: { platformBackoff: Record } }; assert.ok(state.download.platformBackoff.youtube.until > Date.now()); }, ); }); test("an unblocked confirmation is judged exactly as before", async () => { await withFakeSource( [ 'case "$last" in', ` *suspect0001*) echo '{"id":"suspect0001","availability":"public"}';;`, ` *suspect0002*) echo "ERROR: [youtube] suspect0002: Video unavailable" >&2; exit 1;;`, ` *) echo "ERROR: [youtube] x: Unable to download webpage: HTTP Error 403: Forbidden" >&2; exit 1;;`, "esac", ].join("\n"), async (paths, probes) => { await seedConfig(paths, "ch", { handling: "youtube", url: "https://www.youtube.com/@ch/videos", }); await seedProbeable(paths, "listed00001"); for (const id of SUSPECTS) await seedProbeable(paths, id); const verdicts = await verifyBeforeClean({ channelSlug: "ch", paths, candidateIds: ["listed00001", ...SUSPECTS], onLog: () => {}, }); // Every suspect probed (a 403 is not a rate limit, so nothing stops). assert.equal((await probes()).length, 3); // public → cleanable; deleted → pinned; error → unverified. assert.equal(excludedIds(verdicts).has("suspect0001"), false); assert.equal(verdicts.pinned.get("suspect0002"), "deleted"); assert.deepEqual([...verdicts.unverified], ["suspect0003"]); // No cooldown for a run nothing rate-limited. await assert.rejects(readFile(paths.autoQueueStateFile, "utf8")); }, ); }); test("an unblocked confirmation that could not probe a suspect (no webpage_url) leaves it unverified, not judged on its old record", async () => { await withFakeSource( [ 'case "$last" in', ` *suspect0001*) echo '{"id":"suspect0001","availability":"public"}';;`, ` *) echo "ERROR: [youtube] x: Video unavailable" >&2; exit 1;;`, "esac", ].join("\n"), async (paths, probes) => { await seedConfig(paths, "ch", { handling: "youtube", url: "https://www.youtube.com/@ch/videos", }); await seedProbeable(paths, "listed00001"); await seedProbeable(paths, "suspect0001"); // Not listed, and nothing to probe it by — but its old record says // `public`, which would clear it for delete if it were read. await seedProbeable(paths, "nourl000001", { noUrl: true }); const verdicts = await verifyBeforeClean({ channelSlug: "ch", paths, candidateIds: ["listed00001", "suspect0001", "nourl000001"], onLog: () => {}, }); // The check was not blocked: it probed the one suspect it could. assert.deepEqual(await probes(), ["https://www.youtube.com/watch?v=suspect0001"]); assert.deepEqual([...verdicts.unverified], ["nourl000001"]); const excluded = excludedIds(verdicts); assert.ok(excluded.has("nourl000001")); // The probed public suspect and the listed video stay cleanable. assert.equal(excluded.has("suspect0001"), false); assert.equal(excluded.has("listed00001"), false); await assert.rejects(readFile(paths.autoQueueStateFile, "utf8")); }, ); });