179 lines
6.2 KiB
TypeScript
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/);
|
|
});
|
|
});
|
|
}
|