import type { Hono } from "hono"; import type { SessionCreator } from "../api/auth-routes"; import type { JwtHelper } from "../api/jwt"; import type { SessionUser } from "../engine/types"; export type BatchCommand = { type: string; payload: unknown }; type WireErrorBody = { readonly code?: string; readonly details?: { readonly causeName?: string; readonly causeMessage?: string; readonly causeStack?: string; }; }; function formatFailure(kind: "write" | "query", type: string, body: unknown): string { const parsed = body as { isSuccess?: boolean; error?: WireErrorBody | string; }; const code = (typeof parsed.error === "object" ? parsed.error?.code : undefined) ?? (typeof parsed.error === "string" ? parsed.error : "unknown"); const details = typeof parsed.error === "object" && parsed.error?.details !== undefined ? parsed.error.details : undefined; const causeMessage = details && typeof details === "object" && "causeMessage" in details ? String((details as { causeMessage?: unknown }).causeMessage ?? "") : ""; const causeName = details && typeof details === "object" && "causeName" in details ? String((details as { causeName?: unknown }).causeName ?? "") : ""; if (code === "internal_error" && (causeMessage || causeName)) { return `Expected ${kind} "${type}" to succeed but got error: ${code} (${causeName}: ${causeMessage})`; } if (details !== undefined) { return `Expected ${kind} "${type}" to succeed but got error: ${code} — ${JSON.stringify(details)}`; } return `Expected ${kind} "${type}" to succeed but got error: ${code}`; } export type RequestHelper = { write: ( type: string, payload: unknown, user: SessionUser, requestId?: string, ) => Promise; query: (type: string, payload: unknown, user: SessionUser) => Promise; command: (type: string, payload: unknown, user: SessionUser) => Promise; batch: ( commands: readonly BatchCommand[], user: SessionUser, requestId?: string, ) => Promise; raw: ( method: string, path: string, body?: unknown, headers?: Record, ) => Promise; /** write + json + assert isSuccess — returns data directly */ writeOk: >( type: string, payload: unknown, user: SessionUser, requestId?: string, ) => Promise; /** write + json + assert isSuccess === false — returns the structured * WriteErrorInfo with `httpStatus` filled in from the HTTP response. */ writeErr: ( type: string, payload: unknown, user: SessionUser, ) => Promise; /** query + json — returns data directly */ queryOk: (type: string, payload: unknown, user: SessionUser) => Promise; /** query + json + assert the response is an error — returns the structured * WriteErrorInfo with `httpStatus` filled in from the HTTP response. */ queryErr: ( type: string, payload: unknown, user: SessionUser, ) => Promise; /** write + additional HTTP headers (e.g. X-Correlation-ID). Returns the * raw Response so callers can assert on status + headers + body as needed. */ writeWithHeaders: ( type: string, payload: unknown, user: SessionUser, extraHeaders: Record, ) => Promise; /** query + additional HTTP headers (e.g. X-Forwarded-For). Returns the raw * Response so callers can assert on status + headers + body as needed. */ queryWithHeaders: ( type: string, payload: unknown, user: SessionUser, extraHeaders: Record, ) => Promise; }; export type RequestHelperOptions = { // When sessionChecker is wired (sessions feature), JWTs without jti are // rejected as no_sid. Seed/test helpers that only jwt.sign(user) need a // live sid — create one via the same sessionCreator login uses (#1372). readonly sessionCreator?: SessionCreator; }; export function createRequestHelper( app: Hono, jwt: JwtHelper, options: RequestHelperOptions = {}, ): RequestHelper { // sid per (user.id, tenantId), not one mint per authHeader() call: // sessionCreator opens a live session row, so an unmemoized call mints a // fresh one on every request a test makes for the same user — tests // asserting on session counts / massRevoker / revokeAllOthers behavior // then see extra live sids that have nothing to do with what they're // testing. Keyed on tenantId too — the same user.id can hold sessions in // more than one tenant, and sessionCreator writes the row scoped to // `user.tenantId` (session-callbacks.ts), so a user.id-only key would // hand a cross-tenant test its wrong tenant's sid. // Cache the Promise (not the settled sid) so two concurrent authHeader // calls for the same key share one mint instead of racing on a miss. const sidByUserKey = new Map>(); async function authHeader(user: SessionUser): Promise> { let forJwt = user; if (options.sessionCreator && !user.sid) { const key = `${user.id}:${user.tenantId}`; let sidPromise = sidByUserKey.get(key); if (sidPromise === undefined) { sidPromise = options.sessionCreator(user, { ip: "test", userAgent: "request-helper" }); sidByUserKey.set(key, sidPromise); } const sid = await sidPromise; forJwt = { ...user, sid }; } const token = await jwt.sign(forJwt); return { Authorization: `Bearer ${token}` }; } async function req( method: string, path: string, body?: unknown, headers?: Record, ): Promise { const init: RequestInit = { method, headers: { "Content-Type": "application/json", ...headers }, }; if (body) init.body = JSON.stringify(body); return app.request(path, init); } async function writeRaw( type: string, payload: unknown, user: SessionUser, requestId?: string, ): Promise { const headers = await authHeader(user); return req("POST", "/api/write", { type, payload, requestId }, headers); } async function queryRaw(type: string, payload: unknown, user: SessionUser): Promise { const headers = await authHeader(user); return req("POST", "/api/query", { type, payload }, headers); } return { write: writeRaw, query: queryRaw, async command(type, payload, user) { const headers = await authHeader(user); return req("POST", "/api/command", { type, payload }, headers); }, async batch(commands, user, requestId) { const headers = await authHeader(user); return req("POST", "/api/batch", { commands, requestId }, headers); }, raw: req, async writeOk>( type: string, payload: unknown, user: SessionUser, requestId?: string, ): Promise { const res = await writeRaw(type, payload, user, requestId); // wire-body shape direkt nach JSON.parse — Caller-Code prüft danach // selber ob isSuccess/error/data tatsächlich da sind. const rawBody = await res.json(); const body = rawBody as { // @cast-boundary engine-bridge isSuccess?: boolean; data?: unknown; error?: { code?: string } | string; }; // Success path still has { isSuccess: true, data }. Error responses now // follow the error-contract shape { error: { code, i18nKey, ... } } with // a 4xx/5xx status — no isSuccess flag. Detect either. if (body.isSuccess !== true) { throw new Error(formatFailure("write", type, body)); } return body.data as T; // @cast-boundary engine-bridge }, async writeErr( type: string, payload: unknown, user: SessionUser, ): Promise { const res = await writeRaw(type, payload, user); const rawErrorBody = await res.json(); const body = rawErrorBody as { // @cast-boundary engine-bridge isSuccess?: boolean; error?: Omit; }; if (body.isSuccess === true) { throw new Error(`Expected write "${type}" to fail but it succeeded`); } const wire = body.error; if (!wire || typeof wire !== "object" || typeof wire.code !== "string") { throw new Error( `Expected error response for "${type}" but got unexpected shape: ${JSON.stringify(body)}`, ); } // The wire body doesn't carry httpStatus (it would be redundant with // the HTTP response status). Fill it in from res.status so callers can // assert against either code OR status without a second request round. return { ...wire, httpStatus: res.status }; }, async queryOk(type: string, payload: unknown, user: SessionUser): Promise { const res = await queryRaw(type, payload, user); const rawBody = await res.json(); const body = rawBody as { // @cast-boundary engine-bridge data?: unknown; error?: WireErrorBody | string; }; // res.ok mirrors writeOk's isSuccess assertion — belt-and-suspenders // in case a future non-dispatcher rejection skips the error-contract shape. if (!res.ok || body.error !== undefined) { throw new Error(`${formatFailure("query", type, body)} [HTTP ${res.status}]`); } return body.data as T; // @cast-boundary engine-bridge }, async queryErr( type: string, payload: unknown, user: SessionUser, ): Promise { const res = await queryRaw(type, payload, user); const rawErrorBody = await res.json(); const body = rawErrorBody as { // @cast-boundary engine-bridge error?: Omit; }; if (res.ok) { throw new Error(`Expected query "${type}" to fail but it succeeded`); } const wire = body.error; if (!wire || typeof wire !== "object" || typeof wire.code !== "string") { throw new Error( `Expected error response for "${type}" but got unexpected shape: ${JSON.stringify(body)}`, ); } // Same rationale as writeErr: the wire body has no httpStatus (it would // be redundant with the HTTP response status), so fill it in here. return { ...wire, httpStatus: res.status }; }, async writeWithHeaders(type, payload, user, extraHeaders) { const authHeaders = await authHeader(user); return req("POST", "/api/write", { type, payload }, { ...authHeaders, ...extraHeaders }); }, async queryWithHeaders(type, payload, user, extraHeaders) { const authHeaders = await authHeader(user); return req("POST", "/api/query", { type, payload }, { ...authHeaders, ...extraHeaders }); }, }; }