Public: anyone can read this; the crew can change it.

A test fails when a new GET /api/v1 route names no Server-Timing phase #5265

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/5248-a-test-fails-when-a-new-get-api-v1-route → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/5248-a-test-fails-when-a-new-get-api-v1-route was merged into main as 20df7ab.

Review

Sign in to review this change.
1 file changed +91 −0
test/route-timing-coverage.test.ts added +91
@@ -0,0 +1,91 @@
1 /**
2 * A GET /api/v1 read names its Server-Timing phases (#5248). Every GET route of
3 * `apiRoutes` is read from the router's own table and called with a fixture's
4 * names filled in; a route that answers 200 without a `session` phase fails
5 * unless `UNTIMED` names it with the reason or the issue that will time it.
6 * The list only shrinks: removing an entry is how an issue that adds phases
7 * proves itself, and a stale entry fails the second test.
8 */
9 import { afterAll, beforeAll, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { createRepo } from "../src/actions/repos.ts";
15 import { createIssue } from "../src/core/issues.ts";

The temp dir prefix is "forge-listshape-", copied from the list-shape test. Rename it to forge-timing-.

16 import { apiRoutes } from "../src/http/api.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { seedAdmin } from "./helpers/admin.ts";
19
20 /** Routes that answer 200 without a `session` phase → why. */
21 const UNTIMED: Record<string, string> = {
22 "/api/v1/repos/:owner/:name/map": "untimed read today: answers without a session phase",
23 "/metrics": "Prometheus text exposition, scraped by token; not a page read",
24 };
25
26 const FILL: Record<string, string> = {
27 owner: "alice", username: "alice", name: "notes", number: "1", org: "alice",
28 slug: "main", id: "1", team: "core", scope: "alice", job: "1", oid: "0", env: "prod",
29 };
30
31 const getPatterns = (): string[] => {
32 const seen: string[] = [];
33 const spy = { add(method: string, pattern: string) { if (method === "GET") seen.push(pattern); return spy; } };
34 apiRoutes(spy as never);
35 return [...new Set(seen)];
36 };
37
38 let dataDir = "";
39 let app: App, servers: Servers, bearer = "";
40
41 beforeAll(async () => {
42 dataDir = await mkdtemp(join(tmpdir(), "forge-timing-"));
43 app = createApp({ dataDir, port: 0 });
44 servers = await serve(app);
45 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
46 if (!admin.ok) throw new Error(admin.error);
47 const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" });
48 if (!made.ok) throw new Error(String(made.error));
49 createIssue(app.db, { repoId: made.value.id, title: "one", body: "b", authorId: admin.value.id });
50 const minted = await fetch(`${servers.url}/api/v1/tokens`, {
51 method: "POST",
52 headers: { "content-type": "application/json" },
53 body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }),
54 });
55 bearer = ((await minted.json()) as { token: string }).token;
56 });
57
58 afterAll(async () => {
59 servers.stop();
60 app.close();
61 await rm(dataDir, { recursive: true, force: true });
62 });
63
64 async function timed(pattern: string): Promise<{ status: number; session: boolean }> {
65 const path = pattern.replace(/:([a-z_]+)/g, (_, k: string) => FILL[k] ?? "x").replace(/\*[a-z_]+/, "x");
66 const res = await fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${bearer}` }, redirect: "manual" });
67 await res.body?.cancel();
68 const phases = (res.headers.get("server-timing") ?? "").split(",").map((p) => p.trim().split(";")[0]);
69 return { status: res.status, session: phases.includes("session") };
70 }
71
72 test("no GET route answers 200 without a session phase unless the allowlist names it", async () => {
73 const untimed: string[] = [];
outdated

Remove the DUMP console.log. It is debug code and does not belong in the committed test.

74 for (const pattern of getPatterns()) {
75 const r = await timed(pattern);
76 if (r.status === 200 && !r.session) untimed.push(pattern);
77 }
78 expect(untimed.filter((p) => !(p in UNTIMED))).toEqual([]);
79 });
80
81 test("an allowlisted route that now names session leaves the list", async () => {
82 const patterns = new Set(getPatterns());
83 const stale: string[] = [];
84 for (const [pattern, why] of Object.entries(UNTIMED)) {
85 expect(why.length).toBeGreaterThan(0);
86 if (!patterns.has(pattern)) { stale.push(pattern); continue; }
87 const r = await timed(pattern);
88 if (r.status !== 200 || r.session) stale.push(pattern);
89 }
90 expect(stale).toEqual([]);
91 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs