176 lines
5.9 KiB
TypeScript
176 lines
5.9 KiB
TypeScript
import { createContext, type Context, type ProblemDetails } from "@wrnexus/core";
|
|
|
|
export interface TestRequestOptions extends Omit<RequestInit, "body"> {
|
|
body?: BodyInit | Record<string, unknown> | URLSearchParams | FormData | null;
|
|
baseUrl?: string;
|
|
params?: Record<string, string>;
|
|
user?: unknown;
|
|
locals?: Record<string, unknown>;
|
|
}
|
|
|
|
/** Build a web-standard Request with convenient JSON/FormData handling. */
|
|
export function testRequest(path = "/", options: TestRequestOptions = {}): Request {
|
|
const base = options.baseUrl ?? "http://localhost";
|
|
const headers = new Headers(options.headers);
|
|
let body: BodyInit | null | undefined = options.body as BodyInit | null | undefined;
|
|
if (
|
|
body !== null &&
|
|
body !== undefined &&
|
|
typeof body === "object" &&
|
|
!(body instanceof URLSearchParams) &&
|
|
!(body instanceof FormData) &&
|
|
!(body instanceof Blob) &&
|
|
!(body instanceof ArrayBuffer) &&
|
|
!ArrayBuffer.isView(body) &&
|
|
!(body instanceof ReadableStream)
|
|
) {
|
|
body = JSON.stringify(body);
|
|
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
}
|
|
const method = options.method ?? (body == null ? "GET" : "POST");
|
|
return new Request(new URL(path, base), { ...options, method, headers, body });
|
|
}
|
|
|
|
/** Create a complete Context suitable for middleware and route unit tests. */
|
|
export function testContext(path = "/", options: TestRequestOptions = {}): Context {
|
|
const request = testRequest(path, options);
|
|
const ctx = createContext(request, new URL(request.url));
|
|
ctx.params = { ...options.params };
|
|
ctx.user = options.user;
|
|
ctx.locals = { ...options.locals };
|
|
return ctx;
|
|
}
|
|
|
|
/** Preferred descriptive alias for testContext(). */
|
|
export const createTestContext = testContext;
|
|
|
|
/** Fluent, Bun-compatible fetch mock (including Bun.fetch.preconnect). */
|
|
export type FetchMock = typeof fetch & {
|
|
readonly calls: ReadonlyArray<{ input: string | URL | Request; init?: RequestInit }>;
|
|
respondOnce(response: Response | (() => Response | Promise<Response>)): FetchMock;
|
|
reset(): void;
|
|
};
|
|
|
|
export function createFetchMock(): FetchMock {
|
|
const responses: Array<Response | (() => Response | Promise<Response>)> = [];
|
|
const calls: Array<{ input: string | URL | Request; init?: RequestInit }> = [];
|
|
const implementation = async (input: string | URL | Request, init?: RequestInit) => {
|
|
calls.push({ input, init });
|
|
const next = responses.shift();
|
|
if (!next) throw new Error("WRN-TEST-FETCH: no response was queued for this request");
|
|
return typeof next === "function" ? next() : next.clone();
|
|
};
|
|
const mock = implementation as FetchMock;
|
|
Object.defineProperties(mock, {
|
|
calls: { get: () => calls },
|
|
respondOnce: {
|
|
value(response: Response | (() => Response | Promise<Response>)) {
|
|
responses.push(response);
|
|
return mock;
|
|
},
|
|
},
|
|
reset: {
|
|
value() {
|
|
responses.length = 0;
|
|
calls.length = 0;
|
|
},
|
|
},
|
|
preconnect: { value: () => undefined },
|
|
});
|
|
return mock;
|
|
}
|
|
|
|
export interface JsonResponse<T> {
|
|
response: Response;
|
|
body: T;
|
|
}
|
|
|
|
export async function readJsonResponse<T = unknown>(response: Response): Promise<JsonResponse<T>> {
|
|
return { response, body: (await response.json()) as T };
|
|
}
|
|
|
|
export async function expectProblem(response: Response, status?: number): Promise<ProblemDetails> {
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
if (!contentType.includes("application/problem+json")) {
|
|
throw new Error(`Expected application/problem+json but received '${contentType || "none"}'.`);
|
|
}
|
|
if (status !== undefined && response.status !== status) {
|
|
throw new Error(`Expected status ${status} but received ${response.status}.`);
|
|
}
|
|
const value = (await response.json()) as ProblemDetails;
|
|
if (!value.title || !Number.isInteger(value.status)) {
|
|
throw new Error("Response body is not RFC 9457 Problem Details.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export interface Deferred<T> {
|
|
promise: Promise<T>;
|
|
resolve(value: T | PromiseLike<T>): void;
|
|
reject(reason?: unknown): void;
|
|
}
|
|
|
|
export function deferred<T>(): Deferred<T> {
|
|
let resolve!: Deferred<T>["resolve"];
|
|
let reject!: Deferred<T>["reject"];
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
export interface WaitForOptions {
|
|
timeoutMs?: number;
|
|
intervalMs?: number;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
/** Poll a condition without depending on fake timers or a browser runtime. */
|
|
export async function waitFor(
|
|
condition: () => boolean | Promise<boolean>,
|
|
options: WaitForOptions = {},
|
|
): Promise<void> {
|
|
const timeoutMs = options.timeoutMs ?? 1_000;
|
|
const intervalMs = options.intervalMs ?? 10;
|
|
const startedAt = Date.now();
|
|
while (!(await condition())) {
|
|
if (options.signal?.aborted)
|
|
throw options.signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
if (Date.now() - startedAt >= timeoutMs)
|
|
throw new Error(`waitFor timed out after ${timeoutMs}ms`);
|
|
await new Promise<void>((resolve) => setTimeout(resolve, intervalMs));
|
|
}
|
|
}
|
|
|
|
export class MemoryCookieJar {
|
|
readonly #cookies = new Map<string, string>();
|
|
|
|
apply(response: Response): void {
|
|
const value = response.headers.get("set-cookie");
|
|
if (!value) return;
|
|
for (const segment of value.split(/,(?=[^;,]+=)/)) {
|
|
const pair = segment.split(";", 1)[0]?.trim();
|
|
if (!pair) continue;
|
|
const index = pair.indexOf("=");
|
|
if (index <= 0) continue;
|
|
this.#cookies.set(pair.slice(0, index), pair.slice(index + 1));
|
|
}
|
|
}
|
|
|
|
header(): string {
|
|
return [...this.#cookies].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
}
|
|
|
|
request(path: string, options: TestRequestOptions = {}): Request {
|
|
const headers = new Headers(options.headers);
|
|
const cookie = this.header();
|
|
if (cookie) headers.set("cookie", cookie);
|
|
return testRequest(path, { ...options, headers });
|
|
}
|
|
|
|
clear(): void {
|
|
this.#cookies.clear();
|
|
}
|
|
}
|