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/job/route.test.ts" // // The jobs over ops, against a temp `.jobs/` and the process's own registry: // one job's record (live and archived), where a queued job waits, the log tail, // and the gate. Every answer here is a read — nothing revalidates. const ROOT = await mkdtemp(path.join(os.tmpdir(), "ops-job-route-")); process.env.WORKER_TOKEN = "test-token"; process.env.TRANSCRIPTS_DIR = ROOT; process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); const JOBS = path.join(ROOT, ".jobs"); await mkdir(JOBS, { recursive: true }); const { getRegistry } = await import("yt-dlp-transcript-common/jobs/registry"); const one = (await import("./[id]/route")).GET; const verbs = (await import("./route")).POST; const many = (await import("../jobs/route")).GET; async function list(query = ""): Promise { const res = await many( new Request(`http://localhost/api/ops/jobs${query}`, { headers: { authorization: "Bearer test-token" }, }), ); return { status: res.status, json: (await res.json()) as Record }; } async function post(body: unknown): Promise { const res = await verbs( new Request("http://localhost/api/ops/job", { 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 }; } test.after(() => rm(ROOT, { recursive: true, force: true })); type Res = { status: number; json: Record }; async function get(id: string, query = "", token = "test-token"): Promise { const res = await one( new Request(`http://localhost/api/ops/job/${id}${query}`, { headers: token ? { authorization: `Bearer ${token}` } : {}, }), { params: Promise.resolve({ id }) }, ); return { status: res.status, json: (await res.json()) as Record }; } // An archived job: a log and a sidecar, no registry record. async function archived(id: string, status: string, lines: number): Promise { await writeFile( path.join(JOBS, `${id}.log`), Array.from({ length: lines }, (_, i) => `line ${i}`).join("\n") + "\n", ); await writeFile( path.join(JOBS, `${id}.meta.json`), JSON.stringify({ id, kind: "sync", queueKey: "youtube", channelSlug: "demo", status, queuedAt: 1, startedAt: 2, endedAt: 3, exitCode: status === "done" ? 0 : 1, }), ); } // A live job on queue `key`, registered and submitted like a real one. function live(id: string, key: string): void { const record = { id, kind: "fetch-window", queueKey: key, channelSlug: "demo", status: "queued" as const, queuedAt: Date.now(), logPath: path.join(JOBS, `${id}.log`), }; getRegistry().register(record); getRegistry().enqueue(record, { start: () => {}, onCancel: () => {} }); } test("the gate: no token is a 401, and the id is checked before anything is read", async () => { assert.equal((await get("abc", "", "")).status, 401); assert.equal((await get("abc", "", "wrong")).status, 401); const bad = await get("..%2Fx"); assert.equal(bad.status, 400); assert.match(String(bad.json.error), /is not a job id/); }); test("an unknown id is a 404 { ok: false }, which --wait reads as 'gone'", async () => { const res = await get("NOSUCHJOB"); assert.equal(res.status, 404); assert.equal(res.json.ok, false); assert.equal(res.json.error, 'no job "NOSUCHJOB"'); }); test("an archived job answers with its sidecar's status — not 'archived'", async () => { await archived("ARCH1", "done", 3); const res = await get("ARCH1"); assert.equal(res.status, 200); const job = res.json.job as Record; assert.equal(job.status, "done"); assert.equal(job.kind, "sync"); assert.equal(job.inRegistry, false); assert.equal(job.queue, undefined); // The log's path on the server is not part of the answer. assert.equal(job.logPath, undefined); assert.equal(res.json.tail, undefined); }); test("?tail=N adds the log's last N lines, and refuses a bad N or an unknown key", async () => { await archived("ARCH2", "failed", 50); const res = await get("ARCH2", "?tail=3"); assert.deepEqual(res.json.tail, ["line 47", "line 48", "line 49"]); assert.equal((await get("ARCH2", "?tail=0")).status, 400); assert.equal((await get("ARCH2", "?tail=x")).status, 400); const stray = await get("ARCH2", "?tial=3"); assert.equal(stray.status, 400); assert.match(String(stray.json.error), /unknown query key\(s\): tial/); }); test("a queued job says where it waits and who holds the head", async () => { live("HEAD1", "rumble"); live("WAIT1", "rumble"); live("WAIT2", "rumble"); const head = (await get("HEAD1")).json.job as Record; assert.equal(head.status, "running"); assert.deepEqual(head.queue, { key: "rumble", position: 0, queued: 2 }); const waiting = (await get("WAIT2")).json.job as Record; assert.equal(waiting.status, "queued"); const queue = waiting.queue as Record; assert.equal(queue.position, 2); assert.equal(queue.queued, 2); assert.deepEqual( { ...(queue.head as Record), startedAt: undefined }, { id: "HEAD1", kind: "fetch-window", channelSlug: "demo", startedAt: undefined }, ); }); test("get jobs --active is the live head in queue order, each with its place", async () => { const res = await list("?active=1"); assert.equal(res.status, 200); const jobs = res.json.jobs as { id: string; queue?: { position: number } }[]; const rumble = jobs.filter((j) => ["HEAD1", "WAIT1", "WAIT2"].includes(j.id)); assert.deepEqual( rumble.map((j) => [j.id, j.queue?.position]), [ ["HEAD1", 0], ["WAIT1", 1], ["WAIT2", 2], ], ); // The archived jobs are not live. assert.ok(!jobs.some((j) => j.id.startsWith("ARCH"))); }); test("get jobs filters the history by status, kind and channel", async () => { const failed = (await list("?failed=1")).json.jobs as { id: string }[]; assert.deepEqual(failed.map((j) => j.id), ["ARCH2"]); const syncs = (await list("?kind=sync&slug=demo")).json.jobs as { id: string }[]; assert.deepEqual(syncs.map((j) => j.id).sort(), ["ARCH1", "ARCH2"]); const none = (await list("?kind=sync&slug=other")).json; assert.deepEqual(none.jobs, []); assert.equal(typeof none.scanned, "number"); const limited = (await list("?limit=1")).json.jobs as unknown[]; assert.equal(limited.length, 1); }); test("get jobs refuses what matches nothing or is not a filter", async () => { const both = await list("?active=1&failed=1"); assert.equal(both.status, 400); assert.match(String(both.json.error), /a failed job is not active/); assert.equal((await list("?slug=../x")).status, 400); assert.equal((await list("?kind=Sync%20All")).status, 400); assert.equal((await list("?limit=-1")).status, 400); const stray = await list("?status=failed"); assert.equal(stray.status, 400); assert.match(String(stray.json.error), /unknown query key\(s\): status/); }); test("job verbs: an unknown verb, missing ids, ids on retry-failed and a bad id are refused before anything", async () => { const unknown = await post({ verb: "kill", ids: ["WAIT1"] }); assert.equal(unknown.status, 400); assert.match(String(unknown.json.error), /"verb" must be one of cancel, drain, promote/); const noIds = await post({ verb: "cancel" }); assert.equal(noIds.status, 400); assert.match(String(noIds.json.error), /"ids" is required/); const withIds = await post({ verb: "retry-failed", ids: ["WAIT1"] }); assert.equal(withIds.status, 400); assert.match(String(withIds.json.error), /takes no "ids"/); const bad = await post({ verb: "cancel", ids: ["WAIT1", "../x"] }); assert.equal(bad.status, 400); assert.equal(bad.json.error, "not a job id: ../x"); const stray = await post({ verb: "cancel", ids: ["WAIT1"], force: true }); assert.equal(stray.status, 400); assert.match(String(stray.json.error), /unknown key\(s\): force/); // Nothing was cancelled by any of them. assert.equal(getRegistry().get("WAIT1")?.status, "queued"); });