Archilyzer · Source

archilyzer

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

commit 7370d0bb2ace02bf9efb4043f385247a2a2f9dc6
parent 5106875a9a200591c9e636470c25407aabfb716e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 20 Sep 2026 17:48:33 -0400

Pin the ops API on the sentences it shares with the UI

The claim this layer rests on is that an agent over HTTP and an operator
clicking the same button get the same answer from the same code, so the spec
asserts the SHARED sentences — the download-filter message title-filter.spec.ts
reads off the form, channelMediaBusyReason's word for word — and the files on
disk, rather than the routes' own shapes.

The busy-channel case is made busy on purpose: --test-slow sleeps the fake
yt-dlp 30s, so the refusal is genuine rather than raced.

The 503-when-unset branch is NOT covered and the spec header says why: the test
server runs with WORKER_TOKEN set, one server for the whole suite, so no spec
can observe the endpoint disabled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Aeditor/e2e/ops-api.spec.ts | 398+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 398 insertions(+), 0 deletions(-)

diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts @@ -0,0 +1,398 @@ +// /api/ops — the HTTP door onto the editor's server actions. +// +// What this spec is really pinning is the claim the layer rests on: that an +// agent driving the editor over HTTP and an operator clicking the same button +// get the SAME answer from the SAME code. So the assertions are deliberately +// about the shared sentences — the download-filter message that +// title-filter.spec.ts reads off the form, the busy-channel refusal the Storage +// panel shows — and about the files on disk, not about the routes' own shapes. +// +// THE 503 BRANCH IS NOT REACHABLE FROM HERE. The test server runs with +// WORKER_TOKEN=test-worker-token (editor/package.json, dev:test) and there is one +// server for the whole suite, so no spec can observe the endpoint disabled. 401 +// (missing and wrong) is covered below; the 503 is authorizeWorkerRequest's own +// first branch, shared with /api/worker/* and unit-tested by nothing else +// either. See plans/FACTS.md. + +import { test, expect, type APIRequestContext } from "@playwright/test"; +import { baseUrl } from "./baseUrl"; +import { + generateReport, + pathExists, + readJson, + resetData, + writeSettings, +} from "./helpers"; + +const TOKEN = "test-worker-token"; +const AUTH = { authorization: `Bearer ${TOKEN}` }; + +type OpsResponse = { + ok?: boolean; + error?: string; + jobId?: string; + queued?: string[]; + skipped?: { slug: string; reason: string }[]; +}; + +async function ops( + request: APIRequestContext, + action: string, + data: Record<string, unknown>, +): Promise<{ status: number; body: OpsResponse }> { + const res = await request.post(`${baseUrl}/api/ops/${action}`, { + headers: AUTH, + data, + }); + return { status: res.status(), body: (await res.json()) as OpsResponse }; +} + +// EVERY SPEC HERE NAMES THE DISK FLOOR. Without it the merged fixture default +// applies, and a spec that starts a download-shaped job on a nearly-full host +// would be refused by lowDiskError() with a returned { ok: false } no assertion +// reads. 0 is the fixture's own value; naming it makes that a decision. +async function settings(): Promise<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); + } +}); + +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("channel-config round-trips a download filter and refuses a bad regex", async ({ + page, + request, +}) => { + test.setTimeout(120_000); + await resetData("title-filter-channel"); + await settings(); + const SLUG = "test-filter"; + const CONFIG = `test-transcripts/channels/${SLUG}/config.json`; + const before = await readJson<Record<string, unknown>>(CONFIG); + expect(before.downloadFilter).toEqual({ include: "guest" }); + + // THE SAME SENTENCE THE FORM SHOWS. title-filter.spec.ts reads this off the + // page after typing "elf(" into the exclude input; the route reaches it + // through the same parseChannelForm, which is the whole point of routing a + // patch through a FormData rather than writing config.json directly. + const bad = await ops(request, "channel-config", { + slug: SLUG, + patch: { downloadFilterExclude: "elf(" }, + }); + expect(bad.status).toBe(400); + expect(bad.body.error).toMatch( + /Download filter exclude is not a valid regular expression/, + ); + // And nothing was written. + expect( + (await readJson<Record<string, unknown>>(CONFIG)).downloadFilter, + ).toEqual({ include: "guest" }); + + const good = await ops(request, "channel-config", { + slug: SLUG, + patch: { downloadFilterInclude: "guest|special", downloadFilterExclude: "rerun" }, + }); + expect(good.body).toEqual({ ok: true }); + const after = await readJson<Record<string, unknown>>(CONFIG); + expect(after.downloadFilter).toEqual({ + include: "guest|special", + exclude: "rerun", + }); + // A PATCH IS A PATCH. updateChannelAction clears every form-managed key + // before layering the parse result on, so a route that posted only the patch + // would have silently dropped handling/url/name here. + expect(after.name).toBe(before.name); + expect(after.handling).toBe(before.handling); + expect(after.url).toBe(before.url); + + // "" clears a field, exactly as clearing the input does. + const cleared = await ops(request, "channel-config", { + slug: SLUG, + patch: { downloadFilterInclude: "", downloadFilterExclude: "" }, + }); + expect(cleared.body).toEqual({ ok: true }); + expect( + "downloadFilter" in (await readJson<Record<string, unknown>>(CONFIG)), + ).toBe(false); + + // The read route sees the same config, and says whether the media is there. + await generateReport(page, SLUG); + const read = await request.get(`${baseUrl}/api/ops/channel/${SLUG}`, { + headers: AUTH, + }); + expect(read.status()).toBe(200); + const view = (await read.json()) as { + ok: boolean; + config: { name: string }; + media: { status: string }; + report: { totals: { videos: number } } | null; + }; + expect(view.ok).toBe(true); + expect(view.config.name).toBe(before.name); + expect(view.media.status).toBe("in-place"); + expect(view.report?.totals.videos).toBeGreaterThanOrEqual(0); + + const missing = await request.get(`${baseUrl}/api/ops/channel/nope`, { + headers: AUTH, + }); + expect(missing.status()).toBe(404); +}); + +test("channel-priority writes a per-operation override", async ({ request }) => { + await resetData("title-filter-channel"); + await settings(); + const SLUG = "test-filter"; + + const pinned = await ops(request, "channel-priority", { + slugs: [SLUG], + operation: "download", + tier: "paused", + }); + expect(pinned.body).toEqual({ ok: true }); + await expect + .poll(async () => { + const s = await readJson<{ + channelPriority?: { + channels?: Record<string, { overrides?: Record<string, string> }>; + }; + }>("test-settings.json"); + return s.channelPriority?.channels?.[SLUG]?.overrides?.download ?? null; + }) + .toBe("paused"); + + // null clears the override — and only with an operation named, because a bare + // null tier has no meaning for the BASE tier. + const bare = await ops(request, "channel-priority", { + slugs: [SLUG], + tier: null, + }); + expect(bare.status).toBe(400); + expect(bare.body.error).toMatch(/clears an operation override/); + + const cleared = await ops(request, "channel-priority", { + slugs: [SLUG], + operation: "download", + tier: null, + }); + expect(cleared.body).toEqual({ ok: true }); + await expect + .poll(async () => { + const s = await readJson<{ + channelPriority?: { + channels?: Record<string, { overrides?: Record<string, string> }>; + }; + }>("test-settings.json"); + return s.channelPriority?.channels?.[SLUG]?.overrides?.download ?? null; + }) + .toBe(null); + + const bogus = await ops(request, "channel-priority", { + slugs: [SLUG], + tier: "urgent", + }); + expect(bogus.status).toBe(400); + expect(bogus.body.error).toMatch(/normal, low, paused/); +}); + +test("metadata-scan starts a job, and the job says it is a metadata-scan", async ({ + request, +}) => { + test.setTimeout(120_000); + await resetData("title-filter-channel"); + await settings(); + const SLUG = "test-filter"; + + const { status, body } = await ops(request, "metadata-scan", { slug: SLUG }); + expect(status).toBe(200); + expect(body.ok).toBe(true); + expect(body.jobId).toBeTruthy(); + + // THE ROUTE DOES NOT STREAM, so the id is the whole contract: the caller + // follows the same log endpoint the editor's own panel polls. + const meta = await readJson<{ kind: string; channelSlug: string }>( + `test-transcripts/.jobs/${body.jobId}.meta.json`, + ); + expect(meta.kind).toBe("metadata-scan"); + expect(meta.channelSlug).toBe(SLUG); + + const log = await request.get( + `${baseUrl}/api/jobs/${body.jobId}/log?from=0`, + ); + expect(log.status()).toBe(200); + const payload = (await log.json()) as { status: string }; + expect( + ["queued", "running", "done", "failed", "cancelled"].includes( + payload.status, + ), + ).toBe(true); + + const unknownChannel = await ops(request, "metadata-scan", { slug: "nope" }); + expect(unknownChannel.status).toBe(400); + expect(unknownChannel.body.error).toContain('Channel "nope" not found'); +}); + +test("refresh-report regenerates snapshot.json", async ({ request }) => { + test.setTimeout(120_000); + await resetData("title-filter-channel"); + await settings(); + const SLUG = "test-filter"; + const SNAP = `test-transcripts/channels/${SLUG}/snapshot.json`; + // The fixture ships no report — that is what makes the first call's effect + // unambiguous rather than a timestamp comparison. + expect(await pathExists(SNAP)).toBe(false); + + // No page, no click: the route IS the refresh. It regenerates SYNCHRONOUSLY + // (a filesystem scan, not a job), so { ok: true } means the file is there. + const first = await ops(request, "refresh-report", { slug: SLUG }); + expect(first.body).toEqual({ ok: true }); + const snapshot = await readJson<{ generatedAt: string; totals: { videos: number } }>(SNAP); + expect(snapshot.generatedAt).toBeTruthy(); + expect(snapshot.totals.videos).toBeGreaterThanOrEqual(0); + + // Re-running REWRITES it. Polled through the action itself because two scans + // of a six-video fixture can land in the same millisecond. + await expect + .poll(async () => { + await ops(request, "refresh-report", { slug: SLUG }); + return (await readJson<{ generatedAt: string }>(SNAP)).generatedAt; + }) + .not.toBe(snapshot.generatedAt); + + // The bulk form queues a job per channel and reports both lists. + const all = await ops(request, "refresh-report", { all: true }); + expect(all.status).toBe(200); + expect(all.body.ok).toBe(true); + expect([...(all.body.queued ?? []), ...(all.body.skipped ?? []).map((s) => s.slug)]).toContain( + SLUG, + ); + + const both = await ops(request, "refresh-report", { slug: SLUG, all: true }); + expect(both.status).toBe(400); + expect(both.body.error).toMatch(/not both/); +}); + +test("relocate refuses a busy channel with the sentence the panel shows", async ({ + request, +}, testInfo) => { + test.setTimeout(120_000); + await resetData("slow-pipeline-channel"); + await settings(); + const SLUG = "slow-channel"; + + // --test-slow makes the fake yt-dlp sleep 30s, so the channel is genuinely + // busy for the length of this assertion rather than racily so. + const started = await ops(request, "sync", { slug: SLUG }); + expect(started.body.ok).toBe(true); + await expect + .poll(async () => { + const res = await request.get(`${baseUrl}/api/jobs/active`); + const body = (await res.json()) as + | { channelSlug?: string; status: string }[] + | { jobs?: { channelSlug?: string; status: string }[] }; + const jobs = Array.isArray(body) ? body : (body.jobs ?? []); + return jobs.filter( + (j) => + j.channelSlug === SLUG && + (j.status === "running" || j.status === "queued"), + ).length; + }) + .toBeGreaterThan(0); + + const root = testInfo.outputPath("media-root"); + const refused = await ops(request, "relocate", { slugs: [SLUG], root }); + expect(refused.status).toBe(200); + // A SKIP IS NOT A FAILURE — the bulk bar renders both numbers, and so does + // this. The reason is channelMediaBusyReason's, word for word. + expect(refused.body.queued).toEqual([]); + expect(refused.body.skipped?.[0]?.slug).toBe(SLUG); + expect(refused.body.skipped?.[0]?.reason).toMatch( + /running\/queued job\(s\) for this channel/, + ); + + // Exactly one destination, and it must be absolute. + const neither = await ops(request, "relocate", { slugs: [SLUG] }); + expect(neither.status).toBe(400); + expect(neither.body.error).toMatch(/exactly one of "locationId".*or "root"/); +}); + +test("lane flips a hold, and /api/auto-queue/status agrees", async ({ + request, +}) => { + await resetData("empty"); + await settings(); + + const held = async (): Promise<boolean> => { + const res = await request.get(`${baseUrl}/api/auto-queue/status`); + const body = (await res.json()) as Record<string, { held?: boolean }>; + return body.download?.held === true; + }; + expect(await held()).toBe(false); + + const hold = await ops(request, "lane", { lane: "download", held: true }); + expect(hold.body).toEqual({ ok: true, lane: "download" }); + await expect.poll(held).toBe(true); + + const release = await ops(request, "lane", { lane: "download", held: false }); + expect(release.body).toEqual({ ok: true, lane: "download" }); + await expect.poll(held).toBe(false); + + // `enabled` is the policy's master switch and is NOT the gate — two controls + // in the UI, two keys here. + const enable = await ops(request, "lane", { lane: "download", enabled: true }); + expect(enable.body.ok).toBe(true); + await expect + .poll(async () => { + const s = await readJson<{ + autoQueue?: Record<string, { enabled?: boolean }>; + }>("test-settings.json"); + return s.autoQueue?.download?.enabled ?? null; + }) + .toBe(true); + await ops(request, "lane", { lane: "download", enabled: false }); + + const nothing = await ops(request, "lane", { lane: "download" }); + expect(nothing.status).toBe(400); + expect(nothing.body.error).toMatch(/nothing to do/); + + const bogus = await ops(request, "lane", { lane: "transcode", held: true }); + expect(bogus.status).toBe(400); + expect(bogus.body.error).toMatch(/transcription, download, digest, backfill/); +});