UNPKG

mentoss

Version:

A utility to mock fetch requests and responses.

357 lines (356 loc) 14.3 kB
/** * @fileoverview The RequestMatcher class. * @author Nicholas C. Zakas */ /* globals FormData, URLPattern, URLSearchParams */ //----------------------------------------------------------------------------- // Imports //----------------------------------------------------------------------------- import "urlpattern-polyfill"; //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** @typedef {import("./types.js").RequestPattern} RequestPattern */ /** @typedef {import("./types.js").HttpBody} HttpBody */ //----------------------------------------------------------------------------- // Helpers //----------------------------------------------------------------------------- /** * Deeply compares two objects. `actual` is considered to match `expected` * if all properties in `expected` are present in `actual` and have the same * value. All properties in `actual` need not be present in `expected`. * @param {Record<string, any>} actual The first object to compare. * @param {Record<string, any>} expected The second object to compare. * @returns {boolean} True if the objects are deeply equal, false if not. */ function deepCompare(actual, expected) { return Object.entries(expected).every(([key, value]) => { if (value && typeof value === "object") { return deepCompare(actual[key], value); } else { return value === actual[key]; } }); } //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- /** * Represents a matcher for a request. */ export class RequestMatcher { /** * The method to match. * @type {string} */ #method = ""; /** * The URL pattern to match. * @type {URLPattern} * @readonly */ #pattern; /** * The body of the request to match. * @type {HttpBody} */ #body; /** * The headers to match. * @type {Record<string, string>} */ #headers; /** * The query string to match. * @type {Record<string, string> | undefined} */ #query; /** * The URL parameters to match. * @type {Record<string, string> | undefined} */ #params; /** * Creates a new instance. * @param {object} options The options for the route. * @param {string} options.method The method to match. * @param {string} options.url The URL to match. * @param {string} options.baseUrl The base URL to prepend to the url. * @param {HttpBody} [options.body] The body to match. * @param {Record<string, string>} [options.headers] The headers to match. * @param {Record<string, string>} [options.query] The query string to match. * @param {Record<string, string>} [options.params] The URL parameters to match. */ constructor({ method, url, baseUrl, body = null, headers = {}, query, params, }) { this.#method = method; /* * URLPattern treats a leading slash as being an absolute path from * the domain in the base URL (if present). So if the URL is /api * and the base URL is https://example.com/v1, the URLPattern will * match https://example.com/api. To avoid this, we remove the leading * slash from the URL if it's present and add a trailing slash to the * base URL if it's not present. */ this.#pattern = new URLPattern(url.startsWith("/") ? url.slice(1) : url, !baseUrl || baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`); this.#body = body; this.#headers = headers; this.#query = query; this.#params = params; } /** * Checks if the request matches the matcher. Traces all of the details to help * with debugging. * @param {RequestPattern} request The request to check. * @returns {{matches:boolean, messages:string[], params:Record<string, string|undefined>, query:URLSearchParams}} True if the request matches, false if not. */ traceMatches(request) { /* * Check the URL first. This is helpful for tracing when requests don't match * because people more typically get the method wrong rather than the URL. */ const urlMatch = this.#pattern.exec(request.url); if (!urlMatch) { return { matches: false, messages: ["❌ URL does not match."], params: {}, query: new URLSearchParams(), }; } const messages = ["✅ URL matches."]; const params = urlMatch.pathname.groups; const query = new URL(request.url).searchParams; // Method check if (request.method.toLowerCase() !== this.#method.toLowerCase()) { return { matches: false, messages: [ ...messages, `❌ Method does not match. Expected ${this.#method.toUpperCase()} but received ${request.method.toUpperCase()}.`, ], params, query, }; } messages.push(`✅ Method matches: ${this.#method.toUpperCase()}.`); // then check query string const expectedQuery = this.#query; if (expectedQuery) { const actualQuery = request.query; if (!actualQuery) { return { matches: false, messages: [ ...messages, "❌ Query string does not match. Expected query string but received none.", ], params, query, }; } for (const [key, value] of Object.entries(expectedQuery)) { if (actualQuery[key] !== value) { return { matches: false, messages: [ ...messages, `❌ Query string does not match. Expected ${key}=${value} but received ${key}=${actualQuery[key]}.`, ], params, query, }; } } } // then check URL parameters const expectedParams = this.#params; if (expectedParams) { const actualParams = urlMatch.pathname.groups; if (!actualParams) { return { matches: false, messages: [ ...messages, "❌ URL parameters do not match. Expected parameters but received none.", ], params, query, }; } for (const [key, value] of Object.entries(expectedParams)) { if (actualParams[key] !== value) { return { matches: false, messages: [ ...messages, `❌ URL parameters do not match. Expected ${key}=${value} but received ${key}=${actualParams[key]}.`, ], params, query, }; } } messages.push("✅ URL parameters match."); } // then check the headers in a case-insensitive manner if (request.headers) { const expectedHeaders = Object.entries(this.#headers).map(([key, value]) => [key.toLowerCase(), value]); const actualHeaders = Object.entries(request.headers).map(([key, value]) => [key.toLowerCase(), value]); for (const [key, value] of expectedHeaders) { const actualValue = actualHeaders.find(([actualKey]) => actualKey === key); if (!actualValue || actualValue[1] !== value) { return { matches: false, messages: [ ...messages, `❌ Headers do not match. Expected ${key}=${value} but received ${key}=${actualValue ? actualValue[1] : "none"}.`, ], params, query, }; } } messages.push("✅ Headers match."); } // then check the body if (this.#body !== undefined && this.#body !== null) { // if there's no body on the actual request then it can't match if (request.body === null || request.body === undefined) { return { matches: false, messages: [ ...messages, "❌ Body does not match. Expected body but received none.", ], params, query, }; } if (typeof this.#body === "string") { if (this.#body !== request.body) { return { matches: false, messages: [ ...messages, `❌ Body does not match. Expected ${this.#body} but received ${request.body}`, ], params, query, }; } messages.push(`✅ Body matches`); } else if (this.#body instanceof FormData) { if (!(request.body instanceof FormData)) { return { matches: false, messages: [ ...messages, "❌ Body does not match. Expected FormData but received none.", ], params, query, }; } for (const [key, value] of this.#body.entries()) { if (request.body.get(key) !== value) { return { matches: false, messages: [ ...messages, `❌ Body does not match. Expected ${key}=${value} but received ${key}=${request.body.get(key)}.`, ], params, query, }; } } messages.push("✅ Body matches."); } else if (this.#body instanceof ArrayBuffer) { if (!(request.body instanceof ArrayBuffer)) { return { matches: false, messages: [ ...messages, `❌ Body does not match. Expected ArrayBuffer but received ${request.body.constructor.name}.`, ], params, query, }; } // compare array buffers if (request.body.byteLength !== this.#body.byteLength) { return { matches: false, messages: [ ...messages, `❌ Body does not match. Expected array buffer byte length ${this.#body.byteLength} but received ${request.body.byteLength}`, ], params, query, }; } // convert into uint8arrays to compare const expectedBody = new Uint8Array(this.#body); const actualBody = new Uint8Array(request.body); for (let i = 0; i < expectedBody.length; i++) { if (expectedBody[i] !== actualBody[i]) { return { matches: false, messages: [ ...messages, `❌ Body does not match. Expected byte ${i} to be ${expectedBody[i]} but received ${actualBody[i]}.`, ], params, query, }; } } messages.push("✅ Body matches."); } else { // body must be an object here to run a check if (typeof request.body !== "object") { return { matches: false, messages: [ ...messages, "❌ Body does not match. Expected object but received none.", ], params, query, }; } // body is an object so proceed if (!deepCompare(request.body, this.#body)) { return { matches: false, messages: [ ...messages, `❌ Body does not match. Expected ${JSON.stringify(this.#body)} but received ${JSON.stringify(request.body)}.`, ], params, query, }; } messages.push("✅ Body matches."); } } return { matches: true, messages, params, query, }; } /** * Checks if the request matches the matcher. * @param {RequestPattern} request The request to check. * @returns {boolean} True if the request matches, false if not. */ matches(request) { return this.traceMatches(request).matches; } }