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..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,6 +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(); @@ -127,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/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(); }); 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, }, }); 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/package.json b/packages/zimic/package.json index 6dc58bc3..b5af856d 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/__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..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,8 @@ 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'; import { getCrypto } from '@/utils/crypto'; @@ -10,11 +12,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(); @@ -76,15 +92,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]', ].join('\n'); beforeEach(async () => { @@ -120,7 +142,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 +160,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 +180,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 +274,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 +309,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 +374,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,25 +399,19 @@ 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); }); }); 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(); @@ -397,7 +422,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); @@ -411,19 +437,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(); @@ -434,7 +450,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); @@ -458,5 +475,119 @@ describe('CLI (server)', async () => { } }); }); + + 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(); + }); + } + + 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(' '); + 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(); + + 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/cli/browser/init.ts b/packages/zimic/src/cli/browser/init.ts index 0c2d032d..688a6192 100644 --- a/packages/zimic/src/cli/browser/init.ts +++ b/packages/zimic/src/cli/browser/init.ts @@ -1,7 +1,9 @@ +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'; const MSW_ROOT_PATH = path.join(require.resolve('msw'), '..', '..', '..'); @@ -14,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/cli.ts b/packages/zimic/src/cli/cli.ts index 7a1312b2..be9e5c82 100644 --- a/packages/zimic/src/cli/cli.ts +++ b/packages/zimic/src/cli/cli.ts @@ -60,6 +60,14 @@ 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', }), async (cliArguments) => { const onReadyCommand = cliArguments._.at(2)?.toString(); @@ -69,6 +77,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 69626ccd..6902094b 100644 --- a/packages/zimic/src/cli/server/start.ts +++ b/packages/zimic/src/cli/server/start.ts @@ -1,11 +1,9 @@ -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'; +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; @@ -28,7 +36,7 @@ async function startInterceptorServer({ hostname, port, ephemeral, onReady }: Se 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/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/http/types/schema.ts b/packages/zimic/src/http/types/schema.ts index 6475776f..7f3dda35 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); /** * A type representing the currently supported * {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods `HTTP methods`}. diff --git a/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts b/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts index 8d19e3e0..7ad568be 100644 --- a/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts +++ b/packages/zimic/src/interceptor/http/interceptor/HttpInterceptorClient.ts @@ -19,9 +19,10 @@ 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']; +export const SUPPORTED_BASE_URL_PROTOCOLS = Object.freeze(['http', 'https']); class HttpInterceptorClient< Schema extends HttpServiceSchema, @@ -31,6 +32,7 @@ class HttpInterceptorClient< private store: HttpInterceptorStore; private _baseURL: ExtendedURL; private _isRunning = false; + private onUnhandledRequest?: UnhandledRequestStrategy; private Handler: HandlerConstructor; @@ -51,15 +53,17 @@ class HttpInterceptorClient< store: HttpInterceptorStore; baseURL: ExtendedURL; Handler: HandlerConstructor; + onUnhandledRequest?: UnhandledRequestStrategy; }) { 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) { @@ -146,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/interceptor/LocalHttpInterceptor.ts b/packages/zimic/src/interceptor/http/interceptor/LocalHttpInterceptor.ts index 7300b59b..70d55ca1 100644 --- a/packages/zimic/src/interceptor/http/interceptor/LocalHttpInterceptor.ts +++ b/packages/zimic/src/interceptor/http/interceptor/LocalHttpInterceptor.ts @@ -27,8 +27,9 @@ class LocalHttpInterceptor implements PublicLo this._client = new HttpInterceptorClient({ worker, store: this.store, - Handler: LocalHttpRequestHandler, baseURL, + Handler: LocalHttpRequestHandler, + onUnhandledRequest: options.onUnhandledRequest, }); } @@ -37,7 +38,7 @@ class LocalHttpInterceptor implements PublicLo } baseURL() { - return this._client.baseURL(); + return this._client.baseURL().raw; } platform() { @@ -49,13 +50,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 7e9ad258..95d55cf1 100644 --- a/packages/zimic/src/interceptor/http/interceptor/RemoteHttpInterceptor.ts +++ b/packages/zimic/src/interceptor/http/interceptor/RemoteHttpInterceptor.ts @@ -32,8 +32,9 @@ class RemoteHttpInterceptor implements PublicR this._client = new HttpInterceptorClient({ worker, store: this.store, - Handler: RemoteHttpRequestHandler, baseURL, + Handler: RemoteHttpRequestHandler, + onUnhandledRequest: options.onUnhandledRequest, }); } @@ -42,7 +43,7 @@ class RemoteHttpInterceptor implements PublicR } baseURL() { - return this._client.baseURL(); + return this._client.baseURL().raw; } platform() { @@ -54,13 +55,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/__tests__/shared/methods/delete.ts b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/methods/delete.ts index aae8878a..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 @@ -1,8 +1,9 @@ -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'; 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'; @@ -10,15 +11,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 +277,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 +290,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 +309,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 +442,1114 @@ 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', () => { + 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: 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, + }); + }); + }, + ); + }); + } + + 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()); + } + + await usingHttpInterceptor<{ + '/users/:id': { + DELETE: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>( + { + ...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); + }); + }, + ); + }, + ); + + 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); + }); + }); }); }); } 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..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 @@ -1,8 +1,9 @@ -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'; 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'; @@ -10,15 +11,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 +280,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 +431,1043 @@ 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', () => { + 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: 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, + }); + }); + }, + ); + }); + } + + 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()); + } + + await usingHttpInterceptor<{ + '/users': { + GET: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User[] }; + }; + }; + }; + }>( + { + ...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); + }); + }, + ); + }, + ); + + 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); + }); + }); }); }); } 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..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 @@ -1,22 +1,25 @@ -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'; 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'; 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 +283,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 +420,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); - await promiseIfRemote(interceptor.clear(), interceptor); + headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); + expect(headRequests).toHaveLength(1); + let [headRequest] = headRequests; + expect(headRequest).toBeInstanceOf(Request); - const headPromise = fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); - await expectFetchError(headPromise); + expectTypeOf(headRequest.body).toEqualTypeOf(); + expect(headRequest.body).toBe(null); - headRequests = await promiseIfRemote(headHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); + expectTypeOf(headRequest.response.status).toEqualTypeOf<204>(); + expect(headRequest.response.status).toEqual(204); + + 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 +926,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 +934,469 @@ 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 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); + 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 promiseIfRemote( - headHandler.respond({ - status: 200, - }), - interceptor, - ); + await expect(async () => { + await interceptor.head('/'); + }).rejects.toThrowError(new NotStartedHttpInterceptorError()); + }); + }); - await promiseIfRemote(interceptor.clear(), interceptor); + describe('Unhandled requests', () => { + 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(); + }); + } + }); - const noContentHeadHandler = await promiseIfRemote( - headHandler.respond({ - status: 204, - }), - interceptor, - ); + 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: 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, + }); + }); + }, + ); + }); + } + + 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, + }); + }); + }, + ); + }); + } + }); - let headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); - expect(headRequests).toHaveLength(0); + 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()); + } + + await usingHttpInterceptor<{ + '/users': { + HEAD: { + request: { headers: { 'x-value': string } }; + response: { + 200: {}; + }; + }; + }; + }>( + { + ...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); + }); + }, + ); + }, + ); + + 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); + + if (!url.searchParams.has('name')) { + await context.log(); + } + }); - const headResponse = await fetch(joinURL(baseURL, '/users'), { method: 'HEAD' }); - expect(headResponse.status).toBe(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); + + 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, + }); + }); + }); + }); - headRequests = await promiseIfRemote(noContentHeadHandler.requests(), interceptor); - expect(headRequests).toHaveLength(1); - const [headRequest] = headRequests; - expect(headRequest).toBeInstanceOf(Request); + it('should log an error if a custom unhandled HEAD request handler throws', async () => { + const error = new Error('Unhandled request.'); - expectTypeOf(headRequest.body).toEqualTypeOf(); - expect(headRequest.body).toBe(null); + const onUnhandledRequest = vi.fn((request: Request) => { + const url = new URL(request.url); - expectTypeOf(headRequest.response.status).toEqualTypeOf<204>(); - expect(headRequest.response.status).toEqual(204); + if (!url.searchParams.has('name')) { + throw error; + } + }); - expectTypeOf(headRequest.response.body).toEqualTypeOf(); - expect(headRequest.response.body).toBe(null); + 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); + }); + }); }); }); } 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..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 @@ -1,8 +1,9 @@ -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'; 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'; @@ -10,15 +11,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 +262,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 +283,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 +440,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 +1000,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 +1010,495 @@ 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, - ); - - await promiseIfRemote(interceptor.clear(), 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); - const optionsRequest = optionsRequests[numberOfRequestsIncludingPrefetch - 1]; - expect(optionsRequest).toBeInstanceOf(Request); + optionsRequests = await promiseIfRemote(optionsHandler.requests(), interceptor); + expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - expectTypeOf(optionsRequest.body).toEqualTypeOf(); - expect(optionsRequest.body).toBe(null); + 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); + }); + }); + }); - expectTypeOf(optionsRequest.response.status).toEqualTypeOf<200>(); - expect(optionsRequest.response.status).toEqual(200); + 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.response.body).toEqualTypeOf(); - expect(optionsRequest.response.body).toBe(null); + await expect(async () => { + await interceptor.options('/'); + }).rejects.toThrowError(new NotStartedHttpInterceptorError()); }); }); - 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, - ); + describe('Unhandled requests', () => { + 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(); + }); + } + }); - await promiseIfRemote(interceptor.clear(), interceptor); + 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: 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, + }); + }); + }, + ); + }); + } - const otherOptionsHandler = await promiseIfRemote( - optionsHandler.respond({ - status: 200, - headers: DEFAULT_ACCESS_CONTROL_HEADERS, - }), - interceptor, - ); + 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, + }); + }); + }, + ); + }); + } + }); - let optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(0); + 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()); + } + + await usingHttpInterceptor<{ + '/filters': { + OPTIONS: { + request: { searchParams: { 'x-value': string } }; + response: { + 200: { headers: AccessControlHeaders }; + }; + }; + }; + }>( + { + ...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); + }); + }, + ); + }, + ); + + 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(); + } + }); - const optionsResponse = await fetch(joinURL(baseURL, '/filters'), { method: 'OPTIONS' }); - expect(optionsResponse.status).toBe(200); + 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, + }); + }); + }); + }); - optionsRequests = await promiseIfRemote(otherOptionsHandler.requests(), interceptor); - expect(optionsRequests).toHaveLength(numberOfRequestsIncludingPrefetch); - const optionsRequest = optionsRequests[numberOfRequestsIncludingPrefetch - 1]; - expect(optionsRequest).toBeInstanceOf(Request); + it('should log an error if a custom unhandled OPTIONS request handler throws', async () => { + const error = new Error('Unhandled request.'); - expectTypeOf(optionsRequest.body).toEqualTypeOf(); - expect(optionsRequest.body).toBe(null); + const onUnhandledRequest = vi.fn((request: Request) => { + const url = new URL(request.url); - expectTypeOf(optionsRequest.response.status).toEqualTypeOf<200>(); - expect(optionsRequest.response.status).toEqual(200); + if (!url.searchParams.has('name')) { + throw error; + } + }); - expectTypeOf(optionsRequest.response.body).toEqualTypeOf(); - expect(optionsRequest.response.body).toBe(null); + 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); + expect(spies.warn).toHaveBeenCalledTimes(0); + expect(spies.error).toHaveBeenCalledTimes(numberOfRequestsIncludingPrefetch); + + expect(spies.error).toHaveBeenCalledWith(error); + }); + }); }); }); } 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..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 @@ -1,8 +1,9 @@ -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'; 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'; @@ -10,15 +11,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 +279,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 +444,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 +1046,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 +1054,474 @@ 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', () => { + 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: 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, + }); + }); + }, + ); + }); + } + + 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()); + } + + await usingHttpInterceptor<{ + '/users/:id': { + PATCH: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>( + { + ...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); + }); + }, + ); + }, + ); + + 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); + }); + }); }); }); } 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..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 @@ -1,8 +1,9 @@ -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'; 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'; @@ -10,15 +11,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 +290,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 +463,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 +1059,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 +1067,502 @@ 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', () => { + 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 }; + }; + }; + }; + }>( + { + ...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: 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, + }); + }); + }, + ); + }); + } + }); + + 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()); + } + + await usingHttpInterceptor<{ + '/users': { + POST: { + request: { + headers: { 'x-value': string }; + body: UserCreationBody; + }; + response: { + 201: { body: User }; + }; + }; + }; + }>( + { + ...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) => { + 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); + }); + }); }); }); } 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..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 @@ -1,8 +1,9 @@ -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'; 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'; @@ -10,15 +11,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 +152,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 +280,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 +442,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 +1044,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 +1052,474 @@ 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', () => { + 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: 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, + }); + }); + }, + ); + }); + } + + 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()); + } + + await usingHttpInterceptor<{ + '/users/:id': { + PUT: { + request: { headers: { 'x-value': string } }; + response: { + 200: { body: User }; + }; + }; + }; + }>( + { + ...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); + }); + }, + ); + }, + ); + + 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); + }); + }); }); }); } 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..96b340bf --- /dev/null +++ b/packages/zimic/src/interceptor/http/interceptor/__tests__/shared/utils.ts @@ -0,0 +1,53 @@ +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 async function verifyUnhandledRequestMessage( + message: string, + options: { + type: 'warn' | 'error'; + platform: HttpInterceptorPlatform; + request: HttpRequest; + }, +) { + const { type, platform, request: rawRequest } = options; + + const request = await HttpInterceptorWorker.parseRawRequest(rawRequest); + + expect(message).toMatch(/.*\[zimic\].* /); + 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]'); + + 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); + + for (const headerKeyValuePair of formattedHeadersIgnoringWrapperBrackets.split(', ')) { + expect(headersLine.groups!.headers).toContain(headerKeyValuePair.trim()); + } + } + + expect(message).toContain( + platform === 'node' + ? `Search params: ${formatObjectToLog(Object.fromEntries(request.searchParams))}\n` + : 'Search params: [object Object]', + ); + + const body: unknown = request.body; + + expect(message).toContain( + platform === 'node' || typeof body !== 'object' || body === null + ? `Body: ${formatObjectToLog(body)}\n` + : 'Body: [object Object]', + ); +} diff --git a/packages/zimic/src/interceptor/http/interceptor/errors/NotStartedHttpInterceptorError.ts b/packages/zimic/src/interceptor/http/interceptor/errors/NotStartedHttpInterceptorError.ts index c926f133..25e3ca38 100644 --- a/packages/zimic/src/interceptor/http/interceptor/errors/NotStartedHttpInterceptorError.ts +++ b/packages/zimic/src/interceptor/http/interceptor/errors/NotStartedHttpInterceptorError.ts @@ -7,7 +7,7 @@ */ 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 1d09234d..ef64b42b 100644 --- a/packages/zimic/src/interceptor/http/interceptor/errors/UnknownHttpInterceptorPlatform.ts +++ b/packages/zimic/src/interceptor/http/interceptor/errors/UnknownHttpInterceptorPlatform.ts @@ -8,7 +8,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/interceptor/types/options.ts b/packages/zimic/src/interceptor/http/interceptor/types/options.ts index bb7a91ea..1f449df7 100644 --- a/packages/zimic/src/interceptor/http/interceptor/types/options.ts +++ b/packages/zimic/src/interceptor/http/interceptor/types/options.ts @@ -1,3 +1,6 @@ +import { HttpRequest } from '@/http/types/requests'; +import { PossiblePromise } from '@/types/utils'; + /** * An type of an HTTP interceptor. * @@ -12,7 +15,26 @@ export type HttpInterceptorType = 'local' | 'remote'; */ export type HttpInterceptorPlatform = 'node' | 'browser'; -export interface BaseHttpInterceptorOptions { +/** 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; + }>; + + 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 +export type UnhandledRequestStrategy = UnhandledRequestStrategy.Declaration | UnhandledRequestStrategy.Handler; + +export interface SharedHttpInterceptorOptions { /** The type of the HTTP interceptor. */ type: HttpInterceptorType; @@ -25,15 +47,22 @@ export interface BaseHttpInterceptorOptions { * 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; } /** The options to create a local HTTP interceptor. */ -export interface LocalHttpInterceptorOptions extends BaseHttpInterceptorOptions { +export interface LocalHttpInterceptorOptions extends SharedHttpInterceptorOptions { type: 'local'; } /** The options to create a remote HTTP interceptor. */ -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..b35e2706 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,25 +11,36 @@ import { HttpServiceSchema, } from '@/http/types/schema'; import { Default, PossiblePromise } from '@/types/utils'; -import { createURL } from '@/utils/urls'; +import { formatObjectToLog, logWithPrefix } from '@/utils/console'; +import { createURL, excludeNonPathParams } 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, HttpInterceptorRequest, HttpInterceptorResponse, } from '../requestHandler/types/requests'; +import HttpInterceptorWorkerStore from './HttpInterceptorWorkerStore'; import { HttpResponseFactory } from './types/requests'; abstract class HttpInterceptorWorker { + abstract readonly type: 'local' | 'remote'; + private _platform: HttpInterceptorPlatform | null = null; private _isRunning = false; private startingPromise?: Promise; private stoppingPromise?: Promise; + private store = new HttpInterceptorWorkerStore(); + + private unhandledRequestStrategies: { + baseURL: string; + declarationOrHandler: UnhandledRequestStrategy; + }[] = []; + platform() { return this._platform; } @@ -84,6 +96,69 @@ abstract class HttpInterceptorWorker { createResponse: HttpResponseFactory, ): PossiblePromise; + protected async handleUnhandledRequest(request: Request) { + const requestURL = excludeNonPathParams(createURL(request.url)).toString(); + + const defaultDeclarationOrHandler = this.store.defaultUnhandledRequestStrategy(); + + 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) { + await HttpInterceptorWorker.useStaticUnhandledStrategy(request, { log: declarationOrHandler.log }, action); + } else if (typeof defaultDeclarationOrHandler === 'function') { + await HttpInterceptorWorker.useUnhandledRequestStrategyHandler(request, defaultDeclarationOrHandler, action); + } else { + 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); + } + } + + onUnhandledRequest(baseURL: string, strategyOrFactory: UnhandledRequestStrategy) { + this.unhandledRequestStrategies.push({ + baseURL, + declarationOrHandler: strategyOrFactory, + }); + } + + offUnhandledRequest(baseURL: string) { + this.unhandledRequestStrategies = this.unhandledRequestStrategies.filter( + (strategy) => strategy.baseURL !== baseURL, + ); + } + abstract clearHandlers(): PossiblePromise; abstract clearInterceptorHandlers( @@ -218,6 +293,27 @@ abstract class HttpInterceptorWorker { return bodyAsText || null; } } + + static async logUnhandledRequest(rawRequest: HttpRequest, action: UnhandledRequestStrategy.Action) { + const request = await this.parseRawRequest(rawRequest); + + 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\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' }, + ); + } } export default HttpInterceptorWorker; 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..fe9a1ef8 --- /dev/null +++ b/packages/zimic/src/interceptor/http/interceptorWorker/HttpInterceptorWorkerStore.ts @@ -0,0 +1,32 @@ +import { UnhandledRequestStrategy } from '../interceptor/types/options'; + +export type DefaultUnhandledRequestStrategy = + | Required + | UnhandledRequestStrategy.Handler; + +const DEFAULT_UNHANDLED_REQUEST_STRATEGY: Required = Object.freeze({ + log: true, +}); + +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/http/interceptorWorker/LocalHttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts index 3e491fec..8659c93a 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/LocalHttpInterceptorWorker.ts @@ -67,7 +67,11 @@ class LocalHttpInterceptorWorker extends HttpInterceptorWorker { async start() { await super.sharedStart(async () => { const internalWorker = await this.internalWorkerOrLoad(); - const sharedOptions: MSWWorkerSharedOptions = { onUnhandledRequest: 'bypass' }; + const sharedOptions: MSWWorkerSharedOptions = { + onUnhandledRequest: async (request) => { + await super.handleUnhandledRequest(request); + }, + }; if (this.isInternalBrowserWorker(internalWorker)) { super.setPlatform('browser'); @@ -149,12 +153,13 @@ 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(); + + const result = await createResponse({ ...context, request }); if (result.bypass) { + await super.handleUnhandledRequest(requestClone); return passthrough(); } diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts b/packages/zimic/src/interceptor/http/interceptorWorker/RemoteHttpInterceptorWorker.ts index 2e93e4b9..a8f3b61a 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,8 +75,12 @@ class RemoteHttpInterceptorWorker extends HttpInterceptorWorker { const rawResponse = (await handler?.createResponse({ request })) ?? null; const response = rawResponse && request.method === 'HEAD' ? new Response(null, rawResponse) : rawResponse; - const serializedResponse = response ? await serializeResponse(response) : null; - return { response: serializedResponse }; + if (response) { + return { response: await serializeResponse(response) }; + } else { + await super.handleUnhandledRequest(request); + return { response: null }; + } }; private async readPlatform(): Promise { diff --git a/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnregisteredServiceWorkerError.ts b/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnregisteredServiceWorkerError.ts index 80c2a92d..81ad759e 100644 --- a/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnregisteredServiceWorkerError.ts +++ b/packages/zimic/src/interceptor/http/interceptorWorker/errors/UnregisteredServiceWorkerError.ts @@ -8,7 +8,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/http/requestHandler/types/requests.ts b/packages/zimic/src/interceptor/http/requestHandler/types/requests.ts index a32dcfdc..fd5e76da 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), ); /** diff --git a/packages/zimic/src/interceptor/index.ts b/packages/zimic/src/interceptor/index.ts index 41aef407..c49d577e 100644 --- a/packages/zimic/src/interceptor/index.ts +++ b/packages/zimic/src/interceptor/index.ts @@ -1,7 +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 from './http/interceptorWorker/HttpInterceptorWorkerStore'; export { UnknownHttpInterceptorPlatform, NotStartedHttpInterceptorError, UnregisteredServiceWorkerError }; @@ -19,6 +21,12 @@ export type { SyncedRemoteHttpRequestHandler, PendingRemoteHttpRequestHandler, HttpRequestHandler, + HttpRequestHandlerRestriction, + HttpRequestHandlerComputedRestriction, + HttpRequestHandlerHeadersStaticRestriction, + HttpRequestHandlerSearchParamsStaticRestriction, + HttpRequestHandlerStaticRestriction, + HttpRequestHandlerBodyStaticRestriction, } from './http/requestHandler/types/public'; export type { @@ -27,6 +35,7 @@ export type { LocalHttpInterceptorOptions, RemoteHttpInterceptorOptions, HttpInterceptorOptions, + UnhandledRequestStrategy, } from './http/interceptor/types/options'; export type { ExtractHttpInterceptorSchema } from './http/interceptor/types/schema'; @@ -45,9 +54,29 @@ export interface HttpNamespace { * @see {@link https://github.com/diego-aquino/zimic#declaring-http-service-schemas Declaring service schemas} */ createInterceptor: typeof createHttpInterceptor; + + /** Default HTTP settings. */ + default: { + /** + * 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. + */ + onUnhandledRequest: (strategy: UnhandledRequestStrategy) => void; + }; } /** @see {@link https://github.com/diego-aquino/zimic#http `http` API reference} */ -export const http: HttpNamespace = { +export const http: HttpNamespace = Object.freeze({ createInterceptor: createHttpInterceptor, -}; + + default: Object.freeze({ + onUnhandledRequest: (strategy: UnhandledRequestStrategy) => { + const store = new HttpInterceptorWorkerStore(); + store.setDefaultUnhandledRequestStrategy(strategy); + }, + }), +}); diff --git a/packages/zimic/src/interceptor/server/InterceptorServer.ts b/packages/zimic/src/interceptor/server/InterceptorServer.ts index c2578607..d2f06db2 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 { 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'; @@ -17,6 +20,9 @@ import { HttpHandlerCommit, InterceptorServerWebSocketSchema } from './types/sch export interface InterceptorServerOptions { hostname?: string; port?: number; + onUnhandledRequest?: { + log?: boolean; + }; } interface HttpHandler { @@ -32,6 +38,9 @@ class InterceptorServer implements PublicInterceptorServer { private _hostname: string; private _port?: number; + private onUnhandledRequest?: UnhandledRequestStrategy.Declaration; + private workerStore = new HttpInterceptorWorkerStore(); + private httpHandlerGroups: { [Method in HttpMethod]: HttpHandler[]; } = { @@ -49,6 +58,7 @@ class InterceptorServer implements PublicInterceptorServer { constructor(options: InterceptorServerOptions = {}) { this._hostname = options.hostname ?? 'localhost'; this._port = options.port; + this.onUnhandledRequest = options.onUnhandledRequest; } hostname() { @@ -212,7 +222,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 +238,23 @@ class InterceptorServer implements PublicInterceptorServer { await sendNodeResponse(defaultPreflightResponse, nodeResponse, nodeRequest); } + const shouldWarnUnhandledRequest = !isUnhandledPreflightResponse && !matchedAnyInterceptor; + + if (shouldWarnUnhandledRequest) { + 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(); }; @@ -238,6 +265,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 +275,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( 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/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/utils/console.ts b/packages/zimic/src/utils/console.ts new file mode 100644 index 00000000..a4d767fd --- /dev/null +++ b/packages/zimic/src/utils/console.ts @@ -0,0 +1,31 @@ +import chalk from 'chalk'; +import util from 'util'; + +import { isClientSide } from './environment'; + +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, + sorted: true, + }); +} + +export function logWithPrefix( + messageOrMessages: unknown, + options: { + method?: 'log' | 'warn' | 'error'; + } = {}, +) { + const { method = 'log' } = options; + + const messages = Array.isArray(messageOrMessages) ? messageOrMessages : [messageOrMessages]; + 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..c2e190af 100644 --- a/packages/zimic/src/utils/processes.ts +++ b/packages/zimic/src/utils/processes.ts @@ -1,17 +1,20 @@ 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) { - 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 8ea8d0c5..b981794a 100644 --- a/packages/zimic/src/utils/urls.ts +++ b/packages/zimic/src/utils/urls.ts @@ -1,14 +1,14 @@ export class InvalidURLError extends TypeError { constructor(url: unknown) { - super(`Invalid URL: '${url}'`); + super(`[zimic] Invalid URL: '${url}'`); this.name = 'InvalidURL'; } } export class UnsupportedURLProtocolError extends TypeError { - constructor(protocol: string, availableProtocols: string[]) { + constructor(protocol: string, availableProtocols: string[] | readonly string[]) { super( - `Unsupported URL protocol: '${protocol}'. ` + + `[zimic] Unsupported URL protocol: '${protocol}'. ` + `The available options are ${availableProtocols.map((protocol) => `'${protocol}'`).join(', ')}`, ); this.name = 'UnsupportedURLProtocolError'; @@ -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(/:$/, ''); @@ -59,8 +62,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'; } } 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'; } } 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..58b013eb --- /dev/null +++ b/packages/zimic/tests/setup/shared.ts @@ -0,0 +1,7 @@ +import { beforeEach } from 'vitest'; + +import { http } from '@/interceptor'; + +beforeEach(() => { + http.default.onUnhandledRequest({ log: false }); +}); 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( 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 8a2a139a..aafba563 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, diff --git a/packages/zimic/vitest.config.mts b/packages/zimic/vitest.config.mts index f7ff9cbe..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', 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', 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