Files
suaveplan/packages/api-stack/api/api-router/src/testing/conformance.ts
2026-05-27 23:29:53 +01:00

179 lines
6.2 KiB
TypeScript

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> = {}
): 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/);
});
});
}