From ea74e8dbb4204d8bdfc11962223376ca8ffb0603 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sat, 18 May 2024 13:11:59 -0300 Subject: [PATCH 01/22] feat(#zimic): support custom local unhandled request strategy --- packages/zimic/src/cli/browser/init.ts | 3 +- packages/zimic/src/cli/server/start.ts | 2 +- packages/zimic/src/cli/utils/console.ts | 9 ----- .../http/interceptor/types/options.ts | 29 ++++++++++++--- .../HttpInterceptorWorker.ts | 27 +++++++++++++- .../LocalHttpInterceptorWorker.ts | 33 +++++++++++++++-- .../errors/UnhandledRequestError.ts | 8 +++++ .../http/interceptorWorker/types/options.ts | 3 ++ packages/zimic/src/utils/console.ts | 36 +++++++++++++++++++ packages/zimic/src/utils/http.ts | 4 +-- packages/zimic/src/utils/processes.ts | 5 ++- packages/zimic/src/utils/urls.ts | 8 ++--- packages/zimic/src/utils/webSocket.ts | 6 ++-- 13 files changed, 144 insertions(+), 29 deletions(-) delete mode 100644 packages/zimic/src/cli/utils/console.ts create mode 100644 packages/zimic/src/interceptor/http/interceptorWorker/errors/UnhandledRequestError.ts create mode 100644 packages/zimic/src/utils/console.ts diff --git a/packages/zimic/src/cli/browser/init.ts b/packages/zimic/src/cli/browser/init.ts index 0c2d032d..fc0f9169 100644 --- a/packages/zimic/src/cli/browser/init.ts +++ b/packages/zimic/src/cli/browser/init.ts @@ -1,7 +1,8 @@ import filesystem from 'fs/promises'; import path from 'path'; -import { getChalk, logWithPrefix } from '../utils/console'; +import { getChalk, logWithPrefix } from '@/utils/console'; + import { SERVICE_WORKER_FILE_NAME } from './shared/constants'; const MSW_ROOT_PATH = path.join(require.resolve('msw'), '..', '..', '..'); diff --git a/packages/zimic/src/cli/server/start.ts b/packages/zimic/src/cli/server/start.ts index 69626ccd..e5db8b33 100644 --- a/packages/zimic/src/cli/server/start.ts +++ b/packages/zimic/src/cli/server/start.ts @@ -1,4 +1,4 @@ -import { logWithPrefix } from '@/cli/utils/console'; +import { logWithPrefix } from '@/utils/console'; import { runCommand, PROCESS_EXIT_EVENTS } from '@/utils/processes'; import InterceptorServer from '../../interceptor/server/InterceptorServer'; diff --git a/packages/zimic/src/cli/utils/console.ts b/packages/zimic/src/cli/utils/console.ts deleted file mode 100644 index 9c6e2e5a..00000000 --- a/packages/zimic/src/cli/utils/console.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function getChalk() { - const { default: chalk } = await import('chalk'); - return chalk; -} - -export async function logWithPrefix(message: string) { - const chalk = await getChalk(); - console.log(`${chalk.cyan('[zimic]')} ${message}`); -} diff --git a/packages/zimic/src/interceptor/http/interceptor/types/options.ts b/packages/zimic/src/interceptor/http/interceptor/types/options.ts index 279ade69..73caa9f9 100644 --- a/packages/zimic/src/interceptor/http/interceptor/types/options.ts +++ b/packages/zimic/src/interceptor/http/interceptor/types/options.ts @@ -1,18 +1,39 @@ +import { HttpRequest } from '@/http/types/requests'; +import { PossiblePromise } from '@/types/utils'; + export type HttpInterceptorType = 'local' | 'remote'; export type HttpInterceptorPlatform = 'node' | 'browser'; -export interface BaseHttpInterceptorOptions { +interface SharedHttpInterceptorOptions { type: HttpInterceptorType; - /** The base URL used by the interceptor. This URL will be prepended to any paths used by the interceptor. */ baseURL: string | URL; } -export interface LocalHttpInterceptorOptions extends BaseHttpInterceptorOptions { +export namespace UnhandledRequestStrategy { + export type Action = 'bypass' | 'reject'; + + export interface LocalDeclaration { + action: Action; + log: boolean; + } + export type LocalDeclarationHandler = (request: HttpRequest) => PossiblePromise>; + + export interface RemoteDeclaration { + action: Extract; + log: boolean; + } + export type RemoteDeclarationHandler = (request: HttpRequest) => PossiblePromise>; +} + +export interface LocalHttpInterceptorOptions extends SharedHttpInterceptorOptions { type: 'local'; + onUnhandledRequest?: + | Partial + | UnhandledRequestStrategy.LocalDeclarationHandler; } -export interface RemoteHttpInterceptorOptions extends BaseHttpInterceptorOptions { +export interface RemoteHttpInterceptorOptions extends SharedHttpInterceptorOptions { type: 'remote'; } diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts index bc9dd65a..2815e78d 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts @@ -10,11 +10,12 @@ import { HttpServiceSchema, } from '@/http/types/schema'; import { Default, PossiblePromise } from '@/types/utils'; +import { formatObjectToLog, getChalk, logWithPrefix } from '@/utils/console'; import { createURL } from '@/utils/urls'; import HttpSearchParams from '../../../http/searchParams/HttpSearchParams'; import HttpInterceptorClient, { AnyHttpInterceptorClient } from '../interceptor/HttpInterceptorClient'; -import { HttpInterceptorPlatform } from '../interceptor/types/options'; +import { HttpInterceptorPlatform, UnhandledRequestStrategy } from '../interceptor/types/options'; import { HTTP_INTERCEPTOR_REQUEST_HIDDEN_BODY_PROPERTIES, HTTP_INTERCEPTOR_RESPONSE_HIDDEN_BODY_PROPERTIES, @@ -218,6 +219,30 @@ abstract class HttpInterceptorWorker { return bodyAsText || null; } } + + static async warnUnhandledRequest(rawRequest: HttpRequest, action: UnhandledRequestStrategy.Action) { + const request = await this.parseRawRequest(rawRequest); + + const chalk = await getChalk(); + + await logWithPrefix( + [ + `${action === 'bypass' ? 'Warning:' : 'Error:'} Request did not match any handlers and was ` + + `${action === 'bypass' ? chalk.yellow('bypassed') : chalk.red('rejected')}:\n\n `, + request.method, + request.url, + '\n Headers:', + formatObjectToLog(Object.fromEntries(request.headers)), + '\n Search params:', + formatObjectToLog(Object.fromEntries(request.searchParams)), + '\n Body:', + formatObjectToLog(request.body), + '\n\nTo handle this request, use an interceptor to create a handler for it.', + '\nIf you are using restrictions, make sure that they match the content of the request.', + ], + { method: action === 'bypass' ? 'warn' : 'error' }, + ); + } } export default HttpInterceptorWorker; diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts index 3e491fec..e36e69be 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts @@ -13,6 +13,8 @@ import { excludeNonPathParams, ensureUniquePathParams, createURL } from '@/utils import NotStartedHttpInterceptorError from '../interceptor/errors/NotStartedHttpInterceptorError'; import UnknownHttpInterceptorPlatform from '../interceptor/errors/UnknownHttpInterceptorPlatform'; import HttpInterceptorClient from '../interceptor/HttpInterceptorClient'; +import { UnhandledRequestStrategy } from '../interceptor/types/options'; +import UnhandledRequestError from './errors/UnhandledRequestError'; import UnregisteredServiceWorkerError from './errors/UnregisteredServiceWorkerError'; import HttpInterceptorWorker from './HttpInterceptorWorker'; import { LocalHttpInterceptorWorkerOptions } from './types/options'; @@ -22,15 +24,26 @@ class LocalHttpInterceptorWorker extends HttpInterceptorWorker { readonly type: 'local'; private _internalWorker?: HttpWorker; + private onUnhandledRequest: + | UnhandledRequestStrategy.LocalDeclaration + | UnhandledRequestStrategy.LocalDeclarationHandler; private httpHandlerGroups: { interceptor: HttpInterceptorClient; // eslint-disable-line @typescript-eslint/no-explicit-any httpHandler: MSWHttpHandler; }[] = []; - constructor(options: LocalHttpInterceptorWorkerOptions) { + constructor({ type, onUnhandledRequest }: LocalHttpInterceptorWorkerOptions) { super(); - this.type = options.type; + this.type = type; + + this.onUnhandledRequest = + typeof onUnhandledRequest === 'function' + ? onUnhandledRequest + : { + action: onUnhandledRequest?.action ?? 'bypass', + log: onUnhandledRequest?.log ?? true, + }; } internalWorkerOrThrow() { @@ -67,7 +80,9 @@ class LocalHttpInterceptorWorker extends HttpInterceptorWorker { async start() { await super.sharedStart(async () => { const internalWorker = await this.internalWorkerOrLoad(); - const sharedOptions: MSWWorkerSharedOptions = { onUnhandledRequest: 'bypass' }; + const sharedOptions: MSWWorkerSharedOptions = { + onUnhandledRequest: this.handleUnhandledRequest, + }; if (this.isInternalBrowserWorker(internalWorker)) { super.setPlatform('browser'); @@ -81,6 +96,18 @@ class LocalHttpInterceptorWorker extends HttpInterceptorWorker { }); } + private handleUnhandledRequest = async (request: Request) => { + const strategy = + typeof this.onUnhandledRequest === 'function' ? await this.onUnhandledRequest(request) : this.onUnhandledRequest; + + if (strategy.log) { + await HttpInterceptorWorker.warnUnhandledRequest(request, strategy.action); + } + if (strategy.action === 'reject') { + throw new UnhandledRequestError(); + } + }; + private async startInBrowser(internalWorker: BrowserHttpWorker, sharedOptions: MSWWorkerSharedOptions) { try { await internalWorker.start({ ...sharedOptions, quiet: true }); diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnhandledRequestError.ts b/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnhandledRequestError.ts new file mode 100644 index 00000000..3944ab04 --- /dev/null +++ b/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnhandledRequestError.ts @@ -0,0 +1,8 @@ +class UnhandledRequestError extends Error { + constructor() { + super('[zimic] Request did not match any handlers and was rejected.'); + this.name = 'UnhandledRequestError'; + } +} + +export default UnhandledRequestError; diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/types/options.ts b/packages/zimic/src/interceptor/http/interceptorWorker/types/options.ts index 330daf90..f0e71c54 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/types/options.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/types/options.ts @@ -1,7 +1,10 @@ import { ExtendedURL } from '@/utils/urls'; +import { LocalHttpInterceptorOptions } from '../../interceptor/types/options'; + export interface LocalHttpInterceptorWorkerOptions { type: 'local'; + onUnhandledRequest?: LocalHttpInterceptorOptions['onUnhandledRequest']; } export interface RemoteHttpInterceptorWorkerOptions { diff --git a/packages/zimic/src/utils/console.ts b/packages/zimic/src/utils/console.ts new file mode 100644 index 00000000..45418c10 --- /dev/null +++ b/packages/zimic/src/utils/console.ts @@ -0,0 +1,36 @@ +import util from 'util'; + +import { isClientSide } from './environment'; + +export async function getChalk() { + const { default: chalk } = await import('chalk'); + return chalk; +} + +export function formatObjectToLog(value: unknown) { + if (isClientSide()) { + return value; + } + return util.inspect(value, { + colors: true, + compact: true, + depth: Infinity, + maxArrayLength: Infinity, + maxStringLength: Infinity, + breakLength: Infinity, + }); +} + +export async function logWithPrefix( + messageOrMessages: unknown, + options: { + method?: 'log' | 'warn' | 'error'; + } = {}, +) { + const { method = 'log' } = options; + + const messages = Array.isArray(messageOrMessages) ? messageOrMessages : [messageOrMessages]; + + const chalk = await getChalk(); + console[method](chalk.cyan('[zimic]'), ...messages); +} diff --git a/packages/zimic/src/utils/http.ts b/packages/zimic/src/utils/http.ts index 6fe21cd6..07670e89 100644 --- a/packages/zimic/src/utils/http.ts +++ b/packages/zimic/src/utils/http.ts @@ -4,14 +4,14 @@ class HttpServerTimeoutError extends Error {} export class HttpServerStartTimeoutError extends HttpServerTimeoutError { constructor(reachedTimeout: number) { - super(`HTTP server start timed out after ${reachedTimeout}ms.`); + super(`[zimic] HTTP server start timed out after ${reachedTimeout}ms.`); this.name = 'HttpServerStartTimeout'; } } export class HttpServerStopTimeoutError extends HttpServerTimeoutError { constructor(reachedTimeout: number) { - super(`HTTP server stop timed out after ${reachedTimeout}ms.`); + super(`[zimic] HTTP server stop timed out after ${reachedTimeout}ms.`); this.name = 'HttpServerStopTimeout'; } } diff --git a/packages/zimic/src/utils/processes.ts b/packages/zimic/src/utils/processes.ts index f9865e91..7775edf5 100644 --- a/packages/zimic/src/utils/processes.ts +++ b/packages/zimic/src/utils/processes.ts @@ -11,7 +11,10 @@ export const PROCESS_EXIT_EVENTS = [ export class CommandError extends Error { constructor(command: string, exitCode: number | null, signal: NodeJS.Signals | null) { - super(`The command '${command}' exited ${exitCode === null ? `after signal ${signal}` : `with code ${exitCode}`}.`); + super( + `[zimic] Command '${command}' exited ` + + `${exitCode === null ? `after signal ${signal}` : `with code ${exitCode}`}.`, + ); this.name = 'CommandError'; } } diff --git a/packages/zimic/src/utils/urls.ts b/packages/zimic/src/utils/urls.ts index 001f850a..f7ddb6f0 100644 --- a/packages/zimic/src/utils/urls.ts +++ b/packages/zimic/src/utils/urls.ts @@ -1,6 +1,6 @@ export class InvalidURL extends TypeError { constructor(url: unknown) { - super(`Invalid URL: '${url}'`); + super(`[zimic] Invalid URL: '${url}'`); this.name = 'InvalidURL'; } } @@ -8,7 +8,7 @@ export class InvalidURL extends TypeError { export class UnsupportedURLProtocolError extends TypeError { constructor(protocol: string, availableProtocols: string[]) { super( - `Unsupported URL protocol: '${protocol}'. ` + + `[zimic] Unsupported URL protocol: '${protocol}'. ` + `The available options are ${availableProtocols.map((protocol) => `'${protocol}'`).join(', ')}`, ); this.name = 'UnsupportedURLProtocolError'; @@ -59,8 +59,8 @@ export function excludeNonPathParams(url: URL) { export class DuplicatedPathParamError extends Error { constructor(url: string, paramName: string) { super( - `The path parameter '${paramName}' appears more than once in the URL '${url}'. This is not supported. Please` + - ' make sure that each parameter is unique.', + `[zimic] The path parameter '${paramName}' appears more than once in the URL '${url}'. This is not supported. ` + + 'Please make sure that each parameter is unique.', ); this.name = 'DuplicatedPathParamError'; } diff --git a/packages/zimic/src/utils/webSocket.ts b/packages/zimic/src/utils/webSocket.ts index 8e9a4476..a9ed4069 100644 --- a/packages/zimic/src/utils/webSocket.ts +++ b/packages/zimic/src/utils/webSocket.ts @@ -6,21 +6,21 @@ class WebSocketTimeoutError extends Error {} export class WebSocketOpenTimeoutError extends WebSocketTimeoutError { constructor(reachedTimeout: number) { - super(`Web socket open timed out after ${reachedTimeout}ms.`); + super(`[zimic] Web socket open timed out after ${reachedTimeout}ms.`); this.name = 'WebSocketOpenTimeout'; } } export class WebSocketMessageTimeoutError extends WebSocketTimeoutError { constructor(reachedTimeout: number) { - super(`Web socket message timed out after ${reachedTimeout}ms.`); + super(`[zimic] Web socket message timed out after ${reachedTimeout}ms.`); this.name = 'WebSocketMessageTimeout'; } } export class WebSocketCloseTimeoutError extends WebSocketTimeoutError { constructor(reachedTimeout: number) { - super(`Web socket close timed out after ${reachedTimeout}ms.`); + super(`[zimic] Web socket close timed out after ${reachedTimeout}ms.`); this.name = 'WebSocketCloseTimeout'; } } From 0564081c6462603783c12996a7a2aec1cb590a63 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sat, 18 May 2024 13:12:23 -0300 Subject: [PATCH 02/22] refactor(#zimic): improve error messages --- .../http/interceptor/errors/NotStartedHttpInterceptorError.ts | 2 +- .../http/interceptor/errors/UnknownHttpInterceptorPlatform.ts | 2 +- .../http/interceptor/errors/UnknownHttpInterceptorTypeError.ts | 2 +- .../interceptorWorker/errors/UnregisteredServiceWorkerError.ts | 2 +- .../http/requestHandler/errors/NoResponseDefinitionError.ts | 2 +- .../server/errors/NotStartedInterceptorServerError.ts | 2 +- packages/zimic/src/webSocket/errors/InvalidWebSocketMessage.ts | 2 +- .../src/webSocket/errors/NotStartedWebSocketHandlerError.ts | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/zimic/src/interceptor/http/interceptor/errors/NotStartedHttpInterceptorError.ts b/packages/zimic/src/interceptor/http/interceptor/errors/NotStartedHttpInterceptorError.ts index e3de572b..64576c83 100644 --- a/packages/zimic/src/interceptor/http/interceptor/errors/NotStartedHttpInterceptorError.ts +++ b/packages/zimic/src/interceptor/http/interceptor/errors/NotStartedHttpInterceptorError.ts @@ -1,7 +1,7 @@ /** Error thrown when the worker is not running and it's not possible to declare mock responses. */ class NotStartedHttpInterceptorError extends Error { constructor() { - super('The interceptor is not running. Did you forget to call `await interceptor.start()`?'); + super('[zimic] Interceptor is not running. Did you forget to call `await interceptor.start()`?'); this.name = 'NotStartedHttpInterceptorError'; } } diff --git a/packages/zimic/src/interceptor/http/interceptor/errors/UnknownHttpInterceptorPlatform.ts b/packages/zimic/src/interceptor/http/interceptor/errors/UnknownHttpInterceptorPlatform.ts index b773089c..970e5145 100644 --- a/packages/zimic/src/interceptor/http/interceptor/errors/UnknownHttpInterceptorPlatform.ts +++ b/packages/zimic/src/interceptor/http/interceptor/errors/UnknownHttpInterceptorPlatform.ts @@ -2,7 +2,7 @@ class UnknownHttpInterceptorPlatform extends Error { /* istanbul ignore next -- @preserve * Ignoring because checking unknown platforms is currently not possible in our Vitest setup */ constructor() { - super('Unknown interceptor platform.'); + super('[zimic] Unknown interceptor platform.'); this.name = 'UnknownHttpInterceptorPlatform'; } } diff --git a/packages/zimic/src/interceptor/http/interceptor/errors/UnknownHttpInterceptorTypeError.ts b/packages/zimic/src/interceptor/http/interceptor/errors/UnknownHttpInterceptorTypeError.ts index 2aa73a9d..9e3a3ae6 100644 --- a/packages/zimic/src/interceptor/http/interceptor/errors/UnknownHttpInterceptorTypeError.ts +++ b/packages/zimic/src/interceptor/http/interceptor/errors/UnknownHttpInterceptorTypeError.ts @@ -3,7 +3,7 @@ import { HttpInterceptorType } from '../types/options'; class UnknownHttpInterceptorTypeError extends TypeError { constructor(unknownType: unknown) { super( - `Unknown HTTP interceptor type: ${unknownType}. The available options are ` + + `[zimic] Unknown HTTP interceptor type: ${unknownType}. The available options are ` + `'${'local' satisfies HttpInterceptorType}' and ` + `'${'remote' satisfies HttpInterceptorType}'.`, ); diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnregisteredServiceWorkerError.ts b/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnregisteredServiceWorkerError.ts index e74405a4..fe3c6411 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnregisteredServiceWorkerError.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnregisteredServiceWorkerError.ts @@ -4,7 +4,7 @@ import { SERVICE_WORKER_FILE_NAME } from '@/cli/browser/shared/constants'; class UnregisteredServiceWorkerError extends Error { constructor() { super( - `Failed to register the browser service worker: ` + + `[zimic] Failed to register the browser service worker: ` + `script '${window.location.origin}/${SERVICE_WORKER_FILE_NAME}' not found.\n\n` + 'Did you forget to run "npx zimic browser init "?\n\n' + 'Learn more at https://github.com/diego-aquino/zimic#browser-post-install.', diff --git a/packages/zimic/src/interceptor/http/requestHandler/errors/NoResponseDefinitionError.ts b/packages/zimic/src/interceptor/http/requestHandler/errors/NoResponseDefinitionError.ts index 74aedf4b..53f20c53 100644 --- a/packages/zimic/src/interceptor/http/requestHandler/errors/NoResponseDefinitionError.ts +++ b/packages/zimic/src/interceptor/http/requestHandler/errors/NoResponseDefinitionError.ts @@ -1,6 +1,6 @@ class NoResponseDefinitionError extends TypeError { constructor() { - super('Cannot generate a response without a definition. Use .respond() to set a response.'); + super('[zimic] Cannot generate a response without a definition. Use .respond() to set a response.'); this.name = 'NoResponseDefinitionError'; } } diff --git a/packages/zimic/src/interceptor/server/errors/NotStartedInterceptorServerError.ts b/packages/zimic/src/interceptor/server/errors/NotStartedInterceptorServerError.ts index c057663f..d3a3e7c8 100644 --- a/packages/zimic/src/interceptor/server/errors/NotStartedInterceptorServerError.ts +++ b/packages/zimic/src/interceptor/server/errors/NotStartedInterceptorServerError.ts @@ -4,7 +4,7 @@ */ class NotStartedInterceptorServerError extends Error { constructor() { - super('The server is not running.'); + super('[zimic] The interceptor server is not running.'); this.name = 'NotStartedInterceptorServerError'; } } diff --git a/packages/zimic/src/webSocket/errors/InvalidWebSocketMessage.ts b/packages/zimic/src/webSocket/errors/InvalidWebSocketMessage.ts index 07837ac0..57b01222 100644 --- a/packages/zimic/src/webSocket/errors/InvalidWebSocketMessage.ts +++ b/packages/zimic/src/webSocket/errors/InvalidWebSocketMessage.ts @@ -1,6 +1,6 @@ class InvalidWebSocketMessage extends Error { constructor(message: unknown) { - super(`Web socket message is invalid and could not be parsed: ${message}`); + super(`[zimic] Web socket message is invalid and could not be parsed: ${message}`); this.name = 'InvalidWebSocketMessage'; } } diff --git a/packages/zimic/src/webSocket/errors/NotStartedWebSocketHandlerError.ts b/packages/zimic/src/webSocket/errors/NotStartedWebSocketHandlerError.ts index 257f887d..d8b31a69 100644 --- a/packages/zimic/src/webSocket/errors/NotStartedWebSocketHandlerError.ts +++ b/packages/zimic/src/webSocket/errors/NotStartedWebSocketHandlerError.ts @@ -1,6 +1,6 @@ class NotStartedWebSocketHandlerError extends Error { constructor() { - super('The web socket handler is not running.'); + super('[zimic] Web socket handler is not running.'); this.name = 'NotStartedWebSocketHandlerError'; } } From 53f1de76be3ed717d4b1f720c717630af4f637db Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sat, 18 May 2024 19:40:04 -0300 Subject: [PATCH 03/22] feat(#zimic): support custom remote unhandled request strategy --- packages/zimic/src/cli/cli.ts | 14 +++++ packages/zimic/src/cli/server/start.ts | 20 ++++-- .../http/interceptor/HttpInterceptorClient.ts | 15 ++--- .../http/interceptor/LocalHttpInterceptor.ts | 15 +++-- .../http/interceptor/RemoteHttpInterceptor.ts | 15 +++-- .../http/interceptor/types/options.ts | 13 ++-- .../HttpInterceptorWorker.ts | 61 ++++++++++++++++++- .../LocalHttpInterceptorWorker.ts | 33 ++-------- .../RemoteHttpInterceptorWorker.ts | 5 +- .../http/interceptorWorker/types/options.ts | 3 - .../interceptor/server/InterceptorServer.ts | 32 +++++++--- 11 files changed, 160 insertions(+), 66 deletions(-) diff --git a/packages/zimic/src/cli/cli.ts b/packages/zimic/src/cli/cli.ts index 7df71141..f6baf2e6 100644 --- a/packages/zimic/src/cli/cli.ts +++ b/packages/zimic/src/cli/cli.ts @@ -3,6 +3,8 @@ import { hideBin } from 'yargs/helpers'; import { version } from '@@/package.json'; +import { DEFAULT_UNHANDLED_REQUEST_STRATEGY } from '@/interceptor/http/interceptorWorker/HttpInterceptorWorker'; + import initializeBrowserServiceWorker from './browser/init'; import startInterceptorServer from './server/start'; @@ -59,6 +61,15 @@ async function runCLI() { 'starting.', alias: 'e', default: false, + }) + .option('log-unhandled-requests', { + type: 'boolean', + description: + 'Whether to log a warning when no interceptors were found for the base URL of a request. ' + + 'If an interceptor was matched, the logging behavior for that base URL is configured in the ' + + 'interceptor itself.', + alias: 'l', + default: DEFAULT_UNHANDLED_REQUEST_STRATEGY.remote.log, }), async (cliArguments) => { const onReadyCommand = cliArguments._.at(2)?.toString(); @@ -68,6 +79,9 @@ async function runCLI() { hostname: cliArguments.hostname, port: cliArguments.port, ephemeral: cliArguments.ephemeral, + onUnhandledRequest: { + log: cliArguments.logUnhandledRequests, + }, onReady: onReadyCommand ? { command: onReadyCommand.toString(), diff --git a/packages/zimic/src/cli/server/start.ts b/packages/zimic/src/cli/server/start.ts index e5db8b33..fd0c5a8b 100644 --- a/packages/zimic/src/cli/server/start.ts +++ b/packages/zimic/src/cli/server/start.ts @@ -1,11 +1,9 @@ import { logWithPrefix } from '@/utils/console'; import { runCommand, PROCESS_EXIT_EVENTS } from '@/utils/processes'; -import InterceptorServer from '../../interceptor/server/InterceptorServer'; +import InterceptorServer, { InterceptorServerOptions } from '../../interceptor/server/InterceptorServer'; -interface ServerStartOptions { - hostname: string; - port?: number; +interface InterceptorServerStartOptions extends InterceptorServerOptions { ephemeral: boolean; onReady?: { command: string; @@ -15,8 +13,18 @@ interface ServerStartOptions { export let singletonServer: InterceptorServer | undefined; -async function startInterceptorServer({ hostname, port, ephemeral, onReady }: ServerStartOptions) { - const server = new InterceptorServer({ hostname, port }); +async function startInterceptorServer({ + hostname, + port, + ephemeral, + onUnhandledRequest, + onReady, +}: InterceptorServerStartOptions) { + const server = new InterceptorServer({ + hostname, + port, + onUnhandledRequest, + }); singletonServer = server; diff --git a/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts b/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts index 8d19e3e0..2a1bf48b 100644 --- a/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts +++ b/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts @@ -19,6 +19,7 @@ import { HttpRequestHandler } from '../requestHandler/types/public'; import { HttpInterceptorRequest } from '../requestHandler/types/requests'; import NotStartedHttpInterceptorError from './errors/NotStartedHttpInterceptorError'; import HttpInterceptorStore from './HttpInterceptorStore'; +import { UnhandledRequestStrategy } from './types/options'; import { HttpInterceptorRequestContext } from './types/requests'; export const SUPPORTED_BASE_URL_PROTOCOLS = ['http', 'https']; @@ -31,6 +32,7 @@ class HttpInterceptorClient< private store: HttpInterceptorStore; private _baseURL: ExtendedURL; private _isRunning = false; + private onUnhandledRequest?: UnhandledRequestStrategy.Any; private Handler: HandlerConstructor; @@ -51,15 +53,17 @@ class HttpInterceptorClient< store: HttpInterceptorStore; baseURL: ExtendedURL; Handler: HandlerConstructor; + onUnhandledRequest?: UnhandledRequestStrategy.Any; }) { this.worker = options.worker; this.store = options.store; this._baseURL = options.baseURL; this.Handler = options.Handler; + this.onUnhandledRequest = options.onUnhandledRequest; } baseURL() { - return this._baseURL.raw; + return this._baseURL; } platform() { @@ -71,8 +75,8 @@ class HttpInterceptorClient< } async start() { - if (this.isRunning()) { - return; + if (this.onUnhandledRequest) { + this.worker.onUnhandledRequest(this.baseURL().toString(), this.onUnhandledRequest); } await this.worker.start(); @@ -80,11 +84,8 @@ class HttpInterceptorClient< } async stop() { - if (!this.isRunning()) { - return; - } - this.markAsRunning(false); + this.worker.offUnhandledRequest(this.baseURL().toString()); const wasLastRunningInterceptor = this.numberOfRunningInterceptors() === 0; if (wasLastRunningInterceptor) { diff --git a/packages/zimic/src/interceptor/http/interceptor/LocalHttpInterceptor.ts b/packages/zimic/src/interceptor/http/interceptor/LocalHttpInterceptor.ts index 45f5cf4d..4c61e3f0 100644 --- a/packages/zimic/src/interceptor/http/interceptor/LocalHttpInterceptor.ts +++ b/packages/zimic/src/interceptor/http/interceptor/LocalHttpInterceptor.ts @@ -26,8 +26,9 @@ class LocalHttpInterceptor implements PublicLo this._client = new HttpInterceptorClient({ worker, store: this.store, - Handler: LocalHttpRequestHandler, baseURL, + Handler: LocalHttpRequestHandler, + onUnhandledRequest: options.onUnhandledRequest, }); } @@ -36,7 +37,7 @@ class LocalHttpInterceptor implements PublicLo } baseURL() { - return this._client.baseURL(); + return this._client.baseURL().raw; } platform() { @@ -48,13 +49,19 @@ class LocalHttpInterceptor implements PublicLo } async start() { + if (this.isRunning()) { + return; + } + await this._client.start(); } async stop() { - if (this.isRunning()) { - this.clear(); + if (!this.isRunning()) { + return; } + + this.clear(); await this._client.stop(); } diff --git a/packages/zimic/src/interceptor/http/interceptor/RemoteHttpInterceptor.ts b/packages/zimic/src/interceptor/http/interceptor/RemoteHttpInterceptor.ts index e6f19fc4..f17dbb75 100644 --- a/packages/zimic/src/interceptor/http/interceptor/RemoteHttpInterceptor.ts +++ b/packages/zimic/src/interceptor/http/interceptor/RemoteHttpInterceptor.ts @@ -31,8 +31,9 @@ class RemoteHttpInterceptor implements PublicR this._client = new HttpInterceptorClient({ worker, store: this.store, - Handler: RemoteHttpRequestHandler, baseURL, + Handler: RemoteHttpRequestHandler, + onUnhandledRequest: options.onUnhandledRequest, }); } @@ -41,7 +42,7 @@ class RemoteHttpInterceptor implements PublicR } baseURL() { - return this._client.baseURL(); + return this._client.baseURL().raw; } platform() { @@ -53,13 +54,19 @@ class RemoteHttpInterceptor implements PublicR } async start() { + if (this.isRunning()) { + return; + } + await this._client.start(); } async stop() { - if (this.isRunning()) { - await this.clear(); + if (!this.isRunning()) { + return; } + + await this.clear(); await this._client.stop(); } diff --git a/packages/zimic/src/interceptor/http/interceptor/types/options.ts b/packages/zimic/src/interceptor/http/interceptor/types/options.ts index 73caa9f9..69a3e6e4 100644 --- a/packages/zimic/src/interceptor/http/interceptor/types/options.ts +++ b/packages/zimic/src/interceptor/http/interceptor/types/options.ts @@ -17,24 +17,27 @@ export namespace UnhandledRequestStrategy { action: Action; log: boolean; } - export type LocalDeclarationHandler = (request: HttpRequest) => PossiblePromise>; + export type LocalDeclarationFactory = (request: HttpRequest) => PossiblePromise>; + export type Local = Partial | LocalDeclarationFactory; export interface RemoteDeclaration { action: Extract; log: boolean; } - export type RemoteDeclarationHandler = (request: HttpRequest) => PossiblePromise>; + export type RemoteDeclarationFactory = (request: HttpRequest) => PossiblePromise>; + export type Remote = Partial | RemoteDeclarationFactory; + + export type Any = Local | Remote; } export interface LocalHttpInterceptorOptions extends SharedHttpInterceptorOptions { type: 'local'; - onUnhandledRequest?: - | Partial - | UnhandledRequestStrategy.LocalDeclarationHandler; + onUnhandledRequest?: UnhandledRequestStrategy.Local; } export interface RemoteHttpInterceptorOptions extends SharedHttpInterceptorOptions { type: 'remote'; + onUnhandledRequest?: UnhandledRequestStrategy.Remote; } /** Options to create an HTTP interceptor. */ diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts index 2815e78d..b3effe0d 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts @@ -11,7 +11,7 @@ import { } from '@/http/types/schema'; import { Default, PossiblePromise } from '@/types/utils'; import { formatObjectToLog, getChalk, logWithPrefix } from '@/utils/console'; -import { createURL } from '@/utils/urls'; +import { createURL, excludeNonPathParams } from '@/utils/urls'; import HttpSearchParams from '../../../http/searchParams/HttpSearchParams'; import HttpInterceptorClient, { AnyHttpInterceptorClient } from '../interceptor/HttpInterceptorClient'; @@ -22,14 +22,30 @@ import { HttpInterceptorRequest, HttpInterceptorResponse, } from '../requestHandler/types/requests'; +import UnhandledRequestError from './errors/UnhandledRequestError'; import { HttpResponseFactory } from './types/requests'; +export const DEFAULT_UNHANDLED_REQUEST_STRATEGY: { + local: Required; + remote: Required; +} = { + local: { action: 'bypass', log: true }, + remote: { action: 'reject', log: true }, +}; + abstract class HttpInterceptorWorker { + abstract readonly type: 'local' | 'remote'; + private _platform: HttpInterceptorPlatform | null = null; private _isRunning = false; private startingPromise?: Promise; private stoppingPromise?: Promise; + private unhandledRequestStrategies: { + baseURL: string; + declarationOrFactory: UnhandledRequestStrategy.Any; + }[] = []; + platform() { return this._platform; } @@ -85,6 +101,49 @@ abstract class HttpInterceptorWorker { createResponse: HttpResponseFactory, ): PossiblePromise; + protected async handleUnhandledRequest(request: Request, options: { throwOnRejected: boolean }) { + const strategy = await this.getUnhandledRequestStrategy(request); + + if (strategy.log) { + await HttpInterceptorWorker.warnUnhandledRequest(request, strategy.action); + } + if (strategy.action === 'reject' && options.throwOnRejected) { + throw new UnhandledRequestError(); + } + } + + onUnhandledRequest(baseURL: string, strategyOrFactory: UnhandledRequestStrategy.Any) { + this.unhandledRequestStrategies.push({ + baseURL, + declarationOrFactory: strategyOrFactory, + }); + } + + offUnhandledRequest(baseURL: string) { + this.unhandledRequestStrategies = this.unhandledRequestStrategies.filter( + (strategy) => strategy.baseURL !== baseURL, + ); + } + + private async getUnhandledRequestStrategy( + request: HttpRequest, + ): Promise { + const requestURL = excludeNonPathParams(createURL(request.url)).toString(); + + const { declarationOrFactory } = + this.unhandledRequestStrategies.findLast((strategy) => requestURL.startsWith(strategy.baseURL)) ?? {}; + + const strategy = + typeof declarationOrFactory === 'function' ? await declarationOrFactory(request) : declarationOrFactory; + + const defaultStrategy = DEFAULT_UNHANDLED_REQUEST_STRATEGY[this.type]; + + return { + action: strategy?.action ?? defaultStrategy.action, + log: strategy?.log ?? defaultStrategy.log, + }; + } + abstract clearHandlers(): PossiblePromise; abstract clearInterceptorHandlers( diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts index e36e69be..a97d7b96 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts @@ -13,8 +13,6 @@ import { excludeNonPathParams, ensureUniquePathParams, createURL } from '@/utils import NotStartedHttpInterceptorError from '../interceptor/errors/NotStartedHttpInterceptorError'; import UnknownHttpInterceptorPlatform from '../interceptor/errors/UnknownHttpInterceptorPlatform'; import HttpInterceptorClient from '../interceptor/HttpInterceptorClient'; -import { UnhandledRequestStrategy } from '../interceptor/types/options'; -import UnhandledRequestError from './errors/UnhandledRequestError'; import UnregisteredServiceWorkerError from './errors/UnregisteredServiceWorkerError'; import HttpInterceptorWorker from './HttpInterceptorWorker'; import { LocalHttpInterceptorWorkerOptions } from './types/options'; @@ -24,26 +22,15 @@ class LocalHttpInterceptorWorker extends HttpInterceptorWorker { readonly type: 'local'; private _internalWorker?: HttpWorker; - private onUnhandledRequest: - | UnhandledRequestStrategy.LocalDeclaration - | UnhandledRequestStrategy.LocalDeclarationHandler; private httpHandlerGroups: { interceptor: HttpInterceptorClient; // eslint-disable-line @typescript-eslint/no-explicit-any httpHandler: MSWHttpHandler; }[] = []; - constructor({ type, onUnhandledRequest }: LocalHttpInterceptorWorkerOptions) { + constructor(options: LocalHttpInterceptorWorkerOptions) { super(); - this.type = type; - - this.onUnhandledRequest = - typeof onUnhandledRequest === 'function' - ? onUnhandledRequest - : { - action: onUnhandledRequest?.action ?? 'bypass', - log: onUnhandledRequest?.log ?? true, - }; + this.type = options.type; } internalWorkerOrThrow() { @@ -81,7 +68,9 @@ class LocalHttpInterceptorWorker extends HttpInterceptorWorker { await super.sharedStart(async () => { const internalWorker = await this.internalWorkerOrLoad(); const sharedOptions: MSWWorkerSharedOptions = { - onUnhandledRequest: this.handleUnhandledRequest, + onUnhandledRequest: async (request) => { + await super.handleUnhandledRequest(request, { throwOnRejected: true }); + }, }; if (this.isInternalBrowserWorker(internalWorker)) { @@ -96,18 +85,6 @@ class LocalHttpInterceptorWorker extends HttpInterceptorWorker { }); } - private handleUnhandledRequest = async (request: Request) => { - const strategy = - typeof this.onUnhandledRequest === 'function' ? await this.onUnhandledRequest(request) : this.onUnhandledRequest; - - if (strategy.log) { - await HttpInterceptorWorker.warnUnhandledRequest(request, strategy.action); - } - if (strategy.action === 'reject') { - throw new UnhandledRequestError(); - } - }; - private async startInBrowser(internalWorker: BrowserHttpWorker, sharedOptions: MSWWorkerSharedOptions) { try { await internalWorker.start({ ...sharedOptions, quiet: true }); diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts index 2e93e4b9..dd73af90 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts @@ -29,7 +29,6 @@ class RemoteHttpInterceptorWorker extends HttpInterceptorWorker { private _crypto?: IsomorphicCrypto; private webSocketClient: WebSocketClient; - private httpHandlers = new Map(); constructor(options: RemoteHttpInterceptorWorkerOptions) { @@ -76,6 +75,10 @@ class RemoteHttpInterceptorWorker extends HttpInterceptorWorker { const rawResponse = (await handler?.createResponse({ request })) ?? null; const response = rawResponse && request.method === 'HEAD' ? new Response(null, rawResponse) : rawResponse; + if (!response) { + await super.handleUnhandledRequest(request, { throwOnRejected: false }); + } + const serializedResponse = response ? await serializeResponse(response) : null; return { response: serializedResponse }; }; diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/types/options.ts b/packages/zimic/src/interceptor/http/interceptorWorker/types/options.ts index f0e71c54..330daf90 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/types/options.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/types/options.ts @@ -1,10 +1,7 @@ import { ExtendedURL } from '@/utils/urls'; -import { LocalHttpInterceptorOptions } from '../../interceptor/types/options'; - export interface LocalHttpInterceptorWorkerOptions { type: 'local'; - onUnhandledRequest?: LocalHttpInterceptorOptions['onUnhandledRequest']; } export interface RemoteHttpInterceptorWorkerOptions { diff --git a/packages/zimic/src/interceptor/server/InterceptorServer.ts b/packages/zimic/src/interceptor/server/InterceptorServer.ts index c2578607..f4d3066f 100644 --- a/packages/zimic/src/interceptor/server/InterceptorServer.ts +++ b/packages/zimic/src/interceptor/server/InterceptorServer.ts @@ -3,6 +3,9 @@ import { createServer, Server as HttpServer, IncomingMessage, ServerResponse } f import type { WebSocket as Socket } from 'isomorphic-ws'; import { HttpMethod } from '@/http/types/schema'; +import HttpInterceptorWorker, { + DEFAULT_UNHANDLED_REQUEST_STRATEGY, +} from '@/interceptor/http/interceptorWorker/HttpInterceptorWorker'; import { deserializeResponse, serializeRequest } from '@/utils/fetch'; import { getHttpServerPort, startHttpServer, stopHttpServer } from '@/utils/http'; import { createRegexFromURL, createURL, excludeNonPathParams } from '@/utils/urls'; @@ -17,6 +20,9 @@ import { HttpHandlerCommit, InterceptorServerWebSocketSchema } from './types/sch export interface InterceptorServerOptions { hostname?: string; port?: number; + onUnhandledRequest?: { + log?: boolean; + }; } interface HttpHandler { @@ -31,6 +37,9 @@ class InterceptorServer implements PublicInterceptorServer { private _hostname: string; private _port?: number; + private onUnhandledRequest: { + log: boolean; + }; private httpHandlerGroups: { [Method in HttpMethod]: HttpHandler[]; @@ -49,6 +58,9 @@ class InterceptorServer implements PublicInterceptorServer { constructor(options: InterceptorServerOptions = {}) { this._hostname = options.hostname ?? 'localhost'; this._port = options.port; + this.onUnhandledRequest = { + log: options.onUnhandledRequest?.log ?? DEFAULT_UNHANDLED_REQUEST_STRATEGY.remote.log, + }; } hostname() { @@ -212,7 +224,7 @@ class InterceptorServer implements PublicInterceptorServer { private handleHttpRequest = async (nodeRequest: IncomingMessage, nodeResponse: ServerResponse) => { const request = normalizeNodeRequest(nodeRequest, Request); - const response = await this.createResponseForRequest(request); + const { response, matchedAnyInterceptor } = await this.createResponseForRequest(request); if (response) { this.setDefaultAccessControlHeaders(response, ['access-control-allow-origin', 'access-control-expose-headers']); @@ -228,6 +240,11 @@ class InterceptorServer implements PublicInterceptorServer { await sendNodeResponse(defaultPreflightResponse, nodeResponse, nodeRequest); } + if (!matchedAnyInterceptor && this.onUnhandledRequest.log) { + const { action } = DEFAULT_UNHANDLED_REQUEST_STRATEGY.remote; + await HttpInterceptorWorker.warnUnhandledRequest(request, action); + } + nodeResponse.destroy(); }; @@ -238,6 +255,8 @@ class InterceptorServer implements PublicInterceptorServer { const url = excludeNonPathParams(createURL(request.url)).toString(); const serializedRequest = await serializeRequest(request); + let matchedAnyInterceptor = false; + for (let index = handlerGroup.length - 1; index >= 0; index--) { const handler = handlerGroup[index]; @@ -246,22 +265,21 @@ class InterceptorServer implements PublicInterceptorServer { continue; } + matchedAnyInterceptor = true; + const { response: serializedResponse } = await webSocketServer.request( 'interceptors/responses/create', - { - handlerId: handler.id, - request: serializedRequest, - }, + { handlerId: handler.id, request: serializedRequest }, { sockets: [handler.socket] }, ); if (serializedResponse) { const response = deserializeResponse(serializedResponse); - return response; + return { response, matchedAnyInterceptor }; } } - return null; + return { response: null, matchedAnyInterceptor }; } private setDefaultAccessControlHeaders( From 350785af3d1043adab6556b6d5802b5f075a1128 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sat, 18 May 2024 20:08:00 -0300 Subject: [PATCH 04/22] test(#zimic): use default logging as false on tests --- .../http/interceptorWorker/HttpInterceptorWorker.ts | 6 ++++-- packages/zimic/tsup.config.ts | 1 + packages/zimic/vitest.config.mts | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts index b3effe0d..359a43a8 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts @@ -25,12 +25,14 @@ import { import UnhandledRequestError from './errors/UnhandledRequestError'; import { HttpResponseFactory } from './types/requests'; +const DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY = process.env.DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY === 'true'; + export const DEFAULT_UNHANDLED_REQUEST_STRATEGY: { local: Required; remote: Required; } = { - local: { action: 'bypass', log: true }, - remote: { action: 'reject', log: true }, + local: { action: 'bypass', log: DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY }, + remote: { action: 'reject', log: DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY }, }; abstract class HttpInterceptorWorker { diff --git a/packages/zimic/tsup.config.ts b/packages/zimic/tsup.config.ts index 8a2a139a..169ac961 100644 --- a/packages/zimic/tsup.config.ts +++ b/packages/zimic/tsup.config.ts @@ -14,6 +14,7 @@ const sharedConfig: Options = { clean: true, env: { SERVER_ACCESS_CONTROL_MAX_AGE: '', + DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY: 'true', }, }; diff --git a/packages/zimic/vitest.config.mts b/packages/zimic/vitest.config.mts index f7ff9cbe..d160b6b9 100644 --- a/packages/zimic/vitest.config.mts +++ b/packages/zimic/vitest.config.mts @@ -35,6 +35,7 @@ export const defaultConfig: UserConfig = { }, define: { 'process.env.SERVER_ACCESS_CONTROL_MAX_AGE': "'0'", + 'process.env.DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY': "'false'", }, resolve: { alias: { From 83ab6aed1c0e6d85734b29f96ffaeb901a4e61eb Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sat, 18 May 2024 20:09:16 -0300 Subject: [PATCH 05/22] build(#zimic): use default bundle splitting config --- packages/zimic/tsup.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/zimic/tsup.config.ts b/packages/zimic/tsup.config.ts index 169ac961..46aa51ed 100644 --- a/packages/zimic/tsup.config.ts +++ b/packages/zimic/tsup.config.ts @@ -7,7 +7,6 @@ const sharedConfig: Options = { format: ['cjs', 'esm'], dts: true, bundle: true, - splitting: false, sourcemap: true, treeshake: isProductionBuild, minify: false, From 01aab594844a93e21a269ab722bcc386fc0dae9d Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sat, 18 May 2024 20:17:01 -0300 Subject: [PATCH 06/22] test(#zimic): adapt tests to new error messages and logs --- .../cli/__tests__/browser.cli.node.test.ts | 10 +++- .../src/cli/__tests__/server.cli.node.test.ts | 57 ++++++++++++------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/zimic/src/cli/__tests__/browser.cli.node.test.ts b/packages/zimic/src/cli/__tests__/browser.cli.node.test.ts index fe75aefb..966c8018 100644 --- a/packages/zimic/src/cli/__tests__/browser.cli.node.test.ts +++ b/packages/zimic/src/cli/__tests__/browser.cli.node.test.ts @@ -97,8 +97,14 @@ describe('CLI (browser)', () => { expect(copyFileSpy).toHaveBeenCalledWith(MOCK_SERVICE_WORKER_PATH, serviceWorkerDestinationPath); expect(spies.log).toHaveBeenCalledTimes(3); - expect(spies.log).toHaveBeenCalledWith(expect.stringContaining(absolutePublicDirectory)); - expect(spies.log).toHaveBeenCalledWith(expect.stringContaining(serviceWorkerDestinationPath)); + expect(spies.log).toHaveBeenCalledWith( + expect.any(String) as string, + expect.stringContaining(absolutePublicDirectory), + ); + expect(spies.log).toHaveBeenCalledWith( + expect.any(String) as string, + expect.stringContaining(serviceWorkerDestinationPath), + ); }); }); diff --git a/packages/zimic/src/cli/__tests__/server.cli.node.test.ts b/packages/zimic/src/cli/__tests__/server.cli.node.test.ts index 06246d3f..ef294b26 100644 --- a/packages/zimic/src/cli/__tests__/server.cli.node.test.ts +++ b/packages/zimic/src/cli/__tests__/server.cli.node.test.ts @@ -76,15 +76,21 @@ describe('CLI (server)', async () => { ' [string]', '', 'Options:', - ' --help Show help [boolean]', - ' --version Show version number [boolean]', - ' -h, --hostname The hostname to start the server on.', + ' --help Show help [boolean]', + ' --version Show version number [boolean]', + ' -h, --hostname The hostname to start the server on.', ' [string] [default: "localhost"]', - ' -p, --port The port to start the server on. [number]', - ' -e, --ephemeral Whether the server should stop automatically after the on-rea', - ' dy command finishes. If no on-ready command is provided and e', - ' phemeral is true, the server will stop immediately after star', - ' ting. [boolean] [default: false]', + ' -p, --port The port to start the server on. [number]', + ' -e, --ephemeral Whether the server should stop automatically aft', + ' er the on-ready command finishes. If no on-ready', + ' command is provided and ephemeral is true, the', + ' server will stop immediately after starting.', + ' [boolean] [default: false]', + ' -l, --log-unhandled-requests Whether to log a warning when no interceptors we', + ' re found for the base URL of a request. If an in', + ' terceptor was matched, the logging behavior for', + ' that base URL is configured in the interceptor i', + ' tself. [boolean] [default: false]', ].join('\n'); beforeEach(async () => { @@ -120,7 +126,8 @@ describe('CLI (server)', async () => { expect(spies.log).toHaveBeenCalledTimes(1); expect(spies.log).toHaveBeenCalledWith( - `${chalk.cyan('[zimic]')} Server is running on 'http://localhost:3000'.`, + `${chalk.cyan('[zimic]')}`, + `Server is running on 'http://localhost:3000'.`, ); }); }); @@ -137,7 +144,10 @@ describe('CLI (server)', async () => { expect(server!.port()).toBe(3000); expect(spies.log).toHaveBeenCalledTimes(1); - expect(spies.log).toHaveBeenCalledWith(`${chalk.cyan('[zimic]')} Server is running on 'http://0.0.0.0:3000'.`); + expect(spies.log).toHaveBeenCalledWith( + `${chalk.cyan('[zimic]')}`, + `Server is running on 'http://0.0.0.0:3000'.`, + ); }); }); @@ -154,7 +164,8 @@ describe('CLI (server)', async () => { expect(spies.log).toHaveBeenCalledTimes(1); expect(spies.log).toHaveBeenCalledWith( - `${chalk.cyan('[zimic]')} Server is running on 'http://localhost:${server!.port()}'.`, + `${chalk.cyan('[zimic]')}`, + `Server is running on 'http://localhost:${server!.port()}'.`, ); }); }); @@ -247,7 +258,8 @@ describe('CLI (server)', async () => { expect(spies.log).toHaveBeenCalledTimes(1); expect(spies.log).toHaveBeenCalledWith( - `${chalk.cyan('[zimic]')} Ephemeral server is running on 'http://localhost:${server!.port()}'.`, + `${chalk.cyan('[zimic]')}`, + `Ephemeral server is running on 'http://localhost:${server!.port()}'.`, ); const savedFile = await filesystem.readFile(temporarySaveFile, 'utf-8'); @@ -281,7 +293,8 @@ describe('CLI (server)', async () => { expect(spies.log).toHaveBeenCalledTimes(1); expect(spies.log).toHaveBeenCalledWith( - `${chalk.cyan('[zimic]')} Server is running on 'http://localhost:${server!.port()}'.`, + `${chalk.cyan('[zimic]')}`, + `Server is running on 'http://localhost:${server!.port()}'.`, ); const savedFile = await filesystem.readFile(temporarySaveFile, 'utf-8'); @@ -345,10 +358,12 @@ describe('CLI (server)', async () => { ]); await usingIgnoredConsole(['error', 'log'], async (spies) => { - await expect(runCLI()).rejects.toThrowError(`The command 'node' exited with code ${exitCode}.`); + const error = new CommandError('node', exitCode, null); + await expect(runCLI()).rejects.toThrowError(error); + expect(error.message).toBe(`[zimic] Command 'node' exited with code ${exitCode}.`); expect(spies.error).toHaveBeenCalledTimes(1); - expect(spies.error).toHaveBeenCalledWith(new CommandError('node', exitCode, null)); + expect(spies.error).toHaveBeenCalledWith(error); }); }); @@ -368,10 +383,12 @@ describe('CLI (server)', async () => { ]); await usingIgnoredConsole(['error', 'log'], async (spies) => { - await expect(runCLI()).rejects.toThrowError(`The command 'node' exited after signal ${signal}.`); + const error = new CommandError('node', null, signal); + await expect(runCLI()).rejects.toThrowError(error); + expect(error.message).toBe(`[zimic] Command 'node' exited after signal ${signal}.`); expect(spies.error).toHaveBeenCalledTimes(1); - expect(spies.error).toHaveBeenCalledWith(new CommandError('node', null, signal)); + expect(spies.error).toHaveBeenCalledWith(error); }); }); @@ -397,7 +414,8 @@ describe('CLI (server)', async () => { expect(spies.log).toHaveBeenCalledTimes(1); expect(spies.log).toHaveBeenCalledWith( - `${chalk.cyan('[zimic]')} Server is running on 'http://localhost:${server!.port()}'.`, + `${chalk.cyan('[zimic]')}`, + `Server is running on 'http://localhost:${server!.port()}'.`, ); expect(exitEventListeners).toHaveLength(1); @@ -434,7 +452,8 @@ describe('CLI (server)', async () => { expect(spies.log).toHaveBeenCalledTimes(1); expect(spies.log).toHaveBeenCalledWith( - `${chalk.cyan('[zimic]')} Server is running on 'http://localhost:${server!.port()}'.`, + `${chalk.cyan('[zimic]')}`, + `Server is running on 'http://localhost:${server!.port()}'.`, ); expect(exitEventListeners).toHaveLength(1); From e15c90e1d6464871fe88daa7dee95cfa6a0458f8 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sat, 18 May 2024 21:02:56 -0300 Subject: [PATCH 07/22] build(#zimic): use commonjs-compatible chalk version --- packages/zimic/package.json | 2 +- packages/zimic/src/cli/browser/init.ts | 10 +++++----- packages/zimic/src/cli/server/start.ts | 2 +- .../http/interceptorWorker/HttpInterceptorWorker.ts | 7 +++---- packages/zimic/src/utils/console.ts | 10 ++-------- pnpm-lock.yaml | 4 ++-- 6 files changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/zimic/package.json b/packages/zimic/package.json index f9f72ec7..0dbfa880 100644 --- a/packages/zimic/package.json +++ b/packages/zimic/package.json @@ -77,7 +77,7 @@ }, "dependencies": { "@whatwg-node/server": "^0.9.33", - "chalk": "^5.3.0", + "chalk": "^4.1.2", "cross-spawn": "^7.0.3", "isomorphic-ws": "^5.0.0", "msw": "^2.2.13", diff --git a/packages/zimic/src/cli/browser/init.ts b/packages/zimic/src/cli/browser/init.ts index fc0f9169..688a6192 100644 --- a/packages/zimic/src/cli/browser/init.ts +++ b/packages/zimic/src/cli/browser/init.ts @@ -1,7 +1,8 @@ +import chalk from 'chalk'; import filesystem from 'fs/promises'; import path from 'path'; -import { getChalk, logWithPrefix } from '@/utils/console'; +import { logWithPrefix } from '@/utils/console'; import { SERVICE_WORKER_FILE_NAME } from './shared/constants'; @@ -15,15 +16,14 @@ interface BrowserServiceWorkerInitOptions { async function initializeBrowserServiceWorker({ publicDirectory }: BrowserServiceWorkerInitOptions) { const absolutePublicDirectory = path.resolve(publicDirectory); - const chalk = await getChalk(); - await logWithPrefix(`Copying the service worker script to ${chalk.yellow(absolutePublicDirectory)}...`); + logWithPrefix(`Copying the service worker script to ${chalk.yellow(absolutePublicDirectory)}...`); await filesystem.mkdir(absolutePublicDirectory, { recursive: true }); const serviceWorkerDestinationPath = path.join(absolutePublicDirectory, SERVICE_WORKER_FILE_NAME); await filesystem.copyFile(MOCK_SERVICE_WORKER_PATH, serviceWorkerDestinationPath); - await logWithPrefix(`Service worker script saved to ${chalk.yellow(serviceWorkerDestinationPath)}!`); - await logWithPrefix('You can now use browser interceptors!'); + logWithPrefix(`Service worker script saved to ${chalk.yellow(serviceWorkerDestinationPath)}!`); + logWithPrefix('You can now use browser interceptors!'); } export default initializeBrowserServiceWorker; diff --git a/packages/zimic/src/cli/server/start.ts b/packages/zimic/src/cli/server/start.ts index fd0c5a8b..6902094b 100644 --- a/packages/zimic/src/cli/server/start.ts +++ b/packages/zimic/src/cli/server/start.ts @@ -36,7 +36,7 @@ async function startInterceptorServer({ await server.start(); - await logWithPrefix(`${ephemeral ? 'Ephemeral s' : 'S'}erver is running on '${server.httpURL()}'.`); + logWithPrefix(`${ephemeral ? 'Ephemeral s' : 'S'}erver is running on '${server.httpURL()}'.`); if (onReady) { await runCommand(onReady.command, onReady.arguments); diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts index 359a43a8..b0b490f3 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts @@ -1,3 +1,4 @@ +import chalk from 'chalk'; import { HttpResponse as MSWHttpResponse } from 'msw'; import HttpHeaders from '@/http/headers/HttpHeaders'; @@ -10,7 +11,7 @@ import { HttpServiceSchema, } from '@/http/types/schema'; import { Default, PossiblePromise } from '@/types/utils'; -import { formatObjectToLog, getChalk, logWithPrefix } from '@/utils/console'; +import { formatObjectToLog, logWithPrefix } from '@/utils/console'; import { createURL, excludeNonPathParams } from '@/utils/urls'; import HttpSearchParams from '../../../http/searchParams/HttpSearchParams'; @@ -284,9 +285,7 @@ abstract class HttpInterceptorWorker { static async warnUnhandledRequest(rawRequest: HttpRequest, action: UnhandledRequestStrategy.Action) { const request = await this.parseRawRequest(rawRequest); - const chalk = await getChalk(); - - await logWithPrefix( + logWithPrefix( [ `${action === 'bypass' ? 'Warning:' : 'Error:'} Request did not match any handlers and was ` + `${action === 'bypass' ? chalk.yellow('bypassed') : chalk.red('rejected')}:\n\n `, diff --git a/packages/zimic/src/utils/console.ts b/packages/zimic/src/utils/console.ts index 45418c10..7596ced7 100644 --- a/packages/zimic/src/utils/console.ts +++ b/packages/zimic/src/utils/console.ts @@ -1,12 +1,8 @@ +import chalk from 'chalk'; import util from 'util'; import { isClientSide } from './environment'; -export async function getChalk() { - const { default: chalk } = await import('chalk'); - return chalk; -} - export function formatObjectToLog(value: unknown) { if (isClientSide()) { return value; @@ -21,7 +17,7 @@ export function formatObjectToLog(value: unknown) { }); } -export async function logWithPrefix( +export function logWithPrefix( messageOrMessages: unknown, options: { method?: 'log' | 'warn' | 'error'; @@ -30,7 +26,5 @@ export async function logWithPrefix( const { method = 'log' } = options; const messages = Array.isArray(messageOrMessages) ? messageOrMessages : [messageOrMessages]; - - const chalk = await getChalk(); console[method](chalk.cyan('[zimic]'), ...messages); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f80b6bd8..41274258 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -436,8 +436,8 @@ importers: specifier: ^0.9.33 version: 0.9.33 chalk: - specifier: ^5.3.0 - version: 5.3.0 + specifier: ^4.1.2 + version: 4.1.2 cross-spawn: specifier: ^7.0.3 version: 7.0.3 From 26b6bcbb7bda8138299b4819b455b153a0f4561a Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sat, 18 May 2024 21:10:16 -0300 Subject: [PATCH 08/22] fix(#zimic): skip log when using default preflight response --- packages/zimic/src/interceptor/server/InterceptorServer.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/zimic/src/interceptor/server/InterceptorServer.ts b/packages/zimic/src/interceptor/server/InterceptorServer.ts index f4d3066f..7ed909d1 100644 --- a/packages/zimic/src/interceptor/server/InterceptorServer.ts +++ b/packages/zimic/src/interceptor/server/InterceptorServer.ts @@ -238,9 +238,7 @@ class InterceptorServer implements PublicInterceptorServer { const defaultPreflightResponse = new Response(null, { status: DEFAULT_PREFLIGHT_STATUS_CODE }); this.setDefaultAccessControlHeaders(defaultPreflightResponse); await sendNodeResponse(defaultPreflightResponse, nodeResponse, nodeRequest); - } - - if (!matchedAnyInterceptor && this.onUnhandledRequest.log) { + } else if (!matchedAnyInterceptor && this.onUnhandledRequest.log) { const { action } = DEFAULT_UNHANDLED_REQUEST_STRATEGY.remote; await HttpInterceptorWorker.warnUnhandledRequest(request, action); } From a7c6e0b1bb9274b18a3573fc4f1b004b25b38c48 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sat, 18 May 2024 22:42:57 -0300 Subject: [PATCH 09/22] fix(#zimic): return error response on local unhandled request --- .../http/interceptor/HttpInterceptorClient.ts | 1 - .../HttpInterceptorWorker.ts | 2 +- .../LocalHttpInterceptorWorker.ts | 25 +++++++++++++------ packages/zimic/tests/utils/console.ts | 16 ++++++------ packages/zimic/tests/utils/fetch.ts | 7 ++---- 5 files changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts b/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts index 2a1bf48b..7d9f7268 100644 --- a/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts +++ b/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts @@ -147,7 +147,6 @@ class HttpInterceptorClient< } const handler = new this.Handler(this as SharedHttpInterceptorClient, method, path); - this.registerRequestHandler(handler); return handler; } diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts index b0b490f3..2b80d397 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts @@ -128,7 +128,7 @@ abstract class HttpInterceptorWorker { ); } - private async getUnhandledRequestStrategy( + async getUnhandledRequestStrategy( request: HttpRequest, ): Promise { const requestURL = excludeNonPathParams(createURL(request.url)).toString(); diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts index a97d7b96..3dca93c7 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts @@ -153,17 +153,26 @@ class LocalHttpInterceptorWorker extends HttpInterceptorWorker { ensureUniquePathParams(url); const httpHandler = http[lowercaseMethod](url, async (context) => { - const result = await createResponse({ - ...context, - request: context.request as MSWStrictRequest, - }); + const request = context.request as MSWStrictRequest; + const requestClone = request.clone(); - if (result.bypass) { - return passthrough(); + const result = await createResponse({ ...context, request }); + + if (!result.bypass) { + const response = context.request.method === 'HEAD' ? new Response(null, result.response) : result.response; + return response; } - const response = context.request.method === 'HEAD' ? new Response(null, result.response) : result.response; - return response; + const unhandledStrategy = await super.getUnhandledRequestStrategy(requestClone); + + if (unhandledStrategy.log) { + await HttpInterceptorWorker.warnUnhandledRequest(requestClone, unhandledStrategy.action); + } + if (unhandledStrategy.action === 'bypass') { + return passthrough(); + } else { + return undefined; + } }); internalWorker.use(httpHandler); diff --git a/packages/zimic/tests/utils/console.ts b/packages/zimic/tests/utils/console.ts index bdfae6c2..febfd9f1 100644 --- a/packages/zimic/tests/utils/console.ts +++ b/packages/zimic/tests/utils/console.ts @@ -2,24 +2,22 @@ import { MockInstance, vi } from 'vitest'; import { PossiblePromise } from '@/types/utils'; -type SpyByConsoleMethod = { - [Key in keyof Console]?: MockInstance; -}; +type SpyByConsoleMethod = { [Key in Method]: MockInstance }; -export async function usingIgnoredConsole( - ignoredMethods: (keyof Console)[], - callback: (spyByMethod: SpyByConsoleMethod) => PossiblePromise, +export async function usingIgnoredConsole( + ignoredMethods: Method[], + callback: (spyByMethod: SpyByConsoleMethod) => PossiblePromise, ) { - const spyByMethod = ignoredMethods.reduce((groupedSpies, method) => { + const spyByMethod = ignoredMethods.reduce>((groupedSpies, method) => { const spy = vi.spyOn(console, method).mockImplementation(vi.fn()); groupedSpies[method] = spy; return groupedSpies; - }, {}); + }, {} as SpyByConsoleMethod); // eslint-disable-line @typescript-eslint/prefer-reduce-type-parameter try { await callback(spyByMethod); } finally { - for (const spy of Object.values(spyByMethod)) { + for (const spy of Object.values(spyByMethod)) { spy.mockRestore(); } } diff --git a/packages/zimic/tests/utils/fetch.ts b/packages/zimic/tests/utils/fetch.ts index e1f1779c..458e3b95 100644 --- a/packages/zimic/tests/utils/fetch.ts +++ b/packages/zimic/tests/utils/fetch.ts @@ -6,10 +6,7 @@ interface ExpectFetchErrorOptions { canBeAborted?: boolean; } -export async function expectFetchError( - value: Promise | (() => Promise), - options: ExpectFetchErrorOptions = {}, -) { +export async function expectFetchError(fetchPromise: Promise, options: ExpectFetchErrorOptions = {}) { const { canBeAborted = false } = options; const errorMessageOptions = [ @@ -20,7 +17,7 @@ export async function expectFetchError( ].filter((option): option is string => typeof option === 'string'); const errorMessageExpression = new RegExp(`^${errorMessageOptions.join('|')}$`); - await expect(value).rejects.toThrowError(errorMessageExpression); + await expect(fetchPromise).rejects.toThrowError(errorMessageExpression); } export async function expectFetchErrorOrPreflightResponse( From 35dc1869ffbd457dfa271b6a497fdec3fa547085 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sun, 19 May 2024 10:40:07 -0300 Subject: [PATCH 10/22] test(#zimic): verify server unhandled request logs --- .../src/cli/__tests__/server.cli.node.test.ts | 108 ++++++++++++++---- .../http/interceptor/HttpInterceptorClient.ts | 4 +- .../interceptor/__tests__/shared/utils.ts | 44 +++++++ .../http/interceptor/types/options.ts | 9 +- .../HttpInterceptorWorker.ts | 23 ++-- .../interceptor/server/InterceptorServer.ts | 7 +- 6 files changed, 157 insertions(+), 38 deletions(-) create mode 100644 packages/zimic/src/interceptor/http/interceptor/__tests__/shared/utils.ts diff --git a/packages/zimic/src/cli/__tests__/server.cli.node.test.ts b/packages/zimic/src/cli/__tests__/server.cli.node.test.ts index ef294b26..6647ba31 100644 --- a/packages/zimic/src/cli/__tests__/server.cli.node.test.ts +++ b/packages/zimic/src/cli/__tests__/server.cli.node.test.ts @@ -3,6 +3,7 @@ import filesystem from 'fs/promises'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { verifyUnhandledRequestMessage } from '@/interceptor/http/interceptor/__tests__/shared/utils'; import { DEFAULT_SERVER_LIFE_CYCLE_TIMEOUT } from '@/interceptor/server/constants'; import { PossiblePromise } from '@/types/utils'; import { getCrypto } from '@/utils/crypto'; @@ -10,11 +11,25 @@ import { HttpServerStartTimeoutError, HttpServerStopTimeoutError } from '@/utils import { CommandError, PROCESS_EXIT_EVENTS } from '@/utils/processes'; import WebSocketClient from '@/webSocket/WebSocketClient'; import { usingIgnoredConsole } from '@tests/utils/console'; +import { expectFetchError } from '@tests/utils/fetch'; import runCLI from '../cli'; import { singletonServer as server } from '../server/start'; import { delayHttpServerCloseIndefinitely, delayHttpServerListenIndefinitely } from './utils'; +function watchExitEventListeners(exitEvent: (typeof PROCESS_EXIT_EVENTS)[number]) { + const exitEventListeners: (() => PossiblePromise)[] = []; + + vi.spyOn(process, 'on').mockImplementation((event, listener) => { + if (event === exitEvent) { + exitEventListeners.push(listener); + } + return process; + }); + + return exitEventListeners; +} + describe('CLI (server)', async () => { const crypto = await getCrypto(); @@ -393,17 +408,9 @@ describe('CLI (server)', async () => { }); it.each(PROCESS_EXIT_EVENTS)('should stop the sever after a process exit event: %s', async (exitEvent) => { + const exitEventListeners = watchExitEventListeners(exitEvent); processArgvSpy.mockReturnValue(['node', 'cli.js', 'server', 'start']); - const exitEventListeners: (() => PossiblePromise)[] = []; - - vi.spyOn(process, 'on').mockImplementation((event, listener) => { - if (event === exitEvent) { - exitEventListeners.push(listener); - } - return process; - }); - await usingIgnoredConsole(['log'], async (spies) => { await runCLI(); @@ -429,19 +436,9 @@ describe('CLI (server)', async () => { }); it('should stop the server even if a client is connected', async () => { - const exitEvent = PROCESS_EXIT_EVENTS[0]; - + const exitEventListeners = watchExitEventListeners(PROCESS_EXIT_EVENTS[0]); processArgvSpy.mockReturnValue(['node', 'cli.js', 'server', 'start']); - const exitEventListeners: (() => PossiblePromise)[] = []; - - vi.spyOn(process, 'on').mockImplementation((event, listener) => { - if (event === exitEvent) { - exitEventListeners.push(listener); - } - return process; - }); - await usingIgnoredConsole(['log'], async (spies) => { await runCLI(); @@ -478,4 +475,75 @@ describe('CLI (server)', async () => { }); }); }); + + it('should show an error if logging is enabled when a request is received and does not match any interceptors', async () => { + const exitEventListeners = watchExitEventListeners(PROCESS_EXIT_EVENTS[0]); + processArgvSpy.mockReturnValue(['node', 'cli.js', 'server', 'start', '--log-unhandled-requests']); + + await usingIgnoredConsole(['log', 'warn', 'error'], async (spies) => { + await runCLI(); + + expect(server).toBeDefined(); + expect(server!.isRunning()).toBe(true); + expect(server!.hostname()).toBe('localhost'); + expect(server!.port()).toBeGreaterThan(0); + + expect(spies.log).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + expect(spies.log).toHaveBeenCalledWith( + `${chalk.cyan('[zimic]')}`, + `Server is running on 'http://localhost:${server!.port()}'.`, + ); + + expect(exitEventListeners).toHaveLength(1); + + const request = new Request(`http://localhost:${server!.port()}`, { method: 'GET' }); + + const response = fetch(request); + await expectFetchError(response); + + expect(spies.log).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + const errorMessage = spies.error.mock.calls[0].join(' '); + verifyUnhandledRequestMessage(errorMessage, { type: 'error', platform: 'node', request }); + }); + }); + + it('should not show an error if logging is disabled when a request is received and does not match any interceptors', async () => { + const exitEventListeners = watchExitEventListeners(PROCESS_EXIT_EVENTS[0]); + processArgvSpy.mockReturnValue(['node', 'cli.js', 'server', 'start', '--log-unhandled-requests', 'false']); + + await usingIgnoredConsole(['log', 'warn', 'error'], async (spies) => { + await runCLI(); + + expect(server).toBeDefined(); + expect(server!.isRunning()).toBe(true); + expect(server!.hostname()).toBe('localhost'); + expect(server!.port()).toBeGreaterThan(0); + + expect(spies.log).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + expect(spies.log).toHaveBeenCalledWith( + `${chalk.cyan('[zimic]')}`, + `Server is running on 'http://localhost:${server!.port()}'.`, + ); + + expect(exitEventListeners).toHaveLength(1); + + const request = new Request(`http://localhost:${server!.port()}`, { method: 'GET' }); + + const response = fetch(request); + await expectFetchError(response); + + expect(spies.log).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + }); + }); }); diff --git a/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts b/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts index 7d9f7268..2884afbb 100644 --- a/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts +++ b/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts @@ -32,7 +32,7 @@ class HttpInterceptorClient< private store: HttpInterceptorStore; private _baseURL: ExtendedURL; private _isRunning = false; - private onUnhandledRequest?: UnhandledRequestStrategy.Any; + private onUnhandledRequest?: UnhandledRequestStrategy; private Handler: HandlerConstructor; @@ -53,7 +53,7 @@ class HttpInterceptorClient< store: HttpInterceptorStore; baseURL: ExtendedURL; Handler: HandlerConstructor; - onUnhandledRequest?: UnhandledRequestStrategy.Any; + onUnhandledRequest?: UnhandledRequestStrategy; }) { this.worker = options.worker; this.store = options.store; diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/utils.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/utils.ts new file mode 100644 index 00000000..d1235ffe --- /dev/null +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/utils.ts @@ -0,0 +1,44 @@ +import { expect } from 'vitest'; + +import { HttpRequest } from '@/http/types/requests'; +import { formatObjectToLog } from '@/utils/console'; + +import { HttpInterceptorPlatform } from '../../types/options'; + +export function verifyUnhandledRequestMessage( + message: string, + options: { + type: 'warning' | 'error'; + platform: HttpInterceptorPlatform; + request: HttpRequest; + }, +) { + const { type, platform, request } = options; + + expect(message).toMatch(/.*\[zimic\].* /); + expect(message).toContain(type === 'warning' ? 'Warning:' : 'Error:'); + expect(message).toContain(type === 'warning' ? 'bypassed' : 'rejected'); + expect(message).toContain(`${request.method} ${request.url}`); + + expect(message).toContain(platform === 'node' ? 'Headers: ' : 'Headers: [object Object]'); + + if (platform === 'node') { + const headersLine = message.match(/Headers: (?[^\n]*)\n/)!; + expect(headersLine).not.toBe(null); + + const formattedHeaders = formatObjectToLog(Object.fromEntries(request.headers)) as string; + const formattedHeadersIgnoringWrapperBrackets = formattedHeaders.slice(1, -1); + expect(headersLine.groups!.headers).toContain(formattedHeadersIgnoringWrapperBrackets); + } + + expect(message).toContain( + platform === 'node' + ? `Search params: ${formatObjectToLog(Object.fromEntries(new URL(request.url).searchParams))}\n` + : 'Search params: [object Object]', + ); + expect(message).toContain( + platform === 'node' || typeof request.body !== 'object' || request.body === null + ? `Body: ${formatObjectToLog(request.body)}\n` + : 'Body: [object Object]', + ); +} diff --git a/packages/zimic/src/interceptor/http/interceptor/types/options.ts b/packages/zimic/src/interceptor/http/interceptor/types/options.ts index 69a3e6e4..bc7d4efa 100644 --- a/packages/zimic/src/interceptor/http/interceptor/types/options.ts +++ b/packages/zimic/src/interceptor/http/interceptor/types/options.ts @@ -17,18 +17,21 @@ export namespace UnhandledRequestStrategy { action: Action; log: boolean; } - export type LocalDeclarationFactory = (request: HttpRequest) => PossiblePromise>; + export type LocalDeclarationFactory = (request: HttpRequest) => PossiblePromise>; export type Local = Partial | LocalDeclarationFactory; export interface RemoteDeclaration { action: Extract; log: boolean; } - export type RemoteDeclarationFactory = (request: HttpRequest) => PossiblePromise>; + export type RemoteDeclarationFactory = (request: HttpRequest) => PossiblePromise>; export type Remote = Partial | RemoteDeclarationFactory; - export type Any = Local | Remote; + export type Declaration = LocalDeclaration | RemoteDeclaration; + export type DeclarationFactory = LocalDeclarationFactory | RemoteDeclarationFactory; } +// eslint-disable-next-line @typescript-eslint/no-redeclare +export type UnhandledRequestStrategy = UnhandledRequestStrategy.Local | UnhandledRequestStrategy.Remote; export interface LocalHttpInterceptorOptions extends SharedHttpInterceptorOptions { type: 'local'; diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts index 2b80d397..d90e6411 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts @@ -46,7 +46,7 @@ abstract class HttpInterceptorWorker { private unhandledRequestStrategies: { baseURL: string; - declarationOrFactory: UnhandledRequestStrategy.Any; + declarationOrFactory: UnhandledRequestStrategy; }[] = []; platform() { @@ -115,7 +115,7 @@ abstract class HttpInterceptorWorker { } } - onUnhandledRequest(baseURL: string, strategyOrFactory: UnhandledRequestStrategy.Any) { + onUnhandledRequest(baseURL: string, strategyOrFactory: UnhandledRequestStrategy) { this.unhandledRequestStrategies.push({ baseURL, declarationOrFactory: strategyOrFactory, @@ -289,16 +289,15 @@ abstract class HttpInterceptorWorker { [ `${action === 'bypass' ? 'Warning:' : 'Error:'} Request did not match any handlers and was ` + `${action === 'bypass' ? chalk.yellow('bypassed') : chalk.red('rejected')}:\n\n `, - request.method, - request.url, - '\n Headers:', - formatObjectToLog(Object.fromEntries(request.headers)), - '\n Search params:', - formatObjectToLog(Object.fromEntries(request.searchParams)), - '\n Body:', - formatObjectToLog(request.body), - '\n\nTo handle this request, use an interceptor to create a handler for it.', - '\nIf you are using restrictions, make sure that they match the content of the request.', + `${request.method} ${request.url}\n`, + ' Headers:', + `${formatObjectToLog(Object.fromEntries(request.headers))}\n`, + ' Search params:', + `${formatObjectToLog(Object.fromEntries(request.searchParams))}\n`, + ' Body:', + `${formatObjectToLog(request.body)}\n\n`, + 'To handle this request, use an interceptor to create a handler for it.\n', + 'If you are using restrictions, make sure that they match the content of the request.', ], { method: action === 'bypass' ? 'warn' : 'error' }, ); diff --git a/packages/zimic/src/interceptor/server/InterceptorServer.ts b/packages/zimic/src/interceptor/server/InterceptorServer.ts index 7ed909d1..d2174db8 100644 --- a/packages/zimic/src/interceptor/server/InterceptorServer.ts +++ b/packages/zimic/src/interceptor/server/InterceptorServer.ts @@ -238,7 +238,12 @@ class InterceptorServer implements PublicInterceptorServer { const defaultPreflightResponse = new Response(null, { status: DEFAULT_PREFLIGHT_STATUS_CODE }); this.setDefaultAccessControlHeaders(defaultPreflightResponse); await sendNodeResponse(defaultPreflightResponse, nodeResponse, nodeRequest); - } else if (!matchedAnyInterceptor && this.onUnhandledRequest.log) { + } + + const shouldWarnUnhandledRequest = + !isUnhandledPreflightResponse && !matchedAnyInterceptor && this.onUnhandledRequest.log; + + if (shouldWarnUnhandledRequest) { const { action } = DEFAULT_UNHANDLED_REQUEST_STRATEGY.remote; await HttpInterceptorWorker.warnUnhandledRequest(request, action); } From d1c5c6f768f3547bf2cd97c6e64b3c683147f5e8 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sun, 19 May 2024 12:01:37 -0300 Subject: [PATCH 11/22] refactor(#zimic): make unhandled request handling more consistent --- packages/zimic/src/cli/cli.ts | 2 +- .../interceptor/__tests__/shared/utils.ts | 6 +- .../http/interceptor/types/options.ts | 37 ++++------ .../HttpInterceptorWorker.ts | 69 +++++++++---------- .../LocalHttpInterceptorWorker.ts | 20 ++---- .../RemoteHttpInterceptorWorker.ts | 2 +- .../errors/UnhandledRequestError.ts | 8 --- .../interceptor/server/InterceptorServer.ts | 5 +- 8 files changed, 60 insertions(+), 89 deletions(-) delete mode 100644 packages/zimic/src/interceptor/http/interceptorWorker/errors/UnhandledRequestError.ts diff --git a/packages/zimic/src/cli/cli.ts b/packages/zimic/src/cli/cli.ts index f6baf2e6..3706601e 100644 --- a/packages/zimic/src/cli/cli.ts +++ b/packages/zimic/src/cli/cli.ts @@ -69,7 +69,7 @@ async function runCLI() { 'If an interceptor was matched, the logging behavior for that base URL is configured in the ' + 'interceptor itself.', alias: 'l', - default: DEFAULT_UNHANDLED_REQUEST_STRATEGY.remote.log, + default: DEFAULT_UNHANDLED_REQUEST_STRATEGY.log, }), async (cliArguments) => { const onReadyCommand = cliArguments._.at(2)?.toString(); diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/utils.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/utils.ts index d1235ffe..9f4788da 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/utils.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/utils.ts @@ -8,7 +8,7 @@ import { HttpInterceptorPlatform } from '../../types/options'; export function verifyUnhandledRequestMessage( message: string, options: { - type: 'warning' | 'error'; + type: 'warn' | 'error'; platform: HttpInterceptorPlatform; request: HttpRequest; }, @@ -16,8 +16,8 @@ export function verifyUnhandledRequestMessage( const { type, platform, request } = options; expect(message).toMatch(/.*\[zimic\].* /); - expect(message).toContain(type === 'warning' ? 'Warning:' : 'Error:'); - expect(message).toContain(type === 'warning' ? 'bypassed' : 'rejected'); + expect(message).toContain(type === 'warn' ? 'Warning:' : 'Error:'); + expect(message).toContain(type === 'warn' ? 'bypassed' : 'rejected'); expect(message).toContain(`${request.method} ${request.url}`); expect(message).toContain(platform === 'node' ? 'Headers: ' : 'Headers: [object Object]'); diff --git a/packages/zimic/src/interceptor/http/interceptor/types/options.ts b/packages/zimic/src/interceptor/http/interceptor/types/options.ts index bc7d4efa..8c80530f 100644 --- a/packages/zimic/src/interceptor/http/interceptor/types/options.ts +++ b/packages/zimic/src/interceptor/http/interceptor/types/options.ts @@ -4,43 +4,34 @@ import { PossiblePromise } from '@/types/utils'; export type HttpInterceptorType = 'local' | 'remote'; export type HttpInterceptorPlatform = 'node' | 'browser'; -interface SharedHttpInterceptorOptions { - type: HttpInterceptorType; - /** The base URL used by the interceptor. This URL will be prepended to any paths used by the interceptor. */ - baseURL: string | URL; -} - export namespace UnhandledRequestStrategy { - export type Action = 'bypass' | 'reject'; - - export interface LocalDeclaration { - action: Action; + export type Declaration = Partial<{ log: boolean; - } - export type LocalDeclarationFactory = (request: HttpRequest) => PossiblePromise>; - export type Local = Partial | LocalDeclarationFactory; + }>; - export interface RemoteDeclaration { - action: Extract; - log: boolean; + export interface HandlerContext { + log: () => Promise; } - export type RemoteDeclarationFactory = (request: HttpRequest) => PossiblePromise>; - export type Remote = Partial | RemoteDeclarationFactory; + export type Handler = (request: HttpRequest, context: HandlerContext) => PossiblePromise; - export type Declaration = LocalDeclaration | RemoteDeclaration; - export type DeclarationFactory = LocalDeclarationFactory | RemoteDeclarationFactory; + export type Action = 'bypass' | 'reject'; } // eslint-disable-next-line @typescript-eslint/no-redeclare -export type UnhandledRequestStrategy = UnhandledRequestStrategy.Local | UnhandledRequestStrategy.Remote; +export type UnhandledRequestStrategy = UnhandledRequestStrategy.Declaration | UnhandledRequestStrategy.Handler; + +interface SharedHttpInterceptorOptions { + type: HttpInterceptorType; + /** The base URL used by the interceptor. This URL will be prepended to any paths used by the interceptor. */ + baseURL: string | URL; + onUnhandledRequest?: UnhandledRequestStrategy; +} export interface LocalHttpInterceptorOptions extends SharedHttpInterceptorOptions { type: 'local'; - onUnhandledRequest?: UnhandledRequestStrategy.Local; } export interface RemoteHttpInterceptorOptions extends SharedHttpInterceptorOptions { type: 'remote'; - onUnhandledRequest?: UnhandledRequestStrategy.Remote; } /** Options to create an HTTP interceptor. */ diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts index d90e6411..1beada28 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts @@ -23,17 +23,12 @@ import { HttpInterceptorRequest, HttpInterceptorResponse, } from '../requestHandler/types/requests'; -import UnhandledRequestError from './errors/UnhandledRequestError'; import { HttpResponseFactory } from './types/requests'; const DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY = process.env.DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY === 'true'; -export const DEFAULT_UNHANDLED_REQUEST_STRATEGY: { - local: Required; - remote: Required; -} = { - local: { action: 'bypass', log: DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY }, - remote: { action: 'reject', log: DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY }, +export const DEFAULT_UNHANDLED_REQUEST_STRATEGY: Required = { + log: DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY, }; abstract class HttpInterceptorWorker { @@ -46,7 +41,7 @@ abstract class HttpInterceptorWorker { private unhandledRequestStrategies: { baseURL: string; - declarationOrFactory: UnhandledRequestStrategy; + declarationOrHandler: UnhandledRequestStrategy; }[] = []; platform() { @@ -104,21 +99,42 @@ abstract class HttpInterceptorWorker { createResponse: HttpResponseFactory, ): PossiblePromise; - protected async handleUnhandledRequest(request: Request, options: { throwOnRejected: boolean }) { - const strategy = await this.getUnhandledRequestStrategy(request); + protected async handleUnhandledRequest(request: Request) { + const requestURL = excludeNonPathParams(createURL(request.url)).toString(); - if (strategy.log) { - await HttpInterceptorWorker.warnUnhandledRequest(request, strategy.action); - } - if (strategy.action === 'reject' && options.throwOnRejected) { - throw new UnhandledRequestError(); + const { declarationOrHandler } = + this.unhandledRequestStrategies.findLast((strategy) => { + return requestURL.startsWith(strategy.baseURL); + }) ?? {}; + + const action: UnhandledRequestStrategy.Action = this.type === 'local' ? 'bypass' : 'reject'; + + if (typeof declarationOrHandler === 'function') { + const handler = declarationOrHandler; + const requestClone = request.clone(); + + try { + await handler(request, { + async log() { + await HttpInterceptorWorker.logUnhandledRequest(requestClone, action); + }, + }); + } catch (error) { + console.error(error); + } + } else { + const declaration = declarationOrHandler; + const shouldLog = declaration?.log ?? DEFAULT_UNHANDLED_REQUEST_STRATEGY.log; + if (shouldLog) { + await HttpInterceptorWorker.logUnhandledRequest(request, action); + } } } onUnhandledRequest(baseURL: string, strategyOrFactory: UnhandledRequestStrategy) { this.unhandledRequestStrategies.push({ baseURL, - declarationOrFactory: strategyOrFactory, + declarationOrHandler: strategyOrFactory, }); } @@ -128,25 +144,6 @@ abstract class HttpInterceptorWorker { ); } - async getUnhandledRequestStrategy( - request: HttpRequest, - ): Promise { - const requestURL = excludeNonPathParams(createURL(request.url)).toString(); - - const { declarationOrFactory } = - this.unhandledRequestStrategies.findLast((strategy) => requestURL.startsWith(strategy.baseURL)) ?? {}; - - const strategy = - typeof declarationOrFactory === 'function' ? await declarationOrFactory(request) : declarationOrFactory; - - const defaultStrategy = DEFAULT_UNHANDLED_REQUEST_STRATEGY[this.type]; - - return { - action: strategy?.action ?? defaultStrategy.action, - log: strategy?.log ?? defaultStrategy.log, - }; - } - abstract clearHandlers(): PossiblePromise; abstract clearInterceptorHandlers( @@ -282,7 +279,7 @@ abstract class HttpInterceptorWorker { } } - static async warnUnhandledRequest(rawRequest: HttpRequest, action: UnhandledRequestStrategy.Action) { + static async logUnhandledRequest(rawRequest: HttpRequest, action: UnhandledRequestStrategy.Action) { const request = await this.parseRawRequest(rawRequest); logWithPrefix( diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts index 3dca93c7..8659c93a 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts @@ -69,7 +69,7 @@ class LocalHttpInterceptorWorker extends HttpInterceptorWorker { const internalWorker = await this.internalWorkerOrLoad(); const sharedOptions: MSWWorkerSharedOptions = { onUnhandledRequest: async (request) => { - await super.handleUnhandledRequest(request, { throwOnRejected: true }); + await super.handleUnhandledRequest(request); }, }; @@ -158,21 +158,13 @@ class LocalHttpInterceptorWorker extends HttpInterceptorWorker { const result = await createResponse({ ...context, request }); - if (!result.bypass) { - const response = context.request.method === 'HEAD' ? new Response(null, result.response) : result.response; - return response; - } - - const unhandledStrategy = await super.getUnhandledRequestStrategy(requestClone); - - if (unhandledStrategy.log) { - await HttpInterceptorWorker.warnUnhandledRequest(requestClone, unhandledStrategy.action); - } - if (unhandledStrategy.action === 'bypass') { + if (result.bypass) { + await super.handleUnhandledRequest(requestClone); return passthrough(); - } else { - return undefined; } + + const response = context.request.method === 'HEAD' ? new Response(null, result.response) : result.response; + return response; }); internalWorker.use(httpHandler); diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts index dd73af90..a068be57 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts @@ -76,7 +76,7 @@ class RemoteHttpInterceptorWorker extends HttpInterceptorWorker { const response = rawResponse && request.method === 'HEAD' ? new Response(null, rawResponse) : rawResponse; if (!response) { - await super.handleUnhandledRequest(request, { throwOnRejected: false }); + await super.handleUnhandledRequest(request); } const serializedResponse = response ? await serializeResponse(response) : null; diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnhandledRequestError.ts b/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnhandledRequestError.ts deleted file mode 100644 index 3944ab04..00000000 --- a/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnhandledRequestError.ts +++ /dev/null @@ -1,8 +0,0 @@ -class UnhandledRequestError extends Error { - constructor() { - super('[zimic] Request did not match any handlers and was rejected.'); - this.name = 'UnhandledRequestError'; - } -} - -export default UnhandledRequestError; diff --git a/packages/zimic/src/interceptor/server/InterceptorServer.ts b/packages/zimic/src/interceptor/server/InterceptorServer.ts index d2174db8..eab3951d 100644 --- a/packages/zimic/src/interceptor/server/InterceptorServer.ts +++ b/packages/zimic/src/interceptor/server/InterceptorServer.ts @@ -59,7 +59,7 @@ class InterceptorServer implements PublicInterceptorServer { this._hostname = options.hostname ?? 'localhost'; this._port = options.port; this.onUnhandledRequest = { - log: options.onUnhandledRequest?.log ?? DEFAULT_UNHANDLED_REQUEST_STRATEGY.remote.log, + log: options.onUnhandledRequest?.log ?? DEFAULT_UNHANDLED_REQUEST_STRATEGY.log, }; } @@ -244,8 +244,7 @@ class InterceptorServer implements PublicInterceptorServer { !isUnhandledPreflightResponse && !matchedAnyInterceptor && this.onUnhandledRequest.log; if (shouldWarnUnhandledRequest) { - const { action } = DEFAULT_UNHANDLED_REQUEST_STRATEGY.remote; - await HttpInterceptorWorker.warnUnhandledRequest(request, action); + await HttpInterceptorWorker.logUnhandledRequest(request, 'reject'); } nodeResponse.destroy(); From a2befa7c9906ad0dc6944fe33c3e76ff7f959442 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sun, 19 May 2024 12:02:11 -0300 Subject: [PATCH 12/22] test(#zimic): verify interceptor unhandled requests --- .../src/cli/__tests__/server.cli.node.test.ts | 6 +- .../__tests__/shared/methods/delete.ts | 1549 ++++++++++------- .../__tests__/shared/methods/get.ts | 1407 +++++++++------ .../__tests__/shared/methods/head.ts | 1290 +++++++++----- .../__tests__/shared/methods/options.ts | 1425 +++++++++------ .../__tests__/shared/methods/patch.ts | 1475 ++++++++++------ .../__tests__/shared/methods/post.ts | 1487 ++++++++++------ .../__tests__/shared/methods/put.ts | 1482 ++++++++++------ .../interceptor/__tests__/shared/utils.ts | 21 +- .../RemoteHttpInterceptorWorker.ts | 8 +- packages/zimic/src/utils/console.ts | 1 + 11 files changed, 6397 insertions(+), 3754 deletions(-) diff --git a/packages/zimic/src/cli/__tests__/server.cli.node.test.ts b/packages/zimic/src/cli/__tests__/server.cli.node.test.ts index 6647ba31..73047bbf 100644 --- a/packages/zimic/src/cli/__tests__/server.cli.node.test.ts +++ b/packages/zimic/src/cli/__tests__/server.cli.node.test.ts @@ -509,7 +509,11 @@ describe('CLI (server)', async () => { expect(spies.error).toHaveBeenCalledTimes(1); const errorMessage = spies.error.mock.calls[0].join(' '); - verifyUnhandledRequestMessage(errorMessage, { type: 'error', platform: 'node', request }); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform: 'node', + request, + }); }); }); diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/delete.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/delete.ts index aae8878a..f17420c6 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/delete.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/delete.ts @@ -1,4 +1,4 @@ -import { beforeEach, expect, expectTypeOf, it } from 'vitest'; +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; @@ -10,15 +10,17 @@ import { JSONValue } from '@/types/json'; import { getCrypto } from '@/utils/crypto'; import { fetchWithTimeout } from '@/utils/fetch'; import { joinURL } from '@/utils/urls'; +import { usingIgnoredConsole } from '@tests/utils/console'; import { expectFetchError } from '@tests/utils/fetch'; import { createInternalHttpInterceptor, usingHttpInterceptor } from '@tests/utils/interceptors'; import NotStartedHttpInterceptorError from '../../../errors/NotStartedHttpInterceptorError'; -import { HttpInterceptorOptions } from '../../../types/options'; +import { HttpInterceptorOptions, UnhandledRequestStrategy } from '../../../types/options'; import { RuntimeSharedHttpInterceptorTestsOptions } from '../types'; +import { verifyUnhandledRequestMessage } from '../utils'; export async function declareDeleteHttpInterceptorTests(options: RuntimeSharedHttpInterceptorTestsOptions) { - const { getBaseURL, getInterceptorOptions } = options; + const { platform, type, getBaseURL, getInterceptorOptions } = options; const crypto = await getCrypto(); @@ -274,316 +276,6 @@ export async function declareDeleteHttpInterceptorTests(options: RuntimeSharedHt }); }); - it('should support intercepting DELETE requests having headers restrictions', async () => { - type UserDeletionHeaders = HttpSchema.Headers<{ - 'content-type'?: string; - accept?: string; - }>; - - await usingHttpInterceptor<{ - '/users/:id': { - DELETE: { - request: { - headers: UserDeletionHeaders; - }; - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const deletionHandler = await promiseIfRemote( - interceptor - .delete(`/users/:id`) - .with({ - headers: { 'content-type': 'application/json' }, - }) - .with((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - return request.headers.get('accept')?.includes('application/json') ?? false; - }) - .respond((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - return { - status: 200, - body: users[0], - }; - }), - interceptor, - ); - expect(deletionHandler).toBeInstanceOf(Handler); - - let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(0); - - const headers = new HttpHeaders({ - 'content-type': 'application/json', - accept: 'application/json', - }); - - let deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE', headers }); - expect(deletionResponse.status).toBe(200); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); - - headers.append('accept', 'application/xml'); - - deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE', headers }); - expect(deletionResponse.status).toBe(200); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(2); - - headers.delete('accept'); - - let deletionResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE', headers }); - await expectFetchError(deletionResponsePromise); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(2); - - headers.set('accept', 'application/json'); - headers.set('content-type', 'text/plain'); - - deletionResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'DELETE', headers }); - await expectFetchError(deletionResponsePromise); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(2); - }); - }); - - it('should support intercepting DELETE requests having search params restrictions', async () => { - type UserDeletionSearchParams = HttpSchema.SearchParams<{ - tag?: string; - }>; - - await usingHttpInterceptor<{ - '/users/:id': { - DELETE: { - request: { - searchParams: UserDeletionSearchParams; - }; - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const deletionHandler = await promiseIfRemote( - interceptor - .delete('/users/:id') - .with({ - searchParams: { tag: 'admin' }, - }) - .respond((request) => { - expectTypeOf(request.searchParams).toEqualTypeOf>(); - expect(request.searchParams).toBeInstanceOf(HttpSearchParams); - - return { - status: 200, - body: users[0], - }; - }), - interceptor, - ); - expect(deletionHandler).toBeInstanceOf(Handler); - - let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(0); - - const searchParams = new HttpSearchParams({ - tag: 'admin', - }); - - const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { - method: 'DELETE', - }); - expect(deletionResponse.status).toBe(200); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); - - searchParams.delete('tag'); - - const listResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { - method: 'DELETE', - }); - await expectFetchError(listResponsePromise); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); - }); - }); - - it('should support intercepting DELETE requests having body restrictions', async () => { - type UserDeletionBody = JSONValue<{ - tags?: string[]; - other?: string; - }>; - - await usingHttpInterceptor<{ - '/users/:id': { - DELETE: { - request: { - body: UserDeletionBody; - }; - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const deletionHandler = await promiseIfRemote( - interceptor - .delete(`/users/:id`) - .with({ - body: { tags: ['admin'] }, - }) - .with((request) => { - expectTypeOf(request.body).toEqualTypeOf(); - - return request.body.other?.startsWith('extra') ?? false; - }) - .respond((request) => { - expectTypeOf(request.body).toEqualTypeOf(); - - return { - status: 200, - body: users[0], - }; - }), - interceptor, - ); - expect(deletionHandler).toBeInstanceOf(Handler); - - let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(0); - - let deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'DELETE', - body: JSON.stringify({ - tags: ['admin'], - other: 'extra', - } satisfies UserDeletionBody), - }); - expect(deletionResponse.status).toBe(200); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); - - deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'DELETE', - body: JSON.stringify({ - tags: ['admin'], - other: 'extra-other', - } satisfies UserDeletionBody), - }); - expect(deletionResponse.status).toBe(200); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(2); - - let deletionResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'DELETE', - body: JSON.stringify({ - tags: ['admin'], - } satisfies UserDeletionBody), - }); - await expectFetchError(deletionResponsePromise); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(2); - - deletionResponsePromise = fetch(joinURL(baseURL, '/users'), { - method: 'DELETE', - body: JSON.stringify({ - tags: [], - } satisfies UserDeletionBody), - }); - await expectFetchError(deletionResponsePromise); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(2); - }); - }); - - it('should support intercepting DELETE requests with a dynamic path', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - DELETE: { - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const genericDeletionHandler = await promiseIfRemote( - interceptor.delete(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - expect(genericDeletionHandler).toBeInstanceOf(Handler); - - let genericDeletionRequests = await promiseIfRemote(genericDeletionHandler.requests(), interceptor); - expect(genericDeletionRequests).toHaveLength(0); - - const genericDeletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - expect(genericDeletionResponse.status).toBe(200); - - const genericDeletedUser = (await genericDeletionResponse.json()) as User; - expect(genericDeletedUser).toEqual(users[0]); - - genericDeletionRequests = await promiseIfRemote(genericDeletionHandler.requests(), interceptor); - expect(genericDeletionRequests).toHaveLength(1); - const [genericDeletionRequest] = genericDeletionRequests; - expect(genericDeletionRequest).toBeInstanceOf(Request); - - expectTypeOf(genericDeletionRequest.body).toEqualTypeOf(); - expect(genericDeletionRequest.body).toBe(null); - - expectTypeOf(genericDeletionRequest.response.status).toEqualTypeOf<200>(); - expect(genericDeletionRequest.response.status).toEqual(200); - - expectTypeOf(genericDeletionRequest.response.body).toEqualTypeOf(); - expect(genericDeletionRequest.response.body).toEqual(users[0]); - - await promiseIfRemote(genericDeletionHandler.bypass(), interceptor); - - const specificDeletionHandler = await promiseIfRemote( - interceptor.delete(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - expect(specificDeletionHandler).toBeInstanceOf(Handler); - - let specificDeletionRequests = await promiseIfRemote(specificDeletionHandler.requests(), interceptor); - expect(specificDeletionRequests).toHaveLength(0); - - const specificDeletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - expect(specificDeletionResponse.status).toBe(200); - - const specificDeletedUser = (await specificDeletionResponse.json()) as User; - expect(specificDeletedUser).toEqual(users[0]); - - specificDeletionRequests = await promiseIfRemote(specificDeletionHandler.requests(), interceptor); - expect(specificDeletionRequests).toHaveLength(1); - const [specificDeletionRequest] = specificDeletionRequests; - expect(specificDeletionRequest).toBeInstanceOf(Request); - - expectTypeOf(specificDeletionRequest.body).toEqualTypeOf(); - expect(specificDeletionRequest.body).toBe(null); - - expectTypeOf(specificDeletionRequest.response.status).toEqualTypeOf<200>(); - expect(specificDeletionRequest.response.status).toEqual(200); - - expectTypeOf(specificDeletionRequest.response.body).toEqualTypeOf(); - expect(specificDeletionRequest.response.body).toEqual(users[0]); - - const unmatchedDeletionPromise = fetch(joinURL(baseURL, `/users/${2}`), { method: 'DELETE' }); - await expectFetchError(unmatchedDeletionPromise); - }); - }); - it('should not intercept a DELETE request without a registered response', async () => { await usingHttpInterceptor<{ '/users/:id': { @@ -597,11 +289,11 @@ export async function declareDeleteHttpInterceptorTests(options: RuntimeSharedHt }>(interceptorOptions, async (interceptor) => { const userName = 'User (other)'; - let deletionPromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { + let deletionResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE', body: JSON.stringify({ name: userName } satisfies Partial), }); - await expectFetchError(deletionPromise); + await expectFetchError(deletionResponsePromise); const deletionHandlerWithoutResponse = await promiseIfRemote(interceptor.delete(`/users/:id`), interceptor); expect(deletionHandlerWithoutResponse).toBeInstanceOf(Handler); @@ -616,11 +308,11 @@ export async function declareDeleteHttpInterceptorTests(options: RuntimeSharedHt expectTypeOf().toEqualTypeOf>(); expectTypeOf().toEqualTypeOf(); - deletionPromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { + deletionResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE', body: JSON.stringify({ name: userName } satisfies Partial), }); - await expectFetchError(deletionPromise); + await expectFetchError(deletionResponsePromise); deletionRequestsWithoutResponse = await promiseIfRemote(deletionHandlerWithoutResponse.requests(), interceptor); expect(deletionRequestsWithoutResponse).toHaveLength(0); @@ -749,389 +441,1068 @@ export async function declareDeleteHttpInterceptorTests(options: RuntimeSharedHt }); }); - it('should ignore handlers with bypassed responses when intercepting DELETE requests', async () => { - type ServerErrorResponseBody = JSONValue<{ - message: string; - }>; - - await usingHttpInterceptor<{ - '/users/:id': { - DELETE: { - response: { - 200: { body: User }; - 500: { body: ServerErrorResponseBody }; + describe('Dinamic paths', () => { + it('should support intercepting DELETE requests with a dynamic path', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const deletionHandler = await promiseIfRemote( - interceptor - .delete(`/users/${users[0].id}`) - .respond({ + }>(interceptorOptions, async (interceptor) => { + const genericDeletionHandler = await promiseIfRemote( + interceptor.delete(`/users/${users[0].id}`).respond({ status: 200, body: users[0], - }) - .bypass(), - interceptor, - ); + }), + interceptor, + ); + expect(genericDeletionHandler).toBeInstanceOf(Handler); - let initialDeletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(initialDeletionRequests).toHaveLength(0); + let genericDeletionRequests = await promiseIfRemote(genericDeletionHandler.requests(), interceptor); + expect(genericDeletionRequests).toHaveLength(0); - const deletionPromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - await expectFetchError(deletionPromise); + const genericDeletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + expect(genericDeletionResponse.status).toBe(200); - await promiseIfRemote( - deletionHandler.respond({ - status: 200, - body: users[1], - }), - interceptor, - ); + const genericDeletedUser = (await genericDeletionResponse.json()) as User; + expect(genericDeletedUser).toEqual(users[0]); - initialDeletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(initialDeletionRequests).toHaveLength(0); - let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(0); + genericDeletionRequests = await promiseIfRemote(genericDeletionHandler.requests(), interceptor); + expect(genericDeletionRequests).toHaveLength(1); + const [genericDeletionRequest] = genericDeletionRequests; + expect(genericDeletionRequest).toBeInstanceOf(Request); - let deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - expect(deletionResponse.status).toBe(200); + expectTypeOf(genericDeletionRequest.body).toEqualTypeOf(); + expect(genericDeletionRequest.body).toBe(null); - let createdUsers = (await deletionResponse.json()) as User; - expect(createdUsers).toEqual(users[1]); + expectTypeOf(genericDeletionRequest.response.status).toEqualTypeOf<200>(); + expect(genericDeletionRequest.response.status).toEqual(200); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); - let [deletionRequest] = deletionRequests; - expect(deletionRequest).toBeInstanceOf(Request); + expectTypeOf(genericDeletionRequest.response.body).toEqualTypeOf(); + expect(genericDeletionRequest.response.body).toEqual(users[0]); - expectTypeOf(deletionRequest.body).toEqualTypeOf(); - expect(deletionRequest.body).toBe(null); + await promiseIfRemote(genericDeletionHandler.bypass(), interceptor); - expectTypeOf(deletionRequest.response.status).toEqualTypeOf<200>(); - expect(deletionRequest.response.status).toEqual(200); + const specificDeletionHandler = await promiseIfRemote( + interceptor.delete(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + expect(specificDeletionHandler).toBeInstanceOf(Handler); - expectTypeOf(deletionRequest.response.body).toEqualTypeOf(); - expect(deletionRequest.response.body).toEqual(users[1]); + let specificDeletionRequests = await promiseIfRemote(specificDeletionHandler.requests(), interceptor); + expect(specificDeletionRequests).toHaveLength(0); - const errorDeletionHandler = await promiseIfRemote( - interceptor.delete(`/users/${users[0].id}`).respond({ - status: 500, - body: { message: 'Internal server error' }, - }), - interceptor, - ); + const specificDeletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + expect(specificDeletionResponse.status).toBe(200); - let errorDeletionRequests = await promiseIfRemote(errorDeletionHandler.requests(), interceptor); - expect(errorDeletionRequests).toHaveLength(0); + const specificDeletedUser = (await specificDeletionResponse.json()) as User; + expect(specificDeletedUser).toEqual(users[0]); - const otherDeletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - expect(otherDeletionResponse.status).toBe(500); + specificDeletionRequests = await promiseIfRemote(specificDeletionHandler.requests(), interceptor); + expect(specificDeletionRequests).toHaveLength(1); + const [specificDeletionRequest] = specificDeletionRequests; + expect(specificDeletionRequest).toBeInstanceOf(Request); - const serverError = (await otherDeletionResponse.json()) as ServerErrorResponseBody; - expect(serverError).toEqual({ message: 'Internal server error' }); + expectTypeOf(specificDeletionRequest.body).toEqualTypeOf(); + expect(specificDeletionRequest.body).toBe(null); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); + expectTypeOf(specificDeletionRequest.response.status).toEqualTypeOf<200>(); + expect(specificDeletionRequest.response.status).toEqual(200); - errorDeletionRequests = await promiseIfRemote(errorDeletionHandler.requests(), interceptor); - expect(errorDeletionRequests).toHaveLength(1); - const [errorDeletionRequest] = errorDeletionRequests; - expect(errorDeletionRequest).toBeInstanceOf(Request); + expectTypeOf(specificDeletionRequest.response.body).toEqualTypeOf(); + expect(specificDeletionRequest.response.body).toEqual(users[0]); - expectTypeOf(errorDeletionRequest.body).toEqualTypeOf(); - expect(errorDeletionRequest.body).toBe(null); + const unmatchedDeletionResponsePromise = fetch(joinURL(baseURL, `/users/${2}`), { method: 'DELETE' }); + await expectFetchError(unmatchedDeletionResponsePromise); + }); + }); + }); - expectTypeOf(errorDeletionRequest.response.status).toEqualTypeOf<500>(); - expect(errorDeletionRequest.response.status).toEqual(500); + describe('Restrictions', () => { + it('should support intercepting DELETE requests having headers restrictions', async () => { + type UserDeletionHeaders = HttpSchema.Headers<{ + 'content-type'?: string; + accept?: string; + }>; + + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + request: { + headers: UserDeletionHeaders; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor + .delete(`/users/:id`) + .with({ + headers: { 'content-type': 'application/json' }, + }) + .with((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); + + return request.headers.get('accept')?.includes('application/json') ?? false; + }) + .respond((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); + + return { + status: 200, + body: users[0], + }; + }), + interceptor, + ); + expect(deletionHandler).toBeInstanceOf(Handler); + + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); + + const headers = new HttpHeaders({ + 'content-type': 'application/json', + accept: 'application/json', + }); - expectTypeOf(errorDeletionRequest.response.body).toEqualTypeOf(); - expect(errorDeletionRequest.response.body).toEqual({ message: 'Internal server error' }); + let deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE', headers }); + expect(deletionResponse.status).toBe(200); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); - await promiseIfRemote(errorDeletionHandler.bypass(), interceptor); + headers.append('accept', 'application/xml'); - deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - expect(deletionResponse.status).toBe(200); + deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE', headers }); + expect(deletionResponse.status).toBe(200); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(2); - createdUsers = (await deletionResponse.json()) as User; - expect(createdUsers).toEqual(users[1]); + headers.delete('accept'); - errorDeletionRequests = await promiseIfRemote(errorDeletionHandler.requests(), interceptor); - expect(errorDeletionRequests).toHaveLength(1); + let deletionResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE', headers }); + await expectFetchError(deletionResponsePromise); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(2); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(2); - [deletionRequest] = deletionRequests; - expect(deletionRequest).toBeInstanceOf(Request); + headers.set('accept', 'application/json'); + headers.set('content-type', 'text/plain'); - expectTypeOf(deletionRequest.body).toEqualTypeOf(); - expect(deletionRequest.body).toBe(null); + deletionResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'DELETE', headers }); + await expectFetchError(deletionResponsePromise); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(2); + }); + }); - expectTypeOf(deletionRequest.response.status).toEqualTypeOf<200>(); - expect(deletionRequest.response.status).toEqual(200); + it('should support intercepting DELETE requests having search params restrictions', async () => { + type UserDeletionSearchParams = HttpSchema.SearchParams<{ + tag?: string; + }>; - expectTypeOf(deletionRequest.response.body).toEqualTypeOf(); - expect(deletionRequest.response.body).toEqual(users[1]); + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + request: { + searchParams: UserDeletionSearchParams; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor + .delete('/users/:id') + .with({ + searchParams: { tag: 'admin' }, + }) + .respond((request) => { + expectTypeOf(request.searchParams).toEqualTypeOf>(); + expect(request.searchParams).toBeInstanceOf(HttpSearchParams); + + return { + status: 200, + body: users[0], + }; + }), + interceptor, + ); + expect(deletionHandler).toBeInstanceOf(Handler); + + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); + + const searchParams = new HttpSearchParams({ + tag: 'admin', + }); + + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { + method: 'DELETE', + }); + expect(deletionResponse.status).toBe(200); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + searchParams.delete('tag'); + + const deletionResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { + method: 'DELETE', + }); + await expectFetchError(deletionResponsePromise); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + }); + }); + + it('should support intercepting DELETE requests having body restrictions', async () => { + type UserDeletionBody = JSONValue<{ + tags?: string[]; + other?: string; + }>; + + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + request: { + body: UserDeletionBody; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor + .delete(`/users/:id`) + .with({ + body: { tags: ['admin'] }, + }) + .with((request) => { + expectTypeOf(request.body).toEqualTypeOf(); + + return request.body.other?.startsWith('extra') ?? false; + }) + .respond((request) => { + expectTypeOf(request.body).toEqualTypeOf(); + + return { + status: 200, + body: users[0], + }; + }), + interceptor, + ); + expect(deletionHandler).toBeInstanceOf(Handler); + + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); + + let deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + body: JSON.stringify({ + tags: ['admin'], + other: 'extra', + } satisfies UserDeletionBody), + }); + expect(deletionResponse.status).toBe(200); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + body: JSON.stringify({ + tags: ['admin'], + other: 'extra-other', + } satisfies UserDeletionBody), + }); + expect(deletionResponse.status).toBe(200); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(2); + + let deletionResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + body: JSON.stringify({ + tags: ['admin'], + } satisfies UserDeletionBody), + }); + await expectFetchError(deletionResponsePromise); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(2); + + deletionResponsePromise = fetch(joinURL(baseURL, '/users'), { + method: 'DELETE', + body: JSON.stringify({ + tags: [], + } satisfies UserDeletionBody), + }); + await expectFetchError(deletionResponsePromise); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(2); + }); }); }); - it('should ignore all handlers after cleared when intercepting DELETE requests', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - DELETE: { - response: { - 200: { body: User }; + describe('Bypass', () => { + it('should ignore handlers with bypassed responses when intercepting DELETE requests', async () => { + type ServerErrorResponseBody = JSONValue<{ + message: string; + }>; + + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + response: { + 200: { body: User }; + 500: { body: ServerErrorResponseBody }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const deletionHandler = await promiseIfRemote( - interceptor.delete(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor + .delete(`/users/${users[0].id}`) + .respond({ + status: 200, + body: users[0], + }) + .bypass(), + interceptor, + ); - let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(0); + let initialDeletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(initialDeletionRequests).toHaveLength(0); - const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - expect(deletionResponse.status).toBe(200); + const deletionResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + await expectFetchError(deletionResponsePromise); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); + await promiseIfRemote( + deletionHandler.respond({ + status: 200, + body: users[1], + }), + interceptor, + ); - await promiseIfRemote(interceptor.clear(), interceptor); + initialDeletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(initialDeletionRequests).toHaveLength(0); + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); - const deletionPromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - await expectFetchError(deletionPromise); + let deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + expect(deletionResponse.status).toBe(200); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); + let createdUsers = (await deletionResponse.json()) as User; + expect(createdUsers).toEqual(users[1]); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + let [deletionRequest] = deletionRequests; + expect(deletionRequest).toBeInstanceOf(Request); + + expectTypeOf(deletionRequest.body).toEqualTypeOf(); + expect(deletionRequest.body).toBe(null); + + expectTypeOf(deletionRequest.response.status).toEqualTypeOf<200>(); + expect(deletionRequest.response.status).toEqual(200); + + expectTypeOf(deletionRequest.response.body).toEqualTypeOf(); + expect(deletionRequest.response.body).toEqual(users[1]); + + const errorDeletionHandler = await promiseIfRemote( + interceptor.delete(`/users/${users[0].id}`).respond({ + status: 500, + body: { message: 'Internal server error' }, + }), + interceptor, + ); + + let errorDeletionRequests = await promiseIfRemote(errorDeletionHandler.requests(), interceptor); + expect(errorDeletionRequests).toHaveLength(0); + + const otherDeletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + expect(otherDeletionResponse.status).toBe(500); + + const serverError = (await otherDeletionResponse.json()) as ServerErrorResponseBody; + expect(serverError).toEqual({ message: 'Internal server error' }); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + errorDeletionRequests = await promiseIfRemote(errorDeletionHandler.requests(), interceptor); + expect(errorDeletionRequests).toHaveLength(1); + const [errorDeletionRequest] = errorDeletionRequests; + expect(errorDeletionRequest).toBeInstanceOf(Request); + + expectTypeOf(errorDeletionRequest.body).toEqualTypeOf(); + expect(errorDeletionRequest.body).toBe(null); + + expectTypeOf(errorDeletionRequest.response.status).toEqualTypeOf<500>(); + expect(errorDeletionRequest.response.status).toEqual(500); + + expectTypeOf(errorDeletionRequest.response.body).toEqualTypeOf(); + expect(errorDeletionRequest.response.body).toEqual({ + message: 'Internal server error', + }); + + await promiseIfRemote(errorDeletionHandler.bypass(), interceptor); + + deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + expect(deletionResponse.status).toBe(200); + + createdUsers = (await deletionResponse.json()) as User; + expect(createdUsers).toEqual(users[1]); + + errorDeletionRequests = await promiseIfRemote(errorDeletionHandler.requests(), interceptor); + expect(errorDeletionRequests).toHaveLength(1); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(2); + [deletionRequest] = deletionRequests; + expect(deletionRequest).toBeInstanceOf(Request); + + expectTypeOf(deletionRequest.body).toEqualTypeOf(); + expect(deletionRequest.body).toBe(null); + + expectTypeOf(deletionRequest.response.status).toEqualTypeOf<200>(); + expect(deletionRequest.response.status).toEqual(200); + + expectTypeOf(deletionRequest.response.body).toEqualTypeOf(); + expect(deletionRequest.response.body).toEqual(users[1]); + }); }); }); - it('should ignore all handlers after restarted when intercepting DELETE requests', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - DELETE: { - response: { - 200: { body: User }; + describe('Clear', () => { + it('should ignore all handlers after cleared when intercepting DELETE requests', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const deletionHandler = await promiseIfRemote( - interceptor.delete(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor.delete(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(0); + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); - const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - expect(deletionResponse.status).toBe(200); + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + expect(deletionResponse.status).toBe(200); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); - expect(interceptor.isRunning()).toBe(true); - await interceptor.stop(); - expect(interceptor.isRunning()).toBe(false); + await promiseIfRemote(interceptor.clear(), interceptor); - let deletionPromise = fetchWithTimeout(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'DELETE', - timeout: 200, + const deletionResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + await expectFetchError(deletionResponsePromise); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); }); - await expectFetchError(deletionPromise, { canBeAborted: true }); + }); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); + it('should support creating new handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + let deletionHandler = await promiseIfRemote( + interceptor.delete(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - await interceptor.start(); - expect(interceptor.isRunning()).toBe(true); + await promiseIfRemote(interceptor.clear(), interceptor); - deletionPromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - await expectFetchError(deletionPromise); + deletionHandler = await promiseIfRemote( + interceptor.delete(`/users/${users[0].id}`).respond({ + status: 200, + body: users[1], + }), + interceptor, + ); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); + + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + expect(deletionResponse.status).toBe(200); + + const createdUsers = (await deletionResponse.json()) as User; + expect(createdUsers).toEqual(users[1]); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + const [deletionRequest] = deletionRequests; + expect(deletionRequest).toBeInstanceOf(Request); + + expectTypeOf(deletionRequest.body).toEqualTypeOf(); + expect(deletionRequest.body).toBe(null); + + expectTypeOf(deletionRequest.response.status).toEqualTypeOf<200>(); + expect(deletionRequest.response.status).toEqual(200); + + expectTypeOf(deletionRequest.response.body).toEqualTypeOf(); + expect(deletionRequest.response.body).toEqual(users[1]); + }); + }); + + it('should support reusing previous handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor.delete(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + await promiseIfRemote(interceptor.clear(), interceptor); + + await promiseIfRemote( + deletionHandler.respond({ + status: 200, + body: users[1], + }), + interceptor, + ); + + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); + + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + expect(deletionResponse.status).toBe(200); + + const createdUsers = (await deletionResponse.json()) as User; + expect(createdUsers).toEqual(users[1]); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + const [deletionRequest] = deletionRequests; + expect(deletionRequest).toBeInstanceOf(Request); + + expectTypeOf(deletionRequest.body).toEqualTypeOf(); + expect(deletionRequest.body).toBe(null); + + expectTypeOf(deletionRequest.response.status).toEqualTypeOf<200>(); + expect(deletionRequest.response.status).toEqual(200); + + expectTypeOf(deletionRequest.response.body).toEqualTypeOf(); + expect(deletionRequest.response.body).toEqual(users[1]); + }); }); }); - it('should ignore all handlers after restarted when intercepting DELETE requests, even if another interceptor is still running', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - DELETE: { - response: { - 200: { body: User }; + describe('Life cycle', () => { + it('should ignore all handlers after restarted when intercepting DELETE requests', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const deletionHandler = await promiseIfRemote( - interceptor.delete(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor.delete(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(0); + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); - const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - expect(deletionResponse.status).toBe(200); + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + expect(deletionResponse.status).toBe(200); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); - await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); - await interceptor.stop(); expect(interceptor.isRunning()).toBe(false); - expect(otherInterceptor.isRunning()).toBe(true); - let deletionPromise = fetchWithTimeout(joinURL(baseURL, `/users/${users[0].id}`), { + let deletionResponsePromise = fetchWithTimeout(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE', timeout: 200, }); - await expectFetchError(deletionPromise, { canBeAborted: true }); + await expectFetchError(deletionResponsePromise, { canBeAborted: true }); deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); expect(deletionRequests).toHaveLength(1); await interceptor.start(); expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); - deletionPromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - await expectFetchError(deletionPromise); + deletionResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + await expectFetchError(deletionResponsePromise); deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); expect(deletionRequests).toHaveLength(1); }); }); - }); - - it('should throw an error when trying to create a DELETE request handler if not running', async () => { - const interceptor = createInternalHttpInterceptor(interceptorOptions); - expect(interceptor.isRunning()).toBe(false); - await expect(async () => { - await interceptor.delete('/'); - }).rejects.toThrowError(new NotStartedHttpInterceptorError()); - }); - - it('should support creating new handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - DELETE: { - response: { - 200: { body: User }; + it('should ignore all handlers after restarted when intercepting DELETE requests, even if another interceptor is still running', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - let deletionHandler = await promiseIfRemote( - interceptor.delete(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor.delete(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - await promiseIfRemote(interceptor.clear(), interceptor); + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); - deletionHandler = await promiseIfRemote( - interceptor.delete(`/users/${users[0].id}`).respond({ - status: 200, - body: users[1], - }), - interceptor, - ); + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + expect(deletionResponse.status).toBe(200); - let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(0); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); - const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - expect(deletionResponse.status).toBe(200); + await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); - const createdUsers = (await deletionResponse.json()) as User; - expect(createdUsers).toEqual(users[1]); + await interceptor.stop(); + expect(interceptor.isRunning()).toBe(false); + expect(otherInterceptor.isRunning()).toBe(true); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); - const [deletionRequest] = deletionRequests; - expect(deletionRequest).toBeInstanceOf(Request); + let deletionResponsePromise = fetchWithTimeout(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + timeout: 200, + }); + await expectFetchError(deletionResponsePromise, { canBeAborted: true }); - expectTypeOf(deletionRequest.body).toEqualTypeOf(); - expect(deletionRequest.body).toBe(null); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); - expectTypeOf(deletionRequest.response.status).toEqualTypeOf<200>(); - expect(deletionRequest.response.status).toEqual(200); + await interceptor.start(); + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); - expectTypeOf(deletionRequest.response.body).toEqualTypeOf(); - expect(deletionRequest.response.body).toEqual(users[1]); + deletionResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + await expectFetchError(deletionResponsePromise); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + }); + }); + }); + + it('should throw an error when trying to create a DELETE request handler if not running', async () => { + const interceptor = createInternalHttpInterceptor(interceptorOptions); + expect(interceptor.isRunning()).toBe(false); + + await expect(async () => { + await interceptor.delete('/'); + }).rejects.toThrowError(new NotStartedHttpInterceptorError()); }); }); - it('should support reusing previous handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - DELETE: { - response: { - 200: { body: User }; + describe('Unhandled requests', () => { + if (type === 'local') { + it('should show a warning when logging is enabled and a DELETE request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor + .delete(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '1' }, + }); + expect(deletionResponse.status).toBe(200); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const deletionRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + const deletionResponsePromise = fetch(deletionRequest); + await expectFetchError(deletionResponsePromise); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: deletionRequest, + }); + }); + }); + }); + } + + if (type === 'remote') { + it('should show an error when logging is enabled and a DELETE request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor + .delete(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '1' }, + }); + expect(deletionResponse.status).toBe(200); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const deletionRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '2' }, + }); + const deletionResponsePromise = fetch(deletionRequest); + await expectFetchError(deletionResponsePromise); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: deletionRequest, + }); + }); + }); + }); + } + + it('should support a custom unhandled DELETE request handler', async () => { + const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { + const url = new URL(request.url); + + if (!url.searchParams.has('name')) { + await context.log(); + } + }); + + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + request: { + headers: { 'x-value': string }; + searchParams: { name?: string }; + }; + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const deletionHandler = await promiseIfRemote( - interceptor.delete(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor + .delete(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '1' }, + }); + expect(deletionResponse.status).toBe(200); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const searchParams = new HttpSearchParams({ name: 'User 1' }); + + let deletionResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { + method: 'DELETE', + headers: { 'x-value': '2' }, + }); + await expectFetchError(deletionResponsePromise); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const deletionRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '2' }, + }); + deletionResponsePromise = fetch(deletionRequest); + await expectFetchError(deletionResponsePromise); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(2); + + const messageType = type === 'local' ? 'warn' : 'error'; + expect(spies.warn).toHaveBeenCalledTimes(messageType === 'warn' ? 1 : 0); + expect(spies.error).toHaveBeenCalledTimes(messageType === 'error' ? 1 : 0); + + const errorMessage = spies[messageType].mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: messageType, + platform, + request: deletionRequest, + }); + }); + }); + }); - await promiseIfRemote(interceptor.clear(), interceptor); + it('should log an error if a custom unhandled DELETE request handler throws', async () => { + const error = new Error('Unhandled request.'); - await promiseIfRemote( - deletionHandler.respond({ - status: 200, - body: users[1], - }), - interceptor, - ); + const onUnhandledRequest = vi.fn((request: Request) => { + const url = new URL(request.url); - let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(0); + if (!url.searchParams.has('name')) { + throw error; + } + }); - const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - expect(deletionResponse.status).toBe(200); + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + request: { + headers: { 'x-value': string }; + searchParams: { name?: string }; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor + .delete(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - const createdUsers = (await deletionResponse.json()) as User; - expect(createdUsers).toEqual(users[1]); + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); - const [deletionRequest] = deletionRequests; - expect(deletionRequest).toBeInstanceOf(Request); + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '1' }, + }); + expect(deletionResponse.status).toBe(200); - expectTypeOf(deletionRequest.body).toEqualTypeOf(); - expect(deletionRequest.body).toBe(null); + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); - expectTypeOf(deletionRequest.response.status).toEqualTypeOf<200>(); - expect(deletionRequest.response.status).toEqual(200); + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); - expectTypeOf(deletionRequest.response.body).toEqualTypeOf(); - expect(deletionRequest.response.body).toEqual(users[1]); + const searchParams = new HttpSearchParams({ name: 'User 1' }); + + let deletionResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { + method: 'DELETE', + headers: { 'x-value': '2' }, + }); + await expectFetchError(deletionResponsePromise); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const deletionRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '2' }, + }); + deletionResponsePromise = fetch(deletionRequest); + await expectFetchError(deletionResponsePromise); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(2); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + expect(spies.error).toHaveBeenCalledWith(error); + }); + }); + }); + + it('should not show a warning or error when logging is disabled and a DELETE request is unhandled', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor + .delete(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '1' }, + }); + expect(deletionResponse.status).toBe(200); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const deletionRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '2' }, + }); + const deletionResponsePromise = fetch(deletionRequest); + await expectFetchError(deletionResponsePromise); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + }); + }); }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/get.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/get.ts index 24f24926..43325277 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/get.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/get.ts @@ -1,4 +1,4 @@ -import { beforeEach, expect, expectTypeOf, it } from 'vitest'; +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; @@ -10,15 +10,17 @@ import { JSONValue } from '@/types/json'; import { getCrypto } from '@/utils/crypto'; import { fetchWithTimeout } from '@/utils/fetch'; import { joinURL } from '@/utils/urls'; +import { usingIgnoredConsole } from '@tests/utils/console'; import { expectFetchError } from '@tests/utils/fetch'; import { createInternalHttpInterceptor, usingHttpInterceptor } from '@tests/utils/interceptors'; import NotStartedHttpInterceptorError from '../../../errors/NotStartedHttpInterceptorError'; -import { HttpInterceptorOptions } from '../../../types/options'; +import { HttpInterceptorOptions, UnhandledRequestStrategy } from '../../../types/options'; import { RuntimeSharedHttpInterceptorTestsOptions } from '../types'; +import { verifyUnhandledRequestMessage } from '../utils'; export async function declareGetHttpInterceptorTests(options: RuntimeSharedHttpInterceptorTestsOptions) { - const { getBaseURL, getInterceptorOptions } = options; + const { platform, type, getBaseURL, getInterceptorOptions } = options; const crypto = await getCrypto(); @@ -277,247 +279,6 @@ export async function declareGetHttpInterceptorTests(options: RuntimeSharedHttpI }); }); - it('should support intercepting GET requests having headers restrictions', async () => { - type UserListHeaders = HttpSchema.Headers<{ - 'content-type'?: string; - accept?: string; - }>; - - await usingHttpInterceptor<{ - '/users': { - GET: { - request: { - headers: UserListHeaders; - }; - response: { - 200: { body: User[] }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const listHandler = await promiseIfRemote( - interceptor - .get('/users') - .with({ - headers: { 'content-type': 'application/json' }, - }) - .with((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - return request.headers.get('accept')?.includes('application/json') ?? false; - }) - .respond((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - return { - status: 200, - body: users, - }; - }), - interceptor, - ); - expect(listHandler).toBeInstanceOf(Handler); - - let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(0); - - const headers = new HttpHeaders({ - 'content-type': 'application/json', - accept: 'application/json', - }); - - let listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET', headers }); - expect(listResponse.status).toBe(200); - - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); - - headers.append('accept', 'application/xml'); - - listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET', headers }); - expect(listResponse.status).toBe(200); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(2); - - headers.delete('accept'); - - let listResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'GET', headers }); - await expectFetchError(listResponsePromise); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(2); - - headers.set('accept', 'application/json'); - headers.set('content-type', 'text/plain'); - - listResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'GET', headers }); - await expectFetchError(listResponsePromise); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(2); - }); - }); - - it('should support intercepting GET requests having search params restrictions', async () => { - type UserListSearchParams = HttpSchema.SearchParams<{ - name?: string; - orderBy?: ('name' | 'createdAt')[]; - page?: `${number}`; - }>; - - await usingHttpInterceptor<{ - '/users': { - GET: { - request: { - searchParams: UserListSearchParams; - }; - response: { - 200: { body: User[] }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const listHandler = await promiseIfRemote( - interceptor - .get('/users') - .with({ - searchParams: { - name: 'User 1', - }, - }) - .with((request) => { - expectTypeOf(request.searchParams).toEqualTypeOf>(); - expect(request.searchParams).toBeInstanceOf(HttpSearchParams); - - return request.searchParams.getAll('orderBy').length > 0; - }) - .respond((request) => { - expectTypeOf(request.searchParams).toEqualTypeOf>(); - expect(request.searchParams).toBeInstanceOf(HttpSearchParams); - - return { - status: 200, - body: users, - }; - }), - interceptor, - ); - expect(listHandler).toBeInstanceOf(Handler); - - let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(0); - - const searchParams = new HttpSearchParams({ - name: 'User 1', - orderBy: ['createdAt', 'name'], - }); - - const listResponse = await fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { method: 'GET' }); - expect(listResponse.status).toBe(200); - - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); - - searchParams.delete('orderBy'); - - let listResponsePromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { method: 'GET' }); - await expectFetchError(listResponsePromise); - - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); - - searchParams.append('orderBy', 'name'); - searchParams.set('name', 'User 2'); - - listResponsePromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { method: 'GET' }); - await expectFetchError(listResponsePromise); - - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); - }); - }); - - it('should support intercepting GET requests with a dynamic path', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - GET: { - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const genericGetHandler = await promiseIfRemote( - interceptor.get('/users/:id').respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - expect(genericGetHandler).toBeInstanceOf(Handler); - - let genericGetRequests = await promiseIfRemote(genericGetHandler.requests(), interceptor); - expect(genericGetRequests).toHaveLength(0); - - const genericGetResponse = await fetch(joinURL(baseURL, `/users/${1}`), { method: 'GET' }); - expect(genericGetResponse.status).toBe(200); - - const genericFetchedUser = (await genericGetResponse.json()) as User; - expect(genericFetchedUser).toEqual(users[0]); - - genericGetRequests = await promiseIfRemote(genericGetHandler.requests(), interceptor); - expect(genericGetRequests).toHaveLength(1); - const [genericGetRequest] = genericGetRequests; - expect(genericGetRequest).toBeInstanceOf(Request); - - expectTypeOf(genericGetRequest.body).toEqualTypeOf(); - expect(genericGetRequest.body).toBe(null); - - expectTypeOf(genericGetRequest.response.status).toEqualTypeOf<200>(); - expect(genericGetRequest.response.status).toEqual(200); - - expectTypeOf(genericGetRequest.response.body).toEqualTypeOf(); - expect(genericGetRequest.response.body).toEqual(users[0]); - - await promiseIfRemote(genericGetHandler.bypass(), interceptor); - - const specificGetHandler = await promiseIfRemote( - interceptor.get(`/users/${1}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - expect(specificGetHandler).toBeInstanceOf(Handler); - - let specificGetRequests = await promiseIfRemote(specificGetHandler.requests(), interceptor); - expect(specificGetRequests).toHaveLength(0); - - const specificGetResponse = await fetch(joinURL(baseURL, `/users/${1}`), { method: 'GET' }); - expect(specificGetResponse.status).toBe(200); - - const specificFetchedUser = (await specificGetResponse.json()) as User; - expect(specificFetchedUser).toEqual(users[0]); - - specificGetRequests = await promiseIfRemote(specificGetHandler.requests(), interceptor); - expect(specificGetRequests).toHaveLength(1); - const [specificGetRequest] = specificGetRequests; - expect(specificGetRequest).toBeInstanceOf(Request); - - expectTypeOf(specificGetRequest.body).toEqualTypeOf(); - expect(specificGetRequest.body).toBe(null); - - expectTypeOf(specificGetRequest.response.status).toEqualTypeOf<200>(); - expect(specificGetRequest.response.status).toEqual(200); - - expectTypeOf(specificGetRequest.response.body).toEqualTypeOf(); - expect(specificGetRequest.response.body).toEqual(users[0]); - - const unmatchedGetPromise = fetch(joinURL(baseURL, `/users/${2}`), { method: 'GET' }); - await expectFetchError(unmatchedGetPromise); - }); - }); - it('should not intercept a GET request without a registered response', async () => { await usingHttpInterceptor<{ '/users': { @@ -669,389 +430,997 @@ export async function declareGetHttpInterceptorTests(options: RuntimeSharedHttpI }); }); - it('should ignore handlers with bypassed responses when intercepting GET requests', async () => { - type ServerErrorResponseBody = JSONValue<{ - message: string; - }>; - - await usingHttpInterceptor<{ - '/users': { - GET: { - response: { - 200: { body: User[] }; - 500: { body: ServerErrorResponseBody }; + describe('Dynamic paths', () => { + it('should support intercepting GET requests with a dynamic path', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + GET: { + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const listHandler = await promiseIfRemote( - interceptor - .get('/users') - .respond({ + }>(interceptorOptions, async (interceptor) => { + const genericGetHandler = await promiseIfRemote( + interceptor.get('/users/:id').respond({ status: 200, - body: users, - }) - .bypass(), - interceptor, - ); - - let initialListRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(initialListRequests).toHaveLength(0); + body: users[0], + }), + interceptor, + ); + expect(genericGetHandler).toBeInstanceOf(Handler); - const listPromise = fetch(joinURL(baseURL, '/users'), { method: 'GET' }); - await expectFetchError(listPromise); + let genericGetRequests = await promiseIfRemote(genericGetHandler.requests(), interceptor); + expect(genericGetRequests).toHaveLength(0); - await promiseIfRemote( - listHandler.respond({ - status: 200, - body: [], - }), - interceptor, - ); + const genericGetResponse = await fetch(joinURL(baseURL, `/users/${1}`), { method: 'GET' }); + expect(genericGetResponse.status).toBe(200); - initialListRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(initialListRequests).toHaveLength(0); - let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(0); + const genericFetchedUser = (await genericGetResponse.json()) as User; + expect(genericFetchedUser).toEqual(users[0]); - let listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); - expect(listResponse.status).toBe(200); + genericGetRequests = await promiseIfRemote(genericGetHandler.requests(), interceptor); + expect(genericGetRequests).toHaveLength(1); + const [genericGetRequest] = genericGetRequests; + expect(genericGetRequest).toBeInstanceOf(Request); - let fetchedUsers = (await listResponse.json()) as User[]; - expect(fetchedUsers).toEqual([]); + expectTypeOf(genericGetRequest.body).toEqualTypeOf(); + expect(genericGetRequest.body).toBe(null); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); - let [listRequest] = listRequests; - expect(listRequest).toBeInstanceOf(Request); + expectTypeOf(genericGetRequest.response.status).toEqualTypeOf<200>(); + expect(genericGetRequest.response.status).toEqual(200); - expectTypeOf(listRequest.body).toEqualTypeOf(); - expect(listRequest.body).toBe(null); + expectTypeOf(genericGetRequest.response.body).toEqualTypeOf(); + expect(genericGetRequest.response.body).toEqual(users[0]); - expectTypeOf(listRequest.response.status).toEqualTypeOf<200>(); - expect(listRequest.response.status).toEqual(200); + await promiseIfRemote(genericGetHandler.bypass(), interceptor); - expectTypeOf(listRequest.response.body).toEqualTypeOf(); - expect(listRequest.response.body).toEqual([]); + const specificGetHandler = await promiseIfRemote( + interceptor.get(`/users/${1}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + expect(specificGetHandler).toBeInstanceOf(Handler); - const errorListHandler = await promiseIfRemote( - interceptor.get('/users').respond({ - status: 500, - body: { message: 'Internal server error' }, - }), - interceptor, - ); + let specificGetRequests = await promiseIfRemote(specificGetHandler.requests(), interceptor); + expect(specificGetRequests).toHaveLength(0); - let errorListRequests = await promiseIfRemote(errorListHandler.requests(), interceptor); - expect(errorListRequests).toHaveLength(0); + const specificGetResponse = await fetch(joinURL(baseURL, `/users/${1}`), { method: 'GET' }); + expect(specificGetResponse.status).toBe(200); - const otherListResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); - expect(otherListResponse.status).toBe(500); + const specificFetchedUser = (await specificGetResponse.json()) as User; + expect(specificFetchedUser).toEqual(users[0]); - const serverError = (await otherListResponse.json()) as ServerErrorResponseBody; - expect(serverError).toEqual({ message: 'Internal server error' }); + specificGetRequests = await promiseIfRemote(specificGetHandler.requests(), interceptor); + expect(specificGetRequests).toHaveLength(1); + const [specificGetRequest] = specificGetRequests; + expect(specificGetRequest).toBeInstanceOf(Request); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); + expectTypeOf(specificGetRequest.body).toEqualTypeOf(); + expect(specificGetRequest.body).toBe(null); - errorListRequests = await promiseIfRemote(errorListHandler.requests(), interceptor); - expect(errorListRequests).toHaveLength(1); - const [errorListRequest] = errorListRequests; - expect(errorListRequest).toBeInstanceOf(Request); + expectTypeOf(specificGetRequest.response.status).toEqualTypeOf<200>(); + expect(specificGetRequest.response.status).toEqual(200); - expectTypeOf(errorListRequest.body).toEqualTypeOf(); - expect(errorListRequest.body).toBe(null); + expectTypeOf(specificGetRequest.response.body).toEqualTypeOf(); + expect(specificGetRequest.response.body).toEqual(users[0]); - expectTypeOf(errorListRequest.response.status).toEqualTypeOf<500>(); - expect(errorListRequest.response.status).toEqual(500); + const unmatchedGetPromise = fetch(joinURL(baseURL, `/users/${2}`), { method: 'GET' }); + await expectFetchError(unmatchedGetPromise); + }); + }); + }); - expectTypeOf(errorListRequest.response.body).toEqualTypeOf(); - expect(errorListRequest.response.body).toEqual({ message: 'Internal server error' }); + describe('Restrictions', () => { + it('should support intercepting GET requests having headers restrictions', async () => { + type UserListHeaders = HttpSchema.Headers<{ + 'content-type'?: string; + accept?: string; + }>; + + await usingHttpInterceptor<{ + '/users': { + GET: { + request: { + headers: UserListHeaders; + }; + response: { + 200: { body: User[] }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor + .get('/users') + .with({ + headers: { 'content-type': 'application/json' }, + }) + .with((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); + + return request.headers.get('accept')?.includes('application/json') ?? false; + }) + .respond((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); + + return { + status: 200, + body: users, + }; + }), + interceptor, + ); + expect(listHandler).toBeInstanceOf(Handler); + + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); + + const headers = new HttpHeaders({ + 'content-type': 'application/json', + accept: 'application/json', + }); - await promiseIfRemote(errorListHandler.bypass(), interceptor); + let listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET', headers }); + expect(listResponse.status).toBe(200); - listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); - expect(listResponse.status).toBe(200); + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); - fetchedUsers = (await listResponse.json()) as User[]; - expect(fetchedUsers).toEqual([]); + headers.append('accept', 'application/xml'); - errorListRequests = await promiseIfRemote(errorListHandler.requests(), interceptor); - expect(errorListRequests).toHaveLength(1); + listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET', headers }); + expect(listResponse.status).toBe(200); + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(2); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(2); - [listRequest] = listRequests; - expect(listRequest).toBeInstanceOf(Request); + headers.delete('accept'); - expectTypeOf(listRequest.body).toEqualTypeOf(); - expect(listRequest.body).toBe(null); + let listResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'GET', headers }); + await expectFetchError(listResponsePromise); + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(2); - expectTypeOf(listRequest.response.status).toEqualTypeOf<200>(); - expect(listRequest.response.status).toEqual(200); + headers.set('accept', 'application/json'); + headers.set('content-type', 'text/plain'); - expectTypeOf(listRequest.response.body).toEqualTypeOf(); - expect(listRequest.response.body).toEqual([]); + listResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'GET', headers }); + await expectFetchError(listResponsePromise); + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(2); + }); }); - }); - it('should ignore all handlers after cleared when intercepting GET requests', async () => { - await usingHttpInterceptor<{ - '/users': { - GET: { - response: { - 200: { body: User[] }; + it('should support intercepting GET requests having search params restrictions', async () => { + type UserListSearchParams = HttpSchema.SearchParams<{ + name?: string; + orderBy?: ('name' | 'createdAt')[]; + page?: `${number}`; + }>; + + await usingHttpInterceptor<{ + '/users': { + GET: { + request: { + searchParams: UserListSearchParams; + }; + response: { + 200: { body: User[] }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const listHandler = await promiseIfRemote( - interceptor.get('/users').respond({ - status: 200, - body: users, - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor + .get('/users') + .with({ + searchParams: { + name: 'User 1', + }, + }) + .with((request) => { + expectTypeOf(request.searchParams).toEqualTypeOf>(); + expect(request.searchParams).toBeInstanceOf(HttpSearchParams); + + return request.searchParams.getAll('orderBy').length > 0; + }) + .respond((request) => { + expectTypeOf(request.searchParams).toEqualTypeOf>(); + expect(request.searchParams).toBeInstanceOf(HttpSearchParams); + + return { + status: 200, + body: users, + }; + }), + interceptor, + ); + expect(listHandler).toBeInstanceOf(Handler); + + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); + + const searchParams = new HttpSearchParams({ + name: 'User 1', + orderBy: ['createdAt', 'name'], + }); - let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(0); + const listResponse = await fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { method: 'GET' }); + expect(listResponse.status).toBe(200); - const listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); - expect(listResponse.status).toBe(200); + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); + searchParams.delete('orderBy'); - await promiseIfRemote(interceptor.clear(), interceptor); + let listResponsePromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { method: 'GET' }); + await expectFetchError(listResponsePromise); - const listPromise = fetch(joinURL(baseURL, '/users'), { method: 'GET' }); - await expectFetchError(listPromise); + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); + searchParams.append('orderBy', 'name'); + searchParams.set('name', 'User 2'); + + listResponsePromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { method: 'GET' }); + await expectFetchError(listResponsePromise); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + }); }); }); - it('should ignore all handlers after restarted when intercepting GET requests', async () => { - await usingHttpInterceptor<{ - '/users': { - GET: { - response: { - 200: { body: User[] }; + describe('Bypass', () => { + it('should ignore handlers with bypassed responses when intercepting GET requests', async () => { + type ServerErrorResponseBody = JSONValue<{ + message: string; + }>; + + await usingHttpInterceptor<{ + '/users': { + GET: { + response: { + 200: { body: User[] }; + 500: { body: ServerErrorResponseBody }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const listHandler = await promiseIfRemote( - interceptor.get('/users').respond({ - status: 200, - body: users, - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor + .get('/users') + .respond({ + status: 200, + body: users, + }) + .bypass(), + interceptor, + ); - let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(0); + let initialListRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(initialListRequests).toHaveLength(0); - const listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); - expect(listResponse.status).toBe(200); + const listResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'GET' }); + await expectFetchError(listResponsePromise); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); + await promiseIfRemote( + listHandler.respond({ + status: 200, + body: [], + }), + interceptor, + ); - expect(interceptor.isRunning()).toBe(true); - await interceptor.stop(); - expect(interceptor.isRunning()).toBe(false); + initialListRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(initialListRequests).toHaveLength(0); + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); - let listPromise = fetchWithTimeout(joinURL(baseURL, '/users'), { - method: 'GET', - timeout: 200, + let listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); + expect(listResponse.status).toBe(200); + + let fetchedUsers = (await listResponse.json()) as User[]; + expect(fetchedUsers).toEqual([]); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + let [listRequest] = listRequests; + expect(listRequest).toBeInstanceOf(Request); + + expectTypeOf(listRequest.body).toEqualTypeOf(); + expect(listRequest.body).toBe(null); + + expectTypeOf(listRequest.response.status).toEqualTypeOf<200>(); + expect(listRequest.response.status).toEqual(200); + + expectTypeOf(listRequest.response.body).toEqualTypeOf(); + expect(listRequest.response.body).toEqual([]); + + const errorListHandler = await promiseIfRemote( + interceptor.get('/users').respond({ + status: 500, + body: { message: 'Internal server error' }, + }), + interceptor, + ); + + let errorListRequests = await promiseIfRemote(errorListHandler.requests(), interceptor); + expect(errorListRequests).toHaveLength(0); + + const otherListResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); + expect(otherListResponse.status).toBe(500); + + const serverError = (await otherListResponse.json()) as ServerErrorResponseBody; + expect(serverError).toEqual({ message: 'Internal server error' }); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + errorListRequests = await promiseIfRemote(errorListHandler.requests(), interceptor); + expect(errorListRequests).toHaveLength(1); + const [errorListRequest] = errorListRequests; + expect(errorListRequest).toBeInstanceOf(Request); + + expectTypeOf(errorListRequest.body).toEqualTypeOf(); + expect(errorListRequest.body).toBe(null); + + expectTypeOf(errorListRequest.response.status).toEqualTypeOf<500>(); + expect(errorListRequest.response.status).toEqual(500); + + expectTypeOf(errorListRequest.response.body).toEqualTypeOf(); + expect(errorListRequest.response.body).toEqual({ message: 'Internal server error' }); + + await promiseIfRemote(errorListHandler.bypass(), interceptor); + + listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); + expect(listResponse.status).toBe(200); + + fetchedUsers = (await listResponse.json()) as User[]; + expect(fetchedUsers).toEqual([]); + + errorListRequests = await promiseIfRemote(errorListHandler.requests(), interceptor); + expect(errorListRequests).toHaveLength(1); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(2); + [listRequest] = listRequests; + expect(listRequest).toBeInstanceOf(Request); + + expectTypeOf(listRequest.body).toEqualTypeOf(); + expect(listRequest.body).toBe(null); + + expectTypeOf(listRequest.response.status).toEqualTypeOf<200>(); + expect(listRequest.response.status).toEqual(200); + + expectTypeOf(listRequest.response.body).toEqualTypeOf(); + expect(listRequest.response.body).toEqual([]); }); - await expectFetchError(listPromise, { canBeAborted: true }); + }); + }); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); + describe('Clear', () => { + it('should ignore all handlers after cleared when intercepting GET requests', async () => { + await usingHttpInterceptor<{ + '/users': { + GET: { + response: { + 200: { body: User[] }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor.get('/users').respond({ + status: 200, + body: users, + }), + interceptor, + ); - await interceptor.start(); - expect(interceptor.isRunning()).toBe(true); + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); - listPromise = fetch(joinURL(baseURL, '/users'), { method: 'GET' }); - await expectFetchError(listPromise); + const listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); + expect(listResponse.status).toBe(200); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + await promiseIfRemote(interceptor.clear(), interceptor); + + const listResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'GET' }); + await expectFetchError(listResponsePromise); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + }); + }); + + it('should support creating new handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/users': { + GET: { + response: { + 200: { body: User[] }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + let listHandler = await promiseIfRemote( + interceptor.get('/users').respond({ + status: 200, + body: users, + }), + interceptor, + ); + + await promiseIfRemote(interceptor.clear(), interceptor); + + listHandler = await promiseIfRemote( + interceptor.get('/users').respond({ + status: 200, + body: [], + }), + interceptor, + ); + + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); + + const listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); + expect(listResponse.status).toBe(200); + + const fetchedUsers = (await listResponse.json()) as User[]; + expect(fetchedUsers).toEqual([]); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + const [listRequest] = listRequests; + expect(listRequest).toBeInstanceOf(Request); + + expectTypeOf(listRequest.body).toEqualTypeOf(); + expect(listRequest.body).toBe(null); + + expectTypeOf(listRequest.response.status).toEqualTypeOf<200>(); + expect(listRequest.response.status).toEqual(200); + + expectTypeOf(listRequest.response.body).toEqualTypeOf(); + expect(listRequest.response.body).toEqual([]); + }); + }); + + it('should support reusing previous handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/users': { + GET: { + response: { + 200: { body: User[] }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor.get('/users').respond({ + status: 200, + body: users, + }), + interceptor, + ); + + await promiseIfRemote(interceptor.clear(), interceptor); + + await promiseIfRemote( + listHandler.respond({ + status: 200, + body: [], + }), + interceptor, + ); + + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); + + const listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); + expect(listResponse.status).toBe(200); + + const fetchedUsers = (await listResponse.json()) as User[]; + expect(fetchedUsers).toEqual([]); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + const [listRequest] = listRequests; + expect(listRequest).toBeInstanceOf(Request); + + expectTypeOf(listRequest.body).toEqualTypeOf(); + expect(listRequest.body).toBe(null); + + expectTypeOf(listRequest.response.status).toEqualTypeOf<200>(); + expect(listRequest.response.status).toEqual(200); + + expectTypeOf(listRequest.response.body).toEqualTypeOf(); + expect(listRequest.response.body).toEqual([]); + }); }); }); - it('should ignore all handlers after restarted when intercepting GET requests, even if another interceptor is still running', async () => { - await usingHttpInterceptor<{ - '/users': { - GET: { - response: { - 200: { body: User[] }; + describe('Life cycle', () => { + it('should ignore all handlers after restarted when intercepting GET requests', async () => { + await usingHttpInterceptor<{ + '/users': { + GET: { + response: { + 200: { body: User[] }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const listHandler = await promiseIfRemote( - interceptor.get('/users').respond({ - status: 200, - body: users, - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor.get('/users').respond({ + status: 200, + body: users, + }), + interceptor, + ); - let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(0); + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); - const listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); - expect(listResponse.status).toBe(200); + const listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); + expect(listResponse.status).toBe(200); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); - await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); - await interceptor.stop(); expect(interceptor.isRunning()).toBe(false); - expect(otherInterceptor.isRunning()).toBe(true); - let listPromise = fetchWithTimeout(joinURL(baseURL, '/users'), { + let listResponsePromise = fetchWithTimeout(joinURL(baseURL, '/users'), { method: 'GET', timeout: 200, }); - await expectFetchError(listPromise, { canBeAborted: true }); + await expectFetchError(listResponsePromise, { canBeAborted: true }); listRequests = await promiseIfRemote(listHandler.requests(), interceptor); expect(listRequests).toHaveLength(1); await interceptor.start(); expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); - listPromise = fetch(joinURL(baseURL, '/users'), { method: 'GET' }); - await expectFetchError(listPromise); + listResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'GET' }); + await expectFetchError(listResponsePromise); listRequests = await promiseIfRemote(listHandler.requests(), interceptor); expect(listRequests).toHaveLength(1); }); }); - }); - - it('should throw an error when trying to create a GET request handler if not running', async () => { - const interceptor = createInternalHttpInterceptor(interceptorOptions); - expect(interceptor.isRunning()).toBe(false); - await expect(async () => { - await interceptor.get('/'); - }).rejects.toThrowError(new NotStartedHttpInterceptorError()); - }); - - it('should support creating new handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/users': { - GET: { - response: { - 200: { body: User[] }; + it('should ignore all handlers after restarted when intercepting GET requests, even if another interceptor is still running', async () => { + await usingHttpInterceptor<{ + '/users': { + GET: { + response: { + 200: { body: User[] }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - let listHandler = await promiseIfRemote( - interceptor.get('/users').respond({ - status: 200, - body: users, - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor.get('/users').respond({ + status: 200, + body: users, + }), + interceptor, + ); - await promiseIfRemote(interceptor.clear(), interceptor); + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); - listHandler = await promiseIfRemote( - interceptor.get('/users').respond({ - status: 200, - body: [], - }), - interceptor, - ); + const listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); + expect(listResponse.status).toBe(200); - let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(0); + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); - const listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); - expect(listResponse.status).toBe(200); + await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); - const fetchedUsers = (await listResponse.json()) as User[]; - expect(fetchedUsers).toEqual([]); + await interceptor.stop(); + expect(interceptor.isRunning()).toBe(false); + expect(otherInterceptor.isRunning()).toBe(true); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); - const [listRequest] = listRequests; - expect(listRequest).toBeInstanceOf(Request); + let listResponsePromise = fetchWithTimeout(joinURL(baseURL, '/users'), { + method: 'GET', + timeout: 200, + }); + await expectFetchError(listResponsePromise, { canBeAborted: true }); - expectTypeOf(listRequest.body).toEqualTypeOf(); - expect(listRequest.body).toBe(null); + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); - expectTypeOf(listRequest.response.status).toEqualTypeOf<200>(); - expect(listRequest.response.status).toEqual(200); + await interceptor.start(); + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); - expectTypeOf(listRequest.response.body).toEqualTypeOf(); - expect(listRequest.response.body).toEqual([]); + listResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'GET' }); + await expectFetchError(listResponsePromise); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + }); + }); + }); + + it('should throw an error when trying to create a GET request handler if not running', async () => { + const interceptor = createInternalHttpInterceptor(interceptorOptions); + expect(interceptor.isRunning()).toBe(false); + + await expect(async () => { + await interceptor.get('/'); + }).rejects.toThrowError(new NotStartedHttpInterceptorError()); }); }); - it('should support reusing previous handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/users': { - GET: { - response: { - 200: { body: User[] }; + describe('Unhandled requests', () => { + if (type === 'local') { + it('should show a warning when logging is enabled and a GET request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/users': { + GET: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User[] }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor + .get('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users, + }), + interceptor, + ); + + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const listResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '1' }, + }); + expect(listResponse.status).toBe(200); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const listRequest = new Request(joinURL(baseURL, '/users'), { method: 'GET' }); + const listResponsePromise = fetch(listRequest); + await expectFetchError(listResponsePromise); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: listRequest, + }); + }); + }); + }); + } + + if (type === 'remote') { + it('should show an error when logging is enabled and a GET request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/users': { + GET: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User[] }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor + .get('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users, + }), + interceptor, + ); + + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const listResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '1' }, + }); + expect(listResponse.status).toBe(200); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const listRequest = new Request(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '2' }, + }); + const listResponsePromise = fetch(listRequest); + await expectFetchError(listResponsePromise); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: listRequest, + }); + }); + }); + }); + } + + it('should support a custom unhandled GET request handler', async () => { + const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { + const url = new URL(request.url); + + if (!url.searchParams.has('name')) { + await context.log(); + } + }); + + await usingHttpInterceptor<{ + '/users': { + GET: { + request: { + headers: { 'x-value': string }; + searchParams: { name?: string }; + }; + response: { + 200: { body: User[] }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const listHandler = await promiseIfRemote( - interceptor.get('/users').respond({ - status: 200, - body: users, - }), - interceptor, - ); + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor + .get('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users, + }), + interceptor, + ); + + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const listResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '1' }, + }); + expect(listResponse.status).toBe(200); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const searchParams = new HttpSearchParams({ name: 'User 1' }); + + let listResponsePromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { + method: 'GET', + headers: { 'x-value': '2' }, + }); + await expectFetchError(listResponsePromise); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const listRequest = new Request(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '2' }, + }); + listResponsePromise = fetch(listRequest); + await expectFetchError(listResponsePromise); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(2); + + const messageType = type === 'local' ? 'warn' : 'error'; + expect(spies.warn).toHaveBeenCalledTimes(messageType === 'warn' ? 1 : 0); + expect(spies.error).toHaveBeenCalledTimes(messageType === 'error' ? 1 : 0); + + const errorMessage = spies[messageType].mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: messageType, + platform, + request: listRequest, + }); + }); + }); + }); - await promiseIfRemote(interceptor.clear(), interceptor); + it('should log an error if a custom unhandled GET request handler throws', async () => { + const error = new Error('Unhandled request.'); - await promiseIfRemote( - listHandler.respond({ - status: 200, - body: [], - }), - interceptor, - ); + const onUnhandledRequest = vi.fn((request: Request) => { + const url = new URL(request.url); - let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(0); + if (!url.searchParams.has('name')) { + throw error; + } + }); - const listResponse = await fetch(joinURL(baseURL, '/users'), { method: 'GET' }); - expect(listResponse.status).toBe(200); + await usingHttpInterceptor<{ + '/users': { + GET: { + request: { + headers: { 'x-value': string }; + searchParams: { name?: string }; + }; + response: { + 200: { body: User[] }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor + .get('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users, + }), + interceptor, + ); - const fetchedUsers = (await listResponse.json()) as User[]; - expect(fetchedUsers).toEqual([]); + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); - const [listRequest] = listRequests; - expect(listRequest).toBeInstanceOf(Request); + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const listResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '1' }, + }); + expect(listResponse.status).toBe(200); - expectTypeOf(listRequest.body).toEqualTypeOf(); - expect(listRequest.body).toBe(null); + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); - expectTypeOf(listRequest.response.status).toEqualTypeOf<200>(); - expect(listRequest.response.status).toEqual(200); + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); - expectTypeOf(listRequest.response.body).toEqualTypeOf(); - expect(listRequest.response.body).toEqual([]); + const searchParams = new HttpSearchParams({ name: 'User 1' }); + + let listResponsePromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { + method: 'GET', + headers: { 'x-value': '2' }, + }); + await expectFetchError(listResponsePromise); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const listRequest = new Request(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '2' }, + }); + listResponsePromise = fetch(listRequest); + await expectFetchError(listResponsePromise); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(2); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + expect(spies.error).toHaveBeenCalledWith(error); + }); + }); + }); + + it('should not show a warning or error when logging is disabled and a GET request is unhandled', async () => { + await usingHttpInterceptor<{ + '/users': { + GET: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User[] }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor + .get('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users, + }), + interceptor, + ); + + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const listResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '1' }, + }); + expect(listResponse.status).toBe(200); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const listRequest = new Request(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '2' }, + }); + const listResponsePromise = fetch(listRequest); + await expectFetchError(listResponsePromise); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + }); + }); }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/head.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/head.ts index 1c03e8af..19be3d03 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/head.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/head.ts @@ -1,4 +1,4 @@ -import { beforeEach, expect, expectTypeOf, it } from 'vitest'; +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; @@ -8,15 +8,17 @@ import LocalHttpRequestHandler from '@/interceptor/http/requestHandler/LocalHttp import RemoteHttpRequestHandler from '@/interceptor/http/requestHandler/RemoteHttpRequestHandler'; import { fetchWithTimeout } from '@/utils/fetch'; import { joinURL } from '@/utils/urls'; +import { usingIgnoredConsole } from '@tests/utils/console'; import { expectFetchError } from '@tests/utils/fetch'; import { createInternalHttpInterceptor, usingHttpInterceptor } from '@tests/utils/interceptors'; import NotStartedHttpInterceptorError from '../../../errors/NotStartedHttpInterceptorError'; -import { HttpInterceptorOptions } from '../../../types/options'; +import { HttpInterceptorOptions, UnhandledRequestStrategy } from '../../../types/options'; import { RuntimeSharedHttpInterceptorTestsOptions } from '../types'; +import { verifyUnhandledRequestMessage } from '../utils'; export function declareHeadHttpInterceptorTests(options: RuntimeSharedHttpInterceptorTestsOptions) { - const { getBaseURL, getInterceptorOptions } = options; + const { platform, type, getBaseURL, getInterceptorOptions } = options; let baseURL: URL; let interceptorOptions: HttpInterceptorOptions; @@ -280,211 +282,6 @@ export function declareHeadHttpInterceptorTests(options: RuntimeSharedHttpInterc }); }); - it('should support intercepting HEAD requests having headers restrictions', async () => { - type UserHeadHeaders = HttpSchema.Headers<{ - 'content-type'?: string; - accept?: string; - }>; - - await usingHttpInterceptor<{ - '/users': { - HEAD: { - request: { - headers: UserHeadHeaders; - }; - response: { - 200: {}; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const headHandler = interceptor - .head('/users') - .with({ - headers: { 'content-type': 'application/json' }, - }) - .with((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - return request.headers.get('accept')?.includes('application/json') ?? false; - }) - .respond((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - return { - status: 200, - }; - }); - expect(headHandler).toBeInstanceOf(Handler); - - let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(0); - - const headers = new HttpHeaders({ - 'content-type': 'application/json', - accept: 'application/json', - }); - - let headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD', headers }); - expect(headResponse.status).toBe(200); - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); - - headers.append('accept', 'application/xml'); - - headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD', headers }); - expect(headResponse.status).toBe(200); - - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(2); - - headers.delete('accept'); - - let headResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'HEAD', headers }); - await expectFetchError(headResponsePromise); - - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(2); - - headers.set('accept', 'application/json'); - headers.set('content-type', 'text/plain'); - - headResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'HEAD', headers }); - await expectFetchError(headResponsePromise); - - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(2); - }); - }); - - it('should support intercepting HEAD requests having search params restrictions', async () => { - type UserHeadSearchParams = HttpSchema.SearchParams<{ - tag?: string; - }>; - - await usingHttpInterceptor<{ - '/users': { - HEAD: { - request: { - searchParams: UserHeadSearchParams; - }; - response: { - 200: {}; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const headHandler = interceptor - .head('/users') - .with({ - searchParams: { tag: 'admin' }, - }) - .respond((request) => { - expectTypeOf(request.searchParams).toEqualTypeOf>(); - expect(request.searchParams).toBeInstanceOf(HttpSearchParams); - - return { - status: 200, - }; - }); - expect(headHandler).toBeInstanceOf(Handler); - - let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(0); - - const searchParams = new HttpSearchParams({ - tag: 'admin', - }); - - const headResponse = await fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { method: 'HEAD' }); - expect(headResponse.status).toBe(200); - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); - - searchParams.delete('tag'); - - const headResponsePromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { method: 'HEAD' }); - await expectFetchError(headResponsePromise); - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); - }); - }); - - it('should support intercepting HEAD requests with a dynamic path', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - HEAD: { - response: { - 200: {}; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const genericHeadHandler = await promiseIfRemote( - interceptor.head('/users/:id').respond({ - status: 200, - }), - interceptor, - ); - expect(genericHeadHandler).toBeInstanceOf(Handler); - - let genericHeadRequests = await promiseIfRemote(genericHeadHandler.requests(), interceptor); - expect(genericHeadRequests).toHaveLength(0); - - const genericHeadResponse = await fetch(joinURL(baseURL, `/users/${1}`), { method: 'HEAD' }); - expect(genericHeadResponse.status).toBe(200); - - genericHeadRequests = await promiseIfRemote(genericHeadHandler.requests(), interceptor); - expect(genericHeadRequests).toHaveLength(1); - const [genericHeadRequest] = genericHeadRequests; - expect(genericHeadRequest).toBeInstanceOf(Request); - - expectTypeOf(genericHeadRequest.body).toEqualTypeOf(); - expect(genericHeadRequest.body).toBe(null); - - expectTypeOf(genericHeadRequest.response.status).toEqualTypeOf<200>(); - expect(genericHeadRequest.response.status).toEqual(200); - - expectTypeOf(genericHeadRequest.response.body).toEqualTypeOf(); - expect(genericHeadRequest.response.body).toBe(null); - - await promiseIfRemote(genericHeadHandler.bypass(), interceptor); - - const specificHeadHandler = await promiseIfRemote( - interceptor.head(`/users/${1}`).respond({ - status: 200, - }), - interceptor, - ); - expect(specificHeadHandler).toBeInstanceOf(Handler); - - let specificHeadRequests = await promiseIfRemote(specificHeadHandler.requests(), interceptor); - expect(specificHeadRequests).toHaveLength(0); - - const specificHeadResponse = await fetch(joinURL(baseURL, `/users/${1}`), { method: 'HEAD' }); - expect(specificHeadResponse.status).toBe(200); - - specificHeadRequests = await promiseIfRemote(specificHeadHandler.requests(), interceptor); - expect(specificHeadRequests).toHaveLength(1); - const [specificHeadRequest] = specificHeadRequests; - expect(specificHeadRequest).toBeInstanceOf(Request); - - expectTypeOf(specificHeadRequest.body).toEqualTypeOf(); - expect(specificHeadRequest.body).toBe(null); - - expectTypeOf(specificHeadRequest.response.status).toEqualTypeOf<200>(); - expect(specificHeadRequest.response.status).toEqual(200); - - expectTypeOf(specificHeadRequest.response.body).toEqualTypeOf(); - expect(specificHeadRequest.response.body).toBe(null); - - const unmatchedHeadPromise = fetch(joinURL(baseURL, `/users/${2}`), { method: 'HEAD' }); - await expectFetchError(unmatchedHeadPromise); - }); - }); - it('should not intercept a HEAD request without a registered response', async () => { await usingHttpInterceptor<{ '/users': { @@ -622,241 +419,500 @@ export function declareHeadHttpInterceptorTests(options: RuntimeSharedHttpInterc }); }); - it('should ignore handlers with bypassed responses when intercepting HEAD requests', async () => { - await usingHttpInterceptor<{ - '/users': { - HEAD: { - response: { - 200: {}; - 204: {}; - 500: {}; + describe('Dynamic paths', () => { + it('should support intercepting HEAD requests with a dynamic path', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + HEAD: { + response: { + 200: {}; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const headHandler = await promiseIfRemote( - interceptor - .head('/users') - .respond({ + }>(interceptorOptions, async (interceptor) => { + const genericHeadHandler = await promiseIfRemote( + interceptor.head('/users/:id').respond({ status: 200, - }) - .bypass(), - interceptor, - ); + }), + interceptor, + ); + expect(genericHeadHandler).toBeInstanceOf(Handler); - let initialHeadRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(initialHeadRequests).toHaveLength(0); + let genericHeadRequests = await promiseIfRemote(genericHeadHandler.requests(), interceptor); + expect(genericHeadRequests).toHaveLength(0); - const headPromise = fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); - await expectFetchError(headPromise); + const genericHeadResponse = await fetch(joinURL(baseURL, `/users/${1}`), { method: 'HEAD' }); + expect(genericHeadResponse.status).toBe(200); - const noContentHeadHandler = await promiseIfRemote( - headHandler.respond({ - status: 204, - }), - interceptor, - ); + genericHeadRequests = await promiseIfRemote(genericHeadHandler.requests(), interceptor); + expect(genericHeadRequests).toHaveLength(1); + const [genericHeadRequest] = genericHeadRequests; + expect(genericHeadRequest).toBeInstanceOf(Request); - initialHeadRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(initialHeadRequests).toHaveLength(0); - let headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); - expect(headRequests).toHaveLength(0); + expectTypeOf(genericHeadRequest.body).toEqualTypeOf(); + expect(genericHeadRequest.body).toBe(null); - let headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); - expect(headResponse.status).toBe(204); + expectTypeOf(genericHeadRequest.response.status).toEqualTypeOf<200>(); + expect(genericHeadRequest.response.status).toEqual(200); - headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); - let [headRequest] = headRequests; - expect(headRequest).toBeInstanceOf(Request); + expectTypeOf(genericHeadRequest.response.body).toEqualTypeOf(); + expect(genericHeadRequest.response.body).toBe(null); - expectTypeOf(headRequest.body).toEqualTypeOf(); - expect(headRequest.body).toBe(null); + await promiseIfRemote(genericHeadHandler.bypass(), interceptor); - expectTypeOf(headRequest.response.status).toEqualTypeOf<204>(); - expect(headRequest.response.status).toEqual(204); + const specificHeadHandler = await promiseIfRemote( + interceptor.head(`/users/${1}`).respond({ + status: 200, + }), + interceptor, + ); + expect(specificHeadHandler).toBeInstanceOf(Handler); - expectTypeOf(headRequest.response.body).toEqualTypeOf(); - expect(headRequest.response.body).toBe(null); + let specificHeadRequests = await promiseIfRemote(specificHeadHandler.requests(), interceptor); + expect(specificHeadRequests).toHaveLength(0); - const errorHeadHandler = await promiseIfRemote( - interceptor.head('/users').respond({ - status: 500, - }), - interceptor, - ); + const specificHeadResponse = await fetch(joinURL(baseURL, `/users/${1}`), { method: 'HEAD' }); + expect(specificHeadResponse.status).toBe(200); - let errorHeadRequests = await promiseIfRemote(errorHeadHandler.requests(), interceptor); - expect(errorHeadRequests).toHaveLength(0); + specificHeadRequests = await promiseIfRemote(specificHeadHandler.requests(), interceptor); + expect(specificHeadRequests).toHaveLength(1); + const [specificHeadRequest] = specificHeadRequests; + expect(specificHeadRequest).toBeInstanceOf(Request); - const otherHeadResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); - expect(otherHeadResponse.status).toBe(500); + expectTypeOf(specificHeadRequest.body).toEqualTypeOf(); + expect(specificHeadRequest.body).toBe(null); - expect(await otherHeadResponse.text()).toBe(''); + expectTypeOf(specificHeadRequest.response.status).toEqualTypeOf<200>(); + expect(specificHeadRequest.response.status).toEqual(200); - headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); + expectTypeOf(specificHeadRequest.response.body).toEqualTypeOf(); + expect(specificHeadRequest.response.body).toBe(null); - errorHeadRequests = await promiseIfRemote(errorHeadHandler.requests(), interceptor); - expect(errorHeadRequests).toHaveLength(1); - const [errorHeadRequest] = errorHeadRequests; - expect(errorHeadRequest).toBeInstanceOf(Request); + const unmatchedHeadPromise = fetch(joinURL(baseURL, `/users/${2}`), { method: 'HEAD' }); + await expectFetchError(unmatchedHeadPromise); + }); + }); + }); - expectTypeOf(errorHeadRequest.body).toEqualTypeOf(); - expect(errorHeadRequest.body).toBe(null); + describe('Restrictions', () => { + it('should support intercepting HEAD requests having headers restrictions', async () => { + type UserHeadHeaders = HttpSchema.Headers<{ + 'content-type'?: string; + accept?: string; + }>; + + await usingHttpInterceptor<{ + '/users': { + HEAD: { + request: { + headers: UserHeadHeaders; + }; + response: { + 200: {}; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const headHandler = interceptor + .head('/users') + .with({ + headers: { 'content-type': 'application/json' }, + }) + .with((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); - expectTypeOf(errorHeadRequest.response.status).toEqualTypeOf<500>(); - expect(errorHeadRequest.response.status).toEqual(500); + return request.headers.get('accept')?.includes('application/json') ?? false; + }) + .respond((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); - expectTypeOf(errorHeadRequest.response.body).toEqualTypeOf(); - expect(errorHeadRequest.response.body).toBe(null); + return { + status: 200, + }; + }); + expect(headHandler).toBeInstanceOf(Handler); - await promiseIfRemote(errorHeadHandler.bypass(), interceptor); + let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); - headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); - expect(headResponse.status).toBe(204); + const headers = new HttpHeaders({ + 'content-type': 'application/json', + accept: 'application/json', + }); - errorHeadRequests = await promiseIfRemote(errorHeadHandler.requests(), interceptor); - expect(errorHeadRequests).toHaveLength(1); + let headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD', headers }); + expect(headResponse.status).toBe(200); + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); - headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); - expect(headRequests).toHaveLength(2); - [headRequest] = headRequests; - expect(headRequest).toBeInstanceOf(Request); + headers.append('accept', 'application/xml'); - expectTypeOf(headRequest.body).toEqualTypeOf(); - expect(headRequest.body).toBe(null); + headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD', headers }); + expect(headResponse.status).toBe(200); - expectTypeOf(headRequest.response.status).toEqualTypeOf<204>(); - expect(headRequest.response.status).toEqual(204); + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(2); - expectTypeOf(headRequest.response.body).toEqualTypeOf(); - expect(headRequest.response.body).toBe(null); + headers.delete('accept'); + + let headResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'HEAD', headers }); + await expectFetchError(headResponsePromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(2); + + headers.set('accept', 'application/json'); + headers.set('content-type', 'text/plain'); + + headResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'HEAD', headers }); + await expectFetchError(headResponsePromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(2); + }); + }); + + it('should support intercepting HEAD requests having search params restrictions', async () => { + type UserHeadSearchParams = HttpSchema.SearchParams<{ + tag?: string; + }>; + + await usingHttpInterceptor<{ + '/users': { + HEAD: { + request: { + searchParams: UserHeadSearchParams; + }; + response: { + 200: {}; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const headHandler = interceptor + .head('/users') + .with({ + searchParams: { tag: 'admin' }, + }) + .respond((request) => { + expectTypeOf(request.searchParams).toEqualTypeOf>(); + expect(request.searchParams).toBeInstanceOf(HttpSearchParams); + + return { + status: 200, + }; + }); + expect(headHandler).toBeInstanceOf(Handler); + + let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); + + const searchParams = new HttpSearchParams({ + tag: 'admin', + }); + + const headResponse = await fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { method: 'HEAD' }); + expect(headResponse.status).toBe(200); + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + searchParams.delete('tag'); + + const headResponsePromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { method: 'HEAD' }); + await expectFetchError(headResponsePromise); + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + }); }); }); - it('should ignore all handlers after cleared when intercepting HEAD requests', async () => { - await usingHttpInterceptor<{ - '/users': { - HEAD: { - response: { - 200: {}; - 204: {}; + describe('Bypass', () => { + it('should ignore handlers with bypassed responses when intercepting HEAD requests', async () => { + await usingHttpInterceptor<{ + '/users': { + HEAD: { + response: { + 200: {}; + 204: {}; + 500: {}; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const headHandler = await promiseIfRemote( - interceptor.head('/users').respond({ - status: 200, - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const headHandler = await promiseIfRemote( + interceptor + .head('/users') + .respond({ + status: 200, + }) + .bypass(), + interceptor, + ); + + let initialHeadRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(initialHeadRequests).toHaveLength(0); + + const headPromise = fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); + await expectFetchError(headPromise); - let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(0); + const noContentHeadHandler = await promiseIfRemote( + headHandler.respond({ + status: 204, + }), + interceptor, + ); - const headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); - expect(headResponse.status).toBe(200); + initialHeadRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(initialHeadRequests).toHaveLength(0); + let headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); + let headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); + expect(headResponse.status).toBe(204); + + headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + let [headRequest] = headRequests; + expect(headRequest).toBeInstanceOf(Request); - await promiseIfRemote(interceptor.clear(), interceptor); + expectTypeOf(headRequest.body).toEqualTypeOf(); + expect(headRequest.body).toBe(null); - const headPromise = fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); - await expectFetchError(headPromise); + expectTypeOf(headRequest.response.status).toEqualTypeOf<204>(); + expect(headRequest.response.status).toEqual(204); - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); + expectTypeOf(headRequest.response.body).toEqualTypeOf(); + expect(headRequest.response.body).toBe(null); + + const errorHeadHandler = await promiseIfRemote( + interceptor.head('/users').respond({ + status: 500, + }), + interceptor, + ); + + let errorHeadRequests = await promiseIfRemote(errorHeadHandler.requests(), interceptor); + expect(errorHeadRequests).toHaveLength(0); + + const otherHeadResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); + expect(otherHeadResponse.status).toBe(500); + + expect(await otherHeadResponse.text()).toBe(''); + + headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + errorHeadRequests = await promiseIfRemote(errorHeadHandler.requests(), interceptor); + expect(errorHeadRequests).toHaveLength(1); + const [errorHeadRequest] = errorHeadRequests; + expect(errorHeadRequest).toBeInstanceOf(Request); + + expectTypeOf(errorHeadRequest.body).toEqualTypeOf(); + expect(errorHeadRequest.body).toBe(null); + + expectTypeOf(errorHeadRequest.response.status).toEqualTypeOf<500>(); + expect(errorHeadRequest.response.status).toEqual(500); + + expectTypeOf(errorHeadRequest.response.body).toEqualTypeOf(); + expect(errorHeadRequest.response.body).toBe(null); + + await promiseIfRemote(errorHeadHandler.bypass(), interceptor); + + headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); + expect(headResponse.status).toBe(204); + + errorHeadRequests = await promiseIfRemote(errorHeadHandler.requests(), interceptor); + expect(errorHeadRequests).toHaveLength(1); + + headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); + expect(headRequests).toHaveLength(2); + [headRequest] = headRequests; + expect(headRequest).toBeInstanceOf(Request); + + expectTypeOf(headRequest.body).toEqualTypeOf(); + expect(headRequest.body).toBe(null); + + expectTypeOf(headRequest.response.status).toEqualTypeOf<204>(); + expect(headRequest.response.status).toEqual(204); + + expectTypeOf(headRequest.response.body).toEqualTypeOf(); + expect(headRequest.response.body).toBe(null); + }); }); }); - it('should ignore all handlers after restarted when intercepting HEAD requests', async () => { - await usingHttpInterceptor<{ - '/users': { - HEAD: { - response: { - 200: {}; - 204: {}; + describe('Clear', () => { + it('should ignore all handlers after cleared when intercepting HEAD requests', async () => { + await usingHttpInterceptor<{ + '/users': { + HEAD: { + response: { + 200: {}; + 204: {}; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const headHandler = await promiseIfRemote( - interceptor.head('/users').respond({ - status: 200, - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const headHandler = await promiseIfRemote( + interceptor.head('/users').respond({ + status: 200, + }), + interceptor, + ); - let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(0); + let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); - const headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); - expect(headResponse.status).toBe(200); + const headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); + expect(headResponse.status).toBe(200); - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); - expect(interceptor.isRunning()).toBe(true); - await interceptor.stop(); - expect(interceptor.isRunning()).toBe(false); + await promiseIfRemote(interceptor.clear(), interceptor); - let headPromise = fetchWithTimeout(joinURL(baseURL, '/users'), { - method: 'HEAD', - timeout: 200, + const headPromise = fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); + await expectFetchError(headPromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); }); - await expectFetchError(headPromise, { canBeAborted: true }); + }); - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); + it('should support creating new handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/users': { + HEAD: { + response: { + 200: {}; + 204: {}; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + await promiseIfRemote( + interceptor.head('/users').respond({ + status: 200, + }), + interceptor, + ); - await interceptor.start(); - expect(interceptor.isRunning()).toBe(true); + await promiseIfRemote(interceptor.clear(), interceptor); - headPromise = fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); - await expectFetchError(headPromise); + const noContentHeadHandler = await promiseIfRemote( + interceptor.head('/users').respond({ + status: 204, + }), + interceptor, + ); - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); + let headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); + + const headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); + expect(headResponse.status).toBe(204); + + headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + const [headRequest] = headRequests; + expect(headRequest).toBeInstanceOf(Request); + + expectTypeOf(headRequest.body).toEqualTypeOf(); + expect(headRequest.body).toBe(null); + + expectTypeOf(headRequest.response.status).toEqualTypeOf<204>(); + expect(headRequest.response.status).toEqual(204); + + expectTypeOf(headRequest.response.body).toEqualTypeOf(); + expect(headRequest.response.body).toBe(null); + }); + }); + + it('should support reusing previous handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/users': { + HEAD: { + response: { + 200: {}; + 204: {}; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const headHandler = await promiseIfRemote(interceptor.head('/users'), interceptor); + + await promiseIfRemote( + headHandler.respond({ + status: 200, + }), + interceptor, + ); + + await promiseIfRemote(interceptor.clear(), interceptor); + + const noContentHeadHandler = await promiseIfRemote( + headHandler.respond({ + status: 204, + }), + interceptor, + ); + + let headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); + + const headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); + expect(headResponse.status).toBe(204); + + headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + const [headRequest] = headRequests; + expect(headRequest).toBeInstanceOf(Request); + + expectTypeOf(headRequest.body).toEqualTypeOf(); + expect(headRequest.body).toBe(null); + + expectTypeOf(headRequest.response.status).toEqualTypeOf<204>(); + expect(headRequest.response.status).toEqual(204); + + expectTypeOf(headRequest.response.body).toEqualTypeOf(); + expect(headRequest.response.body).toBe(null); + }); }); }); - it('should ignore all handlers after restarted when intercepting HEAD requests, even if another interceptor is still running', async () => { - await usingHttpInterceptor<{ - '/users': { - HEAD: { - response: { - 200: {}; - 204: {}; + describe('Life cycle', () => { + it('should ignore all handlers after restarted when intercepting HEAD requests', async () => { + await usingHttpInterceptor<{ + '/users': { + HEAD: { + response: { + 200: {}; + 204: {}; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const headHandler = await promiseIfRemote( - interceptor.head('/users').respond({ - status: 200, - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const headHandler = await promiseIfRemote( + interceptor.head('/users').respond({ + status: 200, + }), + interceptor, + ); - let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(0); + let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); - const headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); - expect(headResponse.status).toBe(200); + const headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); + expect(headResponse.status).toBe(200); - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); - await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); - await interceptor.stop(); expect(interceptor.isRunning()).toBe(false); - expect(otherInterceptor.isRunning()).toBe(true); let headPromise = fetchWithTimeout(joinURL(baseURL, '/users'), { method: 'HEAD', @@ -869,7 +925,6 @@ export function declareHeadHttpInterceptorTests(options: RuntimeSharedHttpInterc await interceptor.start(); expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); headPromise = fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); await expectFetchError(headPromise); @@ -878,114 +933,423 @@ export function declareHeadHttpInterceptorTests(options: RuntimeSharedHttpInterc expect(headRequests).toHaveLength(1); }); }); - }); - - it('should throw an error when trying to create a HEAD request handler if not running', async () => { - const interceptor = createInternalHttpInterceptor(interceptorOptions); - expect(interceptor.isRunning()).toBe(false); - await expect(async () => { - await interceptor.head('/'); - }).rejects.toThrowError(new NotStartedHttpInterceptorError()); - }); - - it('should support creating new handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/users': { - HEAD: { - response: { - 200: {}; - 204: {}; + it('should ignore all handlers after restarted when intercepting HEAD requests, even if another interceptor is still running', async () => { + await usingHttpInterceptor<{ + '/users': { + HEAD: { + response: { + 200: {}; + 204: {}; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - await promiseIfRemote( - interceptor.head('/users').respond({ - status: 200, - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const headHandler = await promiseIfRemote( + interceptor.head('/users').respond({ + status: 200, + }), + interceptor, + ); - await promiseIfRemote(interceptor.clear(), interceptor); + let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); - const noContentHeadHandler = await promiseIfRemote( - interceptor.head('/users').respond({ - status: 204, - }), - interceptor, - ); + const headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); + expect(headResponse.status).toBe(200); - let headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); - expect(headRequests).toHaveLength(0); + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); - const headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); - expect(headResponse.status).toBe(204); + await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); - headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); - const [headRequest] = headRequests; - expect(headRequest).toBeInstanceOf(Request); + await interceptor.stop(); + expect(interceptor.isRunning()).toBe(false); + expect(otherInterceptor.isRunning()).toBe(true); - expectTypeOf(headRequest.body).toEqualTypeOf(); - expect(headRequest.body).toBe(null); + let headPromise = fetchWithTimeout(joinURL(baseURL, '/users'), { + method: 'HEAD', + timeout: 200, + }); + await expectFetchError(headPromise, { canBeAborted: true }); - expectTypeOf(headRequest.response.status).toEqualTypeOf<204>(); - expect(headRequest.response.status).toEqual(204); + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); - expectTypeOf(headRequest.response.body).toEqualTypeOf(); - expect(headRequest.response.body).toBe(null); + await interceptor.start(); + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); + + headPromise = fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); + await expectFetchError(headPromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + }); + }); + }); + + it('should throw an error when trying to create a HEAD request handler if not running', async () => { + const interceptor = createInternalHttpInterceptor(interceptorOptions); + expect(interceptor.isRunning()).toBe(false); + + await expect(async () => { + await interceptor.head('/'); + }).rejects.toThrowError(new NotStartedHttpInterceptorError()); }); }); - it('should support reusing previous handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/users': { - HEAD: { - response: { - 200: {}; - 204: {}; + describe('Unhandled requests', () => { + if (type === 'local') { + it('should show a warning when logging is enabled and a HEAD request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/users': { + HEAD: { + request: { headers: { 'x-value': string } }; + response: { + 200: {}; + }; + }; }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const headHandler = await promiseIfRemote(interceptor.head('/users'), interceptor); - - await promiseIfRemote( - headHandler.respond({ - status: 200, - }), - interceptor, - ); + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const headHandler = await promiseIfRemote( + interceptor + .head('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + }), + interceptor, + ); + + let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const headResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '1' }, + }); + expect(headResponse.status).toBe(200); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const headRequest = new Request(joinURL(baseURL, '/users'), { method: 'HEAD' }); + const headPromise = fetch(headRequest); + await expectFetchError(headPromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: headRequest, + }); + }); + }); + }); + } + + if (type === 'remote') { + it('should show an error when logging is enabled and a HEAD request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/users': { + HEAD: { + request: { headers: { 'x-value': string } }; + response: { + 200: {}; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const headHandler = await promiseIfRemote( + interceptor + .head('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + }), + interceptor, + ); + + let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const headResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '1' }, + }); + expect(headResponse.status).toBe(200); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const headRequest = new Request(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '2' }, + }); + const headPromise = fetch(headRequest); + await expectFetchError(headPromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: headRequest, + }); + }); + }); + }); + } - await promiseIfRemote(interceptor.clear(), interceptor); + it('should support a custom unhandled HEAD request handler', async () => { + const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { + const url = new URL(request.url); - const noContentHeadHandler = await promiseIfRemote( - headHandler.respond({ - status: 204, - }), - interceptor, - ); + if (!url.searchParams.has('name')) { + await context.log(); + } + }); - let headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); - expect(headRequests).toHaveLength(0); + await usingHttpInterceptor<{ + '/users': { + HEAD: { + request: { + headers: { 'x-value': string }; + searchParams: { name?: string }; + }; + response: { + 200: {}; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const headHandler = await promiseIfRemote( + interceptor + .head('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + }), + interceptor, + ); + + let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const headResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '1' }, + }); + expect(headResponse.status).toBe(200); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const searchParams = new HttpSearchParams({ name: 'User 1' }); + + let headPromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { + method: 'HEAD', + headers: { 'x-value': '2' }, + }); + await expectFetchError(headPromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const headRequest = new Request(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '2' }, + }); + headPromise = fetch(headRequest); + await expectFetchError(headPromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(2); + + const messageType = type === 'local' ? 'warn' : 'error'; + expect(spies.warn).toHaveBeenCalledTimes(messageType === 'warn' ? 1 : 0); + expect(spies.error).toHaveBeenCalledTimes(messageType === 'error' ? 1 : 0); + + const errorMessage = spies[messageType].mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: messageType, + platform, + request: headRequest, + }); + }); + }); + }); - const headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); - expect(headResponse.status).toBe(204); + it('should log an error if a custom unhandled HEAD request handler throws', async () => { + const error = new Error('Unhandled request.'); - headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); - const [headRequest] = headRequests; - expect(headRequest).toBeInstanceOf(Request); + const onUnhandledRequest = vi.fn((request: Request) => { + const url = new URL(request.url); - expectTypeOf(headRequest.body).toEqualTypeOf(); - expect(headRequest.body).toBe(null); + if (!url.searchParams.has('name')) { + throw error; + } + }); - expectTypeOf(headRequest.response.status).toEqualTypeOf<204>(); - expect(headRequest.response.status).toEqual(204); + await usingHttpInterceptor<{ + '/users': { + HEAD: { + request: { + headers: { 'x-value': string }; + searchParams: { name?: string }; + }; + response: { + 200: {}; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const headHandler = await promiseIfRemote( + interceptor + .head('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + }), + interceptor, + ); + + let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const headResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '1' }, + }); + expect(headResponse.status).toBe(200); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const searchParams = new HttpSearchParams({ name: 'User 1' }); + + let headPromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { + method: 'HEAD', + headers: { 'x-value': '2' }, + }); + await expectFetchError(headPromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const headRequest = new Request(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '2' }, + }); + headPromise = fetch(headRequest); + await expectFetchError(headPromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(2); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + expect(spies.error).toHaveBeenCalledWith(error); + }); + }); + }); - expectTypeOf(headRequest.response.body).toEqualTypeOf(); - expect(headRequest.response.body).toBe(null); + it('should not show a warning or error when logging is disabled and a HEAD request is unhandled', async () => { + await usingHttpInterceptor<{ + '/users': { + HEAD: { + request: { headers: { 'x-value': string } }; + response: { + 200: {}; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { + const headHandler = await promiseIfRemote( + interceptor + .head('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + }), + interceptor, + ); + + let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const headResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '1' }, + }); + expect(headResponse.status).toBe(200); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const headRequest = new Request(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '2' }, + }); + const headPromise = fetch(headRequest); + await expectFetchError(headPromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + }); + }); }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/options.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/options.ts index 54274538..39e64082 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/options.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/options.ts @@ -1,4 +1,4 @@ -import { beforeEach, expect, expectTypeOf, it } from 'vitest'; +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; @@ -10,15 +10,17 @@ import { DEFAULT_ACCESS_CONTROL_HEADERS, AccessControlHeaders } from '@/intercep import { JSONValue } from '@/types/json'; import { fetchWithTimeout } from '@/utils/fetch'; import { joinURL } from '@/utils/urls'; +import { usingIgnoredConsole } from '@tests/utils/console'; import { expectFetchError, expectFetchErrorOrPreflightResponse } from '@tests/utils/fetch'; import { createInternalHttpInterceptor, usingHttpInterceptor } from '@tests/utils/interceptors'; import NotStartedHttpInterceptorError from '../../../errors/NotStartedHttpInterceptorError'; -import { HttpInterceptorOptions } from '../../../types/options'; +import { HttpInterceptorOptions, UnhandledRequestStrategy } from '../../../types/options'; import { RuntimeSharedHttpInterceptorTestsOptions } from '../types'; +import { verifyUnhandledRequestMessage } from '../utils'; export function declareOptionsHttpInterceptorTests(options: RuntimeSharedHttpInterceptorTestsOptions) { - const { platform, getBaseURL, getInterceptorOptions } = options; + const { platform, type, getBaseURL, getInterceptorOptions } = options; let baseURL: URL; let interceptorOptions: HttpInterceptorOptions; @@ -259,242 +261,6 @@ export function declareOptionsHttpInterceptorTests(options: RuntimeSharedHttpInt }); }); - it('should support intercepting OPTIONS requests having headers restrictions', async () => { - type FiltersOptionsHeaders = HttpSchema.Headers<{ - 'content-type'?: string; - accept?: string; - }>; - - await usingHttpInterceptor<{ - '/filters': { - OPTIONS: { - request: { - headers: FiltersOptionsHeaders; - }; - response: { - 200: { headers: AccessControlHeaders }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const optionsHandler = await promiseIfRemote( - interceptor - .options('/filters') - .with({ - headers: { 'content-type': 'application/json' }, - }) - .with((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - const acceptHeader = request.headers.get('accept'); - return acceptHeader ? acceptHeader.includes('application/json') : false; - }) - .respond((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - return { - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }; - }), - interceptor, - ); - expect(optionsHandler).toBeInstanceOf(Handler); - - let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(0); - - const headers = new HttpHeaders({ - 'content-type': 'application/json', - accept: 'application/json', - }); - - let optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS', headers }); - expect(optionsResponse.status).toBe(200); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(1); - - headers.append('accept', 'application/xml'); - - optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS', headers }); - expect(optionsResponse.status).toBe(200); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(2); - - headers.delete('accept'); - - let optionsResponsePromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS', headers }); - await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { - shouldBePreflight: overridesPreflightResponse, - }); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(2); - - headers.delete('content-type'); - - optionsResponsePromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS', headers }); - await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { - shouldBePreflight: overridesPreflightResponse, - }); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(2); - - headers.set('accept', 'application/json'); - headers.set('content-type', 'text/plain'); - - optionsResponsePromise = fetch(joinURL(baseURL, `/users`), { method: 'OPTIONS', headers }); - await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { - shouldBePreflight: overridesPreflightResponse, - }); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(2); - }); - }); - - it('should support intercepting OPTIONS requests having search params restrictions', async () => { - type FiltersOptionsSearchParams = HttpSchema.SearchParams<{ - tag?: string; - }>; - - await usingHttpInterceptor<{ - '/filters': { - OPTIONS: { - request: { - searchParams: FiltersOptionsSearchParams; - }; - response: { - 200: { - headers: AccessControlHeaders; - }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const optionsHandler = await promiseIfRemote( - interceptor - .options('/filters') - .with({ - searchParams: { tag: 'admin' }, - }) - .respond((request) => { - expectTypeOf(request.searchParams).toEqualTypeOf>(); - expect(request.searchParams).toBeInstanceOf(HttpSearchParams); - - return { - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }; - }), - interceptor, - ); - expect(optionsHandler).toBeInstanceOf(Handler); - - let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(0); - - const searchParams = new HttpSearchParams({ - tag: 'admin', - }); - - const optionsResponse = await fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { - method: 'OPTIONS', - }); - expect(optionsResponse.status).toBe(200); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - - searchParams.delete('tag'); - - const optionsResponsePromise = fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { - method: 'OPTIONS', - }); - await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { - shouldBePreflight: overridesPreflightResponse, - }); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - }); - }); - - it('should support intercepting OPTIONS requests with a dynamic path', async () => { - await usingHttpInterceptor<{ - '/filters/:id': { - OPTIONS: { - response: { - 200: { headers: AccessControlHeaders }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const genericOptionsHandler = await promiseIfRemote( - interceptor.options('/filters/:id').respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }), - interceptor, - ); - expect(genericOptionsHandler).toBeInstanceOf(Handler); - - let genericOptionsRequests = await promiseIfRemote(genericOptionsHandler.requests(), interceptor); - expect(genericOptionsRequests).toHaveLength(0); - - const genericOptionsResponse = await fetch(joinURL(baseURL, `/filters/${1}`), { method: 'OPTIONS' }); - expect(genericOptionsResponse.status).toBe(200); - - genericOptionsRequests = await promiseIfRemote(genericOptionsHandler.requests(), interceptor); - expect(genericOptionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - const genericOptionsRequest = genericOptionsRequests[numberOfRequestsIncludingPrefetch - 1]; - expect(genericOptionsRequest).toBeInstanceOf(Request); - - expectTypeOf(genericOptionsRequest.body).toEqualTypeOf(); - expect(genericOptionsRequest.body).toBe(null); - - expectTypeOf(genericOptionsRequest.response.status).toEqualTypeOf<200>(); - expect(genericOptionsRequest.response.status).toEqual(200); - - expectTypeOf(genericOptionsRequest.response.body).toEqualTypeOf(); - expect(genericOptionsRequest.response.body).toBe(null); - - await promiseIfRemote(genericOptionsHandler.bypass(), interceptor); - - const specificOptionsHandler = await promiseIfRemote( - interceptor.options(`/filters/${1}`).respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }), - interceptor, - ); - expect(specificOptionsHandler).toBeInstanceOf(Handler); - - let specificOptionsRequests = await promiseIfRemote(specificOptionsHandler.requests(), interceptor); - expect(specificOptionsRequests).toHaveLength(0); - - const specificOptionsResponse = await fetch(joinURL(baseURL, `/filters/${1}`), { method: 'OPTIONS' }); - expect(specificOptionsResponse.status).toBe(200); - - specificOptionsRequests = await promiseIfRemote(specificOptionsHandler.requests(), interceptor); - expect(specificOptionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - const specificOptionsRequest = specificOptionsRequests[numberOfRequestsIncludingPrefetch - 1]; - expect(specificOptionsRequest).toBeInstanceOf(Request); - - expectTypeOf(specificOptionsRequest.body).toEqualTypeOf(); - expect(specificOptionsRequest.body).toBe(null); - - expectTypeOf(specificOptionsRequest.response.status).toEqualTypeOf<200>(); - expect(specificOptionsRequest.response.status).toEqual(200); - - expectTypeOf(specificOptionsRequest.response.body).toEqualTypeOf(); - expect(specificOptionsRequest.response.body).toBe(null); - - const unmatchedOptionsPromise = fetch(joinURL(baseURL, `/filters/${2}`), { method: 'OPTIONS' }); - await expectFetchErrorOrPreflightResponse(unmatchedOptionsPromise, { - shouldBePreflight: overridesPreflightResponse, - }); - }); - }); - it('should result in a browser error after returning a remote OPTIONS request without proper access-control headers', async () => { await usingHttpInterceptor<{ '/filters': { @@ -516,12 +282,12 @@ export function declareOptionsHttpInterceptorTests(options: RuntimeSharedHttpInt const initialOptionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); expect(initialOptionsRequests).toHaveLength(0); - const optionsPromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + const optionsResponsePromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); if (options.type === 'remote' && platform === 'browser') { - await expectFetchError(optionsPromise); + await expectFetchError(optionsResponsePromise); } else { - const optionsResponse = await optionsPromise; + const optionsResponse = await optionsResponsePromise; expect(optionsResponse.status).toBe(200); } }); @@ -673,270 +439,557 @@ export function declareOptionsHttpInterceptorTests(options: RuntimeSharedHttpInt }); }); - it('should ignore handlers with bypassed responses when intercepting OPTIONS requests', async () => { - await usingHttpInterceptor<{ - '/filters': { - OPTIONS: { - response: { - 200: { - headers: AccessControlHeaders; - body: MessageResponseBody; + describe('Dynamic paths', () => { + it('should support intercepting OPTIONS requests with a dynamic path', async () => { + await usingHttpInterceptor<{ + '/filters/:id': { + OPTIONS: { + response: { + 200: { headers: AccessControlHeaders }; }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const optionsHandler = await promiseIfRemote( - interceptor - .options('/filters') - .respond({ + }>(interceptorOptions, async (interceptor) => { + const genericOptionsHandler = await promiseIfRemote( + interceptor.options('/filters/:id').respond({ status: 200, headers: DEFAULT_ACCESS_CONTROL_HEADERS, - body: {}, - }) - .bypass(), - interceptor, - ); + }), + interceptor, + ); + expect(genericOptionsHandler).toBeInstanceOf(Handler); - let initialOptionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(initialOptionsRequests).toHaveLength(0); + let genericOptionsRequests = await promiseIfRemote(genericOptionsHandler.requests(), interceptor); + expect(genericOptionsRequests).toHaveLength(0); - const optionsPromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - await expectFetchErrorOrPreflightResponse(optionsPromise, { - shouldBePreflight: overridesPreflightResponse, - }); + const genericOptionsResponse = await fetch(joinURL(baseURL, `/filters/${1}`), { method: 'OPTIONS' }); + expect(genericOptionsResponse.status).toBe(200); - const otherOptionsHandler = await promiseIfRemote( - optionsHandler.respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - body: { message: 'ok' }, - }), - interceptor, - ); + genericOptionsRequests = await promiseIfRemote(genericOptionsHandler.requests(), interceptor); + expect(genericOptionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + const genericOptionsRequest = genericOptionsRequests[numberOfRequestsIncludingPrefetch - 1]; + expect(genericOptionsRequest).toBeInstanceOf(Request); - initialOptionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(initialOptionsRequests).toHaveLength(0); - let optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(0); + expectTypeOf(genericOptionsRequest.body).toEqualTypeOf(); + expect(genericOptionsRequest.body).toBe(null); - let optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - expect(optionsResponse.status).toBe(200); + expectTypeOf(genericOptionsRequest.response.status).toEqualTypeOf<200>(); + expect(genericOptionsRequest.response.status).toEqual(200); - let optionsResponseBody = (await optionsResponse.json()) as MessageResponseBody; - expect(optionsResponseBody).toEqual({ message: 'ok' }); + expectTypeOf(genericOptionsRequest.response.body).toEqualTypeOf(); + expect(genericOptionsRequest.response.body).toBe(null); - optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - let optionsRequest = optionsRequests[numberOfRequestsIncludingPrefetch - 1]; - expect(optionsRequest).toBeInstanceOf(Request); + await promiseIfRemote(genericOptionsHandler.bypass(), interceptor); - expectTypeOf(optionsRequest.body).toEqualTypeOf(); - expect(optionsRequest.body).toBe(null); + const specificOptionsHandler = await promiseIfRemote( + interceptor.options(`/filters/${1}`).respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); + expect(specificOptionsHandler).toBeInstanceOf(Handler); - expectTypeOf(optionsRequest.response.status).toEqualTypeOf<200>(); - expect(optionsRequest.response.status).toEqual(200); + let specificOptionsRequests = await promiseIfRemote(specificOptionsHandler.requests(), interceptor); + expect(specificOptionsRequests).toHaveLength(0); - expectTypeOf(optionsRequest.response.body).toEqualTypeOf(); - expect(optionsRequest.response.body).toEqual({ message: 'ok' }); + const specificOptionsResponse = await fetch(joinURL(baseURL, `/filters/${1}`), { method: 'OPTIONS' }); + expect(specificOptionsResponse.status).toBe(200); - const optionsHandlerWithMessage = await promiseIfRemote( - interceptor.options('/filters').respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - body: { message: 'other-ok' }, - }), - interceptor, - ); + specificOptionsRequests = await promiseIfRemote(specificOptionsHandler.requests(), interceptor); + expect(specificOptionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + const specificOptionsRequest = specificOptionsRequests[numberOfRequestsIncludingPrefetch - 1]; + expect(specificOptionsRequest).toBeInstanceOf(Request); - let optionsWithMessageRequests = await promiseIfRemote(optionsHandlerWithMessage.requests(), interceptor); - expect(optionsWithMessageRequests).toHaveLength(0); + expectTypeOf(specificOptionsRequest.body).toEqualTypeOf(); + expect(specificOptionsRequest.body).toBe(null); - const otherOptionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - expect(otherOptionsResponse.status).toBe(200); + expectTypeOf(specificOptionsRequest.response.status).toEqualTypeOf<200>(); + expect(specificOptionsRequest.response.status).toEqual(200); - optionsResponseBody = (await otherOptionsResponse.json()) as MessageResponseBody; - expect(optionsResponseBody).toEqual({ message: 'other-ok' }); + expectTypeOf(specificOptionsRequest.response.body).toEqualTypeOf(); + expect(specificOptionsRequest.response.body).toBe(null); - optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + const unmatchedOptionsPromise = fetch(joinURL(baseURL, `/filters/${2}`), { method: 'OPTIONS' }); + await expectFetchErrorOrPreflightResponse(unmatchedOptionsPromise, { + shouldBePreflight: overridesPreflightResponse, + }); + }); + }); + }); - optionsWithMessageRequests = await promiseIfRemote(optionsHandlerWithMessage.requests(), interceptor); - expect(optionsWithMessageRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - const optionsWithMessageRequest = optionsWithMessageRequests[numberOfRequestsIncludingPrefetch - 1]; - expect(optionsWithMessageRequest).toBeInstanceOf(Request); + describe('Restrictions', () => { + it('should support intercepting OPTIONS requests having headers restrictions', async () => { + type FiltersOptionsHeaders = HttpSchema.Headers<{ + 'content-type'?: string; + accept?: string; + }>; + + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + request: { + headers: FiltersOptionsHeaders; + }; + response: { + 200: { headers: AccessControlHeaders }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor + .options('/filters') + .with({ + headers: { 'content-type': 'application/json' }, + }) + .with((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); + + const acceptHeader = request.headers.get('accept'); + return acceptHeader ? acceptHeader.includes('application/json') : false; + }) + .respond((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); + + return { + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }; + }), + interceptor, + ); + expect(optionsHandler).toBeInstanceOf(Handler); + + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); + + const headers = new HttpHeaders({ + 'content-type': 'application/json', + accept: 'application/json', + }); - expectTypeOf(optionsWithMessageRequest.body).toEqualTypeOf(); - expect(optionsWithMessageRequest.body).toBe(null); + let optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS', headers }); + expect(optionsResponse.status).toBe(200); + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(1); - expectTypeOf(optionsWithMessageRequest.response.status).toEqualTypeOf<200>(); - expect(optionsWithMessageRequest.response.status).toEqual(200); + headers.append('accept', 'application/xml'); - expectTypeOf(optionsWithMessageRequest.response.body).toEqualTypeOf(); - expect(optionsWithMessageRequest.response.body).toEqual({ message: 'other-ok' }); + optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS', headers }); + expect(optionsResponse.status).toBe(200); + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(2); - await promiseIfRemote(optionsHandlerWithMessage.bypass(), interceptor); + headers.delete('accept'); - optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - expect(optionsResponse.status).toBe(200); + let optionsResponsePromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS', headers }); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(2); - optionsResponseBody = (await optionsResponse.json()) as MessageResponseBody; - expect(optionsResponseBody).toEqual({ message: 'ok' }); + headers.delete('content-type'); - optionsWithMessageRequests = await promiseIfRemote(optionsHandlerWithMessage.requests(), interceptor); - expect(optionsWithMessageRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + optionsResponsePromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS', headers }); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(2); - optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch * 2); - optionsRequest = optionsRequests[numberOfRequestsIncludingPrefetch - 1]; - expect(optionsRequest).toBeInstanceOf(Request); + headers.set('accept', 'application/json'); + headers.set('content-type', 'text/plain'); - expectTypeOf(optionsRequest.body).toEqualTypeOf(); - expect(optionsRequest.body).toBe(null); + optionsResponsePromise = fetch(joinURL(baseURL, `/users`), { method: 'OPTIONS', headers }); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(2); + }); + }); - expectTypeOf(optionsRequest.response.status).toEqualTypeOf<200>(); - expect(optionsRequest.response.status).toEqual(200); + it('should support intercepting OPTIONS requests having search params restrictions', async () => { + type FiltersOptionsSearchParams = HttpSchema.SearchParams<{ + tag?: string; + }>; - expectTypeOf(optionsRequest.response.body).toEqualTypeOf(); - expect(optionsRequest.response.body).toEqual({ message: 'ok' }); + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + request: { + searchParams: FiltersOptionsSearchParams; + }; + response: { + 200: { + headers: AccessControlHeaders; + }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor + .options('/filters') + .with({ + searchParams: { tag: 'admin' }, + }) + .respond((request) => { + expectTypeOf(request.searchParams).toEqualTypeOf>(); + expect(request.searchParams).toBeInstanceOf(HttpSearchParams); + + return { + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }; + }), + interceptor, + ); + expect(optionsHandler).toBeInstanceOf(Handler); + + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); + + const searchParams = new HttpSearchParams({ + tag: 'admin', + }); + + const optionsResponse = await fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { + method: 'OPTIONS', + }); + expect(optionsResponse.status).toBe(200); + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + searchParams.delete('tag'); + + const optionsResponsePromise = fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { + method: 'OPTIONS', + }); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + }); }); }); - it('should ignore all handlers after cleared when intercepting OPTIONS requests', async () => { - await usingHttpInterceptor<{ - '/filters': { - OPTIONS: { - response: { - 200: { headers: AccessControlHeaders }; + describe('Bypass', () => { + it('should ignore handlers with bypassed responses when intercepting OPTIONS requests', async () => { + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + response: { + 200: { + headers: AccessControlHeaders; + body: MessageResponseBody; + }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const optionsHandler = await promiseIfRemote( - interceptor.options('/filters').respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor + .options('/filters') + .respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + body: {}, + }) + .bypass(), + interceptor, + ); - let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(0); + let initialOptionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(initialOptionsRequests).toHaveLength(0); - const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - expect(optionsResponse.status).toBe(200); + const optionsResponsePromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + const otherOptionsHandler = await promiseIfRemote( + optionsHandler.respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + body: { message: 'ok' }, + }), + interceptor, + ); - await promiseIfRemote(interceptor.clear(), interceptor); + initialOptionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(initialOptionsRequests).toHaveLength(0); + let optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); - const optionsPromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - await expectFetchErrorOrPreflightResponse(optionsPromise, { - shouldBePreflight: overridesPreflightResponse, - }); + let optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + expect(optionsResponse.status).toBe(200); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + let optionsResponseBody = (await optionsResponse.json()) as MessageResponseBody; + expect(optionsResponseBody).toEqual({ message: 'ok' }); + + optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + let optionsRequest = optionsRequests[numberOfRequestsIncludingPrefetch - 1]; + expect(optionsRequest).toBeInstanceOf(Request); + + expectTypeOf(optionsRequest.body).toEqualTypeOf(); + expect(optionsRequest.body).toBe(null); + + expectTypeOf(optionsRequest.response.status).toEqualTypeOf<200>(); + expect(optionsRequest.response.status).toEqual(200); + + expectTypeOf(optionsRequest.response.body).toEqualTypeOf(); + expect(optionsRequest.response.body).toEqual({ message: 'ok' }); + + const optionsHandlerWithMessage = await promiseIfRemote( + interceptor.options('/filters').respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + body: { message: 'other-ok' }, + }), + interceptor, + ); + + let optionsWithMessageRequests = await promiseIfRemote(optionsHandlerWithMessage.requests(), interceptor); + expect(optionsWithMessageRequests).toHaveLength(0); + + const otherOptionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + expect(otherOptionsResponse.status).toBe(200); + + optionsResponseBody = (await otherOptionsResponse.json()) as MessageResponseBody; + expect(optionsResponseBody).toEqual({ message: 'other-ok' }); + + optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + optionsWithMessageRequests = await promiseIfRemote(optionsHandlerWithMessage.requests(), interceptor); + expect(optionsWithMessageRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + const optionsWithMessageRequest = optionsWithMessageRequests[numberOfRequestsIncludingPrefetch - 1]; + expect(optionsWithMessageRequest).toBeInstanceOf(Request); + + expectTypeOf(optionsWithMessageRequest.body).toEqualTypeOf(); + expect(optionsWithMessageRequest.body).toBe(null); + + expectTypeOf(optionsWithMessageRequest.response.status).toEqualTypeOf<200>(); + expect(optionsWithMessageRequest.response.status).toEqual(200); + + expectTypeOf(optionsWithMessageRequest.response.body).toEqualTypeOf(); + expect(optionsWithMessageRequest.response.body).toEqual({ message: 'other-ok' }); + + await promiseIfRemote(optionsHandlerWithMessage.bypass(), interceptor); + + optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + expect(optionsResponse.status).toBe(200); + + optionsResponseBody = (await optionsResponse.json()) as MessageResponseBody; + expect(optionsResponseBody).toEqual({ message: 'ok' }); + + optionsWithMessageRequests = await promiseIfRemote(optionsHandlerWithMessage.requests(), interceptor); + expect(optionsWithMessageRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch * 2); + optionsRequest = optionsRequests[numberOfRequestsIncludingPrefetch - 1]; + expect(optionsRequest).toBeInstanceOf(Request); + + expectTypeOf(optionsRequest.body).toEqualTypeOf(); + expect(optionsRequest.body).toBe(null); + + expectTypeOf(optionsRequest.response.status).toEqualTypeOf<200>(); + expect(optionsRequest.response.status).toEqual(200); + + expectTypeOf(optionsRequest.response.body).toEqualTypeOf(); + expect(optionsRequest.response.body).toEqual({ message: 'ok' }); + }); }); }); - it('should ignore all handlers after restarted when intercepting OPTIONS requests', async () => { - await usingHttpInterceptor<{ - '/filters': { - OPTIONS: { - response: { - 200: { headers: AccessControlHeaders }; + describe('Clear', () => { + it('should ignore all handlers after cleared when intercepting OPTIONS requests', async () => { + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + response: { + 200: { headers: AccessControlHeaders }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const optionsHandler = await promiseIfRemote( - interceptor.options('/filters').respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor.options('/filters').respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); - let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(0); + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); - const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - expect(optionsResponse.status).toBe(200); + const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + expect(optionsResponse.status).toBe(200); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - expect(interceptor.isRunning()).toBe(true); - await interceptor.stop(); - expect(interceptor.isRunning()).toBe(false); + await promiseIfRemote(interceptor.clear(), interceptor); - let optionsPromise = fetchWithTimeout(joinURL(baseURL, '/filters'), { - method: 'OPTIONS', - timeout: 200, - }); - await expectFetchErrorOrPreflightResponse(optionsPromise, { - shouldBePreflight: overridesPreflightResponse, - canBeAborted: true, + const optionsResponsePromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); }); + }); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + it('should support creating new handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + response: { + 200: { headers: AccessControlHeaders }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + await promiseIfRemote( + interceptor.options('/filters').respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); - await interceptor.start(); - expect(interceptor.isRunning()).toBe(true); + await promiseIfRemote(interceptor.clear(), interceptor); - optionsPromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - await expectFetchErrorOrPreflightResponse(optionsPromise, { - shouldBePreflight: overridesPreflightResponse, + const optionsHandler = await promiseIfRemote( + interceptor.options('/filters').respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); + + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); + + const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + expect(optionsResponse.status).toBe(200); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + const optionsRequest = optionsRequests[numberOfRequestsIncludingPrefetch - 1]; + expect(optionsRequest).toBeInstanceOf(Request); + + expectTypeOf(optionsRequest.body).toEqualTypeOf(); + expect(optionsRequest.body).toBe(null); + + expectTypeOf(optionsRequest.response.status).toEqualTypeOf<200>(); + expect(optionsRequest.response.status).toEqual(200); + + expectTypeOf(optionsRequest.response.body).toEqualTypeOf(); + expect(optionsRequest.response.body).toBe(null); }); + }); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + it('should support reusing previous handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + response: { + 200: { headers: AccessControlHeaders }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const optionsHandler = await promiseIfRemote(interceptor.options('/filters'), interceptor); + + await promiseIfRemote( + optionsHandler.respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); + + await promiseIfRemote(interceptor.clear(), interceptor); + + const otherOptionsHandler = await promiseIfRemote( + optionsHandler.respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); + + let optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); + + const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + expect(optionsResponse.status).toBe(200); + + optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + const optionsRequest = optionsRequests[numberOfRequestsIncludingPrefetch - 1]; + expect(optionsRequest).toBeInstanceOf(Request); + + expectTypeOf(optionsRequest.body).toEqualTypeOf(); + expect(optionsRequest.body).toBe(null); + + expectTypeOf(optionsRequest.response.status).toEqualTypeOf<200>(); + expect(optionsRequest.response.status).toEqual(200); + + expectTypeOf(optionsRequest.response.body).toEqualTypeOf(); + expect(optionsRequest.response.body).toBe(null); + }); }); }); - it('should ignore all handlers after restarted when intercepting OPTIONS requests, even if another interceptor is still running', async () => { - await usingHttpInterceptor<{ - '/filters': { - OPTIONS: { - response: { - 200: { headers: AccessControlHeaders }; + describe('Life cycle', () => { + it('should ignore all handlers after restarted when intercepting OPTIONS requests', async () => { + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + response: { + 200: { headers: AccessControlHeaders }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const optionsHandler = await promiseIfRemote( - interceptor.options('/filters').respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor.options('/filters').respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); - let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(0); + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); - const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - expect(optionsResponse.status).toBe(200); + const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + expect(optionsResponse.status).toBe(200); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); - await interceptor.stop(); expect(interceptor.isRunning()).toBe(false); - expect(otherInterceptor.isRunning()).toBe(true); - let optionsPromise = fetchWithTimeout(joinURL(baseURL, '/filters'), { + let optionsResponsePromise = fetchWithTimeout(joinURL(baseURL, '/filters'), { method: 'OPTIONS', timeout: 200, }); - await expectFetchErrorOrPreflightResponse(optionsPromise, { + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { shouldBePreflight: overridesPreflightResponse, canBeAborted: true, }); @@ -946,10 +999,9 @@ export function declareOptionsHttpInterceptorTests(options: RuntimeSharedHttpInt await interceptor.start(); expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); - optionsPromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - await expectFetchErrorOrPreflightResponse(optionsPromise, { + optionsResponsePromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { shouldBePreflight: overridesPreflightResponse, }); @@ -957,116 +1009,449 @@ export function declareOptionsHttpInterceptorTests(options: RuntimeSharedHttpInt expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); }); }); - }); - it('should throw an error when trying to create a OPTIONS request handler if not running', async () => { - const interceptor = createInternalHttpInterceptor(interceptorOptions); - expect(interceptor.isRunning()).toBe(false); - - await expect(async () => { - await interceptor.options('/'); - }).rejects.toThrowError(new NotStartedHttpInterceptorError()); - }); - - it('should support creating new handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/filters': { - OPTIONS: { - response: { - 200: { headers: AccessControlHeaders }; + it('should ignore all handlers after restarted when intercepting OPTIONS requests, even if another interceptor is still running', async () => { + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + response: { + 200: { headers: AccessControlHeaders }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - await promiseIfRemote( - interceptor.options('/filters').respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor.options('/filters').respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); - await promiseIfRemote(interceptor.clear(), interceptor); + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); - const optionsHandler = await promiseIfRemote( - interceptor.options('/filters').respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }), - interceptor, - ); + const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + expect(optionsResponse.status).toBe(200); - let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(0); + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - expect(optionsResponse.status).toBe(200); + await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); + + await interceptor.stop(); + expect(interceptor.isRunning()).toBe(false); + expect(otherInterceptor.isRunning()).toBe(true); + + let optionsResponsePromise = fetchWithTimeout(joinURL(baseURL, '/filters'), { + method: 'OPTIONS', + timeout: 200, + }); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + canBeAborted: true, + }); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + await interceptor.start(); + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); + + optionsResponsePromise = fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + }); + }); + }); - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - const optionsRequest = optionsRequests[numberOfRequestsIncludingPrefetch - 1]; - expect(optionsRequest).toBeInstanceOf(Request); + it('should throw an error when trying to create an OPTIONS request handler if not running', async () => { + const interceptor = createInternalHttpInterceptor(interceptorOptions); + expect(interceptor.isRunning()).toBe(false); - expectTypeOf(optionsRequest.body).toEqualTypeOf(); - expect(optionsRequest.body).toBe(null); + await expect(async () => { + await interceptor.options('/'); + }).rejects.toThrowError(new NotStartedHttpInterceptorError()); + }); + }); - expectTypeOf(optionsRequest.response.status).toEqualTypeOf<200>(); - expect(optionsRequest.response.status).toEqual(200); + describe('Unhandled requests', () => { + if (type === 'local') { + it('should show a warning when logging is enabled and an OPTIONS request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + request: { headers: { 'x-value': string } }; + response: { + 200: { headers: AccessControlHeaders }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor + .options('/filters') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); + + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { + method: 'OPTIONS', + headers: { 'x-value': '1' }, + }); + expect(optionsResponse.status).toBe(200); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const optionsRequest = new Request(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + const optionsResponsePromise = fetch(optionsRequest); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(spies.warn).toHaveBeenCalledTimes(numberOfRequestsIncludingPrefetch); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: optionsRequest, + }); + }); + }); + }); + } + + if (type === 'remote') { + it('should show an error when logging is enabled and an OPTIONS request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + request: { searchParams: { 'x-value': string } }; + response: { + 200: { headers: AccessControlHeaders }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor + .options('/filters') + .with({ searchParams: { 'x-value': '1' } }) + .respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); + + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const searchParams = new HttpSearchParams({ 'x-value': '1' }); + + const optionsResponse = await fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { + method: 'OPTIONS', + }); + expect(optionsResponse.status).toBe(200); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const optionsRequest = new Request(joinURL(baseURL, '/filters'), { + method: 'OPTIONS', + }); + const optionsResponsePromise = fetch(optionsRequest); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(numberOfRequestsIncludingPrefetch); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: optionsRequest, + }); + }); + }); + }); + } - expectTypeOf(optionsRequest.response.body).toEqualTypeOf(); - expect(optionsRequest.response.body).toBe(null); + it('should support a custom unhandled OPTIONS request handler', async () => { + const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { + const url = new URL(request.url); + + if (!url.searchParams.has('name')) { + await context.log(); + } + }); + + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + request: { + searchParams: { 'x-value': string; name?: string }; + }; + response: { + 200: { headers: AccessControlHeaders }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor + .options('/filters') + .with({ searchParams: { 'x-value': '1' } }) + .respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); + + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const searchParams = new HttpSearchParams<{ + 'x-value': string; + name?: string; + }>({ 'x-value': '1' }); + + const optionsResponse = await fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { + method: 'OPTIONS', + }); + expect(optionsResponse.status).toBe(200); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + searchParams.set('x-value', '2'); + searchParams.set('name', 'User 1'); + + let optionsResponsePromise = fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { + method: 'OPTIONS', + }); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(numberOfRequestsIncludingPrefetch); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const optionsRequest = new Request(joinURL(baseURL, '/filters'), { + method: 'OPTIONS', + }); + optionsResponsePromise = fetch(optionsRequest); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(numberOfRequestsIncludingPrefetch * 2); + const messageType = type === 'local' ? 'warn' : 'error'; + expect(spies.warn).toHaveBeenCalledTimes(messageType === 'warn' ? numberOfRequestsIncludingPrefetch : 0); + expect(spies.error).toHaveBeenCalledTimes(messageType === 'error' ? numberOfRequestsIncludingPrefetch : 0); + + const errorMessage = spies[messageType].mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: messageType, + platform, + request: optionsRequest, + }); + }); + }); }); - }); - it('should support reusing previous handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/filters': { - OPTIONS: { - response: { - 200: { headers: AccessControlHeaders }; + it('should log an error if a custom unhandled OPTIONS request handler throws', async () => { + const error = new Error('Unhandled request.'); + + const onUnhandledRequest = vi.fn((request: Request) => { + const url = new URL(request.url); + + if (!url.searchParams.has('name')) { + throw error; + } + }); + + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + request: { + searchParams: { 'x-value': string; name?: string }; + }; + response: { + 200: { headers: AccessControlHeaders }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const optionsHandler = await promiseIfRemote(interceptor.options('/filters'), interceptor); + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor + .options('/filters') + .with({ searchParams: { 'x-value': '1' } }) + .respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); + + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const searchParams = new HttpSearchParams<{ + 'x-value': string; + name?: string; + }>({ 'x-value': '1' }); + + const optionsResponse = await fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { + method: 'OPTIONS', + }); + expect(optionsResponse.status).toBe(200); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + searchParams.set('x-value', '2'); + searchParams.set('name', 'User 1'); + + let optionsResponsePromise = fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { + method: 'OPTIONS', + }); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(numberOfRequestsIncludingPrefetch); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const optionsRequest = new Request(joinURL(baseURL, '/filters'), { + method: 'OPTIONS', + }); + optionsResponsePromise = fetch(optionsRequest); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(numberOfRequestsIncludingPrefetch * 2); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(numberOfRequestsIncludingPrefetch); + + expect(spies.error).toHaveBeenCalledWith(error); + }); + }); + }); - await promiseIfRemote( - optionsHandler.respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }), - interceptor, - ); + it('should not show a warning or error when logging is disabled and an OPTIONS request is unhandled', async () => { + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + request: { searchParams: { 'x-value': string } }; + response: { + 200: { headers: AccessControlHeaders }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor + .options('/filters') + .with({ searchParams: { 'x-value': '1' } }) + .respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); - await promiseIfRemote(interceptor.clear(), interceptor); + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); - const otherOptionsHandler = await promiseIfRemote( - optionsHandler.respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }), - interceptor, - ); + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const searchParams = new HttpSearchParams({ 'x-value': '1' }); - let optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(0); + const optionsResponse = await fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { + method: 'OPTIONS', + }); + expect(optionsResponse.status).toBe(200); - const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - expect(optionsResponse.status).toBe(200); + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - const optionsRequest = optionsRequests[numberOfRequestsIncludingPrefetch - 1]; - expect(optionsRequest).toBeInstanceOf(Request); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); - expectTypeOf(optionsRequest.body).toEqualTypeOf(); - expect(optionsRequest.body).toBe(null); + const optionsRequest = new Request(joinURL(baseURL, '/filters'), { + method: 'OPTIONS', + }); + const optionsResponsePromise = fetch(optionsRequest); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); - expectTypeOf(optionsRequest.response.status).toEqualTypeOf<200>(); - expect(optionsRequest.response.status).toEqual(200); + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - expectTypeOf(optionsRequest.response.body).toEqualTypeOf(); - expect(optionsRequest.response.body).toBe(null); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + }); + }); }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/patch.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/patch.ts index cb490876..ab6451fa 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/patch.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/patch.ts @@ -1,4 +1,4 @@ -import { beforeEach, expect, expectTypeOf, it } from 'vitest'; +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; @@ -10,15 +10,17 @@ import { JSONValue } from '@/types/json'; import { getCrypto } from '@/utils/crypto'; import { fetchWithTimeout } from '@/utils/fetch'; import { joinURL } from '@/utils/urls'; +import { usingIgnoredConsole } from '@tests/utils/console'; import { expectFetchError } from '@tests/utils/fetch'; import { createInternalHttpInterceptor, usingHttpInterceptor } from '@tests/utils/interceptors'; import NotStartedHttpInterceptorError from '../../../errors/NotStartedHttpInterceptorError'; -import { HttpInterceptorOptions } from '../../../types/options'; +import { HttpInterceptorOptions, UnhandledRequestStrategy } from '../../../types/options'; import { RuntimeSharedHttpInterceptorTestsOptions } from '../types'; +import { verifyUnhandledRequestMessage } from '../utils'; export async function declarePatchHttpInterceptorTests(options: RuntimeSharedHttpInterceptorTestsOptions) { - const { getBaseURL, getInterceptorOptions } = options; + const { platform, type, getBaseURL, getInterceptorOptions } = options; const crypto = await getCrypto(); @@ -276,288 +278,6 @@ export async function declarePatchHttpInterceptorTests(options: RuntimeSharedHtt }); }); - it('should support intercepting PATCH requests having headers restrictions', async () => { - type UserUpdateHeaders = HttpSchema.Headers<{ - 'content-type'?: string; - accept?: string; - }>; - - await usingHttpInterceptor<{ - '/users/:id': { - PATCH: { - request: { - headers: UserUpdateHeaders; - }; - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .patch(`/users/${users[0].id}`) - .with({ - headers: { 'content-type': 'application/json' }, - }) - .with((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - return request.headers.get('accept')?.includes('application/json') ?? false; - }) - .respond((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - return { - status: 200, - body: users[0], - }; - }), - interceptor, - ); - expect(updateHandler).toBeInstanceOf(Handler); - - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); - - const headers = new HttpHeaders({ - 'content-type': 'application/json', - accept: 'application/json', - }); - - let updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH', headers }); - expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - headers.append('accept', 'application/xml'); - - updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH', headers }); - expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(2); - - headers.delete('accept'); - - let updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH', headers }); - await expectFetchError(updateResponsePromise); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(2); - - headers.set('accept', 'application/json'); - headers.set('content-type', 'text/plain'); - - updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH', headers }); - await expectFetchError(updateResponsePromise); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(2); - }); - }); - - it('should support intercepting PATCH requests having search params restrictions', async () => { - type UserUpdateSearchParams = HttpSchema.SearchParams<{ - tag?: string; - }>; - - await usingHttpInterceptor<{ - '/users/:id': { - PATCH: { - request: { - searchParams: UserUpdateSearchParams; - }; - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .patch(`/users/${users[0].id}`) - .with({ - searchParams: { tag: 'admin' }, - }) - .respond((request) => { - expectTypeOf(request.searchParams).toEqualTypeOf>(); - expect(request.searchParams).toBeInstanceOf(HttpSearchParams); - - return { - status: 200, - body: users[0], - }; - }), - interceptor, - ); - expect(updateHandler).toBeInstanceOf(Handler); - - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); - - const searchParams = new HttpSearchParams({ - tag: 'admin', - }); - - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { - method: 'PATCH', - }); - expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - searchParams.delete('tag'); - - const updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { - method: 'PATCH', - }); - await expectFetchError(updateResponsePromise); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - }); - }); - - it('should support intercepting PATCH requests having body restrictions', async () => { - type UserUpdateBody = JSONValue; - - await usingHttpInterceptor<{ - '/users/:id': { - PATCH: { - request: { - body: UserUpdateBody; - }; - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .patch(`/users/${users[0].id}`) - .with({ - body: { ...users[0], name: users[1].name }, - }) - .respond((request) => { - expectTypeOf(request.body).toEqualTypeOf(); - - return { - status: 200, - body: users[0], - }; - }), - interceptor, - ); - expect(updateHandler).toBeInstanceOf(Handler); - - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); - - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PATCH', - body: JSON.stringify({ - ...users[0], - name: users[1].name, - } satisfies UserUpdateBody), - }); - expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - const updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PATCH', - body: JSON.stringify({ - ...users[0], - name: users[0].name, - } satisfies UserUpdateBody), - }); - await expectFetchError(updateResponsePromise); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - }); - }); - - it('should support intercepting PATCH requests with a dynamic path', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PATCH: { - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const genericUpdateHandler = await promiseIfRemote( - interceptor.patch('/users/:id').respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - expect(genericUpdateHandler).toBeInstanceOf(Handler); - - let genericUpdateRequests = await promiseIfRemote(genericUpdateHandler.requests(), interceptor); - expect(genericUpdateRequests).toHaveLength(0); - - const genericUpdateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - expect(genericUpdateResponse.status).toBe(200); - - const genericUpdatedUser = (await genericUpdateResponse.json()) as User; - expect(genericUpdatedUser).toEqual(users[0]); - - genericUpdateRequests = await promiseIfRemote(genericUpdateHandler.requests(), interceptor); - expect(genericUpdateRequests).toHaveLength(1); - const [genericUpdateRequest] = genericUpdateRequests; - expect(genericUpdateRequest).toBeInstanceOf(Request); - - expectTypeOf(genericUpdateRequest.body).toEqualTypeOf(); - expect(genericUpdateRequest.body).toBe(null); - - expectTypeOf(genericUpdateRequest.response.status).toEqualTypeOf<200>(); - expect(genericUpdateRequest.response.status).toEqual(200); - - expectTypeOf(genericUpdateRequest.response.body).toEqualTypeOf(); - expect(genericUpdateRequest.response.body).toEqual(users[0]); - - await promiseIfRemote(genericUpdateHandler.bypass(), interceptor); - - const specificUpdateHandler = await promiseIfRemote( - interceptor.patch(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - expect(specificUpdateHandler).toBeInstanceOf(Handler); - - let specificUpdateRequests = await promiseIfRemote(specificUpdateHandler.requests(), interceptor); - expect(specificUpdateRequests).toHaveLength(0); - - const specificUpdateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - expect(specificUpdateResponse.status).toBe(200); - - const specificUpdatedUser = (await specificUpdateResponse.json()) as User; - expect(specificUpdatedUser).toEqual(users[0]); - - specificUpdateRequests = await promiseIfRemote(specificUpdateHandler.requests(), interceptor); - expect(specificUpdateRequests).toHaveLength(1); - const [specificUpdateRequest] = specificUpdateRequests; - expect(specificUpdateRequest).toBeInstanceOf(Request); - - expectTypeOf(specificUpdateRequest.body).toEqualTypeOf(); - expect(specificUpdateRequest.body).toBe(null); - - expectTypeOf(specificUpdateRequest.response.status).toEqualTypeOf<200>(); - expect(specificUpdateRequest.response.status).toEqual(200); - - expectTypeOf(specificUpdateRequest.response.body).toEqualTypeOf(); - expect(specificUpdateRequest.response.body).toEqual(users[0]); - - const unmatchedUpdatePromise = fetch(joinURL(baseURL, `/users/${users[1].id}`), { method: 'PATCH' }); - await expectFetchError(unmatchedUpdatePromise); - }); - }); - it('should not intercept a PATCH request without a registered response', async () => { await usingHttpInterceptor<{ '/users/:id': { @@ -723,254 +443,596 @@ export async function declarePatchHttpInterceptorTests(options: RuntimeSharedHtt }); }); - it('should ignore handlers with bypassed responses when intercepting PATCH requests', async () => { - type ServerErrorResponseBody = JSONValue<{ - message: string; - }>; - - await usingHttpInterceptor<{ - '/users/:id': { - PATCH: { - response: { - 200: { body: User }; - 500: { body: ServerErrorResponseBody }; + describe('Dynamic paths', () => { + it('should support intercepting PATCH requests with a dynamic path', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .patch(`/users/${users[0].id}`) - .respond({ + }>(interceptorOptions, async (interceptor) => { + const genericUpdateHandler = await promiseIfRemote( + interceptor.patch('/users/:id').respond({ status: 200, body: users[0], - }) - .bypass(), - interceptor, - ); + }), + interceptor, + ); + expect(genericUpdateHandler).toBeInstanceOf(Handler); - let initialUpdateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(initialUpdateRequests).toHaveLength(0); + let genericUpdateRequests = await promiseIfRemote(genericUpdateHandler.requests(), interceptor); + expect(genericUpdateRequests).toHaveLength(0); - const updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - await expectFetchError(updatePromise); + const genericUpdateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + expect(genericUpdateResponse.status).toBe(200); - await promiseIfRemote( - updateHandler.respond({ - status: 200, - body: users[1], - }), - interceptor, - ); + const genericUpdatedUser = (await genericUpdateResponse.json()) as User; + expect(genericUpdatedUser).toEqual(users[0]); - initialUpdateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(initialUpdateRequests).toHaveLength(0); - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); + genericUpdateRequests = await promiseIfRemote(genericUpdateHandler.requests(), interceptor); + expect(genericUpdateRequests).toHaveLength(1); + const [genericUpdateRequest] = genericUpdateRequests; + expect(genericUpdateRequest).toBeInstanceOf(Request); - let updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - expect(updateResponse.status).toBe(200); + expectTypeOf(genericUpdateRequest.body).toEqualTypeOf(); + expect(genericUpdateRequest.body).toBe(null); - let createdUsers = (await updateResponse.json()) as User; - expect(createdUsers).toEqual(users[1]); + expectTypeOf(genericUpdateRequest.response.status).toEqualTypeOf<200>(); + expect(genericUpdateRequest.response.status).toEqual(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - let [updateRequest] = updateRequests; - expect(updateRequest).toBeInstanceOf(Request); + expectTypeOf(genericUpdateRequest.response.body).toEqualTypeOf(); + expect(genericUpdateRequest.response.body).toEqual(users[0]); - expectTypeOf(updateRequest.body).toEqualTypeOf(); - expect(updateRequest.body).toBe(null); + await promiseIfRemote(genericUpdateHandler.bypass(), interceptor); - expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); - expect(updateRequest.response.status).toEqual(200); + const specificUpdateHandler = await promiseIfRemote( + interceptor.patch(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + expect(specificUpdateHandler).toBeInstanceOf(Handler); - expectTypeOf(updateRequest.response.body).toEqualTypeOf(); - expect(updateRequest.response.body).toEqual(users[1]); + let specificUpdateRequests = await promiseIfRemote(specificUpdateHandler.requests(), interceptor); + expect(specificUpdateRequests).toHaveLength(0); - const errorUpdateHandler = await promiseIfRemote( - interceptor.patch(`/users/${users[0].id}`).respond({ - status: 500, - body: { message: 'Internal server error' }, - }), - interceptor, - ); + const specificUpdateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + expect(specificUpdateResponse.status).toBe(200); - let errorUpdateRequests = await promiseIfRemote(errorUpdateHandler.requests(), interceptor); - expect(errorUpdateRequests).toHaveLength(0); + const specificUpdatedUser = (await specificUpdateResponse.json()) as User; + expect(specificUpdatedUser).toEqual(users[0]); - const otherUpdateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - expect(otherUpdateResponse.status).toBe(500); + specificUpdateRequests = await promiseIfRemote(specificUpdateHandler.requests(), interceptor); + expect(specificUpdateRequests).toHaveLength(1); + const [specificUpdateRequest] = specificUpdateRequests; + expect(specificUpdateRequest).toBeInstanceOf(Request); - const serverError = (await otherUpdateResponse.json()) as ServerErrorResponseBody; - expect(serverError).toEqual({ message: 'Internal server error' }); + expectTypeOf(specificUpdateRequest.body).toEqualTypeOf(); + expect(specificUpdateRequest.body).toBe(null); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + expectTypeOf(specificUpdateRequest.response.status).toEqualTypeOf<200>(); + expect(specificUpdateRequest.response.status).toEqual(200); - errorUpdateRequests = await promiseIfRemote(errorUpdateHandler.requests(), interceptor); - expect(errorUpdateRequests).toHaveLength(1); - const [errorUpdateRequest] = errorUpdateRequests; - expect(errorUpdateRequest).toBeInstanceOf(Request); + expectTypeOf(specificUpdateRequest.response.body).toEqualTypeOf(); + expect(specificUpdateRequest.response.body).toEqual(users[0]); - expectTypeOf(errorUpdateRequest.body).toEqualTypeOf(); - expect(errorUpdateRequest.body).toBe(null); + const unmatchedUpdatePromise = fetch(joinURL(baseURL, `/users/${users[1].id}`), { method: 'PATCH' }); + await expectFetchError(unmatchedUpdatePromise); + }); + }); + }); - expectTypeOf(errorUpdateRequest.response.status).toEqualTypeOf<500>(); - expect(errorUpdateRequest.response.status).toEqual(500); + describe('Restrictions', () => { + it('should support intercepting PATCH requests having headers restrictions', async () => { + type UserUpdateHeaders = HttpSchema.Headers<{ + 'content-type'?: string; + accept?: string; + }>; + + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + request: { + headers: UserUpdateHeaders; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .patch(`/users/${users[0].id}`) + .with({ + headers: { 'content-type': 'application/json' }, + }) + .with((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); + + return request.headers.get('accept')?.includes('application/json') ?? false; + }) + .respond((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); + + return { + status: 200, + body: users[0], + }; + }), + interceptor, + ); + expect(updateHandler).toBeInstanceOf(Handler); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + const headers = new HttpHeaders({ + 'content-type': 'application/json', + accept: 'application/json', + }); - expectTypeOf(errorUpdateRequest.response.body).toEqualTypeOf(); - expect(errorUpdateRequest.response.body).toEqual({ message: 'Internal server error' }); + let updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH', headers }); + expect(updateResponse.status).toBe(200); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); - await promiseIfRemote(errorUpdateHandler.bypass(), interceptor); + headers.append('accept', 'application/xml'); - updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - expect(updateResponse.status).toBe(200); + updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH', headers }); + expect(updateResponse.status).toBe(200); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(2); - createdUsers = (await updateResponse.json()) as User; - expect(createdUsers).toEqual(users[1]); + headers.delete('accept'); - errorUpdateRequests = await promiseIfRemote(errorUpdateHandler.requests(), interceptor); - expect(errorUpdateRequests).toHaveLength(1); + let updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH', headers }); + await expectFetchError(updateResponsePromise); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(2); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(2); - [updateRequest] = updateRequests; - expect(updateRequest).toBeInstanceOf(Request); + headers.set('accept', 'application/json'); + headers.set('content-type', 'text/plain'); - expectTypeOf(updateRequest.body).toEqualTypeOf(); - expect(updateRequest.body).toBe(null); + updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH', headers }); + await expectFetchError(updateResponsePromise); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(2); + }); + }); - expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); - expect(updateRequest.response.status).toEqual(200); + it('should support intercepting PATCH requests having search params restrictions', async () => { + type UserUpdateSearchParams = HttpSchema.SearchParams<{ + tag?: string; + }>; - expectTypeOf(updateRequest.response.body).toEqualTypeOf(); - expect(updateRequest.response.body).toEqual(users[1]); + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + request: { + searchParams: UserUpdateSearchParams; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .patch(`/users/${users[0].id}`) + .with({ + searchParams: { tag: 'admin' }, + }) + .respond((request) => { + expectTypeOf(request.searchParams).toEqualTypeOf>(); + expect(request.searchParams).toBeInstanceOf(HttpSearchParams); + + return { + status: 200, + body: users[0], + }; + }), + interceptor, + ); + expect(updateHandler).toBeInstanceOf(Handler); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + const searchParams = new HttpSearchParams({ + tag: 'admin', + }); + + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { + method: 'PATCH', + }); + expect(updateResponse.status).toBe(200); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + searchParams.delete('tag'); + + const updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { + method: 'PATCH', + }); + await expectFetchError(updateResponsePromise); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + }); + }); + + it('should support intercepting PATCH requests having body restrictions', async () => { + type UserUpdateBody = JSONValue; + + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + request: { + body: UserUpdateBody; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .patch(`/users/${users[0].id}`) + .with({ + body: { ...users[0], name: users[1].name }, + }) + .respond((request) => { + expectTypeOf(request.body).toEqualTypeOf(); + + return { + status: 200, + body: users[0], + }; + }), + interceptor, + ); + expect(updateHandler).toBeInstanceOf(Handler); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + body: JSON.stringify({ + ...users[0], + name: users[1].name, + } satisfies UserUpdateBody), + }); + expect(updateResponse.status).toBe(200); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + const updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + body: JSON.stringify({ + ...users[0], + name: users[0].name, + } satisfies UserUpdateBody), + }); + await expectFetchError(updateResponsePromise); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + }); }); }); - it('should ignore all handlers after cleared when intercepting PATCH requests', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PATCH: { - response: { - 200: { body: User }; + describe('Bypass', () => { + it('should ignore handlers with bypassed responses when intercepting PATCH requests', async () => { + type ServerErrorResponseBody = JSONValue<{ + message: string; + }>; + + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + response: { + 200: { body: User }; + 500: { body: ServerErrorResponseBody }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor.patch(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .patch(`/users/${users[0].id}`) + .respond({ + status: 200, + body: users[0], + }) + .bypass(), + interceptor, + ); - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); + let initialUpdateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(initialUpdateRequests).toHaveLength(0); - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - expect(updateResponse.status).toBe(200); + const updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + await expectFetchError(updatePromise); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + await promiseIfRemote( + updateHandler.respond({ + status: 200, + body: users[1], + }), + interceptor, + ); - await promiseIfRemote(interceptor.clear(), interceptor); + initialUpdateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(initialUpdateRequests).toHaveLength(0); + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); - const updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - await expectFetchError(updatePromise); + let updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + let createdUsers = (await updateResponse.json()) as User; + expect(createdUsers).toEqual(users[1]); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + let [updateRequest] = updateRequests; + expect(updateRequest).toBeInstanceOf(Request); + + expectTypeOf(updateRequest.body).toEqualTypeOf(); + expect(updateRequest.body).toBe(null); + + expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); + expect(updateRequest.response.status).toEqual(200); + + expectTypeOf(updateRequest.response.body).toEqualTypeOf(); + expect(updateRequest.response.body).toEqual(users[1]); + + const errorUpdateHandler = await promiseIfRemote( + interceptor.patch(`/users/${users[0].id}`).respond({ + status: 500, + body: { message: 'Internal server error' }, + }), + interceptor, + ); + + let errorUpdateRequests = await promiseIfRemote(errorUpdateHandler.requests(), interceptor); + expect(errorUpdateRequests).toHaveLength(0); + + const otherUpdateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + expect(otherUpdateResponse.status).toBe(500); + + const serverError = (await otherUpdateResponse.json()) as ServerErrorResponseBody; + expect(serverError).toEqual({ message: 'Internal server error' }); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + errorUpdateRequests = await promiseIfRemote(errorUpdateHandler.requests(), interceptor); + expect(errorUpdateRequests).toHaveLength(1); + const [errorUpdateRequest] = errorUpdateRequests; + expect(errorUpdateRequest).toBeInstanceOf(Request); + + expectTypeOf(errorUpdateRequest.body).toEqualTypeOf(); + expect(errorUpdateRequest.body).toBe(null); + + expectTypeOf(errorUpdateRequest.response.status).toEqualTypeOf<500>(); + expect(errorUpdateRequest.response.status).toEqual(500); + + expectTypeOf(errorUpdateRequest.response.body).toEqualTypeOf(); + expect(errorUpdateRequest.response.body).toEqual({ message: 'Internal server error' }); + + await promiseIfRemote(errorUpdateHandler.bypass(), interceptor); + + updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + expect(updateResponse.status).toBe(200); + + createdUsers = (await updateResponse.json()) as User; + expect(createdUsers).toEqual(users[1]); + + errorUpdateRequests = await promiseIfRemote(errorUpdateHandler.requests(), interceptor); + expect(errorUpdateRequests).toHaveLength(1); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(2); + [updateRequest] = updateRequests; + expect(updateRequest).toBeInstanceOf(Request); + + expectTypeOf(updateRequest.body).toEqualTypeOf(); + expect(updateRequest.body).toBe(null); + + expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); + expect(updateRequest.response.status).toEqual(200); + + expectTypeOf(updateRequest.response.body).toEqualTypeOf(); + expect(updateRequest.response.body).toEqual(users[1]); + }); }); }); - it('should ignore all handlers after restarted when intercepting PATCH requests', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PATCH: { - response: { - 200: { body: User }; + describe('Clear', () => { + it('should ignore all handlers after cleared when intercepting PATCH requests', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor.patch(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor.patch(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - expect(updateResponse.status).toBe(200); + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); - expect(interceptor.isRunning()).toBe(true); - await interceptor.stop(); - expect(interceptor.isRunning()).toBe(false); + await promiseIfRemote(interceptor.clear(), interceptor); - let updatePromise = fetchWithTimeout(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PATCH', - timeout: 200, + const updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + await expectFetchError(updatePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); }); - await expectFetchError(updatePromise, { canBeAborted: true }); + }); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + it('should support creating new handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + let updateHandler = await promiseIfRemote( + interceptor.patch(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - await interceptor.start(); - expect(interceptor.isRunning()).toBe(true); + await promiseIfRemote(interceptor.clear(), interceptor); - updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - await expectFetchError(updatePromise); + updateHandler = await promiseIfRemote( + interceptor.patch(`/users/${users[0].id}`).respond({ + status: 200, + body: users[1], + }), + interceptor, + ); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + expect(updateResponse.status).toBe(200); + + const updatedUsers = (await updateResponse.json()) as User; + expect(updatedUsers).toEqual(users[1]); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + const [updateRequest] = updateRequests; + expect(updateRequest).toBeInstanceOf(Request); + + expectTypeOf(updateRequest.body).toEqualTypeOf(); + expect(updateRequest.body).toBe(null); + + expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); + expect(updateRequest.response.status).toEqual(200); + + expectTypeOf(updateRequest.response.body).toEqualTypeOf(); + expect(updateRequest.response.body).toEqual(users[1]); + }); + }); + + it('should support reusing current handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor.patch(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + await promiseIfRemote(interceptor.clear(), interceptor); + + await promiseIfRemote( + updateHandler.respond({ + status: 200, + body: users[1], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + expect(updateResponse.status).toBe(200); + + const updatedUsers = (await updateResponse.json()) as User; + expect(updatedUsers).toEqual(users[1]); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + const [updateRequest] = updateRequests; + expect(updateRequest).toBeInstanceOf(Request); + + expectTypeOf(updateRequest.body).toEqualTypeOf(); + expect(updateRequest.body).toBe(null); + + expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); + expect(updateRequest.response.status).toEqual(200); + + expectTypeOf(updateRequest.response.body).toEqualTypeOf(); + expect(updateRequest.response.body).toEqual(users[1]); + }); }); }); - it('should ignore all handlers after restarted when intercepting PATCH requests, even if another interceptor is still running', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PATCH: { - response: { - 200: { body: User }; + describe('Life cycle', () => { + it('should ignore all handlers after restarted when intercepting PATCH requests', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor.patch(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor.patch(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - expect(updateResponse.status).toBe(200); + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); - await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); - await interceptor.stop(); expect(interceptor.isRunning()).toBe(false); - expect(otherInterceptor.isRunning()).toBe(true); let updatePromise = fetchWithTimeout(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH', @@ -983,7 +1045,6 @@ export async function declarePatchHttpInterceptorTests(options: RuntimeSharedHtt await interceptor.start(); expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); await expectFetchError(updatePromise); @@ -992,120 +1053,428 @@ export async function declarePatchHttpInterceptorTests(options: RuntimeSharedHtt expect(updateRequests).toHaveLength(1); }); }); - }); - - it('should throw an error when trying to create a PATCH request handler if not running', async () => { - const interceptor = createInternalHttpInterceptor(interceptorOptions); - expect(interceptor.isRunning()).toBe(false); - - await expect(async () => { - await interceptor.patch('/'); - }).rejects.toThrowError(new NotStartedHttpInterceptorError()); - }); - it('should support creating new handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PATCH: { - response: { - 200: { body: User }; + it('should ignore all handlers after restarted when intercepting PATCH requests, even if another interceptor is still running', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - let updateHandler = await promiseIfRemote( - interceptor.patch(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor.patch(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - await promiseIfRemote(interceptor.clear(), interceptor); + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); - updateHandler = await promiseIfRemote( - interceptor.patch(`/users/${users[0].id}`).respond({ - status: 200, - body: users[1], - }), - interceptor, - ); + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + expect(updateResponse.status).toBe(200); - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - expect(updateResponse.status).toBe(200); + await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); - const updatedUsers = (await updateResponse.json()) as User; - expect(updatedUsers).toEqual(users[1]); + await interceptor.stop(); + expect(interceptor.isRunning()).toBe(false); + expect(otherInterceptor.isRunning()).toBe(true); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - const [updateRequest] = updateRequests; - expect(updateRequest).toBeInstanceOf(Request); + let updatePromise = fetchWithTimeout(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + timeout: 200, + }); + await expectFetchError(updatePromise, { canBeAborted: true }); - expectTypeOf(updateRequest.body).toEqualTypeOf(); - expect(updateRequest.body).toBe(null); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); - expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); - expect(updateRequest.response.status).toEqual(200); + await interceptor.start(); + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); - expectTypeOf(updateRequest.response.body).toEqualTypeOf(); - expect(updateRequest.response.body).toEqual(users[1]); + updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + await expectFetchError(updatePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + }); + }); + }); + + it('should throw an error when trying to create a PATCH request handler if not running', async () => { + const interceptor = createInternalHttpInterceptor(interceptorOptions); + expect(interceptor.isRunning()).toBe(false); + + await expect(async () => { + await interceptor.patch('/'); + }).rejects.toThrowError(new NotStartedHttpInterceptorError()); }); }); - it('should support reusing current handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PATCH: { - response: { - 200: { body: User }; + describe('Unhandled requests', () => { + if (type === 'local') { + it('should show a warning when logging is enabled and a PATCH request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .patch(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + const updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: updateRequest, + }); + }); + }); + }); + } + + if (type === 'remote') { + it('should show an error when logging is enabled and a PATCH request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .patch(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '2' }, + }); + const updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: updateRequest, + }); + }); + }); + }); + } + + it('should support a custom unhandled PATCH request handler', async () => { + const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { + const url = new URL(request.url); + + if (!url.searchParams.has('name')) { + await context.log(); + } + }); + + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + request: { + headers: { 'x-value': string }; + searchParams: { name?: string }; + }; + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor.patch(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .patch(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const searchParams = new HttpSearchParams({ name: 'User 1' }); + + let updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { + method: 'PATCH', + headers: { 'x-value': '2' }, + }); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '2' }, + }); + updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(2); + + const messageType = type === 'local' ? 'warn' : 'error'; + expect(spies.warn).toHaveBeenCalledTimes(messageType === 'warn' ? 1 : 0); + expect(spies.error).toHaveBeenCalledTimes(messageType === 'error' ? 1 : 0); + + const errorMessage = spies[messageType].mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: messageType, + platform, + request: updateRequest, + }); + }); + }); + }); - await promiseIfRemote(interceptor.clear(), interceptor); + it('should log an error if a custom unhandled PATCH request handler throws', async () => { + const error = new Error('Unhandled request.'); - await promiseIfRemote( - updateHandler.respond({ - status: 200, - body: users[1], - }), - interceptor, - ); + const onUnhandledRequest = vi.fn((request: Request) => { + const url = new URL(request.url); - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); + if (!url.searchParams.has('name')) { + throw error; + } + }); - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - expect(updateResponse.status).toBe(200); + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + request: { + headers: { 'x-value': string }; + searchParams: { name?: string }; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .patch(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - const updatedUsers = (await updateResponse.json()) as User; - expect(updatedUsers).toEqual(users[1]); + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - const [updateRequest] = updateRequests; - expect(updateRequest).toBeInstanceOf(Request); + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); - expectTypeOf(updateRequest.body).toEqualTypeOf(); - expect(updateRequest.body).toBe(null); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); - expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); - expect(updateRequest.response.status).toEqual(200); + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); - expectTypeOf(updateRequest.response.body).toEqualTypeOf(); - expect(updateRequest.response.body).toEqual(users[1]); + const searchParams = new HttpSearchParams({ name: 'User 1' }); + + let updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { + method: 'PATCH', + headers: { 'x-value': '2' }, + }); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '2' }, + }); + updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(2); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + expect(spies.error).toHaveBeenCalledWith(error); + }); + }); + }); + + it('should not show a warning or error when logging is disabled and a PATCH request is unhandled', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .patch(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '2' }, + }); + const updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + }); + }); }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/post.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/post.ts index 6b1be8e4..9c5a8a06 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/post.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/post.ts @@ -1,4 +1,4 @@ -import { beforeEach, expect, expectTypeOf, it } from 'vitest'; +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; @@ -10,15 +10,17 @@ import { JSONValue } from '@/types/json'; import { getCrypto } from '@/utils/crypto'; import { fetchWithTimeout } from '@/utils/fetch'; import { joinURL } from '@/utils/urls'; +import { usingIgnoredConsole } from '@tests/utils/console'; import { expectFetchError } from '@tests/utils/fetch'; import { createInternalHttpInterceptor, usingHttpInterceptor } from '@tests/utils/interceptors'; import NotStartedHttpInterceptorError from '../../../errors/NotStartedHttpInterceptorError'; -import { HttpInterceptorOptions } from '../../../types/options'; +import { HttpInterceptorOptions, UnhandledRequestStrategy } from '../../../types/options'; import { RuntimeSharedHttpInterceptorTestsOptions } from '../types'; +import { verifyUnhandledRequestMessage } from '../utils'; export async function declarePostHttpInterceptorTests(options: RuntimeSharedHttpInterceptorTestsOptions) { - const { getBaseURL, getInterceptorOptions } = options; + const { platform, type, getBaseURL, getInterceptorOptions } = options; const crypto = await getCrypto(); @@ -287,278 +289,6 @@ export async function declarePostHttpInterceptorTests(options: RuntimeSharedHttp }); }); - it('should support intercepting POST requests having headers restrictions', async () => { - type UserCreationHeaders = HttpSchema.Headers<{ - 'content-type'?: string; - accept?: string; - }>; - - await usingHttpInterceptor<{ - '/users': { - POST: { - request: { - headers: UserCreationHeaders; - }; - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const creationHandler = await promiseIfRemote( - interceptor - .post('/users') - .with({ - headers: { 'content-type': 'application/json' }, - }) - .with((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - return request.headers.get('accept')?.includes('application/json') ?? false; - }) - .respond((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - return { - status: 200, - body: users[0], - }; - }), - interceptor, - ); - expect(creationHandler).toBeInstanceOf(Handler); - - let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(0); - - const headers = new HttpHeaders({ - 'content-type': 'application/json', - accept: 'application/json', - }); - - let creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST', headers }); - expect(creationResponse.status).toBe(200); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - - headers.append('accept', 'application/xml'); - - creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST', headers }); - expect(creationResponse.status).toBe(200); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(2); - - headers.delete('accept'); - - let creationResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'POST', headers }); - await expectFetchError(creationResponsePromise); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(2); - - headers.set('accept', 'application/json'); - headers.set('content-type', 'text/plain'); - - creationResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'POST', headers }); - await expectFetchError(creationResponsePromise); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(2); - }); - }); - - it('should support intercepting POST requests having search params restrictions', async () => { - type UserCreationSearchParams = HttpSchema.SearchParams<{ - tag?: string; - }>; - - await usingHttpInterceptor<{ - '/users': { - POST: { - request: { - searchParams: UserCreationSearchParams; - }; - response: { - 201: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const creationHandler = await promiseIfRemote( - interceptor - .post('/users') - .with({ - searchParams: { tag: 'admin' }, - }) - .respond((request) => { - expectTypeOf(request.searchParams).toEqualTypeOf>(); - expect(request.searchParams).toBeInstanceOf(HttpSearchParams); - - return { - status: 201, - body: users[0], - }; - }), - interceptor, - ); - expect(creationHandler).toBeInstanceOf(Handler); - - let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(0); - - const searchParams = new HttpSearchParams({ - tag: 'admin', - }); - - const creationResponse = await fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { method: 'POST' }); - expect(creationResponse.status).toBe(201); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - - searchParams.delete('tag'); - - const creationResponsePromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { method: 'POST' }); - await expectFetchError(creationResponsePromise); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - }); - }); - - it('should support intercepting POST requests having body restrictions', async () => { - await usingHttpInterceptor<{ - '/users': { - POST: { - request: { - body: UserCreationBody; - }; - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const creationHandler = await promiseIfRemote( - interceptor - .post('/users') - .with({ - body: { name: users[0].name }, - }) - .respond((request) => { - expectTypeOf(request.body).toEqualTypeOf(); - - return { - status: 200, - body: users[0], - }; - }), - interceptor, - ); - expect(creationHandler).toBeInstanceOf(Handler); - - let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(0); - - const creationResponse = await fetch(joinURL(baseURL, '/users'), { - method: 'POST', - body: JSON.stringify(users[0] satisfies UserCreationBody), - }); - expect(creationResponse.status).toBe(200); - - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - - const creationResponsePromise = fetch(joinURL(baseURL, '/users'), { - method: 'POST', - body: JSON.stringify(users[1] satisfies UserCreationBody), - }); - await expectFetchError(creationResponsePromise); - - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - }); - }); - - it('should support intercepting POST requests with a dynamic path', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - POST: { - response: { - 201: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const genericCreationHandler = await promiseIfRemote( - interceptor.post('/users/:id').respond({ - status: 201, - body: users[0], - }), - interceptor, - ); - expect(genericCreationHandler).toBeInstanceOf(Handler); - - let genericCreationRequests = await promiseIfRemote(genericCreationHandler.requests(), interceptor); - expect(genericCreationRequests).toHaveLength(0); - - const genericCreationResponse = await fetch(joinURL(baseURL, `/users/${1}`), { method: 'POST' }); - expect(genericCreationResponse.status).toBe(201); - - const genericCreatedUser = (await genericCreationResponse.json()) as User; - expect(genericCreatedUser).toEqual(users[0]); - - genericCreationRequests = await promiseIfRemote(genericCreationHandler.requests(), interceptor); - expect(genericCreationRequests).toHaveLength(1); - const [genericCreationRequest] = genericCreationRequests; - expect(genericCreationRequest).toBeInstanceOf(Request); - - expectTypeOf(genericCreationRequest.body).toEqualTypeOf(); - expect(genericCreationRequest.body).toBe(null); - - expectTypeOf(genericCreationRequest.response.status).toEqualTypeOf<201>(); - expect(genericCreationRequest.response.status).toEqual(201); - - expectTypeOf(genericCreationRequest.response.body).toEqualTypeOf(); - expect(genericCreationRequest.response.body).toEqual(users[0]); - - await promiseIfRemote(genericCreationHandler.bypass(), interceptor); - - const specificCreationHandler = await promiseIfRemote( - interceptor.post(`/users/${1}`).respond({ - status: 201, - body: users[0], - }), - interceptor, - ); - expect(specificCreationHandler).toBeInstanceOf(Handler); - - let specificCreationRequests = await promiseIfRemote(specificCreationHandler.requests(), interceptor); - expect(specificCreationRequests).toHaveLength(0); - - const specificCreationResponse = await fetch(joinURL(baseURL, `/users/${1}`), { method: 'POST' }); - expect(specificCreationResponse.status).toBe(201); - - const specificCreatedUser = (await specificCreationResponse.json()) as User; - expect(specificCreatedUser).toEqual(users[0]); - - specificCreationRequests = await promiseIfRemote(specificCreationHandler.requests(), interceptor); - expect(specificCreationRequests).toHaveLength(1); - const [specificCreationRequest] = specificCreationRequests; - expect(specificCreationRequest).toBeInstanceOf(Request); - - expectTypeOf(specificCreationRequest.body).toEqualTypeOf(); - expect(specificCreationRequest.body).toBe(null); - - expectTypeOf(specificCreationRequest.response.status).toEqualTypeOf<201>(); - expect(specificCreationRequest.response.status).toEqual(201); - - expectTypeOf(specificCreationRequest.response.body).toEqualTypeOf(); - expect(specificCreationRequest.response.body).toEqual(users[0]); - - const unmatchedCreationPromise = fetch(joinURL(baseURL, `/users/${2}`), { method: 'POST' }); - await expectFetchError(unmatchedCreationPromise); - }); - }); - it('should not intercept a POST request without a registered response', async () => { await usingHttpInterceptor<{ '/users': { @@ -732,254 +462,590 @@ export async function declarePostHttpInterceptorTests(options: RuntimeSharedHttp }); }); - it('should ignore handlers with bypassed responses when intercepting POST requests', async () => { - type ServerErrorResponseBody = JSONValue<{ - message: string; - }>; - - await usingHttpInterceptor<{ - '/users': { - POST: { - response: { - 201: { body: User }; - 500: { body: ServerErrorResponseBody }; + describe('Dynamic paths', () => { + it('should support intercepting POST requests with a dynamic path', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + POST: { + response: { + 201: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const creationHandler = await promiseIfRemote( - interceptor - .post('/users') - .respond({ + }>(interceptorOptions, async (interceptor) => { + const genericCreationHandler = await promiseIfRemote( + interceptor.post('/users/:id').respond({ status: 201, body: users[0], - }) - .bypass(), - interceptor, - ); + }), + interceptor, + ); + expect(genericCreationHandler).toBeInstanceOf(Handler); - let initialCreationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(initialCreationRequests).toHaveLength(0); + let genericCreationRequests = await promiseIfRemote(genericCreationHandler.requests(), interceptor); + expect(genericCreationRequests).toHaveLength(0); - const creationPromise = fetch(joinURL(baseURL, '/users'), { method: 'POST' }); - await expectFetchError(creationPromise); + const genericCreationResponse = await fetch(joinURL(baseURL, `/users/${1}`), { method: 'POST' }); + expect(genericCreationResponse.status).toBe(201); - await promiseIfRemote( - creationHandler.respond({ - status: 201, - body: users[1], - }), - interceptor, - ); + const genericCreatedUser = (await genericCreationResponse.json()) as User; + expect(genericCreatedUser).toEqual(users[0]); - initialCreationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(initialCreationRequests).toHaveLength(0); - let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(0); + genericCreationRequests = await promiseIfRemote(genericCreationHandler.requests(), interceptor); + expect(genericCreationRequests).toHaveLength(1); + const [genericCreationRequest] = genericCreationRequests; + expect(genericCreationRequest).toBeInstanceOf(Request); - let creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); - expect(creationResponse.status).toBe(201); + expectTypeOf(genericCreationRequest.body).toEqualTypeOf(); + expect(genericCreationRequest.body).toBe(null); - let createdUsers = (await creationResponse.json()) as User; - expect(createdUsers).toEqual(users[1]); + expectTypeOf(genericCreationRequest.response.status).toEqualTypeOf<201>(); + expect(genericCreationRequest.response.status).toEqual(201); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - let [creationRequest] = creationRequests; - expect(creationRequest).toBeInstanceOf(Request); + expectTypeOf(genericCreationRequest.response.body).toEqualTypeOf(); + expect(genericCreationRequest.response.body).toEqual(users[0]); - expectTypeOf(creationRequest.body).toEqualTypeOf(); - expect(creationRequest.body).toBe(null); + await promiseIfRemote(genericCreationHandler.bypass(), interceptor); - expectTypeOf(creationRequest.response.status).toEqualTypeOf<201>(); - expect(creationRequest.response.status).toEqual(201); + const specificCreationHandler = await promiseIfRemote( + interceptor.post(`/users/${1}`).respond({ + status: 201, + body: users[0], + }), + interceptor, + ); + expect(specificCreationHandler).toBeInstanceOf(Handler); - expectTypeOf(creationRequest.response.body).toEqualTypeOf(); - expect(creationRequest.response.body).toEqual(users[1]); + let specificCreationRequests = await promiseIfRemote(specificCreationHandler.requests(), interceptor); + expect(specificCreationRequests).toHaveLength(0); - const errorCreationHandler = await promiseIfRemote( - interceptor.post('/users').respond({ - status: 500, - body: { message: 'Internal server error' }, - }), - interceptor, - ); + const specificCreationResponse = await fetch(joinURL(baseURL, `/users/${1}`), { method: 'POST' }); + expect(specificCreationResponse.status).toBe(201); - let errorCreationRequests = await promiseIfRemote(errorCreationHandler.requests(), interceptor); - expect(errorCreationRequests).toHaveLength(0); + const specificCreatedUser = (await specificCreationResponse.json()) as User; + expect(specificCreatedUser).toEqual(users[0]); - const otherCreationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); - expect(otherCreationResponse.status).toBe(500); + specificCreationRequests = await promiseIfRemote(specificCreationHandler.requests(), interceptor); + expect(specificCreationRequests).toHaveLength(1); + const [specificCreationRequest] = specificCreationRequests; + expect(specificCreationRequest).toBeInstanceOf(Request); - const serverError = (await otherCreationResponse.json()) as ServerErrorResponseBody; - expect(serverError).toEqual({ message: 'Internal server error' }); + expectTypeOf(specificCreationRequest.body).toEqualTypeOf(); + expect(specificCreationRequest.body).toBe(null); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); + expectTypeOf(specificCreationRequest.response.status).toEqualTypeOf<201>(); + expect(specificCreationRequest.response.status).toEqual(201); - errorCreationRequests = await promiseIfRemote(errorCreationHandler.requests(), interceptor); - expect(errorCreationRequests).toHaveLength(1); - const [errorCreationRequest] = errorCreationRequests; - expect(errorCreationRequest).toBeInstanceOf(Request); + expectTypeOf(specificCreationRequest.response.body).toEqualTypeOf(); + expect(specificCreationRequest.response.body).toEqual(users[0]); - expectTypeOf(errorCreationRequest.body).toEqualTypeOf(); - expect(errorCreationRequest.body).toBe(null); + const unmatchedCreationPromise = fetch(joinURL(baseURL, `/users/${2}`), { method: 'POST' }); + await expectFetchError(unmatchedCreationPromise); + }); + }); + }); - expectTypeOf(errorCreationRequest.response.status).toEqualTypeOf<500>(); - expect(errorCreationRequest.response.status).toEqual(500); + describe('Restrictions', () => { + it('should support intercepting POST requests having headers restrictions', async () => { + type UserCreationHeaders = HttpSchema.Headers<{ + 'content-type'?: string; + accept?: string; + }>; + + await usingHttpInterceptor<{ + '/users': { + POST: { + request: { + headers: UserCreationHeaders; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor + .post('/users') + .with({ + headers: { 'content-type': 'application/json' }, + }) + .with((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); + + return request.headers.get('accept')?.includes('application/json') ?? false; + }) + .respond((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); + + return { + status: 200, + body: users[0], + }; + }), + interceptor, + ); + expect(creationHandler).toBeInstanceOf(Handler); + + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); + + const headers = new HttpHeaders({ + 'content-type': 'application/json', + accept: 'application/json', + }); - expectTypeOf(errorCreationRequest.response.body).toEqualTypeOf(); - expect(errorCreationRequest.response.body).toEqual({ message: 'Internal server error' }); + let creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST', headers }); + expect(creationResponse.status).toBe(200); + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); - await promiseIfRemote(errorCreationHandler.bypass(), interceptor); + headers.append('accept', 'application/xml'); - creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); - expect(creationResponse.status).toBe(201); + creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST', headers }); + expect(creationResponse.status).toBe(200); + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(2); - createdUsers = (await creationResponse.json()) as User; - expect(createdUsers).toEqual(users[1]); + headers.delete('accept'); - errorCreationRequests = await promiseIfRemote(errorCreationHandler.requests(), interceptor); - expect(errorCreationRequests).toHaveLength(1); + let creationResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'POST', headers }); + await expectFetchError(creationResponsePromise); + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(2); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(2); - [creationRequest] = creationRequests; - expect(creationRequest).toBeInstanceOf(Request); + headers.set('accept', 'application/json'); + headers.set('content-type', 'text/plain'); - expectTypeOf(creationRequest.body).toEqualTypeOf(); - expect(creationRequest.body).toBe(null); + creationResponsePromise = fetch(joinURL(baseURL, '/users'), { method: 'POST', headers }); + await expectFetchError(creationResponsePromise); + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(2); + }); + }); - expectTypeOf(creationRequest.response.status).toEqualTypeOf<201>(); - expect(creationRequest.response.status).toEqual(201); + it('should support intercepting POST requests having search params restrictions', async () => { + type UserCreationSearchParams = HttpSchema.SearchParams<{ + tag?: string; + }>; - expectTypeOf(creationRequest.response.body).toEqualTypeOf(); - expect(creationRequest.response.body).toEqual(users[1]); + await usingHttpInterceptor<{ + '/users': { + POST: { + request: { + searchParams: UserCreationSearchParams; + }; + response: { + 201: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor + .post('/users') + .with({ + searchParams: { tag: 'admin' }, + }) + .respond((request) => { + expectTypeOf(request.searchParams).toEqualTypeOf>(); + expect(request.searchParams).toBeInstanceOf(HttpSearchParams); + + return { + status: 201, + body: users[0], + }; + }), + interceptor, + ); + expect(creationHandler).toBeInstanceOf(Handler); + + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); + + const searchParams = new HttpSearchParams({ + tag: 'admin', + }); + + const creationResponse = await fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { method: 'POST' }); + expect(creationResponse.status).toBe(201); + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + searchParams.delete('tag'); + + const creationResponsePromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { + method: 'POST', + }); + await expectFetchError(creationResponsePromise); + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + }); + }); + + it('should support intercepting POST requests having body restrictions', async () => { + await usingHttpInterceptor<{ + '/users': { + POST: { + request: { + body: UserCreationBody; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor + .post('/users') + .with({ + body: { name: users[0].name }, + }) + .respond((request) => { + expectTypeOf(request.body).toEqualTypeOf(); + + return { + status: 200, + body: users[0], + }; + }), + interceptor, + ); + expect(creationHandler).toBeInstanceOf(Handler); + + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); + + const creationResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'POST', + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + expect(creationResponse.status).toBe(200); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + const creationResponsePromise = fetch(joinURL(baseURL, '/users'), { + method: 'POST', + body: JSON.stringify(users[1] satisfies UserCreationBody), + }); + await expectFetchError(creationResponsePromise); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + }); }); }); - it('should ignore all handlers after cleared when intercepting POST requests', async () => { - await usingHttpInterceptor<{ - '/users': { - POST: { - response: { - 201: { body: User }; + describe('Bypass', () => { + it('should ignore handlers with bypassed responses when intercepting POST requests', async () => { + type ServerErrorResponseBody = JSONValue<{ + message: string; + }>; + + await usingHttpInterceptor<{ + '/users': { + POST: { + response: { + 201: { body: User }; + 500: { body: ServerErrorResponseBody }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const creationHandler = await promiseIfRemote( - interceptor.post('/users').respond({ - status: 201, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor + .post('/users') + .respond({ + status: 201, + body: users[0], + }) + .bypass(), + interceptor, + ); - let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(0); + let initialCreationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(initialCreationRequests).toHaveLength(0); - const creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); - expect(creationResponse.status).toBe(201); + const creationPromise = fetch(joinURL(baseURL, '/users'), { method: 'POST' }); + await expectFetchError(creationPromise); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); + await promiseIfRemote( + creationHandler.respond({ + status: 201, + body: users[1], + }), + interceptor, + ); - await promiseIfRemote(interceptor.clear(), interceptor); + initialCreationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(initialCreationRequests).toHaveLength(0); + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); - const creationPromise = fetch(joinURL(baseURL, '/users'), { method: 'POST' }); - await expectFetchError(creationPromise); + let creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); + expect(creationResponse.status).toBe(201); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); + let createdUsers = (await creationResponse.json()) as User; + expect(createdUsers).toEqual(users[1]); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + let [creationRequest] = creationRequests; + expect(creationRequest).toBeInstanceOf(Request); + + expectTypeOf(creationRequest.body).toEqualTypeOf(); + expect(creationRequest.body).toBe(null); + + expectTypeOf(creationRequest.response.status).toEqualTypeOf<201>(); + expect(creationRequest.response.status).toEqual(201); + + expectTypeOf(creationRequest.response.body).toEqualTypeOf(); + expect(creationRequest.response.body).toEqual(users[1]); + + const errorCreationHandler = await promiseIfRemote( + interceptor.post('/users').respond({ + status: 500, + body: { message: 'Internal server error' }, + }), + interceptor, + ); + + let errorCreationRequests = await promiseIfRemote(errorCreationHandler.requests(), interceptor); + expect(errorCreationRequests).toHaveLength(0); + + const otherCreationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); + expect(otherCreationResponse.status).toBe(500); + + const serverError = (await otherCreationResponse.json()) as ServerErrorResponseBody; + expect(serverError).toEqual({ message: 'Internal server error' }); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + errorCreationRequests = await promiseIfRemote(errorCreationHandler.requests(), interceptor); + expect(errorCreationRequests).toHaveLength(1); + const [errorCreationRequest] = errorCreationRequests; + expect(errorCreationRequest).toBeInstanceOf(Request); + + expectTypeOf(errorCreationRequest.body).toEqualTypeOf(); + expect(errorCreationRequest.body).toBe(null); + + expectTypeOf(errorCreationRequest.response.status).toEqualTypeOf<500>(); + expect(errorCreationRequest.response.status).toEqual(500); + + expectTypeOf(errorCreationRequest.response.body).toEqualTypeOf(); + expect(errorCreationRequest.response.body).toEqual({ + message: 'Internal server error', + }); + + await promiseIfRemote(errorCreationHandler.bypass(), interceptor); + + creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); + expect(creationResponse.status).toBe(201); + + createdUsers = (await creationResponse.json()) as User; + expect(createdUsers).toEqual(users[1]); + + errorCreationRequests = await promiseIfRemote(errorCreationHandler.requests(), interceptor); + expect(errorCreationRequests).toHaveLength(1); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(2); + [creationRequest] = creationRequests; + expect(creationRequest).toBeInstanceOf(Request); + + expectTypeOf(creationRequest.body).toEqualTypeOf(); + expect(creationRequest.body).toBe(null); + + expectTypeOf(creationRequest.response.status).toEqualTypeOf<201>(); + expect(creationRequest.response.status).toEqual(201); + + expectTypeOf(creationRequest.response.body).toEqualTypeOf(); + expect(creationRequest.response.body).toEqual(users[1]); + }); }); }); - it('should ignore all handlers after restarted when intercepting POST requests', async () => { - await usingHttpInterceptor<{ - '/users': { - POST: { - response: { - 201: { body: User }; + describe('Clear', () => { + it('should ignore all handlers after cleared when intercepting POST requests', async () => { + await usingHttpInterceptor<{ + '/users': { + POST: { + response: { + 201: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const creationHandler = await promiseIfRemote( - interceptor.post('/users').respond({ - status: 201, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor.post('/users').respond({ + status: 201, + body: users[0], + }), + interceptor, + ); - let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(0); + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); - const creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); - expect(creationResponse.status).toBe(201); + const creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); + expect(creationResponse.status).toBe(201); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); - expect(interceptor.isRunning()).toBe(true); - await interceptor.stop(); - expect(interceptor.isRunning()).toBe(false); + await promiseIfRemote(interceptor.clear(), interceptor); - let creationPromise = fetchWithTimeout(joinURL(baseURL, '/users'), { - method: 'POST', - timeout: 200, + const creationPromise = fetch(joinURL(baseURL, '/users'), { method: 'POST' }); + await expectFetchError(creationPromise); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); }); - await expectFetchError(creationPromise, { canBeAborted: true }); + }); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); + it('should support creating new handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/users': { + POST: { + response: { + 201: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + let creationHandler = await promiseIfRemote( + interceptor.post('/users').respond({ + status: 201, + body: users[0], + }), + interceptor, + ); - await interceptor.start(); - expect(interceptor.isRunning()).toBe(true); + await promiseIfRemote(interceptor.clear(), interceptor); - creationPromise = fetch(joinURL(baseURL, '/users'), { method: 'POST' }); - await expectFetchError(creationPromise); + creationHandler = await promiseIfRemote( + interceptor.post('/users').respond({ + status: 201, + body: users[1], + }), + interceptor, + ); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); + + const creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); + expect(creationResponse.status).toBe(201); + + const createdUsers = (await creationResponse.json()) as User; + expect(createdUsers).toEqual(users[1]); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + const [creationRequest] = creationRequests; + expect(creationRequest).toBeInstanceOf(Request); + + expectTypeOf(creationRequest.body).toEqualTypeOf(); + expect(creationRequest.body).toBe(null); + + expectTypeOf(creationRequest.response.status).toEqualTypeOf<201>(); + expect(creationRequest.response.status).toEqual(201); + + expectTypeOf(creationRequest.response.body).toEqualTypeOf(); + expect(creationRequest.response.body).toEqual(users[1]); + }); + }); + + it('should support reusing current handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/users': { + POST: { + response: { + 201: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor.post('/users').respond({ + status: 201, + body: users[0], + }), + interceptor, + ); + + await promiseIfRemote(interceptor.clear(), interceptor); + + await promiseIfRemote( + creationHandler.respond({ + status: 201, + body: users[1], + }), + interceptor, + ); + + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); + + const creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); + expect(creationResponse.status).toBe(201); + + const createdUsers = (await creationResponse.json()) as User; + expect(createdUsers).toEqual(users[1]); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + const [creationRequest] = creationRequests; + expect(creationRequest).toBeInstanceOf(Request); + + expectTypeOf(creationRequest.body).toEqualTypeOf(); + expect(creationRequest.body).toBe(null); + + expectTypeOf(creationRequest.response.status).toEqualTypeOf<201>(); + expect(creationRequest.response.status).toEqual(201); + + expectTypeOf(creationRequest.response.body).toEqualTypeOf(); + expect(creationRequest.response.body).toEqual(users[1]); + }); }); }); - it('should ignore all handlers after restarted when intercepting POST requests, even if another interceptor is still running', async () => { - await usingHttpInterceptor<{ - '/users': { - POST: { - response: { - 201: { body: User }; + describe('Life cycle', () => { + it('should ignore all handlers after restarted when intercepting POST requests', async () => { + await usingHttpInterceptor<{ + '/users': { + POST: { + response: { + 201: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const creationHandler = await promiseIfRemote( - interceptor.post('/users').respond({ - status: 201, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor.post('/users').respond({ + status: 201, + body: users[0], + }), + interceptor, + ); - let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(0); + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); - const creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); - expect(creationResponse.status).toBe(201); + const creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); + expect(creationResponse.status).toBe(201); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); - await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); - await interceptor.stop(); expect(interceptor.isRunning()).toBe(false); - expect(otherInterceptor.isRunning()).toBe(true); let creationPromise = fetchWithTimeout(joinURL(baseURL, '/users'), { method: 'POST', @@ -992,7 +1058,6 @@ export async function declarePostHttpInterceptorTests(options: RuntimeSharedHttp await interceptor.start(); expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); creationPromise = fetch(joinURL(baseURL, '/users'), { method: 'POST' }); await expectFetchError(creationPromise); @@ -1001,120 +1066,456 @@ export async function declarePostHttpInterceptorTests(options: RuntimeSharedHttp expect(creationRequests).toHaveLength(1); }); }); - }); - it('should throw an error when trying to create a POST request handler if not running', async () => { - const interceptor = createInternalHttpInterceptor(interceptorOptions); - expect(interceptor.isRunning()).toBe(false); - - await expect(async () => { - await interceptor.post('/'); - }).rejects.toThrowError(new NotStartedHttpInterceptorError()); - }); - - it('should support creating new handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/users': { - POST: { - response: { - 201: { body: User }; + it('should ignore all handlers after restarted when intercepting POST requests, even if another interceptor is still running', async () => { + await usingHttpInterceptor<{ + '/users': { + POST: { + response: { + 201: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - let creationHandler = await promiseIfRemote( - interceptor.post('/users').respond({ - status: 201, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor.post('/users').respond({ + status: 201, + body: users[0], + }), + interceptor, + ); - await promiseIfRemote(interceptor.clear(), interceptor); + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); - creationHandler = await promiseIfRemote( - interceptor.post('/users').respond({ - status: 201, - body: users[1], - }), - interceptor, - ); + const creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); + expect(creationResponse.status).toBe(201); - let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(0); + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); - const creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); - expect(creationResponse.status).toBe(201); + await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); - const createdUsers = (await creationResponse.json()) as User; - expect(createdUsers).toEqual(users[1]); + await interceptor.stop(); + expect(interceptor.isRunning()).toBe(false); + expect(otherInterceptor.isRunning()).toBe(true); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - const [creationRequest] = creationRequests; - expect(creationRequest).toBeInstanceOf(Request); + let creationPromise = fetchWithTimeout(joinURL(baseURL, '/users'), { + method: 'POST', + timeout: 200, + }); + await expectFetchError(creationPromise, { canBeAborted: true }); - expectTypeOf(creationRequest.body).toEqualTypeOf(); - expect(creationRequest.body).toBe(null); + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); - expectTypeOf(creationRequest.response.status).toEqualTypeOf<201>(); - expect(creationRequest.response.status).toEqual(201); + await interceptor.start(); + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); - expectTypeOf(creationRequest.response.body).toEqualTypeOf(); - expect(creationRequest.response.body).toEqual(users[1]); + creationPromise = fetch(joinURL(baseURL, '/users'), { method: 'POST' }); + await expectFetchError(creationPromise); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + }); + }); + }); + + it('should throw an error when trying to create a POST request handler if not running', async () => { + const interceptor = createInternalHttpInterceptor(interceptorOptions); + expect(interceptor.isRunning()).toBe(false); + + await expect(async () => { + await interceptor.post('/'); + }).rejects.toThrowError(new NotStartedHttpInterceptorError()); }); }); - it('should support reusing current handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/users': { - POST: { - response: { - 201: { body: User }; + describe('Unhandled requests', () => { + if (type === 'local') { + it('should show a warning when logging is enabled and a POST request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/users': { + POST: { + request: { + headers: { 'x-value': string }; + body: UserCreationBody; + }; + response: { + 201: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor + .post('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 201, + body: users[0], + }), + interceptor, + ); + + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const creationResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '1' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + expect(creationResponse.status).toBe(201); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const creationRequest = new Request(joinURL(baseURL, '/users'), { + method: 'POST', + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + const creationRequestClone = creationRequest.clone(); + const creationPromise = fetch(creationRequest); + await expectFetchError(creationPromise); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: creationRequestClone, + }); + }); + }); + }); + } + + if (type === 'remote') { + it('should show an error when logging is enabled and a POST request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/users': { + POST: { + request: { + headers: { 'x-value': string }; + body: UserCreationBody; + }; + response: { + 201: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor + .post('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 201, + body: users[0], + }), + interceptor, + ); + + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const creationResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '1' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + expect(creationResponse.status).toBe(201); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const creationRequest = new Request(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '2' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + const creationRequestClone = creationRequest.clone(); + const creationPromise = fetch(creationRequest); + await expectFetchError(creationPromise); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: creationRequestClone, + }); + }); + }); + }); + } + + it('should support a custom unhandled POST request handler', async () => { + const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { + const url = new URL(request.url); + + if (!url.searchParams.has('name')) { + await context.log(); + } + }); + + await usingHttpInterceptor<{ + '/users': { + POST: { + request: { + headers: { 'x-value': string }; + searchParams: { name?: string }; + body: UserCreationBody; + }; + response: { + 201: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const creationHandler = await promiseIfRemote( - interceptor.post('/users').respond({ - status: 201, - body: users[0], - }), - interceptor, - ); + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor + .post('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 201, + body: users[0], + }), + interceptor, + ); + + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const creationResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '1' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + expect(creationResponse.status).toBe(201); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const searchParams = new HttpSearchParams({ name: 'User 1' }); + + let creationPromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { + method: 'POST', + headers: { 'x-value': '2' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + await expectFetchError(creationPromise); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const creationRequest = new Request(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '2' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + const creationRequestClone = creationRequest.clone(); + creationPromise = fetch(creationRequest); + await expectFetchError(creationPromise); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(2); + + const messageType = type === 'local' ? 'warn' : 'error'; + expect(spies.warn).toHaveBeenCalledTimes(messageType === 'warn' ? 1 : 0); + expect(spies.error).toHaveBeenCalledTimes(messageType === 'error' ? 1 : 0); + + const errorMessage = spies[messageType].mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: messageType, + platform, + request: creationRequestClone, + }); + }); + }); + }); - await promiseIfRemote(interceptor.clear(), interceptor); + it('should log an error if a custom unhandled POST request handler throws', async () => { + const error = new Error('Unhandled request.'); - await promiseIfRemote( - creationHandler.respond({ - status: 201, - body: users[1], - }), - interceptor, - ); + const onUnhandledRequest = vi.fn((request: Request) => { + const url = new URL(request.url); - let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(0); + if (!url.searchParams.has('name')) { + throw error; + } + }); - const creationResponse = await fetch(joinURL(baseURL, '/users'), { method: 'POST' }); - expect(creationResponse.status).toBe(201); + await usingHttpInterceptor<{ + '/users': { + POST: { + request: { + headers: { 'x-value': string }; + searchParams: { name?: string }; + body: UserCreationBody; + }; + response: { + 201: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor + .post('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 201, + body: users[0], + }), + interceptor, + ); - const createdUsers = (await creationResponse.json()) as User; - expect(createdUsers).toEqual(users[1]); + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - const [creationRequest] = creationRequests; - expect(creationRequest).toBeInstanceOf(Request); + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const creationResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '1' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + expect(creationResponse.status).toBe(201); - expectTypeOf(creationRequest.body).toEqualTypeOf(); - expect(creationRequest.body).toBe(null); + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); - expectTypeOf(creationRequest.response.status).toEqualTypeOf<201>(); - expect(creationRequest.response.status).toEqual(201); + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); - expectTypeOf(creationRequest.response.body).toEqualTypeOf(); - expect(creationRequest.response.body).toEqual(users[1]); + const searchParams = new HttpSearchParams({ name: 'User 1' }); + + let creationPromise = fetch(joinURL(baseURL, `/users?${searchParams.toString()}`), { + method: 'POST', + headers: { 'x-value': '2' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + await expectFetchError(creationPromise); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const creationRequest = new Request(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '2' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + creationPromise = fetch(creationRequest); + await expectFetchError(creationPromise); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(2); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + expect(spies.error).toHaveBeenCalledWith(error); + }); + }); + }); + + it('should not show a warning or error when logging is disabled and a POST request is unhandled', async () => { + await usingHttpInterceptor<{ + '/users': { + POST: { + request: { + headers: { 'x-value': string }; + body: UserCreationBody; + }; + response: { + 201: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor + .post('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 201, + body: users[0], + }), + interceptor, + ); + + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const creationResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '1' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + expect(creationResponse.status).toBe(201); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const creationRequest = new Request(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '2' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + const creationPromise = fetch(creationRequest); + await expectFetchError(creationPromise); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + }); + }); }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/put.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/put.ts index 37cb2aaf..bc52fa48 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/put.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/put.ts @@ -1,4 +1,4 @@ -import { beforeEach, expect, expectTypeOf, it } from 'vitest'; +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; @@ -10,15 +10,17 @@ import { JSONValue } from '@/types/json'; import { getCrypto } from '@/utils/crypto'; import { fetchWithTimeout } from '@/utils/fetch'; import { joinURL } from '@/utils/urls'; +import { usingIgnoredConsole } from '@tests/utils/console'; import { expectFetchError } from '@tests/utils/fetch'; import { createInternalHttpInterceptor, usingHttpInterceptor } from '@tests/utils/interceptors'; import NotStartedHttpInterceptorError from '../../../errors/NotStartedHttpInterceptorError'; -import { HttpInterceptorOptions } from '../../../types/options'; +import { HttpInterceptorOptions, UnhandledRequestStrategy } from '../../../types/options'; import { RuntimeSharedHttpInterceptorTestsOptions } from '../types'; +import { verifyUnhandledRequestMessage } from '../utils'; export async function declarePutHttpInterceptorTests(options: RuntimeSharedHttpInterceptorTestsOptions) { - const { getBaseURL, getInterceptorOptions } = options; + const { platform, type, getBaseURL, getInterceptorOptions } = options; const crypto = await getCrypto(); @@ -149,6 +151,7 @@ export async function declarePutHttpInterceptorTests(options: RuntimeSharedHttpI expect(updateRequest.response.body).toEqual({ ...users[0], name: userName }); }); }); + it('should support intercepting PUT requests having headers', async () => { type UserUpdateRequestHeaders = HttpSchema.Headers<{ accept?: string; @@ -276,288 +279,6 @@ export async function declarePutHttpInterceptorTests(options: RuntimeSharedHttpI }); }); - it('should support intercepting PUT requests having headers restrictions', async () => { - type UserUpdateHeaders = HttpSchema.Headers<{ - 'content-type'?: string; - accept?: string; - }>; - - await usingHttpInterceptor<{ - '/users/:id': { - PUT: { - request: { - headers: UserUpdateHeaders; - }; - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .put(`/users/${users[0].id}`) - .with({ - headers: { 'content-type': 'application/json' }, - }) - .with((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - return request.headers.get('accept')?.includes('application/json') ?? false; - }) - .respond((request) => { - expectTypeOf(request.headers).toEqualTypeOf>(); - expect(request.headers).toBeInstanceOf(HttpHeaders); - - return { - status: 200, - body: users[0], - }; - }), - interceptor, - ); - expect(updateHandler).toBeInstanceOf(Handler); - - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); - - const headers = new HttpHeaders({ - 'content-type': 'application/json', - accept: 'application/json', - }); - - let updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT', headers }); - expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - headers.append('accept', 'application/xml'); - - updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT', headers }); - expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(2); - - headers.delete('accept'); - - let updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT', headers }); - await expectFetchError(updateResponsePromise); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(2); - - headers.set('accept', 'application/json'); - headers.set('content-type', 'text/plain'); - - updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT', headers }); - await expectFetchError(updateResponsePromise); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(2); - }); - }); - - it('should support intercepting PUT requests having search params restrictions', async () => { - type UserUpdateSearchParams = HttpSchema.SearchParams<{ - tag?: string; - }>; - - await usingHttpInterceptor<{ - '/users/:id': { - PUT: { - request: { - searchParams: UserUpdateSearchParams; - }; - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .put(`/users/${users[0].id}`) - .with({ - searchParams: { tag: 'admin' }, - }) - .respond((request) => { - expectTypeOf(request.searchParams).toEqualTypeOf>(); - expect(request.searchParams).toBeInstanceOf(HttpSearchParams); - - return { - status: 200, - body: users[0], - }; - }), - interceptor, - ); - expect(updateHandler).toBeInstanceOf(Handler); - - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); - - const searchParams = new HttpSearchParams({ - tag: 'admin', - }); - - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { - method: 'PUT', - }); - expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - searchParams.delete('tag'); - - const updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { - method: 'PUT', - }); - await expectFetchError(updateResponsePromise); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - }); - }); - - it('should support intercepting PUT requests having body restrictions', async () => { - type UserUpdateBody = JSONValue; - - await usingHttpInterceptor<{ - '/users/:id': { - PUT: { - request: { - body: UserUpdateBody; - }; - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .put(`/users/${users[0].id}`) - .with({ - body: { ...users[0], name: users[1].name }, - }) - .respond((request) => { - expectTypeOf(request.body).toEqualTypeOf(); - - return { - status: 200, - body: users[0], - }; - }), - interceptor, - ); - expect(updateHandler).toBeInstanceOf(Handler); - - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); - - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PUT', - body: JSON.stringify({ - ...users[0], - name: users[1].name, - } satisfies UserUpdateBody), - }); - expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - const updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PUT', - body: JSON.stringify({ - ...users[0], - name: users[0].name, - } satisfies UserUpdateBody), - }); - await expectFetchError(updateResponsePromise); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - }); - }); - - it('should support intercepting PUT requests with a dynamic path', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PUT: { - response: { - 200: { body: User }; - }; - }; - }; - }>(interceptorOptions, async (interceptor) => { - const genericUpdateHandler = await promiseIfRemote( - interceptor.put('/users/:id').respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - expect(genericUpdateHandler).toBeInstanceOf(Handler); - - let genericUpdateRequests = await promiseIfRemote(genericUpdateHandler.requests(), interceptor); - expect(genericUpdateRequests).toHaveLength(0); - - const genericUpdateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - expect(genericUpdateResponse.status).toBe(200); - - const genericUpdatedUser = (await genericUpdateResponse.json()) as User; - expect(genericUpdatedUser).toEqual(users[0]); - - genericUpdateRequests = await promiseIfRemote(genericUpdateHandler.requests(), interceptor); - expect(genericUpdateRequests).toHaveLength(1); - const [genericUpdateRequest] = genericUpdateRequests; - expect(genericUpdateRequest).toBeInstanceOf(Request); - - expectTypeOf(genericUpdateRequest.body).toEqualTypeOf(); - expect(genericUpdateRequest.body).toBe(null); - - expectTypeOf(genericUpdateRequest.response.status).toEqualTypeOf<200>(); - expect(genericUpdateRequest.response.status).toEqual(200); - - expectTypeOf(genericUpdateRequest.response.body).toEqualTypeOf(); - expect(genericUpdateRequest.response.body).toEqual(users[0]); - - await promiseIfRemote(genericUpdateHandler.bypass(), interceptor); - - const specificUpdateHandler = await promiseIfRemote( - interceptor.put(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - expect(specificUpdateHandler).toBeInstanceOf(Handler); - - let specificUpdateRequests = await promiseIfRemote(specificUpdateHandler.requests(), interceptor); - expect(specificUpdateRequests).toHaveLength(0); - - const specificUpdateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - expect(specificUpdateResponse.status).toBe(200); - - const specificUpdatedUser = (await specificUpdateResponse.json()) as User; - expect(specificUpdatedUser).toEqual(users[0]); - - specificUpdateRequests = await promiseIfRemote(specificUpdateHandler.requests(), interceptor); - expect(specificUpdateRequests).toHaveLength(1); - const [specificUpdateRequest] = specificUpdateRequests; - expect(specificUpdateRequest).toBeInstanceOf(Request); - - expectTypeOf(specificUpdateRequest.body).toEqualTypeOf(); - expect(specificUpdateRequest.body).toBe(null); - - expectTypeOf(specificUpdateRequest.response.status).toEqualTypeOf<200>(); - expect(specificUpdateRequest.response.status).toEqual(200); - - expectTypeOf(specificUpdateRequest.response.body).toEqualTypeOf(); - expect(specificUpdateRequest.response.body).toEqual(users[0]); - - const unmatchedUpdatePromise = fetch(joinURL(baseURL, `/users/${users[1].id}`), { method: 'PUT' }); - await expectFetchError(unmatchedUpdatePromise); - }); - }); - it('should not intercept a PUT request without a registered response', async () => { await usingHttpInterceptor<{ '/users/:id': { @@ -720,254 +441,596 @@ export async function declarePutHttpInterceptorTests(options: RuntimeSharedHttpI }); }); - it('should ignore handlers with bypassed responses when intercepting PUT requests', async () => { - type ServerErrorResponseBody = JSONValue<{ - message: string; - }>; - - await usingHttpInterceptor<{ - '/users/:id': { - PUT: { - response: { - 200: { body: User }; - 500: { body: ServerErrorResponseBody }; + describe('Dynamic paths', () => { + it('should support intercepting PUT requests with a dynamic path', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .put(`/users/${users[0].id}`) - .respond({ + }>(interceptorOptions, async (interceptor) => { + const genericUpdateHandler = await promiseIfRemote( + interceptor.put('/users/:id').respond({ status: 200, body: users[0], - }) - .bypass(), - interceptor, - ); - - let initialUpdateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(initialUpdateRequests).toHaveLength(0); - - const updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - await expectFetchError(updatePromise); - - await promiseIfRemote( - updateHandler.respond({ - status: 200, - body: users[1], - }), - interceptor, - ); + }), + interceptor, + ); + expect(genericUpdateHandler).toBeInstanceOf(Handler); - initialUpdateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(initialUpdateRequests).toHaveLength(0); - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); + let genericUpdateRequests = await promiseIfRemote(genericUpdateHandler.requests(), interceptor); + expect(genericUpdateRequests).toHaveLength(0); - let updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - expect(updateResponse.status).toBe(200); + const genericUpdateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + expect(genericUpdateResponse.status).toBe(200); - let createdUsers = (await updateResponse.json()) as User; - expect(createdUsers).toEqual(users[1]); + const genericUpdatedUser = (await genericUpdateResponse.json()) as User; + expect(genericUpdatedUser).toEqual(users[0]); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - let [updateRequest] = updateRequests; - expect(updateRequest).toBeInstanceOf(Request); + genericUpdateRequests = await promiseIfRemote(genericUpdateHandler.requests(), interceptor); + expect(genericUpdateRequests).toHaveLength(1); + const [genericUpdateRequest] = genericUpdateRequests; + expect(genericUpdateRequest).toBeInstanceOf(Request); - expectTypeOf(updateRequest.body).toEqualTypeOf(); - expect(updateRequest.body).toBe(null); + expectTypeOf(genericUpdateRequest.body).toEqualTypeOf(); + expect(genericUpdateRequest.body).toBe(null); - expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); - expect(updateRequest.response.status).toEqual(200); + expectTypeOf(genericUpdateRequest.response.status).toEqualTypeOf<200>(); + expect(genericUpdateRequest.response.status).toEqual(200); - expectTypeOf(updateRequest.response.body).toEqualTypeOf(); - expect(updateRequest.response.body).toEqual(users[1]); + expectTypeOf(genericUpdateRequest.response.body).toEqualTypeOf(); + expect(genericUpdateRequest.response.body).toEqual(users[0]); - const errorUpdateHandler = await promiseIfRemote( - interceptor.put(`/users/${users[0].id}`).respond({ - status: 500, - body: { message: 'Internal server error' }, - }), - interceptor, - ); + await promiseIfRemote(genericUpdateHandler.bypass(), interceptor); - let errorUpdateRequests = await promiseIfRemote(errorUpdateHandler.requests(), interceptor); - expect(errorUpdateRequests).toHaveLength(0); + const specificUpdateHandler = await promiseIfRemote( + interceptor.put(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + expect(specificUpdateHandler).toBeInstanceOf(Handler); - const otherUpdateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - expect(otherUpdateResponse.status).toBe(500); + let specificUpdateRequests = await promiseIfRemote(specificUpdateHandler.requests(), interceptor); + expect(specificUpdateRequests).toHaveLength(0); - const serverError = (await otherUpdateResponse.json()) as ServerErrorResponseBody; - expect(serverError).toEqual({ message: 'Internal server error' }); + const specificUpdateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + expect(specificUpdateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + const specificUpdatedUser = (await specificUpdateResponse.json()) as User; + expect(specificUpdatedUser).toEqual(users[0]); - errorUpdateRequests = await promiseIfRemote(errorUpdateHandler.requests(), interceptor); - expect(errorUpdateRequests).toHaveLength(1); - const [errorUpdateRequest] = errorUpdateRequests; - expect(errorUpdateRequest).toBeInstanceOf(Request); + specificUpdateRequests = await promiseIfRemote(specificUpdateHandler.requests(), interceptor); + expect(specificUpdateRequests).toHaveLength(1); + const [specificUpdateRequest] = specificUpdateRequests; + expect(specificUpdateRequest).toBeInstanceOf(Request); - expectTypeOf(errorUpdateRequest.body).toEqualTypeOf(); - expect(errorUpdateRequest.body).toBe(null); + expectTypeOf(specificUpdateRequest.body).toEqualTypeOf(); + expect(specificUpdateRequest.body).toBe(null); - expectTypeOf(errorUpdateRequest.response.status).toEqualTypeOf<500>(); - expect(errorUpdateRequest.response.status).toEqual(500); + expectTypeOf(specificUpdateRequest.response.status).toEqualTypeOf<200>(); + expect(specificUpdateRequest.response.status).toEqual(200); - expectTypeOf(errorUpdateRequest.response.body).toEqualTypeOf(); - expect(errorUpdateRequest.response.body).toEqual({ message: 'Internal server error' }); + expectTypeOf(specificUpdateRequest.response.body).toEqualTypeOf(); + expect(specificUpdateRequest.response.body).toEqual(users[0]); - await promiseIfRemote(errorUpdateHandler.bypass(), interceptor); + const unmatchedUpdatePromise = fetch(joinURL(baseURL, `/users/${users[1].id}`), { method: 'PUT' }); + await expectFetchError(unmatchedUpdatePromise); + }); + }); + }); - updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - expect(updateResponse.status).toBe(200); + describe('Restrictions', () => { + it('should support intercepting PUT requests having headers restrictions', async () => { + type UserUpdateHeaders = HttpSchema.Headers<{ + 'content-type'?: string; + accept?: string; + }>; + + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + request: { + headers: UserUpdateHeaders; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .put(`/users/${users[0].id}`) + .with({ + headers: { 'content-type': 'application/json' }, + }) + .with((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); + + return request.headers.get('accept')?.includes('application/json') ?? false; + }) + .respond((request) => { + expectTypeOf(request.headers).toEqualTypeOf>(); + expect(request.headers).toBeInstanceOf(HttpHeaders); + + return { + status: 200, + body: users[0], + }; + }), + interceptor, + ); + expect(updateHandler).toBeInstanceOf(Handler); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + const headers = new HttpHeaders({ + 'content-type': 'application/json', + accept: 'application/json', + }); - createdUsers = (await updateResponse.json()) as User; - expect(createdUsers).toEqual(users[1]); + let updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT', headers }); + expect(updateResponse.status).toBe(200); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); - errorUpdateRequests = await promiseIfRemote(errorUpdateHandler.requests(), interceptor); - expect(errorUpdateRequests).toHaveLength(1); + headers.append('accept', 'application/xml'); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(2); - [updateRequest] = updateRequests; - expect(updateRequest).toBeInstanceOf(Request); + updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT', headers }); + expect(updateResponse.status).toBe(200); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(2); - expectTypeOf(updateRequest.body).toEqualTypeOf(); - expect(updateRequest.body).toBe(null); + headers.delete('accept'); - expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); - expect(updateRequest.response.status).toEqual(200); + let updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT', headers }); + await expectFetchError(updateResponsePromise); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(2); - expectTypeOf(updateRequest.response.body).toEqualTypeOf(); - expect(updateRequest.response.body).toEqual(users[1]); + headers.set('accept', 'application/json'); + headers.set('content-type', 'text/plain'); + + updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT', headers }); + await expectFetchError(updateResponsePromise); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(2); + }); + }); + + it('should support intercepting PUT requests having search params restrictions', async () => { + type UserUpdateSearchParams = HttpSchema.SearchParams<{ + tag?: string; + }>; + + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + request: { + searchParams: UserUpdateSearchParams; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .put(`/users/${users[0].id}`) + .with({ + searchParams: { tag: 'admin' }, + }) + .respond((request) => { + expectTypeOf(request.searchParams).toEqualTypeOf>(); + expect(request.searchParams).toBeInstanceOf(HttpSearchParams); + + return { + status: 200, + body: users[0], + }; + }), + interceptor, + ); + expect(updateHandler).toBeInstanceOf(Handler); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + const searchParams = new HttpSearchParams({ + tag: 'admin', + }); + + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { + method: 'PUT', + }); + expect(updateResponse.status).toBe(200); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + searchParams.delete('tag'); + + const updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { + method: 'PUT', + }); + await expectFetchError(updateResponsePromise); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + }); + }); + + it('should support intercepting PUT requests having body restrictions', async () => { + type UserUpdateBody = JSONValue; + + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + request: { + body: UserUpdateBody; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .put(`/users/${users[0].id}`) + .with({ + body: { ...users[0], name: users[1].name }, + }) + .respond((request) => { + expectTypeOf(request.body).toEqualTypeOf(); + + return { + status: 200, + body: users[0], + }; + }), + interceptor, + ); + expect(updateHandler).toBeInstanceOf(Handler); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + body: JSON.stringify({ + ...users[0], + name: users[1].name, + } satisfies UserUpdateBody), + }); + expect(updateResponse.status).toBe(200); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + const updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + body: JSON.stringify({ + ...users[0], + name: users[0].name, + } satisfies UserUpdateBody), + }); + await expectFetchError(updateResponsePromise); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + }); }); }); - it('should ignore all handlers after cleared when intercepting PUT requests', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PUT: { - response: { - 200: { body: User }; + describe('Bypass', () => { + it('should ignore handlers with bypassed responses when intercepting PUT requests', async () => { + type ServerErrorResponseBody = JSONValue<{ + message: string; + }>; + + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + response: { + 200: { body: User }; + 500: { body: ServerErrorResponseBody }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor.put(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .put(`/users/${users[0].id}`) + .respond({ + status: 200, + body: users[0], + }) + .bypass(), + interceptor, + ); - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); + let initialUpdateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(initialUpdateRequests).toHaveLength(0); - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - expect(updateResponse.status).toBe(200); + const updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + await expectFetchError(updatePromise); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + await promiseIfRemote( + updateHandler.respond({ + status: 200, + body: users[1], + }), + interceptor, + ); - await promiseIfRemote(interceptor.clear(), interceptor); + initialUpdateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(initialUpdateRequests).toHaveLength(0); + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); - const updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - await expectFetchError(updatePromise); + let updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + let createdUsers = (await updateResponse.json()) as User; + expect(createdUsers).toEqual(users[1]); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + let [updateRequest] = updateRequests; + expect(updateRequest).toBeInstanceOf(Request); + + expectTypeOf(updateRequest.body).toEqualTypeOf(); + expect(updateRequest.body).toBe(null); + + expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); + expect(updateRequest.response.status).toEqual(200); + + expectTypeOf(updateRequest.response.body).toEqualTypeOf(); + expect(updateRequest.response.body).toEqual(users[1]); + + const errorUpdateHandler = await promiseIfRemote( + interceptor.put(`/users/${users[0].id}`).respond({ + status: 500, + body: { message: 'Internal server error' }, + }), + interceptor, + ); + + let errorUpdateRequests = await promiseIfRemote(errorUpdateHandler.requests(), interceptor); + expect(errorUpdateRequests).toHaveLength(0); + + const otherUpdateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + expect(otherUpdateResponse.status).toBe(500); + + const serverError = (await otherUpdateResponse.json()) as ServerErrorResponseBody; + expect(serverError).toEqual({ message: 'Internal server error' }); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + errorUpdateRequests = await promiseIfRemote(errorUpdateHandler.requests(), interceptor); + expect(errorUpdateRequests).toHaveLength(1); + const [errorUpdateRequest] = errorUpdateRequests; + expect(errorUpdateRequest).toBeInstanceOf(Request); + + expectTypeOf(errorUpdateRequest.body).toEqualTypeOf(); + expect(errorUpdateRequest.body).toBe(null); + + expectTypeOf(errorUpdateRequest.response.status).toEqualTypeOf<500>(); + expect(errorUpdateRequest.response.status).toEqual(500); + + expectTypeOf(errorUpdateRequest.response.body).toEqualTypeOf(); + expect(errorUpdateRequest.response.body).toEqual({ message: 'Internal server error' }); + + await promiseIfRemote(errorUpdateHandler.bypass(), interceptor); + + updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + expect(updateResponse.status).toBe(200); + + createdUsers = (await updateResponse.json()) as User; + expect(createdUsers).toEqual(users[1]); + + errorUpdateRequests = await promiseIfRemote(errorUpdateHandler.requests(), interceptor); + expect(errorUpdateRequests).toHaveLength(1); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(2); + [updateRequest] = updateRequests; + expect(updateRequest).toBeInstanceOf(Request); + + expectTypeOf(updateRequest.body).toEqualTypeOf(); + expect(updateRequest.body).toBe(null); + + expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); + expect(updateRequest.response.status).toEqual(200); + + expectTypeOf(updateRequest.response.body).toEqualTypeOf(); + expect(updateRequest.response.body).toEqual(users[1]); + }); }); }); - it('should ignore all handlers after restarted when intercepting PUT requests', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PUT: { - response: { - 200: { body: User }; + describe('Clear', () => { + it('should ignore all handlers after cleared when intercepting PUT requests', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor.put(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor.put(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - expect(updateResponse.status).toBe(200); + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); - expect(interceptor.isRunning()).toBe(true); - await interceptor.stop(); - expect(interceptor.isRunning()).toBe(false); + await promiseIfRemote(interceptor.clear(), interceptor); - let updatePromise = fetchWithTimeout(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PUT', - timeout: 200, + const updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + await expectFetchError(updatePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); }); - await expectFetchError(updatePromise, { canBeAborted: true }); + }); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + it('should support creating new handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + let updateHandler = await promiseIfRemote( + interceptor.put(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - await interceptor.start(); - expect(interceptor.isRunning()).toBe(true); + await promiseIfRemote(interceptor.clear(), interceptor); - updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - await expectFetchError(updatePromise); + updateHandler = await promiseIfRemote( + interceptor.put(`/users/${users[0].id}`).respond({ + status: 200, + body: users[1], + }), + interceptor, + ); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + expect(updateResponse.status).toBe(200); + + const updatedUsers = (await updateResponse.json()) as User; + expect(updatedUsers).toEqual(users[1]); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + const [updateRequest] = updateRequests; + expect(updateRequest).toBeInstanceOf(Request); + + expectTypeOf(updateRequest.body).toEqualTypeOf(); + expect(updateRequest.body).toBe(null); + + expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); + expect(updateRequest.response.status).toEqual(200); + + expectTypeOf(updateRequest.response.body).toEqualTypeOf(); + expect(updateRequest.response.body).toEqual(users[1]); + }); + }); + + it('should support reusing current handlers after cleared', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + response: { + 200: { body: User }; + }; + }; + }; + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor.put(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + await promiseIfRemote(interceptor.clear(), interceptor); + + await promiseIfRemote( + updateHandler.respond({ + status: 200, + body: users[1], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + expect(updateResponse.status).toBe(200); + + const updatedUsers = (await updateResponse.json()) as User; + expect(updatedUsers).toEqual(users[1]); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + const [updateRequest] = updateRequests; + expect(updateRequest).toBeInstanceOf(Request); + + expectTypeOf(updateRequest.body).toEqualTypeOf(); + expect(updateRequest.body).toBe(null); + + expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); + expect(updateRequest.response.status).toEqual(200); + + expectTypeOf(updateRequest.response.body).toEqualTypeOf(); + expect(updateRequest.response.body).toEqual(users[1]); + }); }); }); - it('should ignore all handlers after restarted when intercepting PUT requests, even if another interceptor is still running', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PUT: { - response: { - 200: { body: User }; + describe('Life cycle', () => { + it('should ignore all handlers after restarted when intercepting PUT requests', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor.put(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor.put(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - expect(updateResponse.status).toBe(200); + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + expect(updateResponse.status).toBe(200); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); - await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); - await interceptor.stop(); expect(interceptor.isRunning()).toBe(false); - expect(otherInterceptor.isRunning()).toBe(true); let updatePromise = fetchWithTimeout(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT', @@ -980,7 +1043,6 @@ export async function declarePutHttpInterceptorTests(options: RuntimeSharedHttpI await interceptor.start(); expect(interceptor.isRunning()).toBe(true); - expect(otherInterceptor.isRunning()).toBe(true); updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); await expectFetchError(updatePromise); @@ -989,120 +1051,428 @@ export async function declarePutHttpInterceptorTests(options: RuntimeSharedHttpI expect(updateRequests).toHaveLength(1); }); }); - }); - - it('should throw an error when trying to create a PUT request handler if not running', async () => { - const interceptor = createInternalHttpInterceptor(interceptorOptions); - expect(interceptor.isRunning()).toBe(false); - - await expect(async () => { - await interceptor.put('/'); - }).rejects.toThrowError(new NotStartedHttpInterceptorError()); - }); - it('should support creating new handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PUT: { - response: { - 200: { body: User }; + it('should ignore all handlers after restarted when intercepting PUT requests, even if another interceptor is still running', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - let updateHandler = await promiseIfRemote( - interceptor.put(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>(interceptorOptions, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor.put(`/users/${users[0].id}`).respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - await promiseIfRemote(interceptor.clear(), interceptor); + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); - updateHandler = await promiseIfRemote( - interceptor.put(`/users/${users[0].id}`).respond({ - status: 200, - body: users[1], - }), - interceptor, - ); + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + expect(updateResponse.status).toBe(200); - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - expect(updateResponse.status).toBe(200); + await usingHttpInterceptor(interceptorOptions, async (otherInterceptor) => { + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); - const updatedUsers = (await updateResponse.json()) as User; - expect(updatedUsers).toEqual(users[1]); + await interceptor.stop(); + expect(interceptor.isRunning()).toBe(false); + expect(otherInterceptor.isRunning()).toBe(true); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - const [updateRequest] = updateRequests; - expect(updateRequest).toBeInstanceOf(Request); + let updatePromise = fetchWithTimeout(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + timeout: 200, + }); + await expectFetchError(updatePromise, { canBeAborted: true }); - expectTypeOf(updateRequest.body).toEqualTypeOf(); - expect(updateRequest.body).toBe(null); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); - expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); - expect(updateRequest.response.status).toEqual(200); + await interceptor.start(); + expect(interceptor.isRunning()).toBe(true); + expect(otherInterceptor.isRunning()).toBe(true); - expectTypeOf(updateRequest.response.body).toEqualTypeOf(); - expect(updateRequest.response.body).toEqual(users[1]); + updatePromise = fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + await expectFetchError(updatePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + }); + }); + }); + + it('should throw an error when trying to create a PUT request handler if not running', async () => { + const interceptor = createInternalHttpInterceptor(interceptorOptions); + expect(interceptor.isRunning()).toBe(false); + + await expect(async () => { + await interceptor.put('/'); + }).rejects.toThrowError(new NotStartedHttpInterceptorError()); }); }); - it('should support reusing current handlers after cleared', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PUT: { - response: { - 200: { body: User }; + describe('Unhandled requests', () => { + if (type === 'local') { + it('should show a warning when logging is enabled and a PUT request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .put(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + const updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: updateRequest, + }); + }); + }); + }); + } + + if (type === 'remote') { + it('should show an error when logging is enabled and a PUT request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .put(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '2' }, + }); + const updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: updateRequest, + }); + }); + }); + }); + } + + it('should support a custom unhandled PUT request handler', async () => { + const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { + const url = new URL(request.url); + + if (!url.searchParams.has('name')) { + await context.log(); + } + }); + + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + request: { + headers: { 'x-value': string }; + searchParams: { name?: string }; + }; + response: { + 200: { body: User }; + }; }; }; - }; - }>(interceptorOptions, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor.put(`/users/${users[0].id}`).respond({ - status: 200, - body: users[0], - }), - interceptor, - ); + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .put(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const searchParams = new HttpSearchParams({ name: 'User 1' }); + + let updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { + method: 'PUT', + headers: { 'x-value': '2' }, + }); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '2' }, + }); + updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(2); + + const messageType = type === 'local' ? 'warn' : 'error'; + expect(spies.warn).toHaveBeenCalledTimes(messageType === 'warn' ? 1 : 0); + expect(spies.error).toHaveBeenCalledTimes(messageType === 'error' ? 1 : 0); + + const errorMessage = spies[messageType].mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: messageType, + platform, + request: updateRequest, + }); + }); + }); + }); - await promiseIfRemote(interceptor.clear(), interceptor); + it('should log an error if a custom unhandled PUT request handler throws', async () => { + const error = new Error('Unhandled request.'); - await promiseIfRemote( - updateHandler.respond({ - status: 200, - body: users[1], - }), - interceptor, - ); + const onUnhandledRequest = vi.fn((request: Request) => { + const url = new URL(request.url); - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); + if (!url.searchParams.has('name')) { + throw error; + } + }); - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - expect(updateResponse.status).toBe(200); + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + request: { + headers: { 'x-value': string }; + searchParams: { name?: string }; + }; + response: { + 200: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest }, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .put(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); - const updatedUsers = (await updateResponse.json()) as User; - expect(updatedUsers).toEqual(users[1]); + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - const [updateRequest] = updateRequests; - expect(updateRequest).toBeInstanceOf(Request); + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); - expectTypeOf(updateRequest.body).toEqualTypeOf(); - expect(updateRequest.body).toBe(null); + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); - expectTypeOf(updateRequest.response.status).toEqualTypeOf<200>(); - expect(updateRequest.response.status).toEqual(200); + expect(onUnhandledRequest).toHaveBeenCalledTimes(0); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); - expectTypeOf(updateRequest.response.body).toEqualTypeOf(); - expect(updateRequest.response.body).toEqual(users[1]); + const searchParams = new HttpSearchParams({ name: 'User 1' }); + + let updateResponsePromise = fetch(joinURL(baseURL, `/users/${users[0].id}?${searchParams.toString()}`), { + method: 'PUT', + headers: { 'x-value': '2' }, + }); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '2' }, + }); + updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(onUnhandledRequest).toHaveBeenCalledTimes(2); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + expect(spies.error).toHaveBeenCalledWith(error); + }); + }); + }); + + it('should not show a warning or error when logging is disabled and a PUT request is unhandled', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .put(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '2' }, + }); + const updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + }); + }); }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/utils.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/utils.ts index 9f4788da..96b340bf 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/utils.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/utils.ts @@ -1,11 +1,12 @@ import { expect } from 'vitest'; import { HttpRequest } from '@/http/types/requests'; +import HttpInterceptorWorker from '@/interceptor/http/interceptorWorker/HttpInterceptorWorker'; import { formatObjectToLog } from '@/utils/console'; import { HttpInterceptorPlatform } from '../../types/options'; -export function verifyUnhandledRequestMessage( +export async function verifyUnhandledRequestMessage( message: string, options: { type: 'warn' | 'error'; @@ -13,7 +14,9 @@ export function verifyUnhandledRequestMessage( request: HttpRequest; }, ) { - const { type, platform, request } = options; + const { type, platform, request: rawRequest } = options; + + const request = await HttpInterceptorWorker.parseRawRequest(rawRequest); expect(message).toMatch(/.*\[zimic\].* /); expect(message).toContain(type === 'warn' ? 'Warning:' : 'Error:'); @@ -28,17 +31,23 @@ export function verifyUnhandledRequestMessage( const formattedHeaders = formatObjectToLog(Object.fromEntries(request.headers)) as string; const formattedHeadersIgnoringWrapperBrackets = formattedHeaders.slice(1, -1); - expect(headersLine.groups!.headers).toContain(formattedHeadersIgnoringWrapperBrackets); + + for (const headerKeyValuePair of formattedHeadersIgnoringWrapperBrackets.split(', ')) { + expect(headersLine.groups!.headers).toContain(headerKeyValuePair.trim()); + } } expect(message).toContain( platform === 'node' - ? `Search params: ${formatObjectToLog(Object.fromEntries(new URL(request.url).searchParams))}\n` + ? `Search params: ${formatObjectToLog(Object.fromEntries(request.searchParams))}\n` : 'Search params: [object Object]', ); + + const body: unknown = request.body; + expect(message).toContain( - platform === 'node' || typeof request.body !== 'object' || request.body === null - ? `Body: ${formatObjectToLog(request.body)}\n` + platform === 'node' || typeof body !== 'object' || body === null + ? `Body: ${formatObjectToLog(body)}\n` : 'Body: [object Object]', ); } diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts index a068be57..a8f3b61a 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts @@ -75,12 +75,12 @@ class RemoteHttpInterceptorWorker extends HttpInterceptorWorker { const rawResponse = (await handler?.createResponse({ request })) ?? null; const response = rawResponse && request.method === 'HEAD' ? new Response(null, rawResponse) : rawResponse; - if (!response) { + if (response) { + return { response: await serializeResponse(response) }; + } else { await super.handleUnhandledRequest(request); + return { response: null }; } - - const serializedResponse = response ? await serializeResponse(response) : null; - return { response: serializedResponse }; }; private async readPlatform(): Promise { diff --git a/packages/zimic/src/utils/console.ts b/packages/zimic/src/utils/console.ts index 7596ced7..a4d767fd 100644 --- a/packages/zimic/src/utils/console.ts +++ b/packages/zimic/src/utils/console.ts @@ -14,6 +14,7 @@ export function formatObjectToLog(value: unknown) { maxArrayLength: Infinity, maxStringLength: Infinity, breakLength: Infinity, + sorted: true, }); } From 433bfecc39ee68b496d25caf9a60fa9091d501e3 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sun, 19 May 2024 16:12:52 -0300 Subject: [PATCH 13/22] feat(#zimic): support custom default unhandled request strategies --- .../v0/interceptor/exports/exports.test.ts | 2 + .../interceptor/thirdParty/shared/default.ts | 2 + examples/with-vitest-node/tests/setup.ts | 3 + packages/zimic/src/cli/cli.ts | 2 +- .../HttpInterceptorWorker.ts | 66 ++++++++++++------- .../HttpInterceptorWorkerStore.ts | 34 ++++++++++ packages/zimic/src/interceptor/index.ts | 10 +++ .../interceptor/server/InterceptorServer.ts | 31 +++++---- 8 files changed, 112 insertions(+), 38 deletions(-) create mode 100644 packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore.ts diff --git a/apps/zimic-test-client/tests/v0/interceptor/exports/exports.test.ts b/apps/zimic-test-client/tests/v0/interceptor/exports/exports.test.ts index 2dd357d4..810b1a7c 100644 --- a/apps/zimic-test-client/tests/v0/interceptor/exports/exports.test.ts +++ b/apps/zimic-test-client/tests/v0/interceptor/exports/exports.test.ts @@ -106,6 +106,8 @@ describe('Exports', () => { expectTypeOf(http.createInterceptor).not.toBeAny(); expect(typeof http.createInterceptor).toBe('function'); expectTypeOf().not.toBeAny(); + expectTypeOf(http.default.onUnhandledRequest).not.toBeAny(); + expect(typeof http.default.onUnhandledRequest).toBe('function'); expectTypeOf>().not.toBeAny(); expectTypeOf>().not.toBeAny(); expectTypeOf>().not.toBeAny(); diff --git a/apps/zimic-test-client/tests/v0/interceptor/thirdParty/shared/default.ts b/apps/zimic-test-client/tests/v0/interceptor/thirdParty/shared/default.ts index de36b6cf..01494e4d 100644 --- a/apps/zimic-test-client/tests/v0/interceptor/thirdParty/shared/default.ts +++ b/apps/zimic-test-client/tests/v0/interceptor/thirdParty/shared/default.ts @@ -39,11 +39,13 @@ async function declareDefaultClientTests(options: ClientTestOptionsByWorkerType) const authInterceptor = http.createInterceptor({ type, baseURL: await getAuthBaseURL(type), + onUnhandledRequest: { log: true }, }); const notificationInterceptor = http.createInterceptor({ type, baseURL: await getNotificationsBaseURL(type), + onUnhandledRequest: (_request, context) => context.log(), }); const interceptors = [authInterceptor, notificationInterceptor]; diff --git a/examples/with-vitest-node/tests/setup.ts b/examples/with-vitest-node/tests/setup.ts index 48d7ef1e..fc4f326a 100644 --- a/examples/with-vitest-node/tests/setup.ts +++ b/examples/with-vitest-node/tests/setup.ts @@ -1,7 +1,10 @@ import { afterAll, beforeAll, beforeEach } from 'vitest'; +import { http } from 'zimic/interceptor'; import githubInterceptor from './interceptors/github'; +http.default.onUnhandledRequest({ log: false }); + beforeAll(async () => { await githubInterceptor.start(); }); diff --git a/packages/zimic/src/cli/cli.ts b/packages/zimic/src/cli/cli.ts index 3706601e..1f2ff4fb 100644 --- a/packages/zimic/src/cli/cli.ts +++ b/packages/zimic/src/cli/cli.ts @@ -3,7 +3,7 @@ import { hideBin } from 'yargs/helpers'; import { version } from '@@/package.json'; -import { DEFAULT_UNHANDLED_REQUEST_STRATEGY } from '@/interceptor/http/interceptorWorker/HttpInterceptorWorker'; +import { DEFAULT_UNHANDLED_REQUEST_STRATEGY } from '@/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore'; import initializeBrowserServiceWorker from './browser/init'; import startInterceptorServer from './server/start'; diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts index 1beada28..2831b6a1 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts @@ -23,14 +23,9 @@ import { HttpInterceptorRequest, HttpInterceptorResponse, } from '../requestHandler/types/requests'; +import HttpInterceptorWorkerStore from './HttpInterceptorWorkerStore'; import { HttpResponseFactory } from './types/requests'; -const DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY = process.env.DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY === 'true'; - -export const DEFAULT_UNHANDLED_REQUEST_STRATEGY: Required = { - log: DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY, -}; - abstract class HttpInterceptorWorker { abstract readonly type: 'local' | 'remote'; @@ -39,6 +34,8 @@ abstract class HttpInterceptorWorker { private startingPromise?: Promise; private stoppingPromise?: Promise; + private store = new HttpInterceptorWorkerStore(); + private unhandledRequestStrategies: { baseURL: string; declarationOrHandler: UnhandledRequestStrategy; @@ -102,32 +99,51 @@ abstract class HttpInterceptorWorker { protected async handleUnhandledRequest(request: Request) { const requestURL = excludeNonPathParams(createURL(request.url)).toString(); - const { declarationOrHandler } = + const defaultDeclarationOrHandler = this.store.defaultUnhandledRequestStrategy(); + + const declarationOrHandler = this.unhandledRequestStrategies.findLast((strategy) => { return requestURL.startsWith(strategy.baseURL); - }) ?? {}; + })?.declarationOrHandler ?? defaultDeclarationOrHandler; const action: UnhandledRequestStrategy.Action = this.type === 'local' ? 'bypass' : 'reject'; if (typeof declarationOrHandler === 'function') { - const handler = declarationOrHandler; - const requestClone = request.clone(); - - try { - await handler(request, { - async log() { - await HttpInterceptorWorker.logUnhandledRequest(requestClone, action); - }, - }); - } catch (error) { - console.error(error); - } + await HttpInterceptorWorker.useUnhandledRequestStrategyHandler(request, declarationOrHandler, action); + } else if (declarationOrHandler.log !== undefined) { + await HttpInterceptorWorker.useStaticUnhandledStrategy(request, { log: declarationOrHandler.log }, action); + } else if (typeof defaultDeclarationOrHandler === 'function') { + await HttpInterceptorWorker.useUnhandledRequestStrategyHandler(request, defaultDeclarationOrHandler, action); } else { - const declaration = declarationOrHandler; - const shouldLog = declaration?.log ?? DEFAULT_UNHANDLED_REQUEST_STRATEGY.log; - if (shouldLog) { - await HttpInterceptorWorker.logUnhandledRequest(request, action); - } + await HttpInterceptorWorker.useStaticUnhandledStrategy(request, defaultDeclarationOrHandler, action); + } + } + + static async useUnhandledRequestStrategyHandler( + request: Request, + handler: UnhandledRequestStrategy.Handler, + action: UnhandledRequestStrategy.Action, + ) { + const requestClone = request.clone(); + + try { + await handler(request, { + async log() { + await HttpInterceptorWorker.logUnhandledRequest(requestClone, action); + }, + }); + } catch (error) { + console.error(error); + } + } + + static async useStaticUnhandledStrategy( + request: Request, + declaration: Required, + action: UnhandledRequestStrategy.Action, + ) { + if (declaration.log) { + await HttpInterceptorWorker.logUnhandledRequest(request, action); } } diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore.ts b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore.ts new file mode 100644 index 00000000..aab74e8a --- /dev/null +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore.ts @@ -0,0 +1,34 @@ +import { UnhandledRequestStrategy } from '../interceptor/types/options'; + +const DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY = process.env.DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY === 'true'; + +export type DefaultUnhandledRequestStrategy = + | Required + | UnhandledRequestStrategy.Handler; + +export const DEFAULT_UNHANDLED_REQUEST_STRATEGY: Required = Object.freeze({ + log: DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY, +}); + +class HttpInterceptorWorkerStore { + private static _defaultUnhandledRequestStrategy: DefaultUnhandledRequestStrategy = { + ...DEFAULT_UNHANDLED_REQUEST_STRATEGY, + }; + + private class = HttpInterceptorWorkerStore; + + defaultUnhandledRequestStrategy() { + return this.class._defaultUnhandledRequestStrategy; + } + + setDefaultUnhandledRequestStrategy(strategy: UnhandledRequestStrategy) { + this.class._defaultUnhandledRequestStrategy = + typeof strategy === 'function' + ? strategy + : { + log: strategy.log ?? DEFAULT_UNHANDLED_REQUEST_STRATEGY.log, + }; + } +} + +export default HttpInterceptorWorkerStore; diff --git a/packages/zimic/src/interceptor/index.ts b/packages/zimic/src/interceptor/index.ts index 3199794f..143893c8 100644 --- a/packages/zimic/src/interceptor/index.ts +++ b/packages/zimic/src/interceptor/index.ts @@ -2,6 +2,9 @@ import NotStartedHttpInterceptorError from './http/interceptor/errors/NotStarted import UnknownHttpInterceptorPlatform from './http/interceptor/errors/UnknownHttpInterceptorPlatform'; import { createHttpInterceptor } from './http/interceptor/factory'; import UnregisteredServiceWorkerError from './http/interceptorWorker/errors/UnregisteredServiceWorkerError'; +import HttpInterceptorWorkerStore, { + DefaultUnhandledRequestStrategy, +} from './http/interceptorWorker/HttpInterceptorWorkerStore'; export { UnknownHttpInterceptorPlatform, NotStartedHttpInterceptorError, UnregisteredServiceWorkerError }; @@ -34,6 +37,13 @@ export type { LocalHttpInterceptor, RemoteHttpInterceptor, HttpInterceptor } fro export const http = { createInterceptor: createHttpInterceptor, + + default: { + onUnhandledRequest(strategy: DefaultUnhandledRequestStrategy) { + const store = new HttpInterceptorWorkerStore(); + store.setDefaultUnhandledRequestStrategy(strategy); + }, + }, }; export type HttpNamespace = typeof http; diff --git a/packages/zimic/src/interceptor/server/InterceptorServer.ts b/packages/zimic/src/interceptor/server/InterceptorServer.ts index eab3951d..479beb31 100644 --- a/packages/zimic/src/interceptor/server/InterceptorServer.ts +++ b/packages/zimic/src/interceptor/server/InterceptorServer.ts @@ -3,9 +3,9 @@ import { createServer, Server as HttpServer, IncomingMessage, ServerResponse } f import type { WebSocket as Socket } from 'isomorphic-ws'; import { HttpMethod } from '@/http/types/schema'; -import HttpInterceptorWorker, { - DEFAULT_UNHANDLED_REQUEST_STRATEGY, -} from '@/interceptor/http/interceptorWorker/HttpInterceptorWorker'; +import { UnhandledRequestStrategy } from '@/interceptor/http/interceptor/types/options'; +import HttpInterceptorWorker from '@/interceptor/http/interceptorWorker/HttpInterceptorWorker'; +import HttpInterceptorWorkerStore from '@/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore'; import { deserializeResponse, serializeRequest } from '@/utils/fetch'; import { getHttpServerPort, startHttpServer, stopHttpServer } from '@/utils/http'; import { createRegexFromURL, createURL, excludeNonPathParams } from '@/utils/urls'; @@ -37,9 +37,8 @@ class InterceptorServer implements PublicInterceptorServer { private _hostname: string; private _port?: number; - private onUnhandledRequest: { - log: boolean; - }; + private onUnhandledRequest?: UnhandledRequestStrategy.Declaration; + private workerStore = new HttpInterceptorWorkerStore(); private httpHandlerGroups: { [Method in HttpMethod]: HttpHandler[]; @@ -58,9 +57,7 @@ class InterceptorServer implements PublicInterceptorServer { constructor(options: InterceptorServerOptions = {}) { this._hostname = options.hostname ?? 'localhost'; this._port = options.port; - this.onUnhandledRequest = { - log: options.onUnhandledRequest?.log ?? DEFAULT_UNHANDLED_REQUEST_STRATEGY.log, - }; + this.onUnhandledRequest = options.onUnhandledRequest; } hostname() { @@ -240,11 +237,21 @@ class InterceptorServer implements PublicInterceptorServer { await sendNodeResponse(defaultPreflightResponse, nodeResponse, nodeRequest); } - const shouldWarnUnhandledRequest = - !isUnhandledPreflightResponse && !matchedAnyInterceptor && this.onUnhandledRequest.log; + const shouldWarnUnhandledRequest = !isUnhandledPreflightResponse && !matchedAnyInterceptor; if (shouldWarnUnhandledRequest) { - await HttpInterceptorWorker.logUnhandledRequest(request, 'reject'); + const action: UnhandledRequestStrategy.Action = 'reject'; + + const declaration = this.onUnhandledRequest; + const defaultDeclarationOrHandler = this.workerStore.defaultUnhandledRequestStrategy(); + + if (declaration?.log !== undefined) { + await HttpInterceptorWorker.useStaticUnhandledStrategy(request, { log: declaration.log }, action); + } else if (typeof defaultDeclarationOrHandler === 'function') { + await HttpInterceptorWorker.useUnhandledRequestStrategyHandler(request, defaultDeclarationOrHandler, action); + } else { + await HttpInterceptorWorker.useStaticUnhandledStrategy(request, defaultDeclarationOrHandler, action); + } } nodeResponse.destroy(); From 9d875ca6b0a9439361e634453673acada2d8cfa0 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sun, 19 May 2024 16:46:09 -0300 Subject: [PATCH 14/22] refactor(#zimic): replace default log flag by worker store --- packages/zimic/src/cli/__tests__/server.cli.node.test.ts | 2 +- .../http/interceptorWorker/HttpInterceptorWorkerStore.ts | 4 +--- .../zimic/src/interceptor/server/InterceptorServer.ts | 1 + packages/zimic/src/utils/urls.ts | 7 +++++-- .../serverOnBrowser.ts => setup/global/browser.ts} | 1 + packages/zimic/tests/setup/shared.ts | 9 +++++++++ packages/zimic/tests/utils/interceptors.ts | 2 +- packages/zimic/tsup.config.ts | 1 - packages/zimic/vitest.config.mts | 4 ++-- packages/zimic/vitest.workspace.mts | 2 +- 10 files changed, 22 insertions(+), 11 deletions(-) rename packages/zimic/tests/{globalSetup/serverOnBrowser.ts => setup/global/browser.ts} (93%) create mode 100644 packages/zimic/tests/setup/shared.ts diff --git a/packages/zimic/src/cli/__tests__/server.cli.node.test.ts b/packages/zimic/src/cli/__tests__/server.cli.node.test.ts index 73047bbf..8f730a82 100644 --- a/packages/zimic/src/cli/__tests__/server.cli.node.test.ts +++ b/packages/zimic/src/cli/__tests__/server.cli.node.test.ts @@ -105,7 +105,7 @@ describe('CLI (server)', async () => { ' re found for the base URL of a request. If an in', ' terceptor was matched, the logging behavior for', ' that base URL is configured in the interceptor i', - ' tself. [boolean] [default: false]', + ' tself. [boolean] [default: true]', ].join('\n'); beforeEach(async () => { diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore.ts b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore.ts index aab74e8a..1262b4ed 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore.ts @@ -1,13 +1,11 @@ import { UnhandledRequestStrategy } from '../interceptor/types/options'; -const DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY = process.env.DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY === 'true'; - export type DefaultUnhandledRequestStrategy = | Required | UnhandledRequestStrategy.Handler; export const DEFAULT_UNHANDLED_REQUEST_STRATEGY: Required = Object.freeze({ - log: DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY, + log: true, }); class HttpInterceptorWorkerStore { diff --git a/packages/zimic/src/interceptor/server/InterceptorServer.ts b/packages/zimic/src/interceptor/server/InterceptorServer.ts index 479beb31..d2f06db2 100644 --- a/packages/zimic/src/interceptor/server/InterceptorServer.ts +++ b/packages/zimic/src/interceptor/server/InterceptorServer.ts @@ -37,6 +37,7 @@ class InterceptorServer implements PublicInterceptorServer { private _hostname: string; private _port?: number; + private onUnhandledRequest?: UnhandledRequestStrategy.Declaration; private workerStore = new HttpInterceptorWorkerStore(); diff --git a/packages/zimic/src/utils/urls.ts b/packages/zimic/src/utils/urls.ts index f7ddb6f0..43a40faa 100644 --- a/packages/zimic/src/utils/urls.ts +++ b/packages/zimic/src/utils/urls.ts @@ -6,7 +6,7 @@ export class InvalidURL extends TypeError { } export class UnsupportedURLProtocolError extends TypeError { - constructor(protocol: string, availableProtocols: string[]) { + constructor(protocol: string, availableProtocols: string[] | readonly string[]) { super( `[zimic] Unsupported URL protocol: '${protocol}'. ` + `The available options are ${availableProtocols.map((protocol) => `'${protocol}'`).join(', ')}`, @@ -36,7 +36,10 @@ function createURLOrThrow(rawURL: string | URL) { } } -export function createURL(rawURL: string | URL, options: { protocols?: string[] } = {}): ExtendedURL { +export function createURL( + rawURL: string | URL, + options: { protocols?: string[] | readonly string[] } = {}, +): ExtendedURL { const url = createURLOrThrow(rawURL); const protocol = url.protocol.replace(/:$/, ''); diff --git a/packages/zimic/tests/globalSetup/serverOnBrowser.ts b/packages/zimic/tests/setup/global/browser.ts similarity index 93% rename from packages/zimic/tests/globalSetup/serverOnBrowser.ts rename to packages/zimic/tests/setup/global/browser.ts index 91343807..7c1cc8f0 100644 --- a/packages/zimic/tests/globalSetup/serverOnBrowser.ts +++ b/packages/zimic/tests/setup/global/browser.ts @@ -11,6 +11,7 @@ export async function setup() { server = new Server({ hostname: GLOBAL_SETUP_SERVER_HOSTNAME, port: GLOBAL_SETUP_SERVER_PORT, + onUnhandledRequest: { log: false }, }); await server.start(); diff --git a/packages/zimic/tests/setup/shared.ts b/packages/zimic/tests/setup/shared.ts new file mode 100644 index 00000000..1db61355 --- /dev/null +++ b/packages/zimic/tests/setup/shared.ts @@ -0,0 +1,9 @@ +import { beforeAll } from 'vitest'; + +import HttpInterceptorWorkerStore from '@/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore'; + +const workerStore = new HttpInterceptorWorkerStore(); + +beforeAll(() => { + workerStore.setDefaultUnhandledRequestStrategy({ log: false }); +}); diff --git a/packages/zimic/tests/utils/interceptors.ts b/packages/zimic/tests/utils/interceptors.ts index 083f167a..d0355043 100644 --- a/packages/zimic/tests/utils/interceptors.ts +++ b/packages/zimic/tests/utils/interceptors.ts @@ -23,7 +23,7 @@ import InterceptorServer from '@/interceptor/server/InterceptorServer'; import { PossiblePromise } from '@/types/utils'; import { getCrypto } from '@/utils/crypto'; import { joinURL, createURL, ExtendedURL } from '@/utils/urls'; -import { GLOBAL_SETUP_SERVER_HOSTNAME, GLOBAL_SETUP_SERVER_PORT } from '@tests/globalSetup/serverOnBrowser'; +import { GLOBAL_SETUP_SERVER_HOSTNAME, GLOBAL_SETUP_SERVER_PORT } from '@tests/setup/global/browser'; export async function getBrowserBaseURL(workerType: HttpInterceptorType) { if (workerType === 'local') { diff --git a/packages/zimic/tsup.config.ts b/packages/zimic/tsup.config.ts index 46aa51ed..aafba563 100644 --- a/packages/zimic/tsup.config.ts +++ b/packages/zimic/tsup.config.ts @@ -13,7 +13,6 @@ const sharedConfig: Options = { clean: true, env: { SERVER_ACCESS_CONTROL_MAX_AGE: '', - DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY: 'true', }, }; diff --git a/packages/zimic/vitest.config.mts b/packages/zimic/vitest.config.mts index d160b6b9..721de8b3 100644 --- a/packages/zimic/vitest.config.mts +++ b/packages/zimic/vitest.config.mts @@ -10,6 +10,7 @@ export const defaultConfig: UserConfig = { allowOnly: process.env.CI !== 'true', testTimeout: 5000, hookTimeout: 5000, + setupFiles: ['./tests/setup/shared.ts'], coverage: { provider: 'istanbul', reporter: ['text', 'html'], @@ -23,7 +24,7 @@ export const defaultConfig: UserConfig = { exclude: [ '**/local/**', '**/public/**', - '**/tests/globalSetup/**', + '**/tests/setup/global/**', '**/types/**', '**/types.ts', '**/typescript.ts', @@ -35,7 +36,6 @@ export const defaultConfig: UserConfig = { }, define: { 'process.env.SERVER_ACCESS_CONTROL_MAX_AGE': "'0'", - 'process.env.DEFAULT_UNHANDLED_REQUEST_LOGGING_STRATEGY': "'false'", }, resolve: { alias: { diff --git a/packages/zimic/vitest.workspace.mts b/packages/zimic/vitest.workspace.mts index b185cbb3..69f7adc0 100644 --- a/packages/zimic/vitest.workspace.mts +++ b/packages/zimic/vitest.workspace.mts @@ -26,7 +26,7 @@ export default defineWorkspace([ environment: undefined, include: ['./{src,tests,scripts}/**/*.test.ts', './{src,tests,scripts}/**/*.browser.test.ts'], exclude: ['**/*.node.test.ts', '**/*.browserNoWorker.test.ts'], - globalSetup: './tests/globalSetup/serverOnBrowser.ts', + globalSetup: './tests/setup/global/browser.ts', browser: { name: browserName, provider: 'playwright', From 4c2b74f851dc4dae449336be5e3a4e44d998f0b3 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sun, 19 May 2024 16:48:02 -0300 Subject: [PATCH 15/22] refactor(#zimic): freeze constant objects --- packages/zimic/src/http/types/schema.ts | 2 +- .../http/interceptor/HttpInterceptorClient.ts | 2 +- .../http/requestHandler/types/requests.ts | 18 ++++++++---------- packages/zimic/src/interceptor/index.ts | 4 ++-- .../zimic/src/interceptor/server/constants.ts | 4 ++-- packages/zimic/src/utils/processes.ts | 4 ++-- 6 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/zimic/src/http/types/schema.ts b/packages/zimic/src/http/types/schema.ts index 4851687c..182f3395 100644 --- a/packages/zimic/src/http/types/schema.ts +++ b/packages/zimic/src/http/types/schema.ts @@ -4,7 +4,7 @@ import { HttpHeadersSchema } from '../headers/types'; import { HttpSearchParamsSchema } from '../searchParams/types'; import { HttpBody } from './requests'; -export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const; +export const HTTP_METHODS = Object.freeze(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const); export type HttpMethod = (typeof HTTP_METHODS)[number]; /** A schema representing the structure of an HTTP request. */ diff --git a/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts b/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts index 2884afbb..7ad568be 100644 --- a/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts +++ b/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts @@ -22,7 +22,7 @@ import HttpInterceptorStore from './HttpInterceptorStore'; import { UnhandledRequestStrategy } from './types/options'; import { HttpInterceptorRequestContext } from './types/requests'; -export const SUPPORTED_BASE_URL_PROTOCOLS = ['http', 'https']; +export const SUPPORTED_BASE_URL_PROTOCOLS = Object.freeze(['http', 'https']); class HttpInterceptorClient< Schema extends HttpServiceSchema, diff --git a/packages/zimic/src/interceptor/http/requestHandler/types/requests.ts b/packages/zimic/src/interceptor/http/requestHandler/types/requests.ts index 827c0b71..dc85e773 100644 --- a/packages/zimic/src/interceptor/http/requestHandler/types/requests.ts +++ b/packages/zimic/src/interceptor/http/requestHandler/types/requests.ts @@ -82,17 +82,15 @@ export interface HttpInterceptorResponse< raw: HttpResponse, StatusCode>; } -export const HTTP_INTERCEPTOR_REQUEST_HIDDEN_BODY_PROPERTIES = new Set([ - 'bodyUsed', - 'arrayBuffer', - 'blob', - 'formData', - 'json', - 'text', -] satisfies Exclude>[]); +export const HTTP_INTERCEPTOR_REQUEST_HIDDEN_BODY_PROPERTIES = Object.freeze( + new Set(['bodyUsed', 'arrayBuffer', 'blob', 'formData', 'json', 'text'] satisfies Exclude< + keyof Body, + keyof HttpInterceptorRequest + >[]), +); -export const HTTP_INTERCEPTOR_RESPONSE_HIDDEN_BODY_PROPERTIES = new Set( - HTTP_INTERCEPTOR_REQUEST_HIDDEN_BODY_PROPERTIES, +export const HTTP_INTERCEPTOR_RESPONSE_HIDDEN_BODY_PROPERTIES = Object.freeze( + new Set(HTTP_INTERCEPTOR_REQUEST_HIDDEN_BODY_PROPERTIES), ); /** A strict representation of a tracked, intercepted HTTP request, along with its response. */ diff --git a/packages/zimic/src/interceptor/index.ts b/packages/zimic/src/interceptor/index.ts index 143893c8..b1e2dfb5 100644 --- a/packages/zimic/src/interceptor/index.ts +++ b/packages/zimic/src/interceptor/index.ts @@ -35,7 +35,7 @@ export type { ExtractHttpInterceptorSchema } from './http/interceptor/types/sche export type { LocalHttpInterceptor, RemoteHttpInterceptor, HttpInterceptor } from './http/interceptor/types/public'; -export const http = { +export const http = Object.freeze({ createInterceptor: createHttpInterceptor, default: { @@ -44,6 +44,6 @@ export const http = { store.setDefaultUnhandledRequestStrategy(strategy); }, }, -}; +}); export type HttpNamespace = typeof http; diff --git a/packages/zimic/src/interceptor/server/constants.ts b/packages/zimic/src/interceptor/server/constants.ts index a8554a0b..1e00c52d 100644 --- a/packages/zimic/src/interceptor/server/constants.ts +++ b/packages/zimic/src/interceptor/server/constants.ts @@ -15,12 +15,12 @@ export type AccessControlHeaders = HttpSchema.Headers<{ 'access-control-max-age'?: string; }>; -export const DEFAULT_ACCESS_CONTROL_HEADERS = { +export const DEFAULT_ACCESS_CONTROL_HEADERS = Object.freeze({ 'access-control-allow-origin': '*', 'access-control-allow-methods': ALLOWED_ACCESS_CONTROL_HTTP_METHODS, 'access-control-allow-headers': '*', 'access-control-expose-headers': '*', 'access-control-max-age': process.env.SERVER_ACCESS_CONTROL_MAX_AGE, -} satisfies AccessControlHeaders; +}) satisfies AccessControlHeaders; export const DEFAULT_PREFLIGHT_STATUS_CODE = 204; diff --git a/packages/zimic/src/utils/processes.ts b/packages/zimic/src/utils/processes.ts index 7775edf5..c2e190af 100644 --- a/packages/zimic/src/utils/processes.ts +++ b/packages/zimic/src/utils/processes.ts @@ -1,13 +1,13 @@ import { spawn } from 'cross-spawn'; -export const PROCESS_EXIT_EVENTS = [ +export const PROCESS_EXIT_EVENTS = Object.freeze([ 'beforeExit', 'uncaughtExceptionMonitor', 'SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK', -] as const; +] as const); export class CommandError extends Error { constructor(command: string, exitCode: number | null, signal: NodeJS.Signals | null) { From 881e62b23b1a07e8557ac406da4212b2c44d916e Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sun, 19 May 2024 17:47:42 -0300 Subject: [PATCH 16/22] test(#zimic): verify unhandled request defaults --- .../src/cli/__tests__/server.cli.node.test.ts | 156 ++++--- packages/zimic/src/cli/cli.ts | 3 - .../__tests__/shared/methods/delete.ts | 369 +++++++++------- .../__tests__/shared/methods/get.ts | 369 +++++++++------- .../__tests__/shared/methods/head.ts | 363 +++++++++------- .../__tests__/shared/methods/options.ts | 379 +++++++++-------- .../__tests__/shared/methods/patch.ts | 369 +++++++++------- .../__tests__/shared/methods/post.ts | 399 ++++++++++-------- .../__tests__/shared/methods/put.ts | 369 +++++++++------- .../HttpInterceptorWorker.ts | 9 +- .../HttpInterceptorWorkerStore.ts | 2 +- packages/zimic/src/interceptor/index.ts | 7 +- packages/zimic/tests/setup/shared.ts | 10 +- 13 files changed, 1583 insertions(+), 1221 deletions(-) diff --git a/packages/zimic/src/cli/__tests__/server.cli.node.test.ts b/packages/zimic/src/cli/__tests__/server.cli.node.test.ts index 8f730a82..81324bf5 100644 --- a/packages/zimic/src/cli/__tests__/server.cli.node.test.ts +++ b/packages/zimic/src/cli/__tests__/server.cli.node.test.ts @@ -3,6 +3,7 @@ import filesystem from 'fs/promises'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { http } from '@/interceptor'; import { verifyUnhandledRequestMessage } from '@/interceptor/http/interceptor/__tests__/shared/utils'; import { DEFAULT_SERVER_LIFE_CYCLE_TIMEOUT } from '@/interceptor/server/constants'; import { PossiblePromise } from '@/types/utils'; @@ -105,7 +106,7 @@ describe('CLI (server)', async () => { ' re found for the base URL of a request. If an in', ' terceptor was matched, the logging behavior for', ' that base URL is configured in the interceptor i', - ' tself. [boolean] [default: true]', + ' tself. [boolean]', ].join('\n'); beforeEach(async () => { @@ -474,80 +475,119 @@ describe('CLI (server)', async () => { } }); }); - }); - - it('should show an error if logging is enabled when a request is received and does not match any interceptors', async () => { - const exitEventListeners = watchExitEventListeners(PROCESS_EXIT_EVENTS[0]); - processArgvSpy.mockReturnValue(['node', 'cli.js', 'server', 'start', '--log-unhandled-requests']); - await usingIgnoredConsole(['log', 'warn', 'error'], async (spies) => { - await runCLI(); + it.each([ + { overrideDefault: false as const }, + { overrideDefault: 'static' as const }, + { overrideDefault: 'static-empty' as const }, + { overrideDefault: 'function' as const }, + ])( + 'should show an error if logging is enabled when a request is received and does not match any interceptors: override default $overrideDefault', + async ({ overrideDefault }) => { + const exitEventListeners = watchExitEventListeners(PROCESS_EXIT_EVENTS[0]); + processArgvSpy.mockReturnValue([ + 'node', + 'cli.js', + 'server', + 'start', + ...(overrideDefault === false ? ['--log-unhandled-requests'] : []), + ]); + + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: true }); + } else if (overrideDefault === 'static-empty') { + http.default.onUnhandledRequest({}); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(async (_request, context) => { + await context.log(); + }); + } - expect(server).toBeDefined(); - expect(server!.isRunning()).toBe(true); - expect(server!.hostname()).toBe('localhost'); - expect(server!.port()).toBeGreaterThan(0); + await usingIgnoredConsole(['log', 'warn', 'error'], async (spies) => { + await runCLI(); - expect(spies.log).toHaveBeenCalledTimes(1); - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); + expect(server).toBeDefined(); + expect(server!.isRunning()).toBe(true); + expect(server!.hostname()).toBe('localhost'); + expect(server!.port()).toBeGreaterThan(0); - expect(spies.log).toHaveBeenCalledWith( - `${chalk.cyan('[zimic]')}`, - `Server is running on 'http://localhost:${server!.port()}'.`, - ); + expect(spies.log).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); - expect(exitEventListeners).toHaveLength(1); + expect(spies.log).toHaveBeenCalledWith( + `${chalk.cyan('[zimic]')}`, + `Server is running on 'http://localhost:${server!.port()}'.`, + ); - const request = new Request(`http://localhost:${server!.port()}`, { method: 'GET' }); + expect(exitEventListeners).toHaveLength(1); - const response = fetch(request); - await expectFetchError(response); + const request = new Request(`http://localhost:${server!.port()}`, { method: 'GET' }); - expect(spies.log).toHaveBeenCalledTimes(1); - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(1); + const response = fetch(request); + await expectFetchError(response); - const errorMessage = spies.error.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(errorMessage, { - type: 'error', - platform: 'node', - request, - }); - }); - }); + expect(spies.log).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); - it('should not show an error if logging is disabled when a request is received and does not match any interceptors', async () => { - const exitEventListeners = watchExitEventListeners(PROCESS_EXIT_EVENTS[0]); - processArgvSpy.mockReturnValue(['node', 'cli.js', 'server', 'start', '--log-unhandled-requests', 'false']); + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform: 'node', + request, + }); + }); + }, + ); + + it.each([{ overrideDefault: false }, { overrideDefault: 'static' }, { overrideDefault: 'function' }])( + 'should not show an error if logging is disabled when a request is received and does not match any interceptors: override default $overrideDefault', + async ({ overrideDefault }) => { + const exitEventListeners = watchExitEventListeners(PROCESS_EXIT_EVENTS[0]); + processArgvSpy.mockReturnValue([ + 'node', + 'cli.js', + 'server', + 'start', + ...(overrideDefault === false ? ['--log-unhandled-requests', 'false'] : []), + ]); + + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: false }); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(vi.fn()); + } - await usingIgnoredConsole(['log', 'warn', 'error'], async (spies) => { - await runCLI(); + await usingIgnoredConsole(['log', 'warn', 'error'], async (spies) => { + await runCLI(); - expect(server).toBeDefined(); - expect(server!.isRunning()).toBe(true); - expect(server!.hostname()).toBe('localhost'); - expect(server!.port()).toBeGreaterThan(0); + expect(server).toBeDefined(); + expect(server!.isRunning()).toBe(true); + expect(server!.hostname()).toBe('localhost'); + expect(server!.port()).toBeGreaterThan(0); - expect(spies.log).toHaveBeenCalledTimes(1); - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); + expect(spies.log).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); - expect(spies.log).toHaveBeenCalledWith( - `${chalk.cyan('[zimic]')}`, - `Server is running on 'http://localhost:${server!.port()}'.`, - ); + expect(spies.log).toHaveBeenCalledWith( + `${chalk.cyan('[zimic]')}`, + `Server is running on 'http://localhost:${server!.port()}'.`, + ); - expect(exitEventListeners).toHaveLength(1); + expect(exitEventListeners).toHaveLength(1); - const request = new Request(`http://localhost:${server!.port()}`, { method: 'GET' }); + const request = new Request(`http://localhost:${server!.port()}`, { method: 'GET' }); - const response = fetch(request); - await expectFetchError(response); + const response = fetch(request); + await expectFetchError(response); - expect(spies.log).toHaveBeenCalledTimes(1); - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - }); + expect(spies.log).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + }); + }, + ); }); }); diff --git a/packages/zimic/src/cli/cli.ts b/packages/zimic/src/cli/cli.ts index 1f2ff4fb..ccd95694 100644 --- a/packages/zimic/src/cli/cli.ts +++ b/packages/zimic/src/cli/cli.ts @@ -3,8 +3,6 @@ import { hideBin } from 'yargs/helpers'; import { version } from '@@/package.json'; -import { DEFAULT_UNHANDLED_REQUEST_STRATEGY } from '@/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore'; - import initializeBrowserServiceWorker from './browser/init'; import startInterceptorServer from './server/start'; @@ -69,7 +67,6 @@ async function runCLI() { 'If an interceptor was matched, the logging behavior for that base URL is configured in the ' + 'interceptor itself.', alias: 'l', - default: DEFAULT_UNHANDLED_REQUEST_STRATEGY.log, }), async (cliArguments) => { const onReadyCommand = cliArguments._.at(2)?.toString(); diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/delete.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/delete.ts index f17420c6..e875bf13 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/delete.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/delete.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; import { HttpSchema } from '@/http/types/schema'; +import { http } from '@/interceptor'; import { promiseIfRemote } from '@/interceptor/http/interceptorWorker/__tests__/utils/promises'; import LocalHttpRequestHandler from '@/interceptor/http/requestHandler/LocalHttpRequestHandler'; import RemoteHttpRequestHandler from '@/interceptor/http/requestHandler/RemoteHttpRequestHandler'; @@ -1150,68 +1151,169 @@ export async function declareDeleteHttpInterceptorTests(options: RuntimeSharedHt }); describe('Unhandled requests', () => { - if (type === 'local') { - it('should show a warning when logging is enabled and a DELETE request is unhandled and bypassed', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - DELETE: { - request: { headers: { 'x-value': string } }; - response: { - 200: { body: User }; + describe.each([ + { overrideDefault: false as const }, + { overrideDefault: 'static' as const }, + { overrideDefault: 'static-empty' as const }, + { overrideDefault: 'function' as const }, + ])('Logging enabled or disabled: override default $overrideDefault', ({ overrideDefault }) => { + beforeEach(() => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: true }); + } else if (overrideDefault === 'static-empty') { + http.default.onUnhandledRequest({}); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(async (_request, context) => { + await context.log(); + }); + } + }); + + if (type === 'local') { + it('should show a warning when logging is enabled and a DELETE request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; }; }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const deletionHandler = await promiseIfRemote( - interceptor - .delete(`/users/${users[0].id}`) - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - body: users[0], - }), - interceptor, + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor + .delete(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '1' }, + }); + expect(deletionResponse.status).toBe(200); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const deletionRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); + const deletionResponsePromise = fetch(deletionRequest); + await expectFetchError(deletionResponsePromise); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: deletionRequest, + }); + }); + }, ); - - let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'DELETE', - headers: { 'x-value': '1' }, - }); - expect(deletionResponse.status).toBe(200); - - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const deletionRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { method: 'DELETE' }); - const deletionResponsePromise = fetch(deletionRequest); - await expectFetchError(deletionResponsePromise); - - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(1); - expect(spies.error).toHaveBeenCalledTimes(0); - - const warnMessage = spies.warn.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(warnMessage, { - type: 'warn', - platform, - request: deletionRequest, - }); - }); }); - }); - } + } + + if (type === 'remote') { + it('should show an error when logging is enabled and a DELETE request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor + .delete(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '1' }, + }); + expect(deletionResponse.status).toBe(200); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const deletionRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '2' }, + }); + const deletionResponsePromise = fetch(deletionRequest); + await expectFetchError(deletionResponsePromise); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: deletionRequest, + }); + }); + }, + ); + }); + } + }); + + it.each([{ overrideDefault: false }, { overrideDefault: 'static' }, { overrideDefault: 'function' }])( + 'should not show a warning or error when logging is disabled and a DELETE request is unhandled: override default $overrideDefault', + async ({ overrideDefault }) => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: false }); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(vi.fn()); + } - if (type === 'remote') { - it('should show an error when logging is enabled and a DELETE request is unhandled and rejected', async () => { await usingHttpInterceptor<{ '/users/:id': { DELETE: { @@ -1221,57 +1323,56 @@ export async function declareDeleteHttpInterceptorTests(options: RuntimeSharedHt }; }; }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const deletionHandler = await promiseIfRemote( - interceptor - .delete(`/users/${users[0].id}`) - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - - let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'DELETE', - headers: { 'x-value': '1' }, + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: false } : {}, + }, + async (interceptor) => { + const deletionHandler = await promiseIfRemote( + interceptor + .delete(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '1' }, + }); + expect(deletionResponse.status).toBe(200); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const deletionRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'DELETE', + headers: { 'x-value': '2' }, + }); + const deletionResponsePromise = fetch(deletionRequest); + await expectFetchError(deletionResponsePromise); + + deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); + expect(deletionRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); }); - expect(deletionResponse.status).toBe(200); - - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const deletionRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'DELETE', - headers: { 'x-value': '2' }, - }); - const deletionResponsePromise = fetch(deletionRequest); - await expectFetchError(deletionResponsePromise); - - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(1); - - const errorMessage = spies.error.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(errorMessage, { - type: 'error', - platform, - request: deletionRequest, - }); - }); - }); - }); - } + }, + ); + }, + ); it('should support a custom unhandled DELETE request handler', async () => { const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { @@ -1450,59 +1551,5 @@ export async function declareDeleteHttpInterceptorTests(options: RuntimeSharedHt }); }); }); - - it('should not show a warning or error when logging is disabled and a DELETE request is unhandled', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - DELETE: { - request: { headers: { 'x-value': string } }; - response: { - 200: { body: User }; - }; - }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { - const deletionHandler = await promiseIfRemote( - interceptor - .delete(`/users/${users[0].id}`) - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - - let deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const deletionResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'DELETE', - headers: { 'x-value': '1' }, - }); - expect(deletionResponse.status).toBe(200); - - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const deletionRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'DELETE', - headers: { 'x-value': '2' }, - }); - const deletionResponsePromise = fetch(deletionRequest); - await expectFetchError(deletionResponsePromise); - - deletionRequests = await promiseIfRemote(deletionHandler.requests(), interceptor); - expect(deletionRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - }); - }); - }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/get.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/get.ts index 43325277..edb59101 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/get.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/get.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; import { HttpSchema } from '@/http/types/schema'; +import { http } from '@/interceptor'; import { promiseIfRemote } from '@/interceptor/http/interceptorWorker/__tests__/utils/promises'; import LocalHttpRequestHandler from '@/interceptor/http/requestHandler/LocalHttpRequestHandler'; import RemoteHttpRequestHandler from '@/interceptor/http/requestHandler/RemoteHttpRequestHandler'; @@ -1068,68 +1069,169 @@ export async function declareGetHttpInterceptorTests(options: RuntimeSharedHttpI }); describe('Unhandled requests', () => { - if (type === 'local') { - it('should show a warning when logging is enabled and a GET request is unhandled and bypassed', async () => { - await usingHttpInterceptor<{ - '/users': { - GET: { - request: { headers: { 'x-value': string } }; - response: { - 200: { body: User[] }; + describe.each([ + { overrideDefault: false as const }, + { overrideDefault: 'static' as const }, + { overrideDefault: 'static-empty' as const }, + { overrideDefault: 'function' as const }, + ])('Logging enabled or disabled: override default $overrideDefault', ({ overrideDefault }) => { + beforeEach(() => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: true }); + } else if (overrideDefault === 'static-empty') { + http.default.onUnhandledRequest({}); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(async (_request, context) => { + await context.log(); + }); + } + }); + + if (type === 'local') { + it('should show a warning when logging is enabled and a GET request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/users': { + GET: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User[] }; + }; }; }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const listHandler = await promiseIfRemote( - interceptor - .get('/users') - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - body: users, - }), - interceptor, + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor + .get('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users, + }), + interceptor, + ); + + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const listResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '1' }, + }); + expect(listResponse.status).toBe(200); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const listRequest = new Request(joinURL(baseURL, '/users'), { method: 'GET' }); + const listResponsePromise = fetch(listRequest); + await expectFetchError(listResponsePromise); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: listRequest, + }); + }); + }, ); - - let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const listResponse = await fetch(joinURL(baseURL, '/users'), { - method: 'GET', - headers: { 'x-value': '1' }, - }); - expect(listResponse.status).toBe(200); - - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const listRequest = new Request(joinURL(baseURL, '/users'), { method: 'GET' }); - const listResponsePromise = fetch(listRequest); - await expectFetchError(listResponsePromise); - - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(1); - expect(spies.error).toHaveBeenCalledTimes(0); - - const warnMessage = spies.warn.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(warnMessage, { - type: 'warn', - platform, - request: listRequest, - }); - }); }); - }); - } + } + + if (type === 'remote') { + it('should show an error when logging is enabled and a GET request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/users': { + GET: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User[] }; + }; + }; + }; + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor + .get('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users, + }), + interceptor, + ); + + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const listResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '1' }, + }); + expect(listResponse.status).toBe(200); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const listRequest = new Request(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '2' }, + }); + const listResponsePromise = fetch(listRequest); + await expectFetchError(listResponsePromise); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: listRequest, + }); + }); + }, + ); + }); + } + }); + + it.each([{ overrideDefault: false }, { overrideDefault: 'static' }, { overrideDefault: 'function' }])( + 'should not show a warning or error when logging is disabled and a GET request is unhandled: override default $overrideDefault', + async ({ overrideDefault }) => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: false }); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(vi.fn()); + } - if (type === 'remote') { - it('should show an error when logging is enabled and a GET request is unhandled and rejected', async () => { await usingHttpInterceptor<{ '/users': { GET: { @@ -1139,57 +1241,56 @@ export async function declareGetHttpInterceptorTests(options: RuntimeSharedHttpI }; }; }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const listHandler = await promiseIfRemote( - interceptor - .get('/users') - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - body: users, - }), - interceptor, - ); - - let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const listResponse = await fetch(joinURL(baseURL, '/users'), { - method: 'GET', - headers: { 'x-value': '1' }, + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: false } : {}, + }, + async (interceptor) => { + const listHandler = await promiseIfRemote( + interceptor + .get('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users, + }), + interceptor, + ); + + let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const listResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '1' }, + }); + expect(listResponse.status).toBe(200); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const listRequest = new Request(joinURL(baseURL, '/users'), { + method: 'GET', + headers: { 'x-value': '2' }, + }); + const listResponsePromise = fetch(listRequest); + await expectFetchError(listResponsePromise); + + listRequests = await promiseIfRemote(listHandler.requests(), interceptor); + expect(listRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); }); - expect(listResponse.status).toBe(200); - - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const listRequest = new Request(joinURL(baseURL, '/users'), { - method: 'GET', - headers: { 'x-value': '2' }, - }); - const listResponsePromise = fetch(listRequest); - await expectFetchError(listResponsePromise); - - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(1); - - const errorMessage = spies.error.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(errorMessage, { - type: 'error', - platform, - request: listRequest, - }); - }); - }); - }); - } + }, + ); + }, + ); it('should support a custom unhandled GET request handler', async () => { const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { @@ -1368,59 +1469,5 @@ export async function declareGetHttpInterceptorTests(options: RuntimeSharedHttpI }); }); }); - - it('should not show a warning or error when logging is disabled and a GET request is unhandled', async () => { - await usingHttpInterceptor<{ - '/users': { - GET: { - request: { headers: { 'x-value': string } }; - response: { - 200: { body: User[] }; - }; - }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { - const listHandler = await promiseIfRemote( - interceptor - .get('/users') - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - body: users, - }), - interceptor, - ); - - let listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const listResponse = await fetch(joinURL(baseURL, '/users'), { - method: 'GET', - headers: { 'x-value': '1' }, - }); - expect(listResponse.status).toBe(200); - - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const listRequest = new Request(joinURL(baseURL, '/users'), { - method: 'GET', - headers: { 'x-value': '2' }, - }); - const listResponsePromise = fetch(listRequest); - await expectFetchError(listResponsePromise); - - listRequests = await promiseIfRemote(listHandler.requests(), interceptor); - expect(listRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - }); - }); - }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/head.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/head.ts index 19be3d03..719a00b3 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/head.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/head.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; import { HttpSchema } from '@/http/types/schema'; +import { http } from '@/interceptor'; import { promiseIfRemote } from '@/interceptor/http/interceptorWorker/__tests__/utils/promises'; import LocalHttpRequestHandler from '@/interceptor/http/requestHandler/LocalHttpRequestHandler'; import RemoteHttpRequestHandler from '@/interceptor/http/requestHandler/RemoteHttpRequestHandler'; @@ -1002,67 +1003,167 @@ export function declareHeadHttpInterceptorTests(options: RuntimeSharedHttpInterc }); describe('Unhandled requests', () => { - if (type === 'local') { - it('should show a warning when logging is enabled and a HEAD request is unhandled and bypassed', async () => { - await usingHttpInterceptor<{ - '/users': { - HEAD: { - request: { headers: { 'x-value': string } }; - response: { - 200: {}; + describe.each([ + { overrideDefault: false as const }, + { overrideDefault: 'static' as const }, + { overrideDefault: 'static-empty' as const }, + { overrideDefault: 'function' as const }, + ])('Logging enabled or disabled: override default $overrideDefault', ({ overrideDefault }) => { + beforeEach(() => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: true }); + } else if (overrideDefault === 'static-empty') { + http.default.onUnhandledRequest({}); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(async (_request, context) => { + await context.log(); + }); + } + }); + + if (type === 'local') { + it('should show a warning when logging is enabled and a HEAD request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/users': { + HEAD: { + request: { headers: { 'x-value': string } }; + response: { + 200: {}; + }; }; }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const headHandler = await promiseIfRemote( - interceptor - .head('/users') - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - }), - interceptor, + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const headHandler = await promiseIfRemote( + interceptor + .head('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + }), + interceptor, + ); + + let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const headResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '1' }, + }); + expect(headResponse.status).toBe(200); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const headRequest = new Request(joinURL(baseURL, '/users'), { method: 'HEAD' }); + const headPromise = fetch(headRequest); + await expectFetchError(headPromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: headRequest, + }); + }); + }, ); - - let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const headResponse = await fetch(joinURL(baseURL, '/users'), { - method: 'HEAD', - headers: { 'x-value': '1' }, - }); - expect(headResponse.status).toBe(200); - - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const headRequest = new Request(joinURL(baseURL, '/users'), { method: 'HEAD' }); - const headPromise = fetch(headRequest); - await expectFetchError(headPromise); - - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(1); - expect(spies.error).toHaveBeenCalledTimes(0); - - const warnMessage = spies.warn.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(warnMessage, { - type: 'warn', - platform, - request: headRequest, - }); - }); }); - }); - } + } + + if (type === 'remote') { + it('should show an error when logging is enabled and a HEAD request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/users': { + HEAD: { + request: { headers: { 'x-value': string } }; + response: { + 200: {}; + }; + }; + }; + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const headHandler = await promiseIfRemote( + interceptor + .head('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + }), + interceptor, + ); + + let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const headResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '1' }, + }); + expect(headResponse.status).toBe(200); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const headRequest = new Request(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '2' }, + }); + const headPromise = fetch(headRequest); + await expectFetchError(headPromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: headRequest, + }); + }); + }, + ); + }); + } + }); + + it.each([{ overrideDefault: false }, { overrideDefault: 'static' }, { overrideDefault: 'function' }])( + 'should not show a warning or error when logging is disabled and a HEAD request is unhandled: override default $overrideDefault', + async ({ overrideDefault }) => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: false }); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(vi.fn()); + } - if (type === 'remote') { - it('should show an error when logging is enabled and a HEAD request is unhandled and rejected', async () => { await usingHttpInterceptor<{ '/users': { HEAD: { @@ -1072,56 +1173,55 @@ export function declareHeadHttpInterceptorTests(options: RuntimeSharedHttpInterc }; }; }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const headHandler = await promiseIfRemote( - interceptor - .head('/users') - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - }), - interceptor, - ); - - let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const headResponse = await fetch(joinURL(baseURL, '/users'), { - method: 'HEAD', - headers: { 'x-value': '1' }, + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: false } : {}, + }, + async (interceptor) => { + const headHandler = await promiseIfRemote( + interceptor + .head('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + }), + interceptor, + ); + + let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const headResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '1' }, + }); + expect(headResponse.status).toBe(200); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const headRequest = new Request(joinURL(baseURL, '/users'), { + method: 'HEAD', + headers: { 'x-value': '2' }, + }); + const headPromise = fetch(headRequest); + await expectFetchError(headPromise); + + headRequests = await promiseIfRemote(headHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); }); - expect(headResponse.status).toBe(200); - - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const headRequest = new Request(joinURL(baseURL, '/users'), { - method: 'HEAD', - headers: { 'x-value': '2' }, - }); - const headPromise = fetch(headRequest); - await expectFetchError(headPromise); - - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(1); - - const errorMessage = spies.error.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(errorMessage, { - type: 'error', - platform, - request: headRequest, - }); - }); - }); - }); - } + }, + ); + }, + ); it('should support a custom unhandled HEAD request handler', async () => { const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { @@ -1298,58 +1398,5 @@ export function declareHeadHttpInterceptorTests(options: RuntimeSharedHttpInterc }); }); }); - - it('should not show a warning or error when logging is disabled and a HEAD request is unhandled', async () => { - await usingHttpInterceptor<{ - '/users': { - HEAD: { - request: { headers: { 'x-value': string } }; - response: { - 200: {}; - }; - }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { - const headHandler = await promiseIfRemote( - interceptor - .head('/users') - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - }), - interceptor, - ); - - let headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const headResponse = await fetch(joinURL(baseURL, '/users'), { - method: 'HEAD', - headers: { 'x-value': '1' }, - }); - expect(headResponse.status).toBe(200); - - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const headRequest = new Request(joinURL(baseURL, '/users'), { - method: 'HEAD', - headers: { 'x-value': '2' }, - }); - const headPromise = fetch(headRequest); - await expectFetchError(headPromise); - - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - }); - }); - }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/options.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/options.ts index 39e64082..9fadb08a 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/options.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/options.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; import { HttpSchema } from '@/http/types/schema'; +import { http } from '@/interceptor'; import { promiseIfRemote } from '@/interceptor/http/interceptorWorker/__tests__/utils/promises'; import LocalHttpRequestHandler from '@/interceptor/http/requestHandler/LocalHttpRequestHandler'; import RemoteHttpRequestHandler from '@/interceptor/http/requestHandler/RemoteHttpRequestHandler'; @@ -1083,70 +1084,173 @@ export function declareOptionsHttpInterceptorTests(options: RuntimeSharedHttpInt }); describe('Unhandled requests', () => { - if (type === 'local') { - it('should show a warning when logging is enabled and an OPTIONS request is unhandled and bypassed', async () => { - await usingHttpInterceptor<{ - '/filters': { - OPTIONS: { - request: { headers: { 'x-value': string } }; - response: { - 200: { headers: AccessControlHeaders }; + describe.each([ + { overrideDefault: false as const }, + { overrideDefault: 'static' as const }, + { overrideDefault: 'static-empty' as const }, + { overrideDefault: 'function' as const }, + ])('Logging enabled or disabled: override default $overrideDefault', ({ overrideDefault }) => { + beforeEach(() => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: true }); + } else if (overrideDefault === 'static-empty') { + http.default.onUnhandledRequest({}); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(async (_request, context) => { + await context.log(); + }); + } + }); + + if (type === 'local') { + it('should show a warning when logging is enabled and an OPTIONS request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + request: { headers: { 'x-value': string } }; + response: { + 200: { headers: AccessControlHeaders }; + }; }; }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const optionsHandler = await promiseIfRemote( - interceptor - .options('/filters') - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }), - interceptor, + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor + .options('/filters') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); + + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { + method: 'OPTIONS', + headers: { 'x-value': '1' }, + }); + expect(optionsResponse.status).toBe(200); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const optionsRequest = new Request(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); + const optionsResponsePromise = fetch(optionsRequest); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(spies.warn).toHaveBeenCalledTimes(numberOfRequestsIncludingPrefetch); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: optionsRequest, + }); + }); + }, ); + }); + } - let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { - method: 'OPTIONS', - headers: { 'x-value': '1' }, - }); - expect(optionsResponse.status).toBe(200); - - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const optionsRequest = new Request(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - const optionsResponsePromise = fetch(optionsRequest); - await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { - shouldBePreflight: overridesPreflightResponse, - }); - - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - - expect(spies.warn).toHaveBeenCalledTimes(numberOfRequestsIncludingPrefetch); - expect(spies.error).toHaveBeenCalledTimes(0); - - const warnMessage = spies.warn.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(warnMessage, { - type: 'warn', - platform, - request: optionsRequest, - }); - }); + if (type === 'remote') { + it('should show an error when logging is enabled and an OPTIONS request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + request: { searchParams: { 'x-value': string } }; + response: { + 200: { headers: AccessControlHeaders }; + }; + }; + }; + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor + .options('/filters') + .with({ searchParams: { 'x-value': '1' } }) + .respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); + + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const searchParams = new HttpSearchParams({ 'x-value': '1' }); + + const optionsResponse = await fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { + method: 'OPTIONS', + }); + expect(optionsResponse.status).toBe(200); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const optionsRequest = new Request(joinURL(baseURL, '/filters'), { + method: 'OPTIONS', + }); + const optionsResponsePromise = fetch(optionsRequest); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(numberOfRequestsIncludingPrefetch); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: optionsRequest, + }); + }); + }, + ); }); - }); - } + } + }); + + it.each([{ overrideDefault: false }, { overrideDefault: 'static' }, { overrideDefault: 'function' }])( + 'should not show a warning or error when logging is disabled and an OPTIONS request is unhandled: override default $overrideDefault', + async ({ overrideDefault }) => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: false }); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(vi.fn()); + } - if (type === 'remote') { - it('should show an error when logging is enabled and an OPTIONS request is unhandled and rejected', async () => { await usingHttpInterceptor<{ '/filters': { OPTIONS: { @@ -1156,59 +1260,58 @@ export function declareOptionsHttpInterceptorTests(options: RuntimeSharedHttpInt }; }; }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const optionsHandler = await promiseIfRemote( - interceptor - .options('/filters') - .with({ searchParams: { 'x-value': '1' } }) - .respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }), - interceptor, - ); - - let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const searchParams = new HttpSearchParams({ 'x-value': '1' }); - - const optionsResponse = await fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { - method: 'OPTIONS', + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: false } : {}, + }, + async (interceptor) => { + const optionsHandler = await promiseIfRemote( + interceptor + .options('/filters') + .with({ searchParams: { 'x-value': '1' } }) + .respond({ + status: 200, + headers: DEFAULT_ACCESS_CONTROL_HEADERS, + }), + interceptor, + ); + + let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const searchParams = new HttpSearchParams({ 'x-value': '1' }); + + const optionsResponse = await fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { + method: 'OPTIONS', + }); + expect(optionsResponse.status).toBe(200); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const optionsRequest = new Request(joinURL(baseURL, '/filters'), { + method: 'OPTIONS', + }); + const optionsResponsePromise = fetch(optionsRequest); + await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { + shouldBePreflight: overridesPreflightResponse, + }); + + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); }); - expect(optionsResponse.status).toBe(200); - - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const optionsRequest = new Request(joinURL(baseURL, '/filters'), { - method: 'OPTIONS', - }); - const optionsResponsePromise = fetch(optionsRequest); - await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { - shouldBePreflight: overridesPreflightResponse, - }); - - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(numberOfRequestsIncludingPrefetch); - - const errorMessage = spies.error.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(errorMessage, { - type: 'error', - platform, - request: optionsRequest, - }); - }); - }); - }); - } + }, + ); + }, + ); it('should support a custom unhandled OPTIONS request handler', async () => { const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { @@ -1397,61 +1500,5 @@ export function declareOptionsHttpInterceptorTests(options: RuntimeSharedHttpInt }); }); }); - - it('should not show a warning or error when logging is disabled and an OPTIONS request is unhandled', async () => { - await usingHttpInterceptor<{ - '/filters': { - OPTIONS: { - request: { searchParams: { 'x-value': string } }; - response: { - 200: { headers: AccessControlHeaders }; - }; - }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { - const optionsHandler = await promiseIfRemote( - interceptor - .options('/filters') - .with({ searchParams: { 'x-value': '1' } }) - .respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }), - interceptor, - ); - - let optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const searchParams = new HttpSearchParams({ 'x-value': '1' }); - - const optionsResponse = await fetch(joinURL(baseURL, `/filters?${searchParams.toString()}`), { - method: 'OPTIONS', - }); - expect(optionsResponse.status).toBe(200); - - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const optionsRequest = new Request(joinURL(baseURL, '/filters'), { - method: 'OPTIONS', - }); - const optionsResponsePromise = fetch(optionsRequest); - await expectFetchErrorOrPreflightResponse(optionsResponsePromise, { - shouldBePreflight: overridesPreflightResponse, - }); - - optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - }); - }); - }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/patch.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/patch.ts index ab6451fa..3ba98014 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/patch.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/patch.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; import { HttpSchema } from '@/http/types/schema'; +import { http } from '@/interceptor'; import { promiseIfRemote } from '@/interceptor/http/interceptorWorker/__tests__/utils/promises'; import LocalHttpRequestHandler from '@/interceptor/http/requestHandler/LocalHttpRequestHandler'; import RemoteHttpRequestHandler from '@/interceptor/http/requestHandler/RemoteHttpRequestHandler'; @@ -1122,68 +1123,169 @@ export async function declarePatchHttpInterceptorTests(options: RuntimeSharedHtt }); describe('Unhandled requests', () => { - if (type === 'local') { - it('should show a warning when logging is enabled and a PATCH request is unhandled and bypassed', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PATCH: { - request: { headers: { 'x-value': string } }; - response: { - 200: { body: User }; + describe.each([ + { overrideDefault: false as const }, + { overrideDefault: 'static' as const }, + { overrideDefault: 'static-empty' as const }, + { overrideDefault: 'function' as const }, + ])('Logging enabled or disabled: override default $overrideDefault', ({ overrideDefault }) => { + beforeEach(() => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: true }); + } else if (overrideDefault === 'static-empty') { + http.default.onUnhandledRequest({}); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(async (_request, context) => { + await context.log(); + }); + } + }); + + if (type === 'local') { + it('should show a warning when logging is enabled and a PATCH request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; }; }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .patch(`/users/${users[0].id}`) - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - body: users[0], - }), - interceptor, + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .patch(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); + const updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: updateRequest, + }); + }); + }, ); - - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PATCH', - headers: { 'x-value': '1' }, - }); - expect(updateResponse.status).toBe(200); - - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PATCH' }); - const updateResponsePromise = fetch(updateRequest); - await expectFetchError(updateResponsePromise); - - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(1); - expect(spies.error).toHaveBeenCalledTimes(0); - - const warnMessage = spies.warn.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(warnMessage, { - type: 'warn', - platform, - request: updateRequest, - }); - }); }); - }); - } + } + + if (type === 'remote') { + it('should show an error when logging is enabled and a PATCH request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .patch(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '2' }, + }); + const updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: updateRequest, + }); + }); + }, + ); + }); + } + }); + + it.each([{ overrideDefault: false }, { overrideDefault: 'static' }, { overrideDefault: 'function' }])( + 'should not show a warning or error when logging is disabled and a PATCH request is unhandled: override default $overrideDefault', + async ({ overrideDefault }) => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: false }); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(vi.fn()); + } - if (type === 'remote') { - it('should show an error when logging is enabled and a PATCH request is unhandled and rejected', async () => { await usingHttpInterceptor<{ '/users/:id': { PATCH: { @@ -1193,57 +1295,56 @@ export async function declarePatchHttpInterceptorTests(options: RuntimeSharedHtt }; }; }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .patch(`/users/${users[0].id}`) - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PATCH', - headers: { 'x-value': '1' }, + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: false } : {}, + }, + async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .patch(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PATCH', + headers: { 'x-value': '2' }, + }); + const updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); }); - expect(updateResponse.status).toBe(200); - - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PATCH', - headers: { 'x-value': '2' }, - }); - const updateResponsePromise = fetch(updateRequest); - await expectFetchError(updateResponsePromise); - - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(1); - - const errorMessage = spies.error.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(errorMessage, { - type: 'error', - platform, - request: updateRequest, - }); - }); - }); - }); - } + }, + ); + }, + ); it('should support a custom unhandled PATCH request handler', async () => { const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { @@ -1422,59 +1523,5 @@ export async function declarePatchHttpInterceptorTests(options: RuntimeSharedHtt }); }); }); - - it('should not show a warning or error when logging is disabled and a PATCH request is unhandled', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PATCH: { - request: { headers: { 'x-value': string } }; - response: { - 200: { body: User }; - }; - }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .patch(`/users/${users[0].id}`) - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PATCH', - headers: { 'x-value': '1' }, - }); - expect(updateResponse.status).toBe(200); - - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PATCH', - headers: { 'x-value': '2' }, - }); - const updateResponsePromise = fetch(updateRequest); - await expectFetchError(updateResponsePromise); - - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - }); - }); - }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/post.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/post.ts index 9c5a8a06..483b68e2 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/post.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/post.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; import { HttpSchema } from '@/http/types/schema'; +import { http } from '@/interceptor'; import { promiseIfRemote } from '@/interceptor/http/interceptorWorker/__tests__/utils/promises'; import LocalHttpRequestHandler from '@/interceptor/http/requestHandler/LocalHttpRequestHandler'; import RemoteHttpRequestHandler from '@/interceptor/http/requestHandler/RemoteHttpRequestHandler'; @@ -1135,76 +1136,183 @@ export async function declarePostHttpInterceptorTests(options: RuntimeSharedHttp }); describe('Unhandled requests', () => { - if (type === 'local') { - it('should show a warning when logging is enabled and a POST request is unhandled and bypassed', async () => { - await usingHttpInterceptor<{ - '/users': { - POST: { - request: { - headers: { 'x-value': string }; - body: UserCreationBody; + describe.each([ + { overrideDefault: false as const }, + { overrideDefault: 'static' as const }, + { overrideDefault: 'static-empty' as const }, + { overrideDefault: 'function' as const }, + ])('Logging enabled or disabled: override default $overrideDefault', ({ overrideDefault }) => { + beforeEach(() => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: true }); + } else if (overrideDefault === 'static-empty') { + http.default.onUnhandledRequest({}); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(async (_request, context) => { + await context.log(); + }); + } + }); + + if (type === 'local') { + it('should show a warning when logging is enabled and a POST request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/users': { + POST: { + request: { + headers: { 'x-value': string }; + body: UserCreationBody; + }; + response: { + 201: { body: User }; + }; }; - response: { - 201: { body: User }; + }; + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor + .post('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 201, + body: users[0], + }), + interceptor, + ); + + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const creationResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '1' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + expect(creationResponse.status).toBe(201); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const creationRequest = new Request(joinURL(baseURL, '/users'), { + method: 'POST', + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + const creationRequestClone = creationRequest.clone(); + const creationPromise = fetch(creationRequest); + await expectFetchError(creationPromise); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: creationRequestClone, + }); + }); + }, + ); + }); + } + + if (type === 'remote') { + it('should show an error when logging is enabled and a POST request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/users': { + POST: { + request: { + headers: { 'x-value': string }; + body: UserCreationBody; + }; + response: { + 201: { body: User }; + }; }; }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const creationHandler = await promiseIfRemote( - interceptor - .post('/users') - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 201, - body: users[0], - }), - interceptor, + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor + .post('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 201, + body: users[0], + }), + interceptor, + ); + + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const creationResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '1' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + expect(creationResponse.status).toBe(201); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const creationRequest = new Request(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '2' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + const creationRequestClone = creationRequest.clone(); + const creationPromise = fetch(creationRequest); + await expectFetchError(creationPromise); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: creationRequestClone, + }); + }); + }, ); - - let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const creationResponse = await fetch(joinURL(baseURL, '/users'), { - method: 'POST', - headers: { 'x-value': '1' }, - body: JSON.stringify(users[0] satisfies UserCreationBody), - }); - expect(creationResponse.status).toBe(201); - - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const creationRequest = new Request(joinURL(baseURL, '/users'), { - method: 'POST', - body: JSON.stringify(users[0] satisfies UserCreationBody), - }); - const creationRequestClone = creationRequest.clone(); - const creationPromise = fetch(creationRequest); - await expectFetchError(creationPromise); - - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(1); - expect(spies.error).toHaveBeenCalledTimes(0); - - const warnMessage = spies.warn.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(warnMessage, { - type: 'warn', - platform, - request: creationRequestClone, - }); - }); }); - }); - } + } + }); + + it.each([{ overrideDefault: false }, { overrideDefault: 'static' }, { overrideDefault: 'function' }])( + 'should not show a warning or error when logging is disabled and a POST request is unhandled: override default $overrideDefault', + async ({ overrideDefault }) => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: false }); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(vi.fn()); + } - if (type === 'remote') { - it('should show an error when logging is enabled and a POST request is unhandled and rejected', async () => { await usingHttpInterceptor<{ '/users': { POST: { @@ -1217,60 +1325,58 @@ export async function declarePostHttpInterceptorTests(options: RuntimeSharedHttp }; }; }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const creationHandler = await promiseIfRemote( - interceptor - .post('/users') - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 201, - body: users[0], - }), - interceptor, - ); - - let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const creationResponse = await fetch(joinURL(baseURL, '/users'), { - method: 'POST', - headers: { 'x-value': '1' }, - body: JSON.stringify(users[0] satisfies UserCreationBody), - }); - expect(creationResponse.status).toBe(201); - - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const creationRequest = new Request(joinURL(baseURL, '/users'), { - method: 'POST', - headers: { 'x-value': '2' }, - body: JSON.stringify(users[0] satisfies UserCreationBody), - }); - const creationRequestClone = creationRequest.clone(); - const creationPromise = fetch(creationRequest); - await expectFetchError(creationPromise); - - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(1); - - const errorMessage = spies.error.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(errorMessage, { - type: 'error', - platform, - request: creationRequestClone, + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: false } : {}, + }, + async (interceptor) => { + const creationHandler = await promiseIfRemote( + interceptor + .post('/users') + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 201, + body: users[0], + }), + interceptor, + ); + + let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const creationResponse = await fetch(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '1' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + expect(creationResponse.status).toBe(201); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const creationRequest = new Request(joinURL(baseURL, '/users'), { + method: 'POST', + headers: { 'x-value': '2' }, + body: JSON.stringify(users[0] satisfies UserCreationBody), + }); + const creationPromise = fetch(creationRequest); + await expectFetchError(creationPromise); + + creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); + expect(creationRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); }); - }); - }); - }); - } + }, + ); + }, + ); it('should support a custom unhandled POST request handler', async () => { const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { @@ -1458,64 +1564,5 @@ export async function declarePostHttpInterceptorTests(options: RuntimeSharedHttp }); }); }); - - it('should not show a warning or error when logging is disabled and a POST request is unhandled', async () => { - await usingHttpInterceptor<{ - '/users': { - POST: { - request: { - headers: { 'x-value': string }; - body: UserCreationBody; - }; - response: { - 201: { body: User }; - }; - }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { - const creationHandler = await promiseIfRemote( - interceptor - .post('/users') - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 201, - body: users[0], - }), - interceptor, - ); - - let creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const creationResponse = await fetch(joinURL(baseURL, '/users'), { - method: 'POST', - headers: { 'x-value': '1' }, - body: JSON.stringify(users[0] satisfies UserCreationBody), - }); - expect(creationResponse.status).toBe(201); - - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const creationRequest = new Request(joinURL(baseURL, '/users'), { - method: 'POST', - headers: { 'x-value': '2' }, - body: JSON.stringify(users[0] satisfies UserCreationBody), - }); - const creationPromise = fetch(creationRequest); - await expectFetchError(creationPromise); - - creationRequests = await promiseIfRemote(creationHandler.requests(), interceptor); - expect(creationRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - }); - }); - }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/put.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/put.ts index bc52fa48..61daac73 100644 --- a/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/put.ts +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/put.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import HttpHeaders from '@/http/headers/HttpHeaders'; import HttpSearchParams from '@/http/searchParams/HttpSearchParams'; import { HttpSchema } from '@/http/types/schema'; +import { http } from '@/interceptor'; import { promiseIfRemote } from '@/interceptor/http/interceptorWorker/__tests__/utils/promises'; import LocalHttpRequestHandler from '@/interceptor/http/requestHandler/LocalHttpRequestHandler'; import RemoteHttpRequestHandler from '@/interceptor/http/requestHandler/RemoteHttpRequestHandler'; @@ -1120,68 +1121,169 @@ export async function declarePutHttpInterceptorTests(options: RuntimeSharedHttpI }); describe('Unhandled requests', () => { - if (type === 'local') { - it('should show a warning when logging is enabled and a PUT request is unhandled and bypassed', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PUT: { - request: { headers: { 'x-value': string } }; - response: { - 200: { body: User }; + describe.each([ + { overrideDefault: false as const }, + { overrideDefault: 'static' as const }, + { overrideDefault: 'static-empty' as const }, + { overrideDefault: 'function' as const }, + ])('Logging enabled or disabled: override default $overrideDefault', ({ overrideDefault }) => { + beforeEach(() => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: true }); + } else if (overrideDefault === 'static-empty') { + http.default.onUnhandledRequest({}); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(async (_request, context) => { + await context.log(); + }); + } + }); + + if (type === 'local') { + it('should show a warning when logging is enabled and a PUT request is unhandled and bypassed', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; }; }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .put(`/users/${users[0].id}`) - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - body: users[0], - }), - interceptor, + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .put(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); + const updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(0); + + const warnMessage = spies.warn.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(warnMessage, { + type: 'warn', + platform, + request: updateRequest, + }); + }); + }, ); - - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PUT', - headers: { 'x-value': '1' }, - }); - expect(updateResponse.status).toBe(200); - - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { method: 'PUT' }); - const updateResponsePromise = fetch(updateRequest); - await expectFetchError(updateResponsePromise); - - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(1); - expect(spies.error).toHaveBeenCalledTimes(0); - - const warnMessage = spies.warn.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(warnMessage, { - type: 'warn', - platform, - request: updateRequest, - }); - }); }); - }); - } + } + + if (type === 'remote') { + it('should show an error when logging is enabled and a PUT request is unhandled and rejected', async () => { + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: true } : {}, + }, + async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .put(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '2' }, + }); + const updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(1); + + const errorMessage = spies.error.mock.calls[0].join(' '); + await verifyUnhandledRequestMessage(errorMessage, { + type: 'error', + platform, + request: updateRequest, + }); + }); + }, + ); + }); + } + }); + + it.each([{ overrideDefault: false }, { overrideDefault: 'static' }, { overrideDefault: 'function' }])( + 'should not show a warning or error when logging is disabled and a PUT request is unhandled: override default $overrideDefault', + async ({ overrideDefault }) => { + if (overrideDefault === 'static') { + http.default.onUnhandledRequest({ log: false }); + } else if (overrideDefault === 'function') { + http.default.onUnhandledRequest(vi.fn()); + } - if (type === 'remote') { - it('should show an error when logging is enabled and a PUT request is unhandled and rejected', async () => { await usingHttpInterceptor<{ '/users/:id': { PUT: { @@ -1191,57 +1293,56 @@ export async function declarePutHttpInterceptorTests(options: RuntimeSharedHttpI }; }; }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: true } }, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .put(`/users/${users[0].id}`) - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PUT', - headers: { 'x-value': '1' }, + }>( + { + ...interceptorOptions, + onUnhandledRequest: overrideDefault === false ? { log: false } : {}, + }, + async (interceptor) => { + const updateHandler = await promiseIfRemote( + interceptor + .put(`/users/${users[0].id}`) + .with({ headers: { 'x-value': '1' } }) + .respond({ + status: 200, + body: users[0], + }), + interceptor, + ); + + let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(0); + + await usingIgnoredConsole(['warn', 'error'], async (spies) => { + const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '1' }, + }); + expect(updateResponse.status).toBe(200); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); + + const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { + method: 'PUT', + headers: { 'x-value': '2' }, + }); + const updateResponsePromise = fetch(updateRequest); + await expectFetchError(updateResponsePromise); + + updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); + expect(updateRequests).toHaveLength(1); + + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(0); }); - expect(updateResponse.status).toBe(200); - - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PUT', - headers: { 'x-value': '2' }, - }); - const updateResponsePromise = fetch(updateRequest); - await expectFetchError(updateResponsePromise); - - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(1); - - const errorMessage = spies.error.mock.calls[0].join(' '); - await verifyUnhandledRequestMessage(errorMessage, { - type: 'error', - platform, - request: updateRequest, - }); - }); - }); - }); - } + }, + ); + }, + ); it('should support a custom unhandled PUT request handler', async () => { const onUnhandledRequest = vi.fn(async (request: Request, context: UnhandledRequestStrategy.HandlerContext) => { @@ -1420,59 +1521,5 @@ export async function declarePutHttpInterceptorTests(options: RuntimeSharedHttpI }); }); }); - - it('should not show a warning or error when logging is disabled and a PUT request is unhandled', async () => { - await usingHttpInterceptor<{ - '/users/:id': { - PUT: { - request: { headers: { 'x-value': string } }; - response: { - 200: { body: User }; - }; - }; - }; - }>({ ...interceptorOptions, onUnhandledRequest: { log: false } }, async (interceptor) => { - const updateHandler = await promiseIfRemote( - interceptor - .put(`/users/${users[0].id}`) - .with({ headers: { 'x-value': '1' } }) - .respond({ - status: 200, - body: users[0], - }), - interceptor, - ); - - let updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(0); - - await usingIgnoredConsole(['warn', 'error'], async (spies) => { - const updateResponse = await fetch(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PUT', - headers: { 'x-value': '1' }, - }); - expect(updateResponse.status).toBe(200); - - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - - const updateRequest = new Request(joinURL(baseURL, `/users/${users[0].id}`), { - method: 'PUT', - headers: { 'x-value': '2' }, - }); - const updateResponsePromise = fetch(updateRequest); - await expectFetchError(updateResponsePromise); - - updateRequests = await promiseIfRemote(updateHandler.requests(), interceptor); - expect(updateRequests).toHaveLength(1); - - expect(spies.warn).toHaveBeenCalledTimes(0); - expect(spies.error).toHaveBeenCalledTimes(0); - }); - }); - }); }); } diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts index 2831b6a1..b35e2706 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorker.ts @@ -101,16 +101,15 @@ abstract class HttpInterceptorWorker { const defaultDeclarationOrHandler = this.store.defaultUnhandledRequestStrategy(); - const declarationOrHandler = - this.unhandledRequestStrategies.findLast((strategy) => { - return requestURL.startsWith(strategy.baseURL); - })?.declarationOrHandler ?? defaultDeclarationOrHandler; + const declarationOrHandler = this.unhandledRequestStrategies.findLast((strategy) => { + return requestURL.startsWith(strategy.baseURL); + })?.declarationOrHandler; const action: UnhandledRequestStrategy.Action = this.type === 'local' ? 'bypass' : 'reject'; if (typeof declarationOrHandler === 'function') { await HttpInterceptorWorker.useUnhandledRequestStrategyHandler(request, declarationOrHandler, action); - } else if (declarationOrHandler.log !== undefined) { + } else if (declarationOrHandler?.log !== undefined) { await HttpInterceptorWorker.useStaticUnhandledStrategy(request, { log: declarationOrHandler.log }, action); } else if (typeof defaultDeclarationOrHandler === 'function') { await HttpInterceptorWorker.useUnhandledRequestStrategyHandler(request, defaultDeclarationOrHandler, action); diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore.ts b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore.ts index 1262b4ed..fe9a1ef8 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore.ts @@ -4,7 +4,7 @@ export type DefaultUnhandledRequestStrategy = | Required | UnhandledRequestStrategy.Handler; -export const DEFAULT_UNHANDLED_REQUEST_STRATEGY: Required = Object.freeze({ +const DEFAULT_UNHANDLED_REQUEST_STRATEGY: Required = Object.freeze({ log: true, }); diff --git a/packages/zimic/src/interceptor/index.ts b/packages/zimic/src/interceptor/index.ts index b1e2dfb5..86eb1944 100644 --- a/packages/zimic/src/interceptor/index.ts +++ b/packages/zimic/src/interceptor/index.ts @@ -1,10 +1,9 @@ import NotStartedHttpInterceptorError from './http/interceptor/errors/NotStartedHttpInterceptorError'; import UnknownHttpInterceptorPlatform from './http/interceptor/errors/UnknownHttpInterceptorPlatform'; import { createHttpInterceptor } from './http/interceptor/factory'; +import { UnhandledRequestStrategy } from './http/interceptor/types/options'; import UnregisteredServiceWorkerError from './http/interceptorWorker/errors/UnregisteredServiceWorkerError'; -import HttpInterceptorWorkerStore, { - DefaultUnhandledRequestStrategy, -} from './http/interceptorWorker/HttpInterceptorWorkerStore'; +import HttpInterceptorWorkerStore from './http/interceptorWorker/HttpInterceptorWorkerStore'; export { UnknownHttpInterceptorPlatform, NotStartedHttpInterceptorError, UnregisteredServiceWorkerError }; @@ -39,7 +38,7 @@ export const http = Object.freeze({ createInterceptor: createHttpInterceptor, default: { - onUnhandledRequest(strategy: DefaultUnhandledRequestStrategy) { + onUnhandledRequest(strategy: UnhandledRequestStrategy) { const store = new HttpInterceptorWorkerStore(); store.setDefaultUnhandledRequestStrategy(strategy); }, diff --git a/packages/zimic/tests/setup/shared.ts b/packages/zimic/tests/setup/shared.ts index 1db61355..58b013eb 100644 --- a/packages/zimic/tests/setup/shared.ts +++ b/packages/zimic/tests/setup/shared.ts @@ -1,9 +1,7 @@ -import { beforeAll } from 'vitest'; +import { beforeEach } from 'vitest'; -import HttpInterceptorWorkerStore from '@/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore'; +import { http } from '@/interceptor'; -const workerStore = new HttpInterceptorWorkerStore(); - -beforeAll(() => { - workerStore.setDefaultUnhandledRequestStrategy({ log: false }); +beforeEach(() => { + http.default.onUnhandledRequest({ log: false }); }); From 0b0ee5f839624c44553be67b3c00cb45fe0cffa7 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sun, 19 May 2024 17:55:24 -0300 Subject: [PATCH 17/22] feat(#zimic): add restriction and unhandled request type exports --- .../v0/interceptor/exports/exports.test.ts | 21 +++++++++++++++++++ packages/zimic/src/interceptor/index.ts | 9 +++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/zimic-test-client/tests/v0/interceptor/exports/exports.test.ts b/apps/zimic-test-client/tests/v0/interceptor/exports/exports.test.ts index 810b1a7c..ce66bfed 100644 --- a/apps/zimic-test-client/tests/v0/interceptor/exports/exports.test.ts +++ b/apps/zimic-test-client/tests/v0/interceptor/exports/exports.test.ts @@ -40,6 +40,7 @@ import { type HttpInterceptorOptions, type LocalHttpInterceptorOptions, type RemoteHttpInterceptorOptions, + type UnhandledRequestStrategy, type ExtractHttpInterceptorSchema, type HttpInterceptorRequest, type HttpInterceptorResponse, @@ -51,6 +52,12 @@ import { type PendingRemoteHttpRequestHandler, type HttpRequestHandlerResponseDeclaration, type HttpRequestHandlerResponseDeclarationFactory, + type HttpRequestHandlerRestriction, + type HttpRequestHandlerComputedRestriction, + type HttpRequestHandlerHeadersStaticRestriction, + type HttpRequestHandlerSearchParamsStaticRestriction, + type HttpRequestHandlerStaticRestriction, + type HttpRequestHandlerBodyStaticRestriction, UnknownHttpInterceptorPlatform, NotStartedHttpInterceptorError, UnregisteredServiceWorkerError, @@ -106,8 +113,15 @@ describe('Exports', () => { expectTypeOf(http.createInterceptor).not.toBeAny(); expect(typeof http.createInterceptor).toBe('function'); expectTypeOf().not.toBeAny(); + expectTypeOf(http.default.onUnhandledRequest).not.toBeAny(); expect(typeof http.default.onUnhandledRequest).toBe('function'); + expectTypeOf().not.toBeAny(); + expectTypeOf().not.toBeAny(); + expectTypeOf().not.toBeAny(); + expectTypeOf().not.toBeAny(); + expectTypeOf().not.toBeAny(); + expectTypeOf>().not.toBeAny(); expectTypeOf>().not.toBeAny(); expectTypeOf>().not.toBeAny(); @@ -129,6 +143,13 @@ describe('Exports', () => { expectTypeOf>().not.toBeAny(); expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf().not.toBeAny(); expect(typeof UnknownHttpInterceptorPlatform).toBe('function'); expectTypeOf().not.toBeAny(); diff --git a/packages/zimic/src/interceptor/index.ts b/packages/zimic/src/interceptor/index.ts index 86eb1944..ac2e1b1e 100644 --- a/packages/zimic/src/interceptor/index.ts +++ b/packages/zimic/src/interceptor/index.ts @@ -21,6 +21,12 @@ export type { SyncedRemoteHttpRequestHandler, PendingRemoteHttpRequestHandler, HttpRequestHandler, + HttpRequestHandlerRestriction, + HttpRequestHandlerComputedRestriction, + HttpRequestHandlerHeadersStaticRestriction, + HttpRequestHandlerSearchParamsStaticRestriction, + HttpRequestHandlerStaticRestriction, + HttpRequestHandlerBodyStaticRestriction, } from './http/requestHandler/types/public'; export type { @@ -29,6 +35,7 @@ export type { LocalHttpInterceptorOptions, RemoteHttpInterceptorOptions, HttpInterceptorOptions, + UnhandledRequestStrategy, } from './http/interceptor/types/options'; export type { ExtractHttpInterceptorSchema } from './http/interceptor/types/schema'; @@ -38,7 +45,7 @@ export const http = Object.freeze({ createInterceptor: createHttpInterceptor, default: { - onUnhandledRequest(strategy: UnhandledRequestStrategy) { + onUnhandledRequest: (strategy: UnhandledRequestStrategy) => { const store = new HttpInterceptorWorkerStore(); store.setDefaultUnhandledRequestStrategy(strategy); }, From c61d358cdb3a48dfc009e448e6a00b95e6cf4ca5 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sun, 19 May 2024 18:01:23 -0300 Subject: [PATCH 18/22] docs(examples-jest-node): ignore unhandled requests by default --- examples/with-jest-node/tests/setup.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/with-jest-node/tests/setup.ts b/examples/with-jest-node/tests/setup.ts index 70d68ad5..058fbb1b 100644 --- a/examples/with-jest-node/tests/setup.ts +++ b/examples/with-jest-node/tests/setup.ts @@ -1,7 +1,10 @@ import { beforeAll, beforeEach, afterAll } from '@jest/globals'; +import { http } from 'zimic/interceptor'; import githubInterceptor from './interceptors/github'; +http.default.onUnhandledRequest({ log: false }); + beforeAll(async () => { await githubInterceptor.start(); }); From 8bf02ed8e9937f55c46f9eb537bb291d9dbdbc43 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Sun, 19 May 2024 18:02:01 -0300 Subject: [PATCH 19/22] chore(zimic-test-client): use default unhandled request strategy --- .../tests/v0/interceptor/thirdParty/shared/default.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/zimic-test-client/tests/v0/interceptor/thirdParty/shared/default.ts b/apps/zimic-test-client/tests/v0/interceptor/thirdParty/shared/default.ts index 01494e4d..de36b6cf 100644 --- a/apps/zimic-test-client/tests/v0/interceptor/thirdParty/shared/default.ts +++ b/apps/zimic-test-client/tests/v0/interceptor/thirdParty/shared/default.ts @@ -39,13 +39,11 @@ async function declareDefaultClientTests(options: ClientTestOptionsByWorkerType) const authInterceptor = http.createInterceptor({ type, baseURL: await getAuthBaseURL(type), - onUnhandledRequest: { log: true }, }); const notificationInterceptor = http.createInterceptor({ type, baseURL: await getNotificationsBaseURL(type), - onUnhandledRequest: (_request, context) => context.log(), }); const interceptors = [authInterceptor, notificationInterceptor]; From 8c58baafa7dbcfa5f4699010278fe5cc603e24b3 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Tue, 21 May 2024 16:15:05 -0300 Subject: [PATCH 20/22] chore(examples): increase playwright timeouts --- examples/with-next-js/playwright.config.ts | 2 +- examples/with-playwright/playwright.config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/with-next-js/playwright.config.ts b/examples/with-next-js/playwright.config.ts index e152d1b0..0b20d779 100644 --- a/examples/with-next-js/playwright.config.ts +++ b/examples/with-next-js/playwright.config.ts @@ -38,6 +38,6 @@ export default defineConfig({ stdout: 'pipe', stderr: 'pipe', reuseExistingServer: true, - timeout: 1000 * 20, + timeout: 30 * 1000, }, }); diff --git a/examples/with-playwright/playwright.config.ts b/examples/with-playwright/playwright.config.ts index 0b83d5fa..6975de5c 100644 --- a/examples/with-playwright/playwright.config.ts +++ b/examples/with-playwright/playwright.config.ts @@ -39,6 +39,6 @@ export default defineConfig({ stdout: 'pipe', stderr: 'pipe', reuseExistingServer: true, - timeout: 1000 * 20, + timeout: 30 * 1000, }, }); From d8c2d7bfca1d5251235c91c96b7a228f3f485a38 Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Tue, 21 May 2024 16:15:34 -0300 Subject: [PATCH 21/22] chore(ci): reduce test concurrency to 1 --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ea77c4c1..87cc6fcf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -106,7 +106,7 @@ jobs: pnpm turbo \ test:turbo \ --continue \ - --concurrency 100% \ + --concurrency 1 \ ${{ steps.zimic-setup.outputs.install-filters }} ci-typescript: From 2a785afc06823aefb7973c1d96778b8c4dcb4ccc Mon Sep 17 00:00:00 2001 From: Diego Aquino Date: Thu, 23 May 2024 21:32:23 -0300 Subject: [PATCH 22/22] docs(#zimic): improve documentation --- .../src/interceptor/http/interceptor/types/options.ts | 10 ++++++++++ packages/zimic/src/interceptor/index.ts | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/zimic/src/interceptor/http/interceptor/types/options.ts b/packages/zimic/src/interceptor/http/interceptor/types/options.ts index a67d7286..1f449df7 100644 --- a/packages/zimic/src/interceptor/http/interceptor/types/options.ts +++ b/packages/zimic/src/interceptor/http/interceptor/types/options.ts @@ -15,7 +15,9 @@ export type HttpInterceptorType = 'local' | 'remote'; */ export type HttpInterceptorPlatform = 'node' | 'browser'; +/** The strategy to handle unhandled requests. */ export namespace UnhandledRequestStrategy { + /** A static declaration of the strategy to handle unhandled requests. */ export type Declaration = Partial<{ log: boolean; }>; @@ -23,8 +25,10 @@ export namespace UnhandledRequestStrategy { export interface HandlerContext { log: () => Promise; } + /** A dynamic handler to unhandled requests. */ export type Handler = (request: HttpRequest, context: HandlerContext) => PossiblePromise; + /** The action to take when an unhandled request is intercepted. */ export type Action = 'bypass' | 'reject'; } // eslint-disable-next-line @typescript-eslint/no-redeclare @@ -43,6 +47,12 @@ export interface SharedHttpInterceptorOptions { * paths to differentiate between conflicting mocks. */ baseURL: string | URL; + + /** + * The strategy to handle unhandled requests. If a request starts with the base URL of the interceptor, but no + * matching handler exists, this strategy will be used. If a function is provided, it will be called with the + * unhandled request. + */ onUnhandledRequest?: UnhandledRequestStrategy; } diff --git a/packages/zimic/src/interceptor/index.ts b/packages/zimic/src/interceptor/index.ts index 18b3c2ad..c49d577e 100644 --- a/packages/zimic/src/interceptor/index.ts +++ b/packages/zimic/src/interceptor/index.ts @@ -58,7 +58,10 @@ export interface HttpNamespace { /** Default HTTP settings. */ default: { /** - * Sets the default strategy for unhandled requests. + * Sets the default strategy for unhandled requests. If a request does not start with the base URL of any + * interceptors, this strategy will be used. If a function is provided, it will be called with the unhandled + * request. Defining a custom strategy when creating an interceptor will override this default for that + * interceptor. * * @param strategy The default strategy to be set. */