Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Aeditor/app/api/ops/_door.test.ts | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/_testCorpus.ts | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/build-site/route.test.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/api/ops/capture-posts/route.test.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/deploy-homepage/route.test.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/deploy-hub/route.test.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/deploy-site/route.test.ts | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/api/ops/fetch-posts/route.test.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/persist-videos/route.test.ts | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/publish/route.test.ts | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/retry-bucket/route.test.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/tag-videos/route.test.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/ops-api.spec.ts | 836++-----------------------------------------------------------------------------
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); -});