import test from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; // Run with: // pnpm -C editor exec tsx --test "app/api/ops/fetch-windows/route.test.ts" // // The body's shape, the refusals that come before any job, and a dry run's // plan — answered from a temp corpus. Every request here is a dry run or a // refusal: no job is queued and nothing is fetched. const ROOT = await mkdtemp(path.join(os.tmpdir(), "fetch-windows-route-")); // Set before the route (and getPaths, which caches) is first imported. process.env.WORKER_TOKEN = "test-token"; process.env.TRANSCRIPTS_DIR = ROOT; process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); const { POST } = await import("./route"); test.after(() => rm(ROOT, { recursive: true, force: true })); // Channels on two platforms, one video each, the YouTube one with a window // already on disk. const CH = path.join(ROOT, "channels"); async function channel(slug: string, url: string, id: string, webpage: string) { await mkdir(path.join(CH, slug, "data", id), { recursive: true }); await writeFile( path.join(CH, slug, "config.json"), JSON.stringify({ handling: "youtube", name: slug, url }), ); await writeFile( path.join(CH, slug, "data", id, "metadata.info.json"), JSON.stringify({ id, webpage_url: webpage }), ); } await channel("yt-chan", "https://www.youtube.com/@yt", "vid1", "https://www.youtube.com/watch?v=vid1"); await channel("rb-chan", "https://rumble.com/c/rb", "rb1", "https://rumble.com/rb1-x.html"); // A channel whose own URL is no platform's, holding a Rumble video. await channel("mix-chan", "https://example.test/mix", "rb2", "https://rumble.com/rb2-y.html"); await mkdir(path.join(CH, "yt-chan", "data", "vid1", "clips"), { recursive: true }); await writeFile(path.join(CH, "yt-chan", "data", "vid1", "clips", "0.00-60.00.mp4"), "mp4"); async function post( body: Record, ): Promise<{ status: number; json: Record & { error?: string } }> { const res = await POST( new Request("http://localhost/api/ops/fetch-windows", { method: "POST", headers: { authorization: "Bearer test-token", "content-type": "application/json", }, body: JSON.stringify(body), }), ); return { status: res.status, json: (await res.json()) as Record }; } const item = (o: Record = {}) => ({ slug: "yt-chan", id: "vid1", from: 100, to: 110, ...o }); test("siteId or items, never both, never neither; unknown keys refused", async () => { assert.match((await post({})).json.error!, /"siteId" .* or "items" .* is required/); const both = await post({ siteId: "s", items: [item()], requestedBy: "t" }); assert.equal(both.status, 400); assert.match(both.json.error!, /either "siteId" or "items"/); const unknown = await post({ items: [item()], requestedBy: "t", gapMs: 1 }); assert.equal(unknown.status, 400); assert.match(unknown.json.error!, /unknown key\(s\): gapMs/); }); test("an item list is validated at the door", async () => { const cases: [Record, RegExp][] = [ [{ items: [], requestedBy: "t" }, /non-empty array/], [{ items: [item({ slug: "../x" })], requestedBy: "t" }, /not a valid channel slug/], [{ items: [item({ id: ".." })], requestedBy: "t" }, /not a video id/], [{ items: [item({ from: 20, to: 10 })], requestedBy: "t" }, /must be less than to/], [{ items: [item({ from: 0, to: 2000 })], requestedBy: "t" }, /at most 900s/], [{ items: [item({ from: -1 })], requestedBy: "t" }, /non-negative seconds/], [{ items: [item({ extra: 1 })], requestedBy: "t" }, /items\[0\]" has unknown key\(s\): extra/], [{ items: [item()] }, /"requestedBy" is required/], [{ items: [item()], requestedBy: "t", maxHeight: 50 }, /"maxHeight" must be a whole number of pixels/], ]; for (const [body, re] of cases) { const r = await post(body); assert.equal(r.status, 400, JSON.stringify(body)); assert.match(r.json.error!, re); } }); test("a site's windows name the site; a missing site is refused", async () => { const named = await post({ siteId: "demo-site", requestedBy: "me" }); assert.equal(named.status, 400); assert.match(named.json.error!, /"requestedBy" goes with "items"/); const missing = await post({ siteId: "demo-site" }); assert.equal(missing.status, 400); assert.match(missing.json.error!, /No site "demo-site"/); }); test("a dry run answers the cache, groups by platform queue, and names what it cannot resolve", async () => { const r = await post({ requestedBy: "test", dryRun: true, items: [ item({ from: 10, to: 20 }), // inside the cached 0–60 window item({ from: 100, to: 110 }), item({ from: 100, to: 110 }), // a duplicate collapses item({ slug: "rb-chan", id: "rb1", from: 5, to: 15 }), item({ slug: "mix-chan", id: "rb2", from: 5, to: 15 }), item({ slug: "no-such", id: "x", from: 5, to: 15 }), ], }); assert.equal(r.status, 200); const j = r.json as { dryRun: boolean; groups: { platform: string; queueKey: string; items: { id: string; webpageUrl?: string }[] }[]; cached: { from: number }[]; unresolved: { error: string }[]; jobs?: unknown; }; assert.equal(j.dryRun, true); assert.equal(j.jobs, undefined, "a dry run starts nothing"); assert.deepEqual(j.cached.map((c) => c.from), [10]); assert.deepEqual( j.groups.map((g) => [g.platform, g.items.length]).sort(), [["rumble", 2], ["youtube", 1]], ); // The window's own URL picks the queue: the mix channel's Rumble video joins // the Rumble job rather than starting a second one beside it. The queue is // the platform's CLIP queue (release 19, A5), not its download queue. assert.deepEqual(j.groups.map((g) => g.queueKey).sort(), ["clips:rumble", "clips:youtube"]); const yt = j.groups.find((g) => g.platform === "youtube")!; assert.equal(yt.items[0].webpageUrl, "https://www.youtube.com/watch?v=vid1"); assert.equal(j.unresolved.length, 1); assert.match(j.unresolved[0].error, /Channel "no-such" not found/); }); test("a window a queued job will already write is answered in inFlight with that job, and joins no group", async () => { // A fabricated queued fetch-window job for 95–130 of vid1, behind a running // holder on its queue so it never starts: its spec is the request it was // started with, which is what the dedupe reads. const { getRegistry } = await import("yt-dlp-transcript-common/jobs/registry"); const reg = getRegistry(); const make = (id: string, kind: string, params: Record) => { const record = { id, kind, queueKey: "clips:youtube", channelSlug: "yt-chan", status: "queued" as const, queuedAt: Date.now(), logPath: "/dev/null", spec: { kind, slug: "yt-chan", params }, }; reg.register(record); reg.enqueue(record, { start: () => {}, onCancel: () => {} }); }; make("HOLDER", "fetch-window", { videoId: "other", from: 0, to: 5 }); make("WIN95", "fetch-window", { videoId: "vid1", from: 95, to: 130 }); try { const r = await post({ requestedBy: "test", dryRun: true, items: [item({ from: 100, to: 110 }), item({ from: 200, to: 210 })], }); assert.equal(r.status, 200); const j = r.json as { inFlight: { item: { from: number }; jobId: string }[]; groups: { items: { from: number }[] }[]; }; assert.deepEqual( j.inFlight.map((f) => [f.item.from, f.jobId]), [[100, "WIN95"]], ); assert.deepEqual(j.groups.flatMap((g) => g.items.map((i) => i.from)), [200]); // A different height cap is a different request. const capped = await post({ requestedBy: "test", dryRun: true, maxHeight: 480, items: [item({ from: 100, to: 110 })], }); assert.deepEqual((capped.json as { inFlight: unknown[] }).inFlight, []); } finally { reg.cancel("WIN95"); reg.forceRelease("HOLDER"); } });