import { describe, expect, it } from "@suaveplan/testing/runner"; import { RouterError } from "../error-codes/error-codes.js"; import { composeMiddleware, type Middleware, } from "../middleware/middleware.js"; import { MapHeaders, type RouterRequest } from "../request/request.js"; import { jsonResponse } from "../response/response.js"; import type { Router } from "../router/router.js"; import type { BlueGreenSlot, SlotHandler } from "../slot/slot.js"; /** * Factory bundle the conformance harness consumes — a concrete adapter * supplies one of these and the harness exercises every required case. */ export interface RouterConformanceFactory { /** Build a fresh `Router` (or adapter-equivalent) for each test. */ readonly router: () => Router; /** * Build a fresh `BlueGreenSlot` — adapter may use the reference impl or * its own implementation. The harness passes `(plugin, handler)`. */ readonly slot: (plugin: string, handler: SlotHandler) => BlueGreenSlot; /** Display label used in test names. */ readonly label: string; } /** Noop `OtelSpanLike` — usable in test scenarios that don't assert on spans. */ export function makeNoopSpan(): RouterRequest["span"] { return { setAttribute() { /* noop */ }, setStatus() { /* noop */ }, recordException() { /* noop */ }, end() { /* noop */ }, }; } /** Build a synthetic `RouterRequest` — the harness uses this internally. */ export function makeFixtureRequest( overrides: Partial = {} ): RouterRequest { return { method: "GET", url: "/x", headers: new MapHeaders(), body: undefined, params: {}, query: {}, tenantId: "t" as never, principal: null, requestId: "r", span: makeNoopSpan(), ...overrides, }; } function makeReq(): RouterRequest { return makeFixtureRequest(); } /** * Runs the canonical conformance suite against the supplied factory. * * Every adapter in Stages 4-7 (`api-router-hono`, `api-router-in-memory`, * the worker-bridge router) MUST pass this suite. The harness exercises: * - every verb method * - `mount(prefix, child)` rewriting * - middleware chain ordering via `composeMiddleware` * - route collision detection * - `BlueGreenSlot` lifecycle (set → flip → drain → dispose) * - param parsing * - error propagation */ export function runRouterConformance(factory: RouterConformanceFactory): void { describe(`Router conformance — ${factory.label}`, () => { it("declares every verb method", { timeout: 5000 }, () => { const r = factory.router(); r.get("/g", () => null); r.post("/p", () => null); r.put("/u", () => null); r.patch("/pa", () => null); r.delete("/d", () => null); r.head("/h", () => null); r.options("/o", () => null); expect(r.match("GET", "/g")).not.toBeNull(); expect(r.match("POST", "/p")).not.toBeNull(); expect(r.match("PUT", "/u")).not.toBeNull(); expect(r.match("PATCH", "/pa")).not.toBeNull(); expect(r.match("DELETE", "/d")).not.toBeNull(); expect(r.match("HEAD", "/h")).not.toBeNull(); expect(r.match("OPTIONS", "/o")).not.toBeNull(); }); it("rewrites paths under mount(prefix, sub)", { timeout: 5000 }, () => { const parent = factory.router(); const child = factory.router(); child.get("/invoices", () => null); parent.mount("/billing", child); expect(parent.match("GET", "/billing/invoices")).not.toBeNull(); }); it("composes middleware in declared order", { timeout: 5000, }, async () => { const trace: string[] = []; const a: Middleware = async (_, next) => { trace.push("a"); const r = await next(); trace.push("a-out"); return r; }; const b: Middleware = async (_, next) => { trace.push("b"); const r = await next(); trace.push("b-out"); return r; }; const composed = composeMiddleware([a, b]); await composed(makeReq(), async () => { trace.push("terminal"); return jsonResponse(200, null); }); expect(trace).toEqual(["a", "b", "terminal", "b-out", "a-out"]); }); it("rejects same-plugin route collisions", { timeout: 5000 }, () => { const r = factory.router(); r.get("/x", () => null); expect(() => r.get("/x", () => null)).toThrow(RouterError); }); it("parses path parameters", { timeout: 5000 }, () => { const r = factory.router(); r.get("/users/:id", () => null); const m = r.match("GET", "/users/u_42"); expect(m?.params).toEqual({ id: "u_42" }); }); it("supports blue-green slot lifecycle", { timeout: 5000, }, async () => { const slot = factory.slot("conf", () => "v1"); slot.setShadow(() => "v2"); const draining = slot.flip(); expect(slot.live.slotId).not.toBe(draining.slotId); await expect( slot.dispose(draining.slotId, { timeoutMs: 100 }) ).resolves.toBeUndefined(); }); it("flip with no shadow throws SLOT_FLIP_FAILED", { timeout: 5000, }, () => { const slot = factory.slot("conf", () => null); expect(() => slot.flip()).toThrow(RouterError); }); it("propagates errors through the middleware chain", { timeout: 5000, }, async () => { const boom: Middleware = async () => { throw new Error("conformance-boom"); }; const composed = composeMiddleware([boom]); await expect( composed(makeReq(), async () => jsonResponse(200, null)) ).rejects.toThrow(/conformance-boom/); }); }); }