Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@askrjs/testing",
"version": "0.0.6",
"version": "0.0.7",
"description": "Transport-neutral request injection and HTTP testing utilities for Askr applications.",
"keywords": [
"askr",
Expand Down
35 changes: 35 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
TestCookieJar,
} from "./types";

/** A reusable HTTP client that injects requests into a target and follows redirects. */
export interface TestClient {
readonly cookies?: TestCookieJar;
request(path: string | URL, options?: InjectOptions): Promise<Response>;
Expand Down Expand Up @@ -86,6 +87,20 @@ async function run(
}
}

/**
* Create a {@link TestClient} bound to a target for repeated request injection.
*
* The returned client applies shared defaults (base URL, headers, cookie jar,
* redirect behavior) to every request made through it, and follows redirects
* automatically unless `redirect` is overridden.
*
* @param target - The handler or {@link RequestTarget} to inject requests into.
* @param options - Default options applied to every request made by this client.
* @returns A {@link TestClient} with `request`, `get`, `post`, and other HTTP-method helpers.
* @example
* const client = createTestClient(app, { baseUrl: "https://example.com", cookies: true });
* const response = await client.get("/users");
*/
export function createTestClient(target: Injectable, options: TestClientOptions = {}): TestClient {
const jar = options.cookies === true ? createTestCookieJar() : options.cookies;
const request = (path: string | URL, requestOptions: InjectOptions = {}) => {
Expand Down Expand Up @@ -116,11 +131,31 @@ export function createTestClient(target: Injectable, options: TestClientOptions
} as TestClient;
}

/**
* Inject a single request into a target and return the resulting response,
* following redirects up to `maxRedirects` hops.
*
* @param target - The handler or {@link RequestTarget} to inject the request into.
* @param request - An existing `Request` to dispatch as-is.
* @param options - Only `maxRedirects` is honored when a `Request` is passed directly.
* @returns The final `Response` after any redirects have been followed.
*/
export function inject(
target: Injectable,
request: Request,
options?: Pick<InjectOptions, "maxRedirects">,
): Promise<Response>;
/**
* Inject a request built from a path/URL and options into a target and return
* the resulting response, following redirects up to `maxRedirects` hops.
*
* @param target - The handler or {@link RequestTarget} to inject the request into.
* @param input - The request path or URL, resolved against `options.baseUrl`.
* @param options - Request options such as method, headers, query, and body.
* @returns The final `Response` after any redirects have been followed.
* @example
* const response = await inject(app, "/users", { method: "GET" });
*/
export function inject(
target: Injectable,
input: string | URL,
Expand Down
7 changes: 7 additions & 0 deletions src/cookies.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { CookieJar } from "tough-cookie";
import type { TestCookie, TestCookieJar } from "./types";

/**
* Create an in-memory {@link TestCookieJar} backed by `tough-cookie`, suitable
* for use as the `cookies` option of a {@link TestClient}.
*
* @returns A cookie jar that persists cookies across injected requests and
* enforces standard cookie-prefix security.
*/
export function createTestCookieJar(): TestCookieJar {
const jar = new CookieJar(undefined, { prefixSecurity: "strict" });
const api: TestCookieJar = {
Expand Down
14 changes: 14 additions & 0 deletions src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ function bodyModes(options: InjectOptions): string[] {
);
}

/**
* Build a `Request` for testing from a path or URL and a set of options.
*
* Resolves `input` against `options.baseUrl` (defaulting to `https://askr.test/`),
* appends any `query` parameters, and serializes at most one of `body`, `json`,
* or `form` into the request body, setting an appropriate `content-type` header
* when one isn't already present. Throws a `TypeError` if more than one body
* mode is supplied, if `json` is `undefined`, or if a `GET`/`HEAD` request is
* given a body.
*
* @param input - The request path or URL.
* @param options - Request options such as method, headers, query, and body.
* @returns A `Request` ready to be dispatched to a test target.
*/
export function createTestRequest(input: string | URL, options: InjectOptions = {}): Request {
const modes = bodyModes(options);
if (modes.length > 1) throw new TypeError(`Request body modes conflict: ${modes.join(", ")}`);
Expand Down
14 changes: 14 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
/** A target that can receive an injected request directly via a `fetch`-style method. */
export interface RequestTarget {
fetch(request: Request): Response | Promise<Response>;
}

/** A function that handles a `Request` and produces a `Response`, synchronously or asynchronously. */
export type RequestHandler = (request: Request) => Response | Promise<Response>;
/** Anything that can receive an injected test request: a {@link RequestTarget} or a {@link RequestHandler}. */
export type Injectable = RequestTarget | RequestHandler;

/** A single query string value, coerced to `string` when serialized. */
export type QueryValue = string | number | boolean;
/** Query string parameters, accepted as `URLSearchParams`, an iterable of entries, or a plain record. */
export type Query =
| URLSearchParams
| Iterable<readonly [string, QueryValue]>
| Record<string, QueryValue | readonly QueryValue[] | undefined>;

/** A single form field value, coerced to `string` when serialized. */
export type FormValue = string | number | boolean;
/** URL-encoded form body data, accepted as `URLSearchParams`, an iterable of entries, or a plain record. */
export type Form =
| URLSearchParams
| Iterable<readonly [string, FormValue]>
Expand All @@ -30,10 +37,12 @@ type RequestOptionsBase = Omit<RequestInit, "body" | "headers" | "method"> & {
maxRedirects?: number;
};

/** Options for a body-less `GET` or `HEAD` request. */
export type GetHeadOptions = RequestOptionsBase & {
method?: "GET" | "HEAD" | "get" | "head";
} & NoBody;

/** Options for a request that may carry a body, restricted to methods that support one. */
export type BodyRequestOptions = RequestOptionsBase & {
method:
| "POST"
Expand All @@ -48,8 +57,10 @@ export type BodyRequestOptions = RequestOptionsBase & {
| "options";
} & BodyMode;

/** Options accepted when injecting a request, covering both body-less and body-carrying methods. */
export type InjectOptions = GetHeadOptions | BodyRequestOptions;

/** A cookie as read back from a {@link TestCookieJar}. */
export interface TestCookie {
name: string;
value: string;
Expand All @@ -61,15 +72,18 @@ export interface TestCookie {
sameSite?: "strict" | "lax" | "none";
}

/** A cookie jar used to persist and replay cookies across injected requests. */
export interface TestCookieJar {
setCookie(cookie: string, url: string | URL): Promise<void>;
getCookies(url: string | URL): Promise<TestCookie[]>;
clear(): Promise<void>;
}

/** Options for constructing a {@link TestClient}. */
export interface TestClientOptions {
baseUrl?: string | URL;
headers?: HeadersInit;
/** Enable an automatically managed cookie jar (`true`), or supply an existing {@link TestCookieJar}. */
cookies?: true | TestCookieJar;
redirect?: RequestRedirect;
maxRedirects?: number;
Expand Down