import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { AVAILABILITY_FILENAME, stateFromAvailability, summaryState, type Availability, } from "./availability"; import { resolveMaybeMissingState } from "./availability-server"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test common/lib/videoState.test.ts // // Covers the maybe_missing predicate — the one piece of the VideoState work // that is a genuine decision rather than a mapping. Its two non-obvious rules // (a probe that explains the absence wins; a probe made AFTER the scan that // came back public clears the flag) are what keep the "Missing?" badge from // either sticking forever on legitimately-unlisted videos or lingering after a // truncated playlist fetch is disproved. const SCAN_AT = "2026-08-01T12:00:00.000Z"; const SCAN_AT_MS = Date.parse(SCAN_AT); const BEFORE_SCAN = "2026-07-20T00:00:00.000Z"; const AFTER_SCAN = "2026-08-02T00:00:00.000Z"; async function withVideoDir( fn: (videoDir: string) => Promise, ): Promise { const dir = await mkdtemp(path.join(tmpdir(), "ttb-vstate-")); const videoDir = path.join(dir, "data", "vid1"); await mkdir(videoDir, { recursive: true }); try { await fn(videoDir); } finally { await rm(dir, { recursive: true, force: true }); } } async function seedAvailability( videoDir: string, availability: Availability, checkedAt: string, ): Promise { await writeFile( path.join(videoDir, AVAILABILITY_FILENAME), JSON.stringify({ checkedAt, availability }), ); } test("in the maybe-missing set with no availability.json → maybe_missing", async () => { await withVideoDir(async (videoDir) => { assert.equal( await resolveMaybeMissingState(videoDir, SCAN_AT_MS), "maybe_missing", ); }); }); // A probe that names a reason for the absence wins outright. This is not a // nicety: checkMaybeMissingAction passes skipExpectedAbsent, so these videos // are never re-probed — without this rule they would carry "Missing?" forever. for (const [availability, expected] of [ ["deleted", "deleted"], ["private", "private"], ["members_only", "members_only"], ["unlisted", "unlisted"], ] as const) { test(`in the set + ${availability} → ${expected}, not maybe_missing`, async () => { await withVideoDir(async (videoDir) => { await seedAvailability(videoDir, availability, BEFORE_SCAN); assert.equal( await resolveMaybeMissingState(videoDir, SCAN_AT_MS), expected, ); }); }); } test("in the set + public, re-probed AFTER the scan → available", async () => { await withVideoDir(async (videoDir) => { await seedAvailability(videoDir, "public", AFTER_SCAN); assert.equal( await resolveMaybeMissingState(videoDir, SCAN_AT_MS), "available", ); }); }); test("in the set + public, last probed BEFORE the scan → maybe_missing", async () => { await withVideoDir(async (videoDir) => { await seedAvailability(videoDir, "public", BEFORE_SCAN); assert.equal( await resolveMaybeMissingState(videoDir, SCAN_AT_MS), "maybe_missing", ); }); }); // A probe made AFTER the scan that FAILED saw nothing: a 403, a network error, // the bot check, the one rate-limited probe of a blocked run. It must not read // as a confirmation the way a public re-probe does (release 11 slice O3; it // used to, through stateFromAvailability's `error` → available fold). test("in the set + error, probed AFTER the scan → still maybe_missing", async () => { await withVideoDir(async (videoDir) => { await seedAvailability(videoDir, "error", AFTER_SCAN); assert.equal( await resolveMaybeMissingState(videoDir, SCAN_AT_MS), "maybe_missing", ); }); }); // An age gate is the video answering: a needs_auth re-probe after the scan // confirms it is there, exactly like public. test("in the set + needs_auth, probed AFTER the scan → available", async () => { await withVideoDir(async (videoDir) => { await seedAvailability(videoDir, "needs_auth", AFTER_SCAN); assert.equal( await resolveMaybeMissingState(videoDir, SCAN_AT_MS), "available", ); }); }); // needs_auth/error are not evidence the video left its channel, so before the // scan they behave exactly like public: still absent, still unconfirmed. for (const availability of ["needs_auth", "error"] as const) { test(`in the set + ${availability} before the scan → maybe_missing`, async () => { await withVideoDir(async (videoDir) => { await seedAvailability(videoDir, availability, BEFORE_SCAN); assert.equal( await resolveMaybeMissingState(videoDir, SCAN_AT_MS), "maybe_missing", ); }); }); } test("a malformed checkedAt cannot resolve the flag", async () => { await withVideoDir(async (videoDir) => { await seedAvailability(videoDir, "public", "not-a-date"); assert.equal( await resolveMaybeMissingState(videoDir, SCAN_AT_MS), "maybe_missing", ); }); }); // ─── the availability → state mapping ─── test("stateFromAvailability folds needs_auth/error/public into available", () => { assert.equal(stateFromAvailability("public"), "available"); assert.equal(stateFromAvailability("needs_auth"), "available"); assert.equal(stateFromAvailability("error"), "available"); assert.equal(stateFromAvailability(null), "available"); assert.equal(stateFromAvailability(undefined), "available"); assert.equal(stateFromAvailability("deleted"), "deleted"); assert.equal(stateFromAvailability("private"), "private"); assert.equal(stateFromAvailability("members_only"), "members_only"); assert.equal(stateFromAvailability("unlisted"), "unlisted"); }); // A video not in any channel's maybe-missing set never reaches the predicate; // its state comes from the summary, which is `available` when nothing is set. test("summaryState: absent state + no legacy booleans → available", () => { assert.equal(summaryState({}), "available"); assert.equal(summaryState({ isDeleted: false, isUnlisted: false }), "available"); }); // The hub reads summaries pages off member origins it does not control, so a // page built before `state` existed must still filter correctly. test("summaryState: falls back to the legacy booleans", () => { assert.equal(summaryState({ isDeleted: true }), "deleted"); assert.equal(summaryState({ isUnlisted: true }), "unlisted"); }); test("summaryState: an explicit state wins over the legacy booleans", () => { assert.equal( summaryState({ state: "private", isDeleted: true }), "private", ); });