// /api/ops — the HTTP door onto the editor's server actions. // // What this spec is really pinning is the claim the layer rests on: that an // agent driving the editor over HTTP and an operator clicking the same button // get the SAME answer from the SAME code. So the assertions are deliberately // about the shared sentences — the download-filter message that // title-filter.spec.ts reads off the form, the busy-channel refusal the Storage // panel shows — and about the files on disk, not about the routes' own shapes. // // WHAT IS NOT HERE ANY MORE (release 19, A2b). Every test that exercised only a // route's request and response — the token gate's three answers (401, 503, // pass), unknown keys, traversing slugs, and each refusal answered before any // job — is a unit test now, beside its route: `app/api/ops/_door.test.ts` and // `app/api/ops//route.test.ts`, run by `pnpm --filter editor test` // against a temp copy of the same fixtures (`app/api/ops/_testCorpus.ts`). // What stays needs the running server: a write that revalidates a page, a job // that runs, a queue the server holds. import { mkdir, rm, writeFile } from "node:fs/promises"; import { test, expect, type APIRequestContext } from "@playwright/test"; import { baseUrl } from "./baseUrl"; import { generateReport, pathExists, readJson, resetData, resolvePath, writeChannelConfig, writeSettings, writeSite, } from "./helpers"; const TOKEN = "test-worker-token"; const AUTH = { authorization: `Bearer ${TOKEN}` }; type OpsResponse = { ok?: boolean; error?: string; jobId?: string; queued?: string[]; jobIds?: string[]; skipped?: { slug?: string; siteId?: string; reason: string }[]; jobs?: { siteId: string; jobId: string; previewUrl?: string }[]; previewUrl?: string; runId?: string; }; async function ops( request: APIRequestContext, action: string, data: Record, ): Promise<{ status: number; body: OpsResponse }> { const res = await request.post(`${baseUrl}/api/ops/${action}`, { headers: AUTH, data, }); return { status: res.status(), body: (await res.json()) as OpsResponse }; } // EVERY SPEC HERE NAMES THE DISK FLOOR. Without it the merged fixture default // applies, and a spec that starts a download-shaped job on a nearly-full host // would be refused by lowDiskError() with a returned { ok: false } no assertion // reads. 0 is the fixture's own value; naming it makes that a decision. async function settings(): Promise { await writeSettings({ minFreeDiskGB: 0 }); } // THE CHANNEL'S LIFECYCLE, OVER HTTP. create-channel is the New form, // rename-channel and delete-channel the Danger zone's two forms, channel-config // grows the Configure form's Sites section and the rack's two exclusion // switches, and `get channels` is the list a caller picks a slug from. Each is // the form's own action (actions.ts' *FromForm cores), so the files on disk are // what the browser would have left. test("a channel's lifecycle over ops: create on a site, list, re-site, rename, delete", async ({ request, }) => { test.setTimeout(120_000); await resetData("empty"); await settings(); await writeSite("mysite", { channels: [] }); const SITE = "test-transcripts/sites/mysite/site.json"; const list = async () => { const res = await request.get(`${baseUrl}/api/ops/channels`, { headers: AUTH }); expect(res.status()).toBe(200); return ((await res.json()) as { channels: { slug: string; kind: string; sites: string[]; excludeFromBuild?: true }[]; }).channels; }; const made = await ops(request, "create-channel", { slug: "made-x", fields: { name: "Made (X)", handling: "transcribe", url: "https://x.com/made_user", sourceKind: "social", postFetcher: "x-gallery-dl", }, sites: [{ siteId: "mysite" }], }); expect(made.body).toEqual({ ok: true, slug: "made-x", jobIds: [] }); const cfg = await readJson>( "test-transcripts/channels/made-x/config.json", ); expect(cfg.sourceKind).toBe("social"); expect(cfg.socialHandle).toBe("made_user"); expect( (await readJson<{ channels: { slug: string }[] }>(SITE)).channels.map((c) => c.slug), ).toEqual(["made-x"]); expect(await list()).toEqual([ { slug: "made-x", name: "Made (X)", kind: "social", platform: "twitter", handling: "transcribe", url: "https://x.com/made_user", sites: ["mysite"] }, ]); // Off every site, and out of the build — set, so a second call is a no-op. for (let i = 0; i < 2; i++) { const res = await ops(request, "channel-config", { slug: "made-x", sites: [], excludeFromBuild: true, }); expect(res.body).toEqual({ ok: true }); } expect((await readJson<{ channels: unknown[] }>(SITE)).channels).toEqual([]); const [row] = await list(); expect(row.sites).toEqual([]); expect(row.excludeFromBuild).toBe(true); const renamed = await ops(request, "rename-channel", { slug: "made-x", newSlug: "made-renamed-x", }); expect(renamed.status).toBe(200); expect((renamed.body as { slug?: string }).slug).toBe("made-renamed-x"); expect((await list()).map((c) => c.slug)).toEqual(["made-renamed-x"]); expect(await pathExists("test-transcripts/channels/made-x")).toBe(false); const refused = await ops(request, "delete-channel", { slug: "made-renamed-x", confirm: "made-x", }); expect(refused.status).toBe(400); expect(refused.body.error).toBe( 'Type the channel slug "made-renamed-x" exactly to confirm deletion', ); const gone = await ops(request, "delete-channel", { slug: "made-renamed-x", confirm: "made-renamed-x", }); expect(gone.status).toBe(200); expect(await list()).toEqual([]); }); test("channel-config round-trips a download filter and refuses a bad regex", async ({ page, request, }) => { test.setTimeout(120_000); await resetData("title-filter-channel"); await settings(); const SLUG = "test-filter"; const CONFIG = `test-transcripts/channels/${SLUG}/config.json`; // TWO FIELDS THE CLEAR-THEN-LAYER PATH ACTUALLY THREATENS. name/handling/url // survive a patch trivially — they are re-posted by the serializer because // parseChannelForm requires the first two and CHANNEL_FORM_FIELDS spares // none of the rest. `cookieMode` and `keepLatest` are in CHANNEL_FORM_FIELDS, // so updateChannelAction deletes them from the baseline before layering, and // a patch that failed to re-post them would silently clear both. // // `keepLatest: 0` is the sharp one: 0 is the explicit "disabled" sentinel, and // a serializer that tested the value for truthiness rather than for // null/undefined would drop it and read as "inherit" on the next save. await writeChannelConfig(SLUG, { handling: "youtube", name: "Test Title Filter", url: "https://www.youtube.com/@example/videos", downloadFilter: { include: "guest" }, cookieMode: "always", keepLatest: 0, }); const before = await readJson>(CONFIG); expect(before.downloadFilter).toEqual({ include: "guest" }); expect(before.cookieMode).toBe("always"); expect(before.keepLatest).toBe(0); // THE SAME SENTENCE THE FORM SHOWS. title-filter.spec.ts reads this off the // page after typing "elf(" into the exclude input; the route reaches it // through the same parseChannelForm, which is the whole point of routing a // patch through a FormData rather than writing config.json directly. const bad = await ops(request, "channel-config", { slug: SLUG, patch: { downloadFilterExclude: "elf(" }, }); expect(bad.status).toBe(400); expect(bad.body.error).toMatch( /Download filter exclude is not a valid regular expression/, ); // And nothing was written. expect( (await readJson>(CONFIG)).downloadFilter, ).toEqual({ include: "guest" }); const good = await ops(request, "channel-config", { slug: SLUG, patch: { downloadFilterInclude: "guest|special", downloadFilterExclude: "rerun" }, }); expect(good.body).toEqual({ ok: true }); const after = await readJson>(CONFIG); expect(after.downloadFilter).toEqual({ include: "guest|special", exclude: "rerun", }); // A PATCH IS A PATCH. updateChannelAction clears every form-managed key // before layering the parse result on, so a route that posted only the patch // would have silently dropped every one of these. expect(after.name).toBe(before.name); expect(after.handling).toBe(before.handling); expect(after.url).toBe(before.url); expect(after.cookieMode).toBe("always"); expect(after.keepLatest).toBe(0); // "" clears a field, exactly as clearing the input does. const cleared = await ops(request, "channel-config", { slug: SLUG, patch: { downloadFilterInclude: "", downloadFilterExclude: "" }, }); expect(cleared.body).toEqual({ ok: true }); const emptied = await readJson>(CONFIG); expect("downloadFilter" in emptied).toBe(false); // Clearing one field clears ONLY that field. expect(emptied.cookieMode).toBe("always"); expect(emptied.keepLatest).toBe(0); // The read route sees the same config, and says whether the media is there. await generateReport(page, SLUG); const read = await request.get(`${baseUrl}/api/ops/channel/${SLUG}`, { headers: AUTH, }); expect(read.status()).toBe(200); const view = (await read.json()) as { ok: boolean; config: { name: string }; media: { status: string }; report: { totals: { videos: number } } | null; }; expect(view.ok).toBe(true); expect(view.config.name).toBe(before.name); expect(view.media.status).toBe("in-place"); expect(view.report?.totals.videos).toBeGreaterThanOrEqual(0); const missing = await request.get(`${baseUrl}/api/ops/channel/nope`, { headers: AUTH, }); expect(missing.status()).toBe(404); }); test("channel-priority writes a per-operation override", async ({ request }) => { await resetData("title-filter-channel"); await settings(); const SLUG = "test-filter"; const pinned = await ops(request, "channel-priority", { slugs: [SLUG], operation: "download", tier: "paused", }); expect(pinned.body).toEqual({ ok: true }); await expect .poll(async () => { const s = await readJson<{ channelPriority?: { channels?: Record }>; }; }>("test-settings.json"); return s.channelPriority?.channels?.[SLUG]?.overrides?.download ?? null; }) .toBe("paused"); // null clears the override — and only with an operation named, because a bare // null tier has no meaning for the BASE tier. const bare = await ops(request, "channel-priority", { slugs: [SLUG], tier: null, }); expect(bare.status).toBe(400); expect(bare.body.error).toMatch(/clears an operation override/); const cleared = await ops(request, "channel-priority", { slugs: [SLUG], operation: "download", tier: null, }); expect(cleared.body).toEqual({ ok: true }); await expect .poll(async () => { const s = await readJson<{ channelPriority?: { channels?: Record }>; }; }>("test-settings.json"); return s.channelPriority?.channels?.[SLUG]?.overrides?.download ?? null; }) .toBe(null); const bogus = await ops(request, "channel-priority", { slugs: [SLUG], tier: "urgent", }); expect(bogus.status).toBe(400); expect(bogus.body.error).toMatch(/normal, low, paused/); }); test("metadata-scan starts a job, and the job says it is a metadata-scan", async ({ request, }) => { test.setTimeout(120_000); await resetData("title-filter-channel"); await settings(); const SLUG = "test-filter"; const { status, body } = await ops(request, "metadata-scan", { slug: SLUG }); expect(status).toBe(200); expect(body.ok).toBe(true); expect(body.jobId).toBeTruthy(); // THE ROUTE DOES NOT STREAM, so the id is the whole contract: the caller // follows the same log endpoint the editor's own panel polls. // The sidecar is written with a `void` promise right after enqueue, so poll. const metaPath = `test-transcripts/.jobs/${body.jobId}.meta.json`; await expect .poll(async () => { const meta = await readJson<{ kind: string; channelSlug: string }>( metaPath, ).catch(() => null); return meta ? `${meta.kind}/${meta.channelSlug}` : null; }) .toBe(`metadata-scan/${SLUG}`); const log = await request.get( `${baseUrl}/api/jobs/${body.jobId}/log?from=0`, ); expect(log.status()).toBe(200); const payload = (await log.json()) as { status: string }; expect( ["queued", "running", "done", "failed", "cancelled"].includes( payload.status, ), ).toBe(true); const unknownChannel = await ops(request, "metadata-scan", { slug: "nope" }); expect(unknownChannel.status).toBe(400); expect(unknownChannel.body.error).toContain('Channel "nope" not found'); }); // A QUEUED JOB MUST NOT END `failed` FOR REVALIDATING (release 9, B1). // // The scheduler starts the next queued job synchronously when the one ahead // finishes, so the next job inherits the async context of whatever finished // it. When that is a job the auto-runner or the sync heartbeat started — no // request anywhere up the chain — Next has no work store, and revalidatePath // throws "static generation store missing". The sync body calls it last, so the // throw landed after the work was done: the job read `failed` and `pnpm ops … // --wait` exited 1 (the teamrcn sync, 2026-09-25). // // The holder is /api/test/stuck-job on platform:youtube, released after 10 s // from OUTSIDE any request (`releaseAfterMs`; `detached` says the harness // managed it). The sync is submitted behind it, so the release is what starts // it — with no store, as live. test("a sync queued behind another platform job ends done, not failed", async ({ request, }) => { test.setTimeout(90_000); await resetData("two-slow-channels"); await settings(); // slow-b without its --test-slow flag: a sync that finishes in seconds. await writeChannelConfig("slow-b", { name: "Slow B", url: "https://www.youtube.com/@slow-b/videos", }); type Meta = { status: string; queueKey?: string }; const meta = (id: string) => readJson(`test-transcripts/.jobs/${id}.meta.json`).catch(() => null); const hold = await request.get( `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent("platform:youtube")}&releaseAfterMs=10000`, ); expect(hold.ok()).toBe(true); const holder = (await hold.json()) as { id: string; detached: boolean }; expect(holder.detached).toBe(true); const sync = await ops(request, "sync", { slug: "slow-b" }); expect(sync.body.ok).toBe(true); const jobId = sync.body.jobId!; // Behind the holder on the same platform queue: the release starts it. The // 10 s window leaves room for a cold-compiled /api/ops POST and this poll to // land before the release fires. await expect.poll(async () => (await meta(jobId))?.status ?? null).toBe("queued"); expect((await meta(jobId))?.queueKey).toBe("platform:youtube"); await expect .poll(async () => (await meta(jobId))?.status ?? null, { timeout: 60_000, intervals: [1_000], }) .toMatch(/^(done|failed|cancelled)$/); const log = await request.get(`${baseUrl}/api/jobs/${jobId}/log?from=0`); expect(JSON.stringify(await log.json())).not.toContain( "static generation store missing", ); expect((await meta(jobId))?.status).toBe("done"); }); test("refresh-report regenerates snapshot.json", async ({ request }) => { test.setTimeout(120_000); await resetData("title-filter-channel"); await settings(); const SLUG = "test-filter"; const SNAP = `test-transcripts/channels/${SLUG}/snapshot.json`; // Removed rather than assumed absent: a debounced regen armed by the previous // spec can land between resetData's copy and here, and the point of the // assertion below is the ROUTE's effect, not the scheduler's. await rm(resolvePath(SNAP), { force: true }); expect(await pathExists(SNAP)).toBe(false); // No page, no click: the route IS the refresh. It answers once the // regeneration is QUEUED (release 17: one serial refresh-report queue, and // the ops rule — a job-starting route returns a jobId), so the job is // followed to `done` before the file is read. const doneJob = async (jobId: string | undefined) => { expect(jobId).toBeTruthy(); await expect .poll( async () => ( await readJson<{ status: string }>( `test-transcripts/.jobs/${jobId}.meta.json`, ).catch(() => null) )?.status ?? null, { timeout: 30_000 }, ) .toBe("done"); }; const first = await ops(request, "refresh-report", { slug: SLUG }); expect(first.body.ok).toBe(true); await doneJob(first.body.jobId); const snapshot = await readJson<{ generatedAt: string; totals: { videos: number } }>(SNAP); expect(snapshot.generatedAt).toBeTruthy(); expect(snapshot.totals.videos).toBeGreaterThanOrEqual(0); // Re-running REWRITES it. Polled because two scans of a six-video fixture // can land in the same millisecond. await expect .poll(async () => { const again = await ops(request, "refresh-report", { slug: SLUG }); await doneJob(again.body.jobId); return (await readJson<{ generatedAt: string }>(SNAP)).generatedAt; }) .not.toBe(snapshot.generatedAt); // An unknown channel is a 404 naming it, and starts nothing. const missing = await ops(request, "refresh-report", { slug: "no-such-channel" }); expect(missing.status).toBe(404); expect(missing.body.error).toMatch(/no-such-channel/); // The bulk form queues a job per channel and reports both lists. const all = await ops(request, "refresh-report", { all: true }); expect(all.status).toBe(200); expect(all.body.ok).toBe(true); expect([...(all.body.queued ?? []), ...(all.body.skipped ?? []).map((s) => s.slug)]).toContain( SLUG, ); // ONE JOB ID PER QUEUED SLUG, in the same order. This is what `--wait` // follows; without it a fan-out response was indistinguishable from an // action that started nothing. expect(all.body.jobIds?.length).toBe(all.body.queued?.length); for (const id of all.body.jobIds ?? []) expect(id).toBeTruthy(); const both = await ops(request, "refresh-report", { slug: SLUG, all: true }); expect(both.status).toBe(400); expect(both.body.error).toMatch(/not both/); }); test("relocate refuses a busy channel with the sentence the panel shows", async ({ request, }, testInfo) => { test.setTimeout(120_000); await resetData("slow-pipeline-channel"); await settings(); const SLUG = "slow-channel"; // --test-slow makes the fake yt-dlp sleep 30s, so the channel is genuinely // busy for the length of this assertion rather than racily so. const started = await ops(request, "sync", { slug: SLUG }); expect(started.body.ok).toBe(true); await expect .poll(async () => { const res = await request.get(`${baseUrl}/api/jobs/active`); const body = (await res.json()) as | { channelSlug?: string; status: string }[] | { jobs?: { channelSlug?: string; status: string }[] }; const jobs = Array.isArray(body) ? body : (body.jobs ?? []); return jobs.filter( (j) => j.channelSlug === SLUG && (j.status === "running" || j.status === "queued"), ).length; }) .toBeGreaterThan(0); const root = testInfo.outputPath("media-root"); const refused = await ops(request, "relocate", { slugs: [SLUG], root }); expect(refused.status).toBe(200); // A SKIP IS NOT A FAILURE — the bulk bar renders both numbers, and so does // this. The reason is channelMediaBusyReason's, word for word. expect(refused.body.queued).toEqual([]); // AND NO JOB IDS, which is the half `--wait` reads: a fan-out that started // nothing must be distinguishable from one that started something. Before // `jobIds` existed a relocate response carried no id at all, so // `pnpm ops relocate --wait` found none, concluded nothing had been started // and returned 0 — reporting success about a 130 GB copy that had not begun. expect(refused.body.jobIds).toEqual([]); expect(refused.body.skipped?.[0]?.slug).toBe(SLUG); expect(refused.body.skipped?.[0]?.reason).toMatch( /running\/queued job\(s\) for this channel/, ); // Exactly one destination, and it must be absolute. const neither = await ops(request, "relocate", { slugs: [SLUG] }); expect(neither.status).toBe(400); expect(neither.body.error).toMatch(/exactly one of "locationId".*or "root"/); }); test("lane flips a hold, and /api/auto-queue/status agrees", async ({ request, }) => { await resetData("empty"); await settings(); const held = async (): Promise => { const res = await request.get(`${baseUrl}/api/auto-queue/status`); const body = (await res.json()) as Record; return body.download?.held === true; }; expect(await held()).toBe(false); const hold = await ops(request, "lane", { lane: "download", held: true }); expect(hold.body).toEqual({ ok: true, lane: "download" }); await expect.poll(held).toBe(true); const release = await ops(request, "lane", { lane: "download", held: false }); expect(release.body).toEqual({ ok: true, lane: "download" }); await expect.poll(held).toBe(false); // `enabled` is the policy's master switch and is NOT the gate — two controls // in the UI, two keys here. const enable = await ops(request, "lane", { lane: "download", enabled: true }); expect(enable.body.ok).toBe(true); await expect .poll(async () => { const s = await readJson<{ autoQueue?: Record; }>("test-settings.json"); return s.autoQueue?.download?.enabled ?? null; }) .toBe(true); await ops(request, "lane", { lane: "download", enabled: false }); const nothing = await ops(request, "lane", { lane: "download" }); expect(nothing.status).toBe(400); expect(nothing.body.error).toMatch(/nothing to do/); const bogus = await ops(request, "lane", { lane: "transcode", held: true }); expect(bogus.status).toBe(400); expect(bogus.body.error).toMatch(/transcription, download, digest, backfill/); }); // --- jobs (release 19, A2) -------------------------------------------------- // // The /jobs row buttons over HTTP. Two fabricated jobs on one queue (the // /api/test/stuck-job harness: a running head that never finishes and one // queued behind it) are what a real backlog looks like, without the race of a // real one. The success paths live here because each verb is a server action // that revalidates /jobs, which only a running Next server can do. test("jobs over ops: the live head, one job's place, and the row buttons", async ({ request, }) => { const queue = `ops-jobs-${Date.now()}`; const stuck = async () => (await (await request.get(`${baseUrl}/api/test/stuck-job?queue=${queue}`)).json()) as { id: string; }; const head = await stuck(); const waiting = await stuck(); try { const active = await request.get(`${baseUrl}/api/ops/jobs?active=1`, { headers: AUTH }); expect(active.status()).toBe(200); const rows = ((await active.json()) as { jobs: { id: string; status: string; queue?: { key: string; position: number } }[]; }).jobs.filter((j) => j.queue?.key === queue); expect(rows.map((j) => [j.id, j.status, j.queue?.position])).toEqual([ [head.id, "running", 0], [waiting.id, "queued", 1], ]); const one = await request.get(`${baseUrl}/api/ops/job/${waiting.id}`, { headers: AUTH }); const job = ((await one.json()) as { job: { queue: { position: number; queued: number; head?: { id: string } } }; }).job; expect(job.queue.position).toBe(1); expect(job.queue.head?.id).toBe(head.id); const tail = await request.get(`${baseUrl}/api/ops/job/${head.id}?tail=5`, { headers: AUTH }); expect(((await tail.json()) as { tail: string[] }).tail).toEqual([ "[fake] wedged job holding its slot", ]); // Already at the front of its queue: the row's own refusal, named. const promote = await ops(request, "job", { verb: "promote", ids: [waiting.id] }); expect(promote.status).toBe(400); expect(promote.body.error).toMatch(/1 of 1 not promoted: .*already at the front/); // One unknown id does not stop the known one. const cancel = await ops(request, "job", { verb: "cancel", ids: [waiting.id, "NOSUCHJOB"] }); expect(cancel.status).toBe(400); expect(cancel.body.error).toMatch(/1 of 2 not cancelled: NOSUCHJOB/); const cancelled = await request.get(`${baseUrl}/api/ops/job/${waiting.id}`, { headers: AUTH }); expect(((await cancelled.json()) as { job: { status: string } }).job.status).toBe("cancelled"); } finally { // The head never ends on its own: free its slot whatever happened above. const released = await ops(request, "job", { verb: "force-release", ids: [head.id] }); expect(released.body.ok).toBe(true); } const after = await request.get(`${baseUrl}/api/ops/job/${head.id}`, { headers: AUTH }); const ended = ((await after.json()) as { job: { status: string; queue?: unknown } }).job; expect(ended.status).toBe("cancelled"); expect(ended.queue).toBeUndefined(); }); // --- curated tags ----------------------------------------------------------- // // The same two claims as everywhere else in this file: the route is an adapter // over the action the UI calls, and the thing to assert is the FILE plus the // shared sentences — not the route's own shape. test("tags defines a vocabulary, and tag-videos writes pins with their source", async ({ request, }) => { await resetData("one-youtube-channel-with-data"); const slug = "test-youtube"; const id = "20240101_test1234567"; const define = await ops(request, "tags", { op: "define", tag: { id: "eva-collab", label: "Collab", group: "eva", rules: [{ id: "meta", kind: "metadata", pattern: "synthetic" }], }, }); expect(define.status, JSON.stringify(define.body)).toBe(200); const pin = await ops(request, "tag-videos", { tag: "eva-collab", op: "add", videos: [{ slug, id }], }); expect(pin.status, JSON.stringify(pin.body)).toBe(200); expect((pin.body as { changed?: number }).changed).toBe(1); const file = await readJson<{ tags: { id: string; rules?: unknown[] }[]; assignments: Record< string, { manual?: string[]; sources?: Record } >; }>("test-transcripts/tags.json"); expect(file.tags[0].id).toBe("eva-collab"); expect(file.assignments[`${slug}/${id}`].manual).toEqual(["eva-collab"]); // A CALL ARRIVING HERE IS NOT THE BROWSER, and the default provenance says so // rather than claiming an operator pressed something. expect(file.assignments[`${slug}/${id}`].sources?.["eva-collab"].source).toBe( "agent:ops", ); // Re-pinning what is pinned MOVES NOTHING, and says 0 rather than claiming // work it did not do. const again = await ops(request, "tag-videos", { tag: "eva-collab", op: "add", videos: [{ slug, id }], source: "agent:spec", }); expect((again.body as { changed?: number }).changed).toBe(0); // The read side: definitions with the counts somebody ASSERTED, then one // tag's assignments with their provenance. const all = await request.get(`${baseUrl}/api/ops/tags`, { headers: AUTH }); const allBody = (await all.json()) as { tags: { id: string; pinned: number; suppressed: number }[]; }; expect(allBody.tags[0]).toMatchObject({ id: "eva-collab", pinned: 1, suppressed: 0, }); const one = await request.get(`${baseUrl}/api/ops/tags?tag=eva-collab`, { headers: AUTH, }); const oneBody = (await one.json()) as { assignments: { channelSlug: string; id: string; state: string; source: string }[]; }; expect(oneBody.assignments).toEqual([ { channelSlug: slug, id, state: "pinned", source: "agent:ops", setAt: expect.any(String), }, ]); // UNPIN IS NOT SUPPRESS. `remove` clears the pin and leaves no rejection // behind, so a rule that matches this video still puts the tag on it. const unpin = await ops(request, "tag-videos", { tag: "eva-collab", op: "remove", videos: [{ slug, id }], }); expect((unpin.body as { changed?: number }).changed).toBe(1); const after = await readJson<{ assignments: Record }>( "test-transcripts/tags.json", ); expect(after.assignments).toEqual({}); // Removing a DEFINITION leaves assignments alone — it is a label, not a fact. await ops(request, "tag-videos", { tag: "eva-collab", op: "suppress", videos: [{ slug, id }], }); const undefine = await ops(request, "tags", { op: "remove", tag: "eva-collab" }); expect(undefine.status).toBe(200); const left = await readJson<{ tags: unknown[]; assignments: Record; }>("test-transcripts/tags.json"); expect(left.tags).toEqual([]); expect(left.assignments[`${slug}/${id}`].suppressed).toEqual(["eva-collab"]); }); // --- keep-videos: the bulk do-not-clean toggle -------------------------------- // // The marker is the video page's own do-not-clean.json; what this pins is the // loop around it — the download filter's matcher over title + description, a // dry run that writes nothing, and a scan-only match that is REPORTED rather // than given a directory it was never downloaded into. test("keep-videos marks the matching video only, after a dry run that writes nothing", async ({ request, }) => { await resetData("one-youtube-channel-with-data"); const slug = "test-youtube"; const kept = "20240102_keepme12345"; const other = "20240101_test1234567"; // "Synthetic Test Video" — no needle const channel = `test-transcripts/channels/${slug}`; await mkdir(resolvePath(`${channel}/data/${kept}`), { recursive: true }); await writeFile( resolvePath(`${channel}/data/${kept}/metadata.info.json`), JSON.stringify({ id: kept, title: "Reacting to THEQUARTERING", description: "" }), ); await writeFile( resolvePath(`${channel}/metadata-scan.json`), JSON.stringify({ version: 1, updatedAt: "", lastRun: null, errors: {}, entries: { scanonly123: { title: "Plain", description: "with TheQuartering", uploadDate: "20240103", scannedAt: "2026-09-25T00:00:00.000Z", }, }, }), ); const marker = (id: string) => `${channel}/data/${id}/do-not-clean.json`; type KeepBody = OpsResponse & { matched?: { id: string; marked: boolean }[]; marked?: number; notDownloaded?: string[]; }; const dry = await ops(request, "keep-videos", { slug, match: "thequartering", dryRun: true, }); expect(dry.status, JSON.stringify(dry.body)).toBe(200); const dryBody = dry.body as KeepBody; expect(dryBody.matched?.filter((m) => m.id === kept)).toHaveLength(1); expect(dryBody.marked).toBe(0); expect(await pathExists(marker(kept))).toBe(false); const real = await ops(request, "keep-videos", { slug, match: "thequartering" }); expect(real.status, JSON.stringify(real.body)).toBe(200); const body = real.body as KeepBody; expect(body.marked).toBe(1); expect(body.notDownloaded).toEqual(["scanonly123"]); expect(await pathExists(marker(kept))).toBe(true); expect(await pathExists(marker(other))).toBe(false); // A scan-only match gets no directory — that would read as "downloaded". expect(await pathExists(`${channel}/data/scanonly123`)).toBe(false); const unknown = await ops(request, "keep-videos", { slug, match: "x", pattern: "x" }); expect(unknown.status).toBe(400); expect(unknown.body.error).toContain("unknown key(s): pattern"); const badRegex = await ops(request, "keep-videos", { slug, match: "(" }); expect(badRegex.status).toBe(400); expect(badRegex.body.error).toContain("invalid pattern"); }); // Hold the `publish` queue with a fabricated running job (/api/test/stuck-job; // never released by the caller), so any build or deploy stage that a refusal // under test failed to stop can only QUEUE, never run — every build and deploy // is a publish stage on that one queue since release 18. The next resetData // cancels jobs newest first, so a queued stage is removed before the holder's // slot is freed and nothing is ever promoted. async function holdPublishQueue(request: APIRequestContext) { const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`); expect(hold.ok()).toBe(true); } test("build-site with a bare siteId starts its build stage (the index first: there is none)", async ({ request, }) => { test.setTimeout(120_000); await resetData("title-filter-channel"); await settings(); await writeSite("buildsite", {}); const { status, body } = await ops(request, "build-site", { siteId: "buildsite", }); expect(status).toBe(200); expect(body.ok).toBe(true); // The response is the LIST shape whichever key was used — one job per site, // named: the site's build stage (the run's index update is not the site's). expect(body.jobs?.length).toBe(1); expect(body.jobs?.[0].siteId).toBe("buildsite"); expect(body.skipped).toEqual([]); expect(typeof body.runId).toBe("string"); const jobId = body.jobs![0].jobId; await expect .poll(async () => { const meta = await readJson<{ kind: string }>( `test-transcripts/.jobs/${jobId}.meta.json`, ).catch(() => null); return meta?.kind ?? null; }) .toBe("publish-build-site"); }); // --- publish (release 18) --------------------------------------------------- // // ONE route for the stages, a verb in the body, and its GET the status. The // publish queue is held throughout (holdPublishQueue): every stage the calls // enqueue only QUEUES, so what is asserted is the run each verb enqueues — its // kinds, its one run id, a duplicate answered with the existing job — and the // refusals before any job. publish.spec runs the stages. type PublishJobs = { ok?: boolean; error?: string; runId?: string; jobId?: string; jobs?: { target: string; kind: string; jobId: string; existing?: boolean; previewUrl?: string }[]; skipped?: { kind: string; target: string; reason: string }[]; refused?: { target: string; error: string }[]; }; test("publish: each verb enqueues its run of stages on the publish queue; a duplicate stage is the existing job", async ({ request, }) => { await resetData("title-filter-channel"); await settings(); await writeSite("pubsite", { cloudflareProject: "pubproj" }); await holdPublishQueue(request); const index = (await ops(request, "publish", { verb: "index" })).body as PublishJobs; expect(index.ok).toBe(true); expect(index.jobs?.map((j) => [j.kind, j.target])).toEqual([["update-index", "_index"]]); expect(index.jobId).toBe(index.jobs?.[0].jobId); const meta = await readJson<{ kind: string; queueKey: string; status: string }>( `test-transcripts/.jobs/${index.jobId}.meta.json`, ); expect([meta.kind, meta.queueKey, meta.status]).toEqual(["publish-update-index", "publish", "queued"]); // Publish now, every policy off: the index update alone — already queued, // so it is the same job, not a second one. const now = (await ops(request, "publish", { verb: "now" })).body as PublishJobs; expect(now.ok).toBe(true); expect(now.jobs?.map((j) => [j.kind, j.target, j.jobId, j.existing === true])).toEqual([ ["update-index", "_index", index.jobId, true], ]); expect(now.skipped?.some((x) => x.target === "pubsite")).toBe(true); // Build: the site's build after the (queued) index update, one run. const build = (await ops(request, "publish", { verb: "build", siteIds: ["pubsite"] })).body as PublishJobs; expect(build.ok).toBe(true); expect(build.jobs?.map((j) => [j.kind, j.target])).toEqual([ ["update-index", "_index"], ["build-site", "pubsite"], ]); expect(build.jobs?.[0].existing).toBe(true); // Build all stale: the same build, queued already. const stale = (await ops(request, "publish", { verb: "stale" })).body as PublishJobs; expect(stale.ok).toBe(true); expect(stale.jobs?.find((j) => j.kind === "build-site")?.existing).toBe(true); });