commit a35e901abbf219ff0ce44600f0d8e9464c530ec4
parent e7272a7246f174bdf21d506ad885049998668741
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 11:48:06 -0400
editor: ops-api.spec's request/response tests become route unit tests
Release 19 slice A2b (moved here from B6). 19 of ops-api.spec's 33 tests
exercised only a route's request and response — the token gate's 401 and 503,
unknown keys, the traversing slug on fourteen routes, and every refusal
answered before a job — and needed no browser and no job. They are unit tests
now, beside their routes (_door.test.ts for the shared door; route.test.ts
for retry-bucket, tag-videos, persist-videos, build-site, deploy-site,
deploy-hub, deploy-homepage and publish; fetch-posts and capture-posts gain
one each), with the same assertions.
_testCorpus.ts copies the same e2e fixture into a temp dir, sets the e2e test
server's environment (the fake yt-dlp, gallery-dl, ffmpeg, whisper, wrangler
and next, the fake Cloudflare token, ARCHILYZER_BRANCH=main) and holds the
publish queue as e2e's holdPublishQueue did, so a refusal that regresses can
only queue a fake. The 503 no longer needs /api/test/worker-token: the unit
process unsets its own WORKER_TOKEN, which getWorkerToken reads per call.
ops-api.spec keeps the 14 that need the running server (writes that
revalidate, jobs that run, the publish queue's runs, the jobs verbs).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
13 files changed, 946 insertions(+), 823 deletions(-)
diff --git a/editor/app/api/ops/_door.test.ts b/editor/app/api/ops/_door.test.ts
@@ -0,0 +1,144 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readdir, readFile } from "node:fs/promises";
+import path from "node:path";
+import { TOKEN, callGet, callPost, setupOpsCorpus } from "./_testCorpus";
+
+// Run with:
+// pnpm -C editor exec tsx --test "app/api/ops/_door.test.ts"
+//
+// THE DOOR EVERY OPS ROUTE SHARES: the token gate's three answers, unknown
+// keys, and the traversing slug — checked against the routes themselves, in
+// the request/response alone. Moved from e2e/ops-api.spec.ts (release 19,
+// A2b): none of it needs a browser or a job, and none of it writes.
+//
+// The 503 used to need /api/test/worker-token to switch the variable off
+// inside the one test server; here it is this process's own environment,
+// read per call by getWorkerToken().
+
+const corpus = await setupOpsCorpus("title-filter-channel");
+const route = async (name: string) =>
+ (await import(`./${name}/route`)) as {
+ POST?: Parameters<typeof callPost>[0];
+ GET?: Parameters<typeof callGet>[0];
+ };
+test.after(() => corpus.cleanup());
+
+test("the token gate answers 401 for a missing and for a wrong bearer", async () => {
+ const refresh = (await route("refresh-report")).POST!;
+ const channel = (await route("channel/[slug]")).GET!;
+ const cases: Record<string, string>[] = [{}, { authorization: "Bearer wrong" }];
+ for (const headers of cases) {
+ const post = await callPost(refresh, { all: true }, headers);
+ assert.equal(post.status, 401, JSON.stringify(headers));
+ // The READ side is gated by the same token, not merely the write side.
+ const get = await callGet(channel, "http://localhost/api/ops/channel/anything", { slug: "anything" }, headers);
+ assert.equal(get.status, 401, JSON.stringify(headers));
+ }
+});
+
+// UNSET IS OFF, on the ops door and on the worker door alike — one branch,
+// shared, and neither surface may decide for itself that "no token configured"
+// means "let them in".
+test("with no token configured every guarded route answers 503", async () => {
+ const tags = (await route("tags")).GET!;
+ const health = (await import("../worker/health/route")).GET as unknown as Parameters<typeof callGet>[0];
+ const saved = process.env.WORKER_TOKEN;
+ delete process.env.WORKER_TOKEN;
+ try {
+ // The ops door, with the RIGHT token: it is the surface being off that
+ // answers, not the credential being wrong.
+ const off = await callGet(tags);
+ assert.equal(off.status, 503);
+ assert.match(String(off.body.error), /set WORKER_TOKEN to enable/);
+ // And the LAN worker door, which shares the branch.
+ const worker = await callGet(health);
+ assert.equal(worker.status, 503);
+ assert.match(String(worker.body.error), /set WORKER_TOKEN to enable/);
+ // With no token configured a MISSING header is still 503, not 401: there
+ // is nothing to be unauthorized against.
+ assert.equal((await callGet(tags, undefined, {}, {})).status, 503);
+ } finally {
+ process.env.WORKER_TOKEN = saved;
+ }
+ assert.equal((await callGet(tags)).status, 200);
+ assert.equal(saved, TOKEN);
+});
+
+test("an unknown body key is a 400 that names the accepted keys", async () => {
+ // A misspelled key would otherwise get a cheerful { ok: true } and a channel
+ // that did not change.
+ const sync = await callPost((await route("sync")).POST!, { slug: "x", fullSweep: true });
+ assert.equal(sync.status, 400);
+ assert.equal(sync.body.ok, false);
+ assert.match(String(sync.body.error), /unknown key\(s\): fullSweep/);
+ assert.match(String(sync.body.error), /full/);
+ // So is a nested one, on the route whose body carries an object.
+ const patch = await callPost((await route("channel-config")).POST!, {
+ slug: "x",
+ patch: { downloadFilterExcluded: "rerun" },
+ });
+ assert.equal(patch.status, 400);
+ assert.match(String(patch.body.error), /downloadFilterExcluded/);
+});
+
+test("a traversing slug is refused at the door, on every route that takes one", async () => {
+ const channelsDir = path.join(corpus.transcripts, "channels");
+ const before = (await readdir(channelsDir)).sort();
+ assert.deepEqual(before, ["test-filter"]);
+
+ // EVERY SLUG BELOW REACHES A path.join UNDER channelsDir, and the readers
+ // swallow their own errors — so an unchecked traversing segment would fail
+ // SILENTLY (an empty config read as "channel not found") rather than loudly,
+ // and any future writer on that path would land outside the corpus. reqSlug
+ // is one check for all of them; this is the assertion that it is wired to
+ // each.
+ const cases: [string, Record<string, unknown>][] = [
+ ["metadata-scan", { slug: "../../escape" }],
+ ["refresh-metadata", { slug: "../../escape", id: "abc123" }],
+ ["sync", { slug: "../../escape" }],
+ ["download-missing", { slug: "../../escape" }],
+ ["import-video", { slug: "../../escape", url: "https://example.com/v" }],
+ ["retry-bucket", { slug: "../../escape", bucket: "noTranscript" }],
+ ["refresh-report", { slug: "../../escape" }],
+ ["channel-config", { slug: "../../escape", patch: { cookieMode: "always" } }],
+ ["channel-priority", { slugs: ["../../escape"], tier: "paused" }],
+ ["relocate", { slugs: ["../../escape"], root: "/tmp/ops-api-never" }],
+ ["relocate-back", { slugs: ["../../escape"] }],
+ ["fetch-posts", { slug: "../../escape", older: true }],
+ ["capture-posts", { slug: "../../escape", ids: ["1"] }],
+ ["persist-videos", { items: [{ slug: "../../escape", id: "abc123" }] }],
+ ];
+ for (const [action, data] of cases) {
+ const { status, body } = await callPost((await route(action)).POST!, data);
+ assert.equal(status, 400, action);
+ assert.match(String(body.error), /is not a valid channel slug/, action);
+ }
+
+ // The read route takes its slug as a path SEGMENT. A one-segment name
+ // CHANNEL_SLUG_RE still refuses — a leading dot, how a dotfile beside the
+ // channels dir would be named at — reaches the handler and is refused there.
+ const read = await callGet(
+ (await route("channel/[slug]")).GET!,
+ "http://localhost/api/ops/channel/.escape",
+ { slug: ".escape" },
+ );
+ assert.equal(read.status, 400);
+ assert.match(String(read.body.error), /is not a valid channel slug/);
+
+ // NOTHING WAS TOUCHED: the corpus still holds exactly the fixture channel,
+ // and the fixture's own config is byte-identical.
+ assert.deepEqual((await readdir(channelsDir)).sort(), before);
+ assert.deepEqual(
+ JSON.parse(
+ await readFile(path.join(channelsDir, "test-filter", "config.json"), "utf8"),
+ ),
+ {
+ handling: "youtube",
+ name: "Test Title Filter",
+ url: "https://www.youtube.com/@example/videos",
+ downloadFilter: { include: "guest" },
+ },
+ );
+ assert.deepEqual(await corpus.listJobIds(), []);
+});
diff --git a/editor/app/api/ops/_testCorpus.ts b/editor/app/api/ops/_testCorpus.ts
@@ -0,0 +1,177 @@
+import { cp, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+// A TEST CORPUS FOR THE OPS ROUTES' UNIT TESTS — imported ONLY by
+// `*.test.ts` files under app/api/ops. Never by a route.
+//
+// The refusals an ops route answers before any job are request/response and
+// nothing else, so they are tested here, in a tsx process, rather than in
+// e2e/ops-api.spec.ts against a running server (release 19, A2b): the same
+// e2e fixtures (`e2e/fixtures/test-transcripts/<name>`), copied into a temp
+// dir; the same default test settings; and the same fake binaries, so a
+// refusal that regresses and starts a job runs a fake, never yt-dlp, next or
+// wrangler.
+//
+// The environment is set by `setupOpsCorpus`, which MUST run before the first
+// import of a route or of anything that calls getPaths() (it caches).
+// Each test file is its own process under `tsx --test`, so each gets its own.
+
+export const TOKEN = "test-token";
+
+const EDITOR = path.resolve(import.meta.dirname, "..", "..", "..");
+const FIXTURES = path.join(EDITOR, "e2e", "fixtures");
+const BIN = path.join(FIXTURES, "bin");
+
+export type OpsCorpus = {
+ root: string;
+ transcripts: string;
+ settingsFile: string;
+ // Settings as e2e's writeSettings writes them: the default test settings
+ // with `patch` laid over, one level deep.
+ writeSettings: (patch?: Record<string, unknown>) => Promise<void>;
+ writeChannelConfig: (slug: string, config: Record<string, unknown>) => Promise<void>;
+ writeSite: (siteId: string, site?: Record<string, unknown>) => Promise<void>;
+ // The job sidecars on disk — "and nothing was queued".
+ listJobIds: () => Promise<string[]>;
+ cleanup: () => Promise<void>;
+};
+
+export async function setupOpsCorpus(fixture: string | null): Promise<OpsCorpus> {
+ const root = await mkdtemp(path.join(os.tmpdir(), "ops-route-"));
+ const transcripts = path.join(root, "test-transcripts");
+ const settingsFile = path.join(root, "test-settings.json");
+ await mkdir(transcripts, { recursive: true });
+ if (fixture) {
+ await cp(path.join(FIXTURES, "test-transcripts", fixture), transcripts, {
+ recursive: true,
+ });
+ }
+ Object.assign(process.env, {
+ WORKER_TOKEN: TOKEN,
+ TRANSCRIPTS_DIR: transcripts,
+ EXPORT_PUBLIC_DIR: path.join(transcripts, ".export-public"),
+ SETTINGS_FILE: settingsFile,
+ YTDLP_BIN: path.join(BIN, "fake-ytdlp.mjs"),
+ GALLERY_DL_BIN: path.join(BIN, "fake-gallery-dl.mjs"),
+ FFMPEG_BIN: path.join(BIN, "fake-ffmpeg.mjs"),
+ FFPROBE_BIN: path.join(BIN, "fake-ffprobe.mjs"),
+ WHISPER_BIN: path.join(BIN, "fake-whisper.mjs"),
+ WHISPER_MODEL: "/dev/null",
+ WRANGLER_BIN: path.join(BIN, "fake-wrangler.mjs"),
+ EXPORT_NEXT_BIN: path.join(BIN, "fake-next.mjs"),
+ ARCHILYZER_BRANCH: "main",
+ CLOUDFLARE_API_TOKEN: "e2e-fake-token-never-sent",
+ E2E_LIVE_CHECK: "skip",
+ });
+
+ const writeSettings = async (patch: Record<string, unknown> = {}) => {
+ const base = JSON.parse(
+ await readFile(path.join(FIXTURES, "test-settings.default.json"), "utf8"),
+ ) as Record<string, unknown>;
+ const merged: Record<string, unknown> = { ...base, ...patch };
+ for (const [k, v] of Object.entries(patch)) {
+ const b = base[k];
+ if (isObject(v) && isObject(b)) merged[k] = { ...b, ...v };
+ }
+ await writeFile(settingsFile, JSON.stringify(merged, null, 2));
+ };
+ await writeSettings({ minFreeDiskGB: 0 });
+
+ return {
+ root,
+ transcripts,
+ settingsFile,
+ writeSettings,
+ async writeChannelConfig(slug, config) {
+ const dir = path.join(transcripts, "channels", slug);
+ await mkdir(dir, { recursive: true });
+ await writeFile(
+ path.join(dir, "config.json"),
+ JSON.stringify({ handling: "youtube", name: slug, ...config }, null, 2),
+ );
+ },
+ async writeSite(siteId, site = {}) {
+ const dir = path.join(transcripts, "sites", siteId);
+ await mkdir(dir, { recursive: true });
+ await writeFile(
+ path.join(dir, "site.json"),
+ JSON.stringify(
+ {
+ siteId,
+ siteTitle: siteId,
+ siteDescription: "",
+ headerTitle: siteId,
+ homeTagline: "",
+ groups: [{ id: "default", name: "All channels", selectedByDefault: true }],
+ defaultGroupId: "default",
+ channels: [],
+ ...site,
+ },
+ null,
+ 2,
+ ),
+ );
+ },
+ async listJobIds() {
+ return (await readdir(path.join(transcripts, ".jobs")).catch(() => []))
+ .filter((f) => f.endsWith(".meta.json"))
+ .sort();
+ },
+ cleanup: () => rm(root, { recursive: true, force: true }),
+ };
+}
+
+function isObject(v: unknown): v is Record<string, unknown> {
+ return typeof v === "object" && v !== null && !Array.isArray(v);
+}
+
+type Handler = (request: Request, ctx?: never) => Promise<Response>;
+export type OpsRes = { status: number; body: Record<string, unknown> & { error?: string } };
+
+// POST a JSON body to a route handler, with the token unless told otherwise.
+export async function callPost(
+ handler: Handler,
+ body: unknown,
+ headers: Record<string, string> = { authorization: `Bearer ${TOKEN}` },
+): Promise<OpsRes> {
+ const res = await handler(
+ new Request("http://localhost/api/ops/x", {
+ method: "POST",
+ headers: { ...headers, "content-type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ );
+ return { status: res.status, body: (await res.json()) as OpsRes["body"] };
+}
+
+// GET a route handler. `params` for a dynamic segment ([slug], [id]).
+export async function callGet(
+ handler: (request: Request, ctx: { params: Promise<Record<string, string>> }) => Promise<Response>,
+ url = "http://localhost/api/ops/x",
+ params: Record<string, string> = {},
+ headers: Record<string, string> = { authorization: `Bearer ${TOKEN}` },
+): Promise<OpsRes> {
+ const res = await handler(new Request(url, { headers }), {
+ params: Promise.resolve(params),
+ });
+ return { status: res.status, body: (await res.json()) as OpsRes["body"] };
+}
+
+// HOLD THE PUBLISH QUEUE with a fabricated running job, as e2e's
+// holdPublishQueue does: a build or deploy stage a regressed refusal failed to
+// stop can then only QUEUE, never spawn. Writes no sidecar, so listJobIds does
+// not see it.
+export async function holdPublishQueue(): Promise<void> {
+ const { getRegistry, newJobId } = await import("yt-dlp-transcript-common/jobs/registry");
+ const record = {
+ id: newJobId(),
+ kind: "publish-hold",
+ queueKey: "publish",
+ status: "queued" as const,
+ queuedAt: Date.now(),
+ logPath: "/dev/null",
+ };
+ getRegistry().register(record);
+ getRegistry().enqueue(record, { start: () => {}, onCancel: () => {} });
+}
diff --git a/editor/app/api/ops/build-site/route.test.ts b/editor/app/api/ops/build-site/route.test.ts
@@ -0,0 +1,53 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { callPost, holdPublishQueue, setupOpsCorpus } from "../_testCorpus";
+
+// Run with:
+// pnpm -C editor exec tsx --test "app/api/ops/build-site/route.test.ts"
+//
+// build-site and build-deploy speak the same body — `siteId` or `siteIds`,
+// never both, never neither — and refuse before any job. Moved from
+// e2e/ops-api.spec.ts (release 19, A2b). The publish queue is held throughout,
+// so a stage a regressed refusal failed to stop could only queue.
+
+const corpus = await setupOpsCorpus("title-filter-channel");
+const buildSite = (await import("./route")).POST;
+const buildDeploy = (await import("../build-deploy/route")).POST;
+await holdPublishQueue();
+test.after(() => corpus.cleanup());
+
+test("build-site and build-deploy each take siteId or siteIds, and refuse both or neither", async () => {
+ for (const [action, handler] of [
+ ["build-site", buildSite],
+ ["build-deploy", buildDeploy],
+ ] as const) {
+ const both = await callPost(handler, { siteId: "a", siteIds: ["a"] });
+ assert.equal(both.status, 400, action);
+ assert.match(String(both.body.error), /not both/, action);
+
+ const neither = await callPost(handler, {});
+ assert.equal(neither.status, 400, action);
+ assert.match(String(neither.body.error), /siteId/, action);
+
+ // `all` is still exclusive of either spelling.
+ const withAll = await callPost(handler, { all: true, siteId: "a" });
+ assert.equal(withAll.status, 400, action);
+ assert.match(String(withAll.body.error), /not both/, action);
+
+ // A MALFORMED ID IS A 400 BEFORE ANY JOB STARTS: an id list of
+ // ["good", "BAD"] used to queue the first build, throw on the second and
+ // answer 500 with no ids at all.
+ const bad = await callPost(handler, { siteIds: ["buildsite", "BAD ID"] });
+ assert.equal(bad.status, 400, action);
+ assert.match(String(bad.body.error), /not a valid site id/, action);
+ assert.equal(bad.body.jobs, undefined, action);
+ }
+ assert.deepEqual(await corpus.listJobIds(), []);
+});
+
+test("build-deploy refuses a preview alongside all rather than building everything", async () => {
+ const { status, body } = await callPost(buildDeploy, { all: true, preview: "tags-exclude" });
+ assert.equal(status, 400);
+ assert.match(String(body.error), /"preview" is not supported with "all"/);
+ assert.deepEqual(await corpus.listJobIds(), []);
+});
diff --git a/editor/app/api/ops/capture-posts/route.test.ts b/editor/app/api/ops/capture-posts/route.test.ts
@@ -71,3 +71,67 @@ test("both halves off is nothing to do unless the articles are asked for by name
// No job was ever written.
assert.deepEqual(await readdir(path.join(ROOT, ".jobs")).catch(() => []), []);
});
+
+// Moved from e2e/ops-api.spec.ts (release 19, A2b): nothing below reaches X —
+// every case is refused before a job exists.
+async function channel(slug: string, config: Record<string, unknown>): Promise<void> {
+ await mkdir(path.join(ROOT, "channels", slug), { recursive: true });
+ await writeFile(path.join(ROOT, "channels", slug, "config.json"), JSON.stringify(config));
+}
+
+test("capture-posts refuses what it cannot capture, and an id not in the archive — before any job", async () => {
+ await channel("test-filter", {
+ handling: "youtube",
+ name: "Test Title Filter",
+ url: "https://www.youtube.com/@example/videos",
+ });
+ await channel("example-bsky", {
+ handling: "transcribe",
+ sourceKind: "social",
+ platform: "bluesky",
+ postFetcher: "bluesky-atproto",
+ socialHandle: "example.bsky.social",
+ name: "Example (Bluesky)",
+ url: "https://bsky.app/profile/example.bsky.social",
+ });
+ // An X channel with an empty posts archive.
+ await channel("example-x", {
+ handling: "transcribe",
+ sourceKind: "social",
+ platform: "twitter",
+ postFetcher: "x-gallery-dl",
+ socialHandle: "example_user",
+ name: "Example (X)",
+ url: "https://x.com/example_user",
+ });
+
+ const video = await post({ slug: "test-filter", ids: ["1"] });
+ assert.equal(video.status, 400);
+ assert.equal(video.error, "test-filter is not a social channel.");
+
+ const bsky = await post({ slug: "example-bsky", ids: ["1"] });
+ assert.equal(bsky.status, 400);
+ assert.match(bsky.error, /cannot capture posts/);
+
+ const neither = await post({ slug: "example-x", ids: ["1"], shots: false, media: false });
+ assert.equal(neither.status, 400);
+ assert.match(neither.error, /both the screenshot and the media are turned off/);
+
+ // The channel's posts archive is empty: every id is a stray, named.
+ const stray = await post({ slug: "example-x", ids: ["111", "222"] });
+ assert.equal(stray.status, 400);
+ assert.equal(stray.error, "2 id(s) not in example-x's posts archive: 111, 222");
+
+ // The body's shape.
+ const noIds = await post({ slug: "example-x" });
+ assert.equal(noIds.status, 400);
+ assert.match(noIds.error, /"ids" is required/);
+ const badFlag = await post({ slug: "example-x", ids: ["1"], shots: "yes" });
+ assert.equal(badFlag.status, 400);
+ assert.match(badFlag.error, /"shots" must be a boolean/);
+ const unknown = await post({ slug: "example-x", ids: ["1"], limit: 5 });
+ assert.equal(unknown.status, 400);
+ assert.match(unknown.error, /unknown key\(s\): limit/);
+
+ assert.deepEqual(await readdir(path.join(ROOT, ".jobs")).catch(() => []), []);
+});
diff --git a/editor/app/api/ops/deploy-homepage/route.test.ts b/editor/app/api/ops/deploy-homepage/route.test.ts
@@ -0,0 +1,47 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { callPost, holdPublishQueue, setupOpsCorpus } from "../_testCorpus";
+
+// Run with:
+// pnpm -C editor exec tsx --test "app/api/ops/deploy-homepage/route.test.ts"
+//
+// The homepage's deploy path refuses BEFORE a job exists, like deploy-hub's.
+// Moved from e2e/ops-api.spec.ts (release 19, A2b). Nothing here can reach
+// wrangler: "built" is the homepage's stamp in the corpus's .export-builds
+// (there is none), the missing-build call asks for a PREVIEW, and the publish
+// queue is held, so a stage a regressed refusal failed to stop only queues.
+
+const corpus = await setupOpsCorpus("empty");
+const deployHomepage = (await import("./route")).POST;
+const buildHomepage = (await import("../build-homepage/route")).POST;
+await holdPublishQueue();
+test.after(() => corpus.cleanup());
+
+test("deploy-homepage refuses a bad preview name and a missing build; build-homepage refuses a preview without a deploy", async () => {
+ // A bad preview name is judged before any build is looked at.
+ const production = await callPost(deployHomepage, { preview: "main" });
+ assert.equal(production.status, 400);
+ assert.equal(production.body.error, '"main" is the production branch; a preview needs another name.');
+ const shape = await callPost(deployHomepage, { preview: "Not_Valid" });
+ assert.equal(shape.status, 400);
+ assert.match(String(shape.body.error), /not a valid preview branch name/);
+ // And on the build-then-deploy, before anything is built.
+ const buildDeploy = await callPost(buildHomepage, { deploy: true, preview: "main" });
+ assert.equal(buildDeploy.status, 400);
+ assert.match(String(buildDeploy.body.error), /is the production branch/);
+
+ // build-homepage: a preview without a deploy is a mistake, not a build.
+ const previewOnly = await callPost(buildHomepage, { preview: "home-check" });
+ assert.equal(previewOnly.status, 400);
+ assert.match(String(previewOnly.body.error), /"preview" needs "deploy": true/);
+
+ // No build: the homepage's stamp is the corpus's.
+ const unbuilt = await callPost(deployHomepage, { preview: "home-check" });
+ assert.equal(unbuilt.status, 400);
+ assert.match(
+ String(unbuilt.body.error),
+ /^no build of _homepage in .*\.export-builds\/_homepage — archilyzer publish homepage$/,
+ );
+
+ assert.deepEqual(await corpus.listJobIds(), []);
+});
diff --git a/editor/app/api/ops/deploy-hub/route.test.ts b/editor/app/api/ops/deploy-hub/route.test.ts
@@ -0,0 +1,56 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { callPost, holdPublishQueue, setupOpsCorpus } from "../_testCorpus";
+
+// Run with:
+// pnpm -C editor exec tsx --test "app/api/ops/deploy-hub/route.test.ts"
+//
+// The hub's deploy path refuses BEFORE a job exists, which is what lets a
+// runbook's `pnpm ops deploy-hub --wait` fail fast instead of queueing a deploy
+// that can only fail. Moved from e2e/ops-api.spec.ts (release 19, A2b), with
+// the publish queue held as it was there.
+
+const corpus = await setupOpsCorpus("title-filter-channel");
+const deployHub = (await import("./route")).POST;
+const buildHub = (await import("../build-hub/route")).POST;
+await holdPublishQueue();
+test.after(() => corpus.cleanup());
+
+test("deploy-hub refuses no project, the homepage's project, and a bundle that is not the hub", async () => {
+ // The fixture has no homepage.json at all: no project.
+ const none = await callPost(deployHub, {});
+ assert.equal(none.status, 400);
+ assert.equal(
+ none.body.error,
+ "The hub has no Cloudflare Pages project configured — set it on /sites under Hub.",
+ );
+
+ // The homepage's project is refused by name: a hub deployed there would
+ // replace the software's own site.
+ const dir = path.join(corpus.transcripts, "sites", "_homepage");
+ await mkdir(dir, { recursive: true });
+ const hubFile = path.join(dir, "homepage.json");
+ await writeFile(hubFile, JSON.stringify({ cloudflareProject: "archilyzer" }));
+ const homepages = await callPost(deployHub, {});
+ assert.equal(homepages.status, 400);
+ assert.match(String(homepages.body.error), /"archilyzer", which is the Archilyzer homepage's/);
+
+ // A real project, but no hub build: the hub's bundle is the corpus's own
+ // .export-builds/_hub, stamped built.json.
+ await writeFile(hubFile, JSON.stringify({ cloudflareProject: "archilyzer-hub" }));
+ const unbuilt = await callPost(deployHub, { preview: "hub-check" });
+ assert.equal(unbuilt.status, 400);
+ assert.match(
+ String(unbuilt.body.error),
+ /^no build of _hub in .*\.export-builds\/_hub — archilyzer publish hub$/,
+ );
+
+ // build-hub: a preview without a deploy is a mistake, not a build.
+ const previewOnly = await callPost(buildHub, { preview: "hub-check" });
+ assert.equal(previewOnly.status, 400);
+ assert.match(String(previewOnly.body.error), /"preview" needs "deploy": true/);
+
+ assert.deepEqual(await corpus.listJobIds(), []);
+});
diff --git a/editor/app/api/ops/deploy-site/route.test.ts b/editor/app/api/ops/deploy-site/route.test.ts
@@ -0,0 +1,102 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { callPost, holdPublishQueue, setupOpsCorpus } from "../_testCorpus";
+
+// Run with:
+// pnpm -C editor exec tsx --test "app/api/ops/deploy-site/route.test.ts"
+//
+// The deploy routes' refusals — preview names, a site with no Pages project, a
+// site never built, the body's spelling — all decided BEFORE a job exists,
+// which is the whole of what they are. Moved from e2e/ops-api.spec.ts
+// (release 19, A2b). The publish queue is held, and wrangler and next are the
+// e2e fakes, so a regressed refusal could only queue a stage.
+
+const corpus = await setupOpsCorpus("title-filter-channel");
+await corpus.writeSite("previewsite", { cloudflareProject: "proj" });
+// NO Cloudflare project, on purpose (see the valid-preview test).
+await corpus.writeSite("noproj", {});
+const deploySite = (await import("./route")).POST;
+const buildDeploy = (await import("../build-deploy/route")).POST;
+await holdPublishQueue();
+test.after(() => corpus.cleanup());
+
+const BOTH = [
+ ["deploy-site", deploySite],
+ ["build-deploy", buildDeploy],
+] as const;
+
+test("deploy-site and build-deploy refuse a preview name that is not one, before any job", async () => {
+ for (const [action, handler] of BOTH) {
+ // "main" is the production branch: deploying there is not a preview, it is
+ // the live site, and the refusal says so rather than shipping it.
+ const main = await callPost(handler, { siteId: "previewsite", preview: "main" });
+ assert.equal(main.status, 400, action);
+ assert.match(String(main.body.error), /production branch/, action);
+
+ // Uppercase is refused rather than lowercased FOR the caller: silently
+ // rewriting "Main" into "main" would deploy to production.
+ const upper = await callPost(handler, { siteId: "previewsite", preview: "Main" });
+ assert.equal(upper.status, 400, action);
+ assert.match(String(upper.body.error), /not a valid preview branch name/, action);
+ assert.doesNotMatch(String(upper.body.error), /production branch/, action);
+
+ const spaced = await callPost(handler, { siteId: "previewsite", preview: "bad name" });
+ assert.equal(spaced.status, 400, action);
+ assert.match(String(spaced.body.error), /not a valid preview branch name/, action);
+
+ const tooLong = await callPost(handler, { siteId: "previewsite", preview: "a".repeat(29) });
+ assert.equal(tooLong.status, 400, action);
+ assert.match(String(tooLong.body.error), /at most 28 characters/, action);
+ }
+ assert.deepEqual(await corpus.listJobIds(), []);
+});
+
+test("a valid preview is accepted and reaches the action, on both deploy routes", async () => {
+ // The action's own refusal is the last gate before wrangler, so a 400 saying
+ // THAT — rather than "unknown key: preview" or a branch complaint — proves
+ // the name passed validation and the route got all the way to the action.
+ for (const [action, handler] of BOTH) {
+ const { status, body } = await callPost(handler, { siteId: "noproj", preview: "tags-exclude" });
+ assert.equal(status, 400, action);
+ assert.match(String(body.error), /no Cloudflare Pages project/, action);
+ assert.doesNotMatch(String(body.error), /unknown key/, action);
+ }
+ assert.deepEqual(await corpus.listJobIds(), []);
+});
+
+test("deploy-site refuses a site that was never built, in the deploy stage's words, before any job", async () => {
+ // A deploy ships the site's OWN bundle (export/.export-builds/<id>/out,
+ // stamped built.json) — never "whatever export/out holds".
+ const { status, body } = await callPost(deploySite, { siteId: "previewsite" });
+ assert.equal(status, 400);
+ assert.match(
+ String(body.error),
+ /^no build of previewsite in .*\.export-builds\/previewsite — archilyzer publish build previewsite$/,
+ );
+ // A preview is refused for the same reason and just as early.
+ const preview = await callPost(deploySite, { siteId: "previewsite", preview: "tags-exclude" });
+ assert.equal(preview.status, 400);
+ assert.match(String(preview.body.error), /no build of previewsite/);
+ assert.deepEqual(await corpus.listJobIds(), []);
+});
+
+test("deploy-site takes siteId or siteIds, and has no all", async () => {
+ const both = await callPost(deploySite, { siteId: "a", siteIds: ["a"] });
+ assert.equal(both.status, 400);
+ assert.match(String(both.body.error), /not both/);
+
+ const neither = await callPost(deploySite, {});
+ assert.equal(neither.status, 400);
+ assert.match(String(neither.body.error), /siteId/);
+
+ const bad = await callPost(deploySite, { siteIds: ["deploysite", "BAD ID"] });
+ assert.equal(bad.status, 400);
+ assert.match(String(bad.body.error), /not a valid site id/);
+
+ // Deploy-only has no all-sites form — build-deploy owns that — so `all` is
+ // an unknown key here rather than a second spelling of it.
+ const all = await callPost(deploySite, { all: true });
+ assert.equal(all.status, 400);
+ assert.match(String(all.body.error), /unknown key/);
+ assert.deepEqual(await corpus.listJobIds(), []);
+});
diff --git a/editor/app/api/ops/fetch-posts/route.test.ts b/editor/app/api/ops/fetch-posts/route.test.ts
@@ -79,3 +79,61 @@ test("pages must be a positive whole number", async () => {
assert.equal(res.status, 400);
assert.match(res.error, /pages/);
});
+
+// Moved from e2e/ops-api.spec.ts (release 19, A2b): every case is refused
+// before a job exists, so it needs no server.
+async function channel(slug: string, config: Record<string, unknown>): Promise<void> {
+ await mkdir(path.join(ROOT, "channels", slug), { recursive: true });
+ await writeFile(path.join(ROOT, "channels", slug, "config.json"), JSON.stringify(config));
+}
+
+test("fetch-posts refuses a channel that is not social, full with older, and an older fetch its fetcher cannot do — before any job", async () => {
+ await channel("test-filter", {
+ handling: "youtube",
+ name: "Test Title Filter",
+ url: "https://www.youtube.com/@example/videos",
+ });
+ // A social channel whose fetcher has no older-posts walk.
+ await channel("example-bsky", {
+ handling: "transcribe",
+ sourceKind: "social",
+ platform: "bluesky",
+ postFetcher: "bluesky-atproto",
+ socialHandle: "example.bsky.social",
+ name: "Example (Bluesky)",
+ url: "https://bsky.app/profile/example.bsky.social",
+ });
+
+ // The sentence the action gives for a video channel.
+ const video = await post({ slug: "test-filter" });
+ assert.equal(video.status, 400);
+ assert.equal(video.error, "test-filter is not a social channel.");
+ const videoOlder = await post({ slug: "test-filter", older: true });
+ assert.equal(videoOlder.status, 400);
+ assert.equal(videoOlder.error, "test-filter is not a social channel.");
+
+ // Two walks at once, on any channel.
+ const both = await post({ slug: "example-bsky", full: true, older: true });
+ assert.equal(both.status, 400);
+ assert.match(both.error, /different walks — run one at a time/);
+ const bothVideo = await post({ slug: "test-filter", full: true, older: true });
+ assert.equal(bothVideo.status, 400);
+ assert.match(bothVideo.error, /different walks/);
+
+ // A fetcher with no older walk, a floor that is not a date, a floor
+ // without older, and a limit that is not a count.
+ const bsky = await post({ slug: "example-bsky", older: true });
+ assert.equal(bsky.status, 400);
+ assert.match(bsky.error, /cannot fetch older posts/);
+ const floor = await post({ slug: "example-bsky", older: true, floor: "2020-13-01" });
+ assert.equal(floor.status, 400);
+ assert.match(floor.error, /is not a date/);
+ const floorAlone = await post({ slug: "example-bsky", floor: "2020-01-01" });
+ assert.equal(floorAlone.status, 400);
+ assert.match(floorAlone.error, /only to an older-posts fetch/);
+ const limit = await post({ slug: "example-bsky", limit: 0 });
+ assert.equal(limit.status, 400);
+ assert.match(limit.error, /"limit" must be a whole number above zero/);
+
+ assert.deepEqual(await readdir(path.join(ROOT, ".jobs")).catch(() => []), []);
+});
diff --git a/editor/app/api/ops/persist-videos/route.test.ts b/editor/app/api/ops/persist-videos/route.test.ts
@@ -0,0 +1,73 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { callPost, setupOpsCorpus } from "../_testCorpus";
+
+// Run with:
+// pnpm -C editor exec tsx --test "app/api/ops/persist-videos/route.test.ts"
+//
+// persist-videos' dry run, its body's shape and its disk floor — all answered
+// before any job. Moved from e2e/ops-api.spec.ts (release 19, A2b).
+
+const corpus = await setupOpsCorpus("one-youtube-channel-with-data");
+const { POST } = await import("./route");
+test.after(() => corpus.cleanup());
+
+const slug = "test-youtube";
+const video = "20240101_test1234567";
+const items = [
+ { slug, id: video },
+ { slug, id: "missing12345" },
+ { slug: "no-such-channel", id: "abc123" },
+];
+
+type Bucket = { count: number; items: { slug: string; id: string }[] };
+type Plan = Record<"saved" | "wrongHeight" | "toFetch" | "noUrl" | "unknown", Bucket> & {
+ willFetch: number;
+};
+
+test("persist-videos buckets a list in a dry run, and refuses a bad body and a low disk — before any job", async () => {
+ const dry = await callPost(POST, { items, format: "video_720", dryRun: true });
+ assert.equal(dry.status, 200, JSON.stringify(dry.body));
+ assert.equal(dry.body.dryRun, true);
+ const plan = dry.body.plan as Plan;
+ assert.deepEqual(plan.toFetch.items, [{ slug, id: video }]);
+ assert.equal(plan.unknown.count, 2);
+ assert.equal(plan.saved.count, 0);
+ assert.equal(plan.willFetch, 1);
+
+ // Nothing to fetch is an answer, not a job.
+ const none = await callPost(POST, { items: [{ slug: "no-such-channel", id: "abc123" }] });
+ assert.equal(none.status, 200, JSON.stringify(none.body));
+ assert.deepEqual(none.body.jobIds, []);
+
+ // The body's shape.
+ const cases: [Record<string, unknown>, RegExp][] = [
+ [{}, /"items" is required/],
+ [{ items: [] }, /"items" is required/],
+ [{ items: [{ slug, id: video, height: 720 }] }, /unknown key\(s\): height/],
+ [{ items: [{ slug, id: "../escape" }] }, /is not a video id/],
+ [{ items, format: "1080p" }, /"format" must be one of original, video_720/],
+ [{ items, replace: "always" }, /"replace" must be one of never, above-height/],
+ [{ items, gapMs: -1 }, /"gapMs" must be a whole number, zero or above/],
+ [{ items, minFreeMemMb: "4096" }, /"minFreeMemMb" must be a whole number/],
+ [{ items, dryRun: "yes" }, /"dryRun" must be a boolean/],
+ [{ items, ids: ["x"] }, /unknown key\(s\): ids/],
+ ];
+ for (const [data, error] of cases) {
+ const res = await callPost(POST, data);
+ assert.equal(res.status, 400, JSON.stringify(data));
+ assert.match(String(res.body.error), error, JSON.stringify(data));
+ }
+
+ // A real run asks the disk floor before it queues anything.
+ await corpus.writeSettings({ minFreeDiskGB: 1_000_000 });
+ try {
+ const low = await callPost(POST, { items });
+ assert.equal(low.status, 400, JSON.stringify(low.body));
+ assert.match(String(low.body.error), /^Low disk space/);
+ } finally {
+ await corpus.writeSettings({ minFreeDiskGB: 0 });
+ }
+
+ assert.deepEqual(await corpus.listJobIds(), []);
+});
diff --git a/editor/app/api/ops/publish/route.test.ts b/editor/app/api/ops/publish/route.test.ts
@@ -0,0 +1,68 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { callGet, callPost, holdPublishQueue, setupOpsCorpus } from "../_testCorpus";
+
+// Run with:
+// pnpm -C editor exec tsx --test "app/api/ops/publish/route.test.ts"
+//
+// The publish route's refusals before any job, and its GET — the status.
+// Moved from e2e/ops-api.spec.ts (release 19, A2b); the runs each verb
+// enqueues are still asserted there, against the running server's queue.
+
+const corpus = await setupOpsCorpus("title-filter-channel");
+await corpus.writeSite("pubsite", { cloudflareProject: "pubproj" });
+const { POST, GET } = await import("./route");
+await holdPublishQueue();
+test.after(() => corpus.cleanup());
+
+test("publish: refusals before any job — a never-built deploy, a preview that is not one, a key the verb does not take", async () => {
+ const unbuilt = await callPost(POST, { verb: "deploy", siteId: "pubsite", preview: "r18" });
+ assert.equal(unbuilt.status, 400);
+ assert.match(String(unbuilt.body.error), /^no build of pubsite in .* — archilyzer publish build pubsite$/);
+
+ const main = await callPost(POST, { verb: "deploy", siteId: "pubsite", preview: "main" });
+ assert.equal(main.status, 400);
+ assert.match(String(main.body.error), /production branch/);
+
+ const verb = await callPost(POST, { verb: "launch" });
+ assert.equal(verb.status, 400);
+ assert.match(
+ String(verb.body.error),
+ /"verb" must be one of index, build, deploy, hub, homepage, now, stale/,
+ );
+
+ const extra = await callPost(POST, { verb: "index", siteId: "pubsite" });
+ assert.equal(extra.status, 400);
+ assert.match(String(extra.body.error), /verb "index" takes no other keys/);
+
+ const hubPreview = await callPost(POST, { verb: "hub", preview: "r18" });
+ assert.equal(hubPreview.status, 400);
+ assert.match(String(hubPreview.body.error), /need "deploy": true/);
+
+ assert.deepEqual(await corpus.listJobIds(), []);
+});
+
+test("GET publish is the publish status: the index, the lane, a row per target", async () => {
+ const res = await callGet(GET as unknown as Parameters<typeof callGet>[0]);
+ assert.equal(res.status, 200);
+ const status = res.body as unknown as {
+ ok: boolean;
+ index: { chip: { text: string } };
+ lane: { held: boolean; enabled: boolean };
+ sites: { target: string; chips: { built: { text: string } } }[];
+ hub: { target: string };
+ homepage: { target: string };
+ plan: { steps: { kind: string }[] };
+ };
+ assert.equal(status.ok, true);
+ assert.equal(status.index.chip.text, "no index yet");
+ assert.equal(status.lane.held, false);
+ assert.equal(status.lane.enabled, false);
+ assert.deepEqual(status.sites.map((x) => x.target), ["pubsite"]);
+ assert.equal(status.sites[0].chips.built.text, "update the index first");
+ assert.deepEqual([status.hub.target, status.homepage.target], ["_hub", "_homepage"]);
+ assert.deepEqual(status.plan.steps.map((x) => x.kind), ["update-index"]);
+
+ const anon = await callGet(GET as unknown as Parameters<typeof callGet>[0], undefined, {}, {});
+ assert.equal(anon.status, 401);
+});
diff --git a/editor/app/api/ops/retry-bucket/route.test.ts b/editor/app/api/ops/retry-bucket/route.test.ts
@@ -0,0 +1,45 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { writeFile } from "node:fs/promises";
+import path from "node:path";
+import { callPost, setupOpsCorpus } from "../_testCorpus";
+
+// Run with:
+// pnpm -C editor exec tsx --test "app/api/ops/retry-bucket/route.test.ts"
+//
+// retry-bucket and transcribe-bucket narrow a bucket with "ids", and every id
+// must be in it. Moved from e2e/ops-api.spec.ts (release 19, A2b): refused
+// before any job, from a report written by hand.
+
+const corpus = await setupOpsCorpus("title-filter-channel");
+const SLUG = "test-filter";
+// A report by hand, so the bucket is known without a regen.
+await writeFile(
+ path.join(corpus.transcripts, "channels", SLUG, "snapshot.json"),
+ JSON.stringify({ buckets: { downloadedNoTranscript: ["in-bucket-1"] } }),
+);
+const retry = (await import("./route")).POST;
+const transcribe = (await import("../transcribe-bucket/route")).POST;
+test.after(() => corpus.cleanup());
+
+test("retry-bucket and transcribe-bucket ids must be in the bucket: a stray is refused, named, and nothing runs", async () => {
+ const stray = await callPost(retry, {
+ slug: SLUG,
+ bucket: "downloadedNoTranscript",
+ ids: ["stray-aaa", "stray-bbb"],
+ });
+ assert.equal(stray.status, 400);
+ assert.match(String(stray.body.error), /2 of "ids" not in .*: stray-aaa, stray-bbb/);
+ // transcribe-bucket narrows the same bucket by the same rule.
+ const strayT = await callPost(transcribe, { slug: SLUG, ids: ["stray-ccc"] });
+ assert.equal(strayT.status, 400);
+ assert.match(String(strayT.body.error), /1 of "ids" not in .*: stray-ccc/);
+ // An empty list is not "the whole bucket".
+ const empty = await callPost(retry, {
+ slug: SLUG,
+ bucket: "downloadedNoTranscript",
+ ids: [],
+ });
+ assert.equal(empty.status, 400);
+ assert.deepEqual(await corpus.listJobIds(), []);
+});
diff --git a/editor/app/api/ops/tag-videos/route.test.ts b/editor/app/api/ops/tag-videos/route.test.ts
@@ -0,0 +1,46 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { access } from "node:fs/promises";
+import path from "node:path";
+import { callPost, setupOpsCorpus } from "../_testCorpus";
+
+// Run with:
+// pnpm -C editor exec tsx --test "app/api/ops/tag-videos/route.test.ts"
+//
+// The refusals of the curated-tag batch writer, before tags.json is touched.
+// Moved from e2e/ops-api.spec.ts (release 19, A2b); the writes it makes are
+// still driven over HTTP there.
+
+const corpus = await setupOpsCorpus("one-youtube-channel-with-data");
+const { POST } = await import("./route");
+test.after(() => corpus.cleanup());
+
+test("tag-videos refuses a traversing slug, a bad op and an unknown key", async () => {
+ const traversing = await callPost(POST, {
+ tag: "x",
+ op: "add",
+ videos: [{ slug: "../escape", id: "v" }],
+ });
+ assert.equal(traversing.status, 400);
+ assert.match(String(traversing.body.error), /not a valid channel slug/);
+
+ const badOp = await callPost(POST, {
+ tag: "x",
+ op: "pin",
+ videos: [{ slug: "test-youtube", id: "v" }],
+ });
+ assert.equal(badOp.status, 400);
+ assert.match(String(badOp.body.error), /add, remove, suppress, unsuppress/);
+
+ const unknown = await callPost(POST, {
+ tag: "x",
+ op: "add",
+ videos: [{ slug: "test-youtube", id: "v" }],
+ provenance: "me",
+ });
+ assert.equal(unknown.status, 400);
+ assert.match(String(unknown.body.error), /unknown key\(s\): provenance/);
+
+ // Nothing was written by any of the three.
+ await assert.rejects(access(path.join(corpus.transcripts, "tags.json")));
+});
diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts
@@ -7,26 +7,16 @@
// 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.
//
-// THE TOKEN GATE HAS THREE ANSWERS, AND ALL THREE ARE PINNED HERE.
-//
-// `WORKER_TOKEN` unset => 503, wrong or missing => 401, matching => the route
-// runs. The 503 is the consequential one: it is what stops an instance that
-// never set the variable being an open transcription server, and 401 in its
-// place would tell a scanner "there is a secret here, guess it".
-//
-// It used to be unreachable from a spec — the test server boots with
-// WORKER_TOKEN=test-worker-token (editor/package.json, dev:test) and one server
-// serves the whole suite, so nothing could observe the endpoint disabled. It is
-// reachable now because /api/test/worker-token turns the variable off and back
-// on INSIDE that server, which works only because `getWorkerToken()` reads
-// process.env per call (common/lib/workerToken.test.ts pins that, and covers
-// the branch table itself).
-//
-// ⚠️ THE VARIABLE IS PROCESS-WIDE AND THE SERVER OUTLIVES THE SPEC. Restoring
-// it is a `finally`, never a trailing line: one failed assertion with the token
-// still unset leaves every later /api/ops and /api/worker spec answering 503.
-
-import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
+// 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>/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 {
@@ -75,332 +65,6 @@ async function settings(): Promise<void> {
await writeSettings({ minFreeDiskGB: 0 });
}
-test("the token gate answers 401 for a missing and for a wrong bearer", async ({
- request,
-}) => {
- await resetData("empty");
- for (const headers of [undefined, { authorization: "Bearer wrong" }]) {
- const post = await request.post(`${baseUrl}/api/ops/refresh-report`, {
- ...(headers ? { headers } : {}),
- data: { all: true },
- });
- expect(post.status(), JSON.stringify(headers)).toBe(401);
- // The READ side is gated by the same token, not merely the write side.
- const get = await request.get(`${baseUrl}/api/ops/channel/anything`, {
- ...(headers ? { headers } : {}),
- });
- expect(get.status(), JSON.stringify(headers)).toBe(401);
- }
-});
-
-// UNSET IS OFF, on the ops door and on the worker door alike — one branch,
-// shared, and neither surface may decide for itself that "no token configured"
-// means "let them in".
-test("with no token configured every guarded route answers 503", async ({
- request,
-}) => {
- await resetData("empty");
- const toggle = async (query: string) => {
- const res = await request.get(`${baseUrl}/api/test/worker-token?${query}`);
- expect(res.status(), query).toBe(200);
- return (await res.json()) as { ok: boolean; enabled: boolean };
- };
-
- const off = await toggle("unset=1");
- expect(off.enabled).toBe(false);
- try {
- // The ops door, with the RIGHT token: it is the surface being off that
- // answers, not the credential being wrong.
- const tags = await request.get(`${baseUrl}/api/ops/tags`, {
- headers: AUTH,
- });
- expect(tags.status()).toBe(503);
- expect(((await tags.json()) as OpsResponse).error).toMatch(
- /set WORKER_TOKEN to enable/,
- );
-
- // And the LAN worker door, which shares the branch.
- const health = await request.get(`${baseUrl}/api/worker/health`, {
- headers: AUTH,
- });
- expect(health.status()).toBe(503);
- expect(((await health.json()) as OpsResponse).error).toMatch(
- /set WORKER_TOKEN to enable/,
- );
-
- // With no token configured a MISSING header is still 503, not 401: there is
- // nothing to be unauthorized against.
- const bare = await request.get(`${baseUrl}/api/ops/tags`);
- expect(bare.status()).toBe(503);
- } finally {
- // NOT a trailing line. See the header: the server outlives this spec.
- const on = await toggle(`set=${TOKEN}`);
- expect(on.enabled).toBe(true);
- }
-
- // Restored, and the same request now works — which is also the assertion
- // that the `finally` above did what it claims.
- const after = await request.get(`${baseUrl}/api/ops/tags`, { headers: AUTH });
- expect(after.status()).toBe(200);
-});
-
-test("an unknown body key is a 400 that names the accepted keys", async ({
- request,
-}) => {
- await resetData("empty");
- // A misspelled key would otherwise get a cheerful { ok: true } and a channel
- // that did not change.
- const { status, body } = await ops(request, "sync", {
- slug: "x",
- fullSweep: true,
- });
- expect(status).toBe(400);
- expect(body.ok).toBe(false);
- expect(body.error).toContain("unknown key(s): fullSweep");
- expect(body.error).toContain("full");
-
- // So is a nested one, on the route whose body carries an object.
- const patch = await ops(request, "channel-config", {
- slug: "x",
- patch: { downloadFilterExcluded: "rerun" },
- });
- expect(patch.status).toBe(400);
- expect(patch.body.error).toContain("downloadFilterExcluded");
-});
-
-test("a traversing slug is refused at the door, on every route that takes one", async ({
- request,
-}) => {
- await resetData("title-filter-channel");
- const channelsDir = resolvePath("test-transcripts/channels");
- const before = (await readdir(channelsDir)).sort();
- expect(before).toEqual(["test-filter"]);
-
- // EVERY SLUG BELOW REACHES A path.join UNDER channelsDir, and the readers
- // swallow their own errors — so an unchecked traversing segment would fail
- // SILENTLY (an empty config read as "channel not found") rather than loudly,
- // and any future writer on that path would land outside the corpus. reqSlug
- // is one check for all of them; this is the assertion that it is wired to
- // each.
- const cases: [string, Record<string, unknown>][] = [
- ["metadata-scan", { slug: "../../escape" }],
- ["refresh-metadata", { slug: "../../escape", id: "abc123" }],
- ["sync", { slug: "../../escape" }],
- ["download-missing", { slug: "../../escape" }],
- ["import-video", { slug: "../../escape", url: "https://example.com/v" }],
- ["retry-bucket", { slug: "../../escape", bucket: "noTranscript" }],
- ["refresh-report", { slug: "../../escape" }],
- ["channel-config", { slug: "../../escape", patch: { cookieMode: "always" } }],
- ["channel-priority", { slugs: ["../../escape"], tier: "paused" }],
- ["relocate", { slugs: ["../../escape"], root: "/tmp/ops-api-never" }],
- ["relocate-back", { slugs: ["../../escape"] }],
- ["fetch-posts", { slug: "../../escape", older: true }],
- ["capture-posts", { slug: "../../escape", ids: ["1"] }],
- ["persist-videos", { items: [{ slug: "../../escape", id: "abc123" }] }],
- ];
- for (const [action, data] of cases) {
- const { status, body } = await ops(request, action, data);
- expect(status, action).toBe(400);
- expect(body.error, action).toMatch(/is not a valid channel slug/);
- }
-
- // The read route takes its slug as a path SEGMENT rather than in a body, so
- // it spells the same check out. A slash-bearing value is not the case to
- // assert here — the router never matches one to a single dynamic segment, so
- // it 404s before the handler exists. What DOES reach the handler is a
- // one-segment name CHANNEL_SLUG_RE still refuses, and a leading dot is the
- // one that matters: it is how a dotfile beside the channels dir would be
- // named at.
- const read = await request.get(`${baseUrl}/api/ops/channel/.escape`, {
- headers: AUTH,
- });
- expect(read.status()).toBe(400);
- expect(((await read.json()) as OpsResponse).error).toMatch(
- /is not a valid channel slug/,
- );
-
- // NOTHING WAS TOUCHED: the corpus still holds exactly the fixture channel,
- // and the fixture's own config is byte-identical.
- expect((await readdir(channelsDir)).sort()).toEqual(before);
- expect(
- await readJson<Record<string, unknown>>(
- "test-transcripts/channels/test-filter/config.json",
- ),
- ).toEqual({
- handling: "youtube",
- name: "Test Title Filter",
- url: "https://www.youtube.com/@example/videos",
- downloadFilter: { include: "guest" },
- });
-});
-
-test("retry-bucket and transcribe-bucket ids must be in the bucket: a stray is refused, named, and nothing runs", async ({
- request,
-}) => {
- await resetData("title-filter-channel");
- await settings();
- const SLUG = "test-filter";
- // A report by hand, so the bucket is known without a regen. The stray ids are
- // in NO bucket of ANY report, so a debounced regen landing between this write
- // and the call cannot change the answer.
- await writeFile(
- resolvePath(`test-transcripts/channels/${SLUG}/snapshot.json`),
- JSON.stringify({ buckets: { downloadedNoTranscript: ["in-bucket-1"] } }),
- );
- const before = await listJobIds();
- const stray = await ops(request, "retry-bucket", {
- slug: SLUG,
- bucket: "downloadedNoTranscript",
- ids: ["stray-aaa", "stray-bbb"],
- });
- expect(stray.status).toBe(400);
- expect(stray.body.error).toMatch(/2 of "ids" not in .*: stray-aaa, stray-bbb/);
- // transcribe-bucket narrows the same bucket by the same rule.
- const strayT = await ops(request, "transcribe-bucket", {
- slug: SLUG,
- ids: ["stray-ccc"],
- });
- expect(strayT.status).toBe(400);
- expect(strayT.body.error).toMatch(/1 of "ids" not in .*: stray-ccc/);
- // An empty list is not "the whole bucket".
- const empty = await ops(request, "retry-bucket", {
- slug: SLUG,
- bucket: "downloadedNoTranscript",
- ids: [],
- });
- expect(empty.status).toBe(400);
- expect(await listJobIds()).toEqual(before);
-});
-
-test("fetch-posts refuses a channel that is not social, full with older, and an older fetch its fetcher cannot do — before any job", async ({
- request,
-}) => {
- await resetData("title-filter-channel");
- await settings();
- // A social channel whose fetcher has no older-posts walk. Nothing below
- // reaches a fetch: every case is refused before a job exists.
- await writeChannelConfig("example-bsky", {
- handling: "transcribe",
- sourceKind: "social",
- platform: "bluesky",
- postFetcher: "bluesky-atproto",
- socialHandle: "example.bsky.social",
- name: "Example (Bluesky)",
- url: "https://bsky.app/profile/example.bsky.social",
- });
- const before = await listJobIds();
-
- // The sentence the action gives for a video channel.
- const video = await ops(request, "fetch-posts", { slug: "test-filter" });
- expect(video.status).toBe(400);
- expect(video.body.error).toBe("test-filter is not a social channel.");
- const videoOlder = await ops(request, "fetch-posts", { slug: "test-filter", older: true });
- expect(videoOlder.status).toBe(400);
- expect(videoOlder.body.error).toBe("test-filter is not a social channel.");
-
- // Two walks at once, on any channel.
- const both = await ops(request, "fetch-posts", {
- slug: "example-bsky",
- full: true,
- older: true,
- });
- expect(both.status).toBe(400);
- expect(both.body.error).toMatch(/different walks — run one at a time/);
- const bothVideo = await ops(request, "fetch-posts", {
- slug: "test-filter",
- full: true,
- older: true,
- });
- expect(bothVideo.status).toBe(400);
- expect(bothVideo.body.error).toMatch(/different walks/);
-
- // A fetcher with no older walk, a floor that is not a date, a floor
- // without older, and a limit that is not a count.
- const bsky = await ops(request, "fetch-posts", { slug: "example-bsky", older: true });
- expect(bsky.status).toBe(400);
- expect(bsky.body.error).toMatch(/cannot fetch older posts/);
- const floor = await ops(request, "fetch-posts", {
- slug: "example-bsky",
- older: true,
- floor: "2020-13-01",
- });
- expect(floor.status).toBe(400);
- expect(floor.body.error).toMatch(/is not a date/);
- const floorAlone = await ops(request, "fetch-posts", {
- slug: "example-bsky",
- floor: "2020-01-01",
- });
- expect(floorAlone.status).toBe(400);
- expect(floorAlone.body.error).toMatch(/only to an older-posts fetch/);
- const limit = await ops(request, "fetch-posts", { slug: "example-bsky", limit: 0 });
- expect(limit.status).toBe(400);
- expect(limit.body.error).toMatch(/"limit" must be a whole number above zero/);
-
- expect(await listJobIds()).toEqual(before);
-});
-
-test("capture-posts refuses what it cannot capture, and an id not in the archive — before any job", async ({
- request,
-}) => {
- await resetData("title-filter-channel");
- await settings();
- // Nothing below reaches X: every case is refused before a job exists.
- await writeChannelConfig("example-bsky", {
- handling: "transcribe",
- sourceKind: "social",
- platform: "bluesky",
- postFetcher: "bluesky-atproto",
- socialHandle: "example.bsky.social",
- name: "Example (Bluesky)",
- url: "https://bsky.app/profile/example.bsky.social",
- });
- await writeChannelConfig("example-x", {
- handling: "transcribe",
- sourceKind: "social",
- platform: "twitter",
- postFetcher: "x-gallery-dl",
- socialHandle: "example_user",
- name: "Example (X)",
- url: "https://x.com/example_user",
- });
- const before = await listJobIds();
-
- const video = await ops(request, "capture-posts", { slug: "test-filter", ids: ["1"] });
- expect(video.status).toBe(400);
- expect(video.body.error).toBe("test-filter is not a social channel.");
-
- const bsky = await ops(request, "capture-posts", { slug: "example-bsky", ids: ["1"] });
- expect(bsky.status).toBe(400);
- expect(bsky.body.error).toMatch(/cannot capture posts/);
-
- const neither = await ops(request, "capture-posts", {
- slug: "example-x",
- ids: ["1"],
- shots: false,
- media: false,
- });
- expect(neither.status).toBe(400);
- expect(neither.body.error).toMatch(/both the screenshot and the media are turned off/);
-
- // The channel's posts archive is empty: every id is a stray, named.
- const stray = await ops(request, "capture-posts", { slug: "example-x", ids: ["111", "222"] });
- expect(stray.status).toBe(400);
- expect(stray.body.error).toBe("2 id(s) not in example-x's posts archive: 111, 222");
-
- // The body's shape.
- const noIds = await ops(request, "capture-posts", { slug: "example-x" });
- expect(noIds.status).toBe(400);
- expect(noIds.body.error).toMatch(/"ids" is required/);
- const badFlag = await ops(request, "capture-posts", { slug: "example-x", ids: ["1"], shots: "yes" });
- expect(badFlag.status).toBe(400);
- expect(badFlag.body.error).toMatch(/"shots" must be a boolean/);
- const unknown = await ops(request, "capture-posts", { slug: "example-x", ids: ["1"], limit: 5 });
- expect(unknown.status).toBe(400);
- expect(unknown.body.error).toMatch(/unknown key\(s\): limit/);
-
- expect(await listJobIds()).toEqual(before);
-});
-
// 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
@@ -1086,39 +750,6 @@ test("tags defines a vocabulary, and tag-videos writes pins with their source",
expect(left.assignments[`${slug}/${id}`].suppressed).toEqual(["eva-collab"]);
});
-test("tag-videos refuses a traversing slug, a bad op and an unknown key", async ({
- request,
-}) => {
- await resetData("one-youtube-channel-with-data");
- const traversing = await ops(request, "tag-videos", {
- tag: "x",
- op: "add",
- videos: [{ slug: "../escape", id: "v" }],
- });
- expect(traversing.status).toBe(400);
- expect(traversing.body.error).toContain("not a valid channel slug");
-
- const badOp = await ops(request, "tag-videos", {
- tag: "x",
- op: "pin",
- videos: [{ slug: "test-youtube", id: "v" }],
- });
- expect(badOp.status).toBe(400);
- expect(badOp.body.error).toContain("add, remove, suppress, unsuppress");
-
- const unknown = await ops(request, "tag-videos", {
- tag: "x",
- op: "add",
- videos: [{ slug: "test-youtube", id: "v" }],
- provenance: "me",
- });
- expect(unknown.status).toBe(400);
- expect(unknown.body.error).toContain("unknown key(s): provenance");
-
- // Nothing was written by any of the three.
- expect(await pathExists("test-transcripts/tags.json")).toBe(false);
-});
-
// --- 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
@@ -1192,294 +823,17 @@ test("keep-videos marks the matching video only, after a dry run that writes not
expect(badRegex.body.error).toContain("invalid pattern");
});
-test("persist-videos buckets a list in a dry run, and refuses a bad body and a low disk — before any job", async ({
- request,
-}) => {
- await resetData("one-youtube-channel-with-data");
- await settings();
- const slug = "test-youtube";
- const video = "20240101_test1234567";
- const before = await listJobIds();
-
- type Bucket = { count: number; items: { slug: string; id: string }[] };
- type PersistBody = OpsResponse & {
- dryRun?: boolean;
- plan?: Record<"saved" | "wrongHeight" | "toFetch" | "noUrl" | "unknown", Bucket> & {
- willFetch: number;
- };
- };
- const items = [
- { slug, id: video },
- { slug, id: "missing12345" },
- { slug: "no-such-channel", id: "abc123" },
- ];
- const dry = await ops(request, "persist-videos", {
- items,
- format: "video_720",
- dryRun: true,
- });
- expect(dry.status, JSON.stringify(dry.body)).toBe(200);
- const plan = (dry.body as PersistBody).plan!;
- expect((dry.body as PersistBody).dryRun).toBe(true);
- expect(plan.toFetch.items).toEqual([{ slug, id: video }]);
- expect(plan.unknown.count).toBe(2);
- expect(plan.saved.count).toBe(0);
- expect(plan.willFetch).toBe(1);
-
- // Nothing to fetch is an answer, not a job.
- const none = await ops(request, "persist-videos", {
- items: [{ slug: "no-such-channel", id: "abc123" }],
- });
- expect(none.status, JSON.stringify(none.body)).toBe(200);
- expect(none.body.jobIds).toEqual([]);
-
- // The body's shape.
- const cases: [Record<string, unknown>, RegExp][] = [
- [{}, /"items" is required/],
- [{ items: [] }, /"items" is required/],
- [{ items: [{ slug, id: video, height: 720 }] }, /unknown key\(s\): height/],
- [{ items: [{ slug, id: "../escape" }] }, /is not a video id/],
- [{ items, format: "1080p" }, /"format" must be one of original, video_720/],
- [{ items, replace: "always" }, /"replace" must be one of never, above-height/],
- [{ items, gapMs: -1 }, /"gapMs" must be a whole number, zero or above/],
- [{ items, minFreeMemMb: "4096" }, /"minFreeMemMb" must be a whole number/],
- [{ items, dryRun: "yes" }, /"dryRun" must be a boolean/],
- [{ items, ids: ["x"] }, /unknown key\(s\): ids/],
- ];
- for (const [data, error] of cases) {
- const res = await ops(request, "persist-videos", data);
- expect(res.status, JSON.stringify(data)).toBe(400);
- expect(res.body.error, JSON.stringify(data)).toMatch(error);
- }
-
- // A real run asks the disk floor before it queues anything.
- await writeSettings({ minFreeDiskGB: 1_000_000 });
- try {
- const low = await ops(request, "persist-videos", { items });
- expect(low.status, JSON.stringify(low.body)).toBe(400);
- expect(low.body.error).toMatch(/^Low disk space/);
- } finally {
- await settings();
- }
-
- expect(await listJobIds()).toEqual(before);
-});
-
-// --- the two build routes speak the same body ---------------------------------
-//
-// They did not. build-site took `siteIds` (a list) and build-deploy took
-// `siteId` (one), so the two halves of the same runbook sentence needed
-// different JSON and the only way to learn which was to get a 400. Both now
-// accept both keys; sending BOTH is still refused, because a caller with two
-// ideas about what to build should not have one picked for it.
-// Job sidecars on disk — the only way to say "and nothing was queued".
-async function listJobIds(): Promise<string[]> {
- return (await readdir(resolvePath("test-transcripts/.jobs")).catch(() => []))
- .filter((f) => f.endsWith(".meta.json"))
- .sort();
-}
-
// 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 fabricated holder
-// writes no .meta.json, so it is invisible to listJobIds; take `before` after
-// this. 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.
+// 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 and build-deploy each take siteId or siteIds, and refuse both or neither", async ({
- request,
-}) => {
- await resetData("title-filter-channel");
- await settings();
-
- for (const action of ["build-site", "build-deploy"]) {
- const both = await ops(request, action, {
- siteId: "a",
- siteIds: ["a"],
- });
- expect(both.status, action).toBe(400);
- expect(both.body.error, action).toContain("not both");
-
- const neither = await ops(request, action, {});
- expect(neither.status, action).toBe(400);
- expect(neither.body.error, action).toContain("siteId");
-
- // `all` is still exclusive of either spelling.
- const withAll = await ops(request, action, { all: true, siteId: "a" });
- expect(withAll.status, action).toBe(400);
- expect(withAll.body.error, action).toContain("not both");
-
- // A MALFORMED ID IS A 400 BEFORE ANY JOB STARTS, and that is the point of
- // validating in reqSiteIds rather than letting getSite throw: an id list of
- // ["good", "BAD"] used to queue the first build, throw on the second and
- // answer 500 with no ids at all — a real build running that nothing was
- // watching, and a --wait exiting 1 about it.
- const before = await listJobIds();
- const bad = await ops(request, action, { siteIds: ["buildsite", "BAD ID"] });
- expect(bad.status, action).toBe(400);
- expect(bad.body.error, action).toContain("not a valid site id");
- expect(bad.body.jobs, action).toBeUndefined();
- expect(await listJobIds(), action).toEqual(before);
- }
-});
-
-// --- preview deploys ----------------------------------------------------------
-//
-// NOTHING HERE STARTS A DEPLOY JOB. Wrangler has a fake since release 18
-// (fake-wrangler.mjs; publish.spec deploys through it), but the preview rules
-// are decided BEFORE a job exists — that is the whole of what they are — so
-// these assert that half, and prove it by watching the job sidecars not
-// appear.
-test("deploy-site and build-deploy refuse a preview name that is not one, before any job", async ({
- request,
-}) => {
- await resetData("title-filter-channel");
- await settings();
- await writeSite("previewsite", { cloudflareProject: "proj" });
-
- for (const action of ["deploy-site", "build-deploy"]) {
- // "main" is the production branch: deploying there is not a preview, it is
- // the live site, and the refusal says so rather than shipping it.
- const before = await listJobIds();
- const main = await ops(request, action, {
- siteId: "previewsite",
- preview: "main",
- });
- expect(main.status, action).toBe(400);
- expect(main.body.error, action).toContain("production branch");
- expect(await listJobIds(), action).toEqual(before);
-
- // Uppercase is refused rather than lowercased FOR the caller: silently
- // rewriting "Main" into "main" would deploy to production.
- const upper = await ops(request, action, {
- siteId: "previewsite",
- preview: "Main",
- });
- expect(upper.status, action).toBe(400);
- expect(upper.body.error, action).toContain("not a valid preview branch name");
- expect(upper.body.error, action).not.toContain("production branch");
-
- const spaced = await ops(request, action, {
- siteId: "previewsite",
- preview: "bad name",
- });
- expect(spaced.status, action).toBe(400);
- expect(spaced.body.error, action).toContain("not a valid preview branch name");
-
- const tooLong = await ops(request, action, {
- siteId: "previewsite",
- preview: "a".repeat(29),
- });
- expect(tooLong.status, action).toBe(400);
- expect(tooLong.body.error, action).toContain("at most 28 characters");
-
- expect(await listJobIds(), action).toEqual(before);
- }
-});
-
-test("a valid preview is accepted and reaches the action, on both deploy routes", async ({
- request,
-}) => {
- await resetData("title-filter-channel");
- await settings();
- // NO Cloudflare project, on purpose: the action's own refusal is the last
- // gate before wrangler, so a 400 saying THAT — rather than "unknown key:
- // preview" or a branch complaint — proves the name passed validation and the
- // route got all the way to the action, without a deploy ever starting.
- await writeSite("noproj", {});
-
- for (const action of ["deploy-site", "build-deploy"]) {
- const before = await listJobIds();
- const { status, body } = await ops(request, action, {
- siteId: "noproj",
- preview: "tags-exclude",
- });
- expect(status, action).toBe(400);
- expect(body.error, action).toContain("no Cloudflare Pages project");
- expect(body.error, action).not.toContain("unknown key");
- expect(await listJobIds(), action).toEqual(before);
- }
-});
-
-test("deploy-site refuses a site that was never built, in the deploy stage's words, before any job", async ({
- request,
-}) => {
- await resetData("title-filter-channel");
- await settings();
- await writeSite("previewsite", { cloudflareProject: "proj" });
-
- // A deploy ships the site's OWN bundle (export/.export-builds/<id>/out,
- // stamped built.json) — never "whatever export/out holds", which is how a
- // deploy-only used to ship one site's build to another's project. A site
- // with no build is refused before any job, with the stage's sentence.
- const before = await listJobIds();
- const { status, body } = await ops(request, "deploy-site", {
- siteId: "previewsite",
- });
- expect(status).toBe(400);
- expect(body.error).toMatch(
- /^no build of previewsite in .*\.export-builds\/previewsite — archilyzer publish build previewsite$/,
- );
- expect(await listJobIds()).toEqual(before);
-
- // A preview is refused for the same reason and just as early.
- const preview = await ops(request, "deploy-site", {
- siteId: "previewsite",
- preview: "tags-exclude",
- });
- expect(preview.status).toBe(400);
- expect(preview.body.error).toContain("no build of previewsite");
- expect(await listJobIds()).toEqual(before);
-});
-
-test("deploy-site takes siteId or siteIds, and has no all", async ({
- request,
-}) => {
- await resetData("title-filter-channel");
- await settings();
-
- const both = await ops(request, "deploy-site", { siteId: "a", siteIds: ["a"] });
- expect(both.status).toBe(400);
- expect(both.body.error).toContain("not both");
-
- const neither = await ops(request, "deploy-site", {});
- expect(neither.status).toBe(400);
- expect(neither.body.error).toContain("siteId");
-
- const before = await listJobIds();
- const bad = await ops(request, "deploy-site", { siteIds: ["deploysite", "BAD ID"] });
- expect(bad.status).toBe(400);
- expect(bad.body.error).toContain("not a valid site id");
- expect(await listJobIds()).toEqual(before);
-
- // Deploy-only has no all-sites form — build-deploy owns that — so `all` is
- // an unknown key here rather than a second spelling of it.
- const all = await ops(request, "deploy-site", { all: true });
- expect(all.status).toBe(400);
- expect(all.body.error).toContain("unknown key");
-});
-
-test("build-deploy refuses a preview alongside all rather than building everything", async ({
- request,
-}) => {
- await resetData("title-filter-channel");
- await settings();
-
- const before = await listJobIds();
- const { status, body } = await ops(request, "build-deploy", {
- all: true,
- preview: "tags-exclude",
- });
- expect(status).toBe(400);
- expect(body.error).toContain('"preview" is not supported with "all"');
- expect(await listJobIds()).toEqual(before);
-});
-
test("build-site with a bare siteId starts its build stage (the index first: there is none)", async ({
request,
}) => {
@@ -1511,108 +865,6 @@ test("build-site with a bare siteId starts its build stage (the index first: the
.toBe("publish-build-site");
});
-// The hub's deploy path (release 7). Every refusal here is answered BEFORE a
-// job exists, which is what lets a runbook's `pnpm ops deploy-hub --wait` fail
-// fast instead of queueing a deploy that can only fail. Both queues are held
-// (release 11, O4 review): a regression of a refusal below would otherwise
-// start a real hub build into export/out, or a real deploy.
-test("deploy-hub refuses no project, the homepage's project, and a bundle that is not the hub", async ({
- request,
-}) => {
- await resetData("title-filter-channel");
- await settings();
- await holdPublishQueue(request);
- const before = await listJobIds();
-
- // The fixture has no homepage.json at all: no project.
- const none = await ops(request, "deploy-hub", {});
- expect(none.status).toBe(400);
- expect(none.body.error).toBe(
- "The hub has no Cloudflare Pages project configured — set it on /sites under Hub.",
- );
-
- // The homepage's project is refused by name — homepage.json said
- // "archilyzer" before the hub could deploy, and a hub deployed there would
- // replace the software's own site.
- const hubFile = resolvePath("test-transcripts/sites/_homepage/homepage.json");
- await mkdir(resolvePath("test-transcripts/sites/_homepage"), { recursive: true });
- await writeFile(hubFile, JSON.stringify({ cloudflareProject: "archilyzer" }));
- const homepages = await ops(request, "deploy-hub", {});
- expect(homepages.status).toBe(400);
- expect(homepages.body.error).toContain('"archilyzer", which is the Archilyzer homepage\'s');
-
- // A real project, but no hub build: the hub's bundle is the FIXTURE's
- // (test-transcripts/.export-builds/_hub, stamped built.json) since release
- // 18, so this refusal fires in any checkout — export/out is not read.
- await writeFile(hubFile, JSON.stringify({ cloudflareProject: "archilyzer-hub" }));
- const unbuilt = await ops(request, "deploy-hub", { preview: "hub-check" });
- expect(unbuilt.status).toBe(400);
- expect(unbuilt.body.error).toMatch(/^no build of _hub in .*\.export-builds\/_hub — archilyzer publish hub$/);
-
- // build-hub: a preview without a deploy is a mistake, not a build.
- const previewOnly = await ops(request, "build-hub", { preview: "hub-check" });
- expect(previewOnly.status).toBe(400);
- expect(previewOnly.body.error).toContain('"preview" needs "deploy": true');
-
- expect(await listJobIds()).toEqual(before);
-});
-
-// The homepage's deploy path (release 11, slice O4). Every refusal is answered
-// BEFORE a job exists, like deploy-hub's.
-//
-// NOTHING HERE CAN REACH WRANGLER (the fake's, even), and three things make
-// sure of it:
-// 1. "built" is the homepage's stamp, `_homepage/built.json` in the fixture's
-// .export-builds (release 18) — the checkout's homepage/out is not asked
-// until a stamp says it was built, and the fixture has none;
-// 2. the missing-build call asks for a PREVIEW, never production;
-// 3. the whole test runs with the `publish` queue held by a fabricated job
-// (holdPublishQueue). A stage that a refusal failed to stop —
-// `build-homepage {preview}` is a build if the "preview needs deploy"
-// guard regresses — would only QUEUE; the listJobIds assertion then fails,
-// and the next resetData cancels jobs newest first, so the queued stage is
-// removed before the holder's slot is freed.
-test("deploy-homepage refuses a bad preview name and a missing build; build-homepage refuses a preview without a deploy", async ({
- request,
-}) => {
- await resetData("empty");
- await settings();
- await holdPublishQueue(request);
- const before = await listJobIds();
-
- // A bad preview name is judged before homepage/out is even looked at, so
- // these hold in any checkout.
- const production = await ops(request, "deploy-homepage", { preview: "main" });
- expect(production.status).toBe(400);
- expect(production.body.error).toBe(
- '"main" is the production branch; a preview needs another name.',
- );
- const shape = await ops(request, "deploy-homepage", { preview: "Not_Valid" });
- expect(shape.status).toBe(400);
- expect(shape.body.error).toMatch(/not a valid preview branch name/);
- // And on the build-then-deploy, before anything is built.
- const buildDeploy = await ops(request, "build-homepage", {
- deploy: true,
- preview: "main",
- });
- expect(buildDeploy.status).toBe(400);
- expect(buildDeploy.body.error).toContain("is the production branch");
-
- // build-homepage: a preview without a deploy is a mistake, not a build.
- const previewOnly = await ops(request, "build-homepage", { preview: "home-check" });
- expect(previewOnly.status).toBe(400);
- expect(previewOnly.body.error).toContain('"preview" needs "deploy": true');
-
- // No build: the homepage's stamp is the fixture's.
- const unbuilt = await ops(request, "deploy-homepage", { preview: "home-check" });
- expect(unbuilt.status).toBe(400);
- expect(unbuilt.body.error).toMatch(
- /^no build of _homepage in .*\.export-builds\/_homepage — archilyzer publish homepage$/,
- );
-
- expect(await listJobIds()).toEqual(before);
-});
-
// --- publish (release 18) ---------------------------------------------------
//
// ONE route for the stages, a verb in the body, and its GET the status. The
@@ -1670,65 +922,3 @@ test("publish: each verb enqueues its run of stages on the publish queue; a dupl
expect(stale.ok).toBe(true);
expect(stale.jobs?.find((j) => j.kind === "build-site")?.existing).toBe(true);
});
-
-test("publish: refusals before any job — a never-built deploy, a preview that is not one, a key the verb does not take", async ({
- request,
-}) => {
- await resetData("title-filter-channel");
- await settings();
- await writeSite("pubsite", { cloudflareProject: "pubproj" });
- await holdPublishQueue(request);
- const before = await listJobIds();
-
- const unbuilt = await ops(request, "publish", { verb: "deploy", siteId: "pubsite", preview: "r18" });
- expect(unbuilt.status).toBe(400);
- expect(unbuilt.body.error).toMatch(/^no build of pubsite in .* — archilyzer publish build pubsite$/);
-
- const main = await ops(request, "publish", { verb: "deploy", siteId: "pubsite", preview: "main" });
- expect(main.status).toBe(400);
- expect(main.body.error).toContain("production branch");
-
- const verb = await ops(request, "publish", { verb: "launch" });
- expect(verb.status).toBe(400);
- expect(verb.body.error).toContain('"verb" must be one of index, build, deploy, hub, homepage, now, stale');
-
- const extra = await ops(request, "publish", { verb: "index", siteId: "pubsite" });
- expect(extra.status).toBe(400);
- expect(extra.body.error).toContain('verb "index" takes no other keys');
-
- const hubPreview = await ops(request, "publish", { verb: "hub", preview: "r18" });
- expect(hubPreview.status).toBe(400);
- expect(hubPreview.body.error).toContain('need "deploy": true');
-
- expect(await listJobIds()).toEqual(before);
-});
-
-test("GET publish is the publish status: the index, the lane, a row per target", async ({ request }) => {
- await resetData("title-filter-channel");
- await settings();
- await writeSite("pubsite", { cloudflareProject: "pubproj" });
-
- const res = await request.get(`${baseUrl}/api/ops/publish`, {
- headers: { authorization: "Bearer test-worker-token" },
- });
- expect(res.status()).toBe(200);
- const status = (await res.json()) as {
- ok: boolean;
- index: { chip: { text: string } };
- lane: { held: boolean; enabled: boolean };
- sites: { target: string; chips: { built: { text: string } } }[];
- hub: { target: string };
- homepage: { target: string };
- plan: { steps: { kind: string }[] };
- };
- expect(status.ok).toBe(true);
- expect(status.index.chip.text).toBe("no index yet");
- expect(status.lane).toMatchObject({ held: false, enabled: false });
- expect(status.sites.map((x) => x.target)).toEqual(["pubsite"]);
- expect(status.sites[0].chips.built.text).toBe("update the index first");
- expect([status.hub.target, status.homepage.target]).toEqual(["_hub", "_homepage"]);
- expect(status.plan.steps.map((x) => x.kind)).toEqual(["update-index"]);
-
- const anon = await request.get(`${baseUrl}/api/ops/publish`);
- expect(anon.status()).toBe(401);
-});