diff --git a/docs/blog/2024-07-21-introducing-core.md b/docs/blog/2024-07-21-introducing-core.md index f450b80f..a28a5a7f 100644 --- a/docs/blog/2024-07-21-introducing-core.md +++ b/docs/blog/2024-07-21-introducing-core.md @@ -97,7 +97,6 @@ These are the new methods for resetting fetch mock to its default state. The nam A replacement for `sandbox()` that eschews all the weird wiring that `.sandbox()` used. Possibly not very useful for the average user, but I use it a lot in my tests for fetch mock, so it stays :-). - ## What's still to come There are a bunch of [breaking changes](https://github.com/wheresrhys/fetch-mock/issues?q=is%3Aopen+is%3Aissue+label%3A%22breaking+change%22) I'd like to ship before getting to v1.0.0. I also want to give users an incentive to migrate so there are a variety of new features I'd like to add and bugs to fix. Have a look at [the issues list](https://github.com/wheresrhys/fetch-mock/issues) and vote for any you like. diff --git a/docs/docs/@fetch-mock/core/CallHistory.md b/docs/docs/@fetch-mock/core/CallHistory.md index df4922e0..8954a3bc 100644 --- a/docs/docs/@fetch-mock/core/CallHistory.md +++ b/docs/docs/@fetch-mock/core/CallHistory.md @@ -60,6 +60,8 @@ An options object compatible with the [route api](#api-mockingmock_options) to b `fetchMock.callHistory` exposes the following methods. +> Note that fetch calls made using `(url, options)` pairs are added synchronously, but calls using a `Request` are added asynchronously. This is because when a `Request` is used access to many of its internals is via asynchronous methods, while for an options object they can be read directly. In general it's best to `await` your code to complete before attempting to access call history. + ### .recordCall(callLog) For internal use. diff --git a/packages/core/src/CallHistory.js b/packages/core/src/CallHistory.js index 182a9ad4..1b519b22 100644 --- a/packages/core/src/CallHistory.js +++ b/packages/core/src/CallHistory.js @@ -4,7 +4,7 @@ /** @typedef {import('./RequestUtils').NormalizedRequestOptions} NormalizedRequestOptions */ /** @typedef {import('./Matchers').RouteMatcher} RouteMatcher */ /** @typedef {import('./FetchMock').FetchMockConfig} FetchMockConfig */ -import { createCallLog } from './RequestUtils.js'; +import { createCallLogFromUrlAndOptions } from './RequestUtils.js'; import { isUrlMatcher } from './Matchers.js'; import Route from './Route.js'; import Router from './Router.js'; @@ -18,8 +18,8 @@ import Router from './Router.js'; * @property {AbortSignal} [signal] * @property {Route} [route] * @property {Response} [response] - * @property {Object.} [expressParameters] - * @property {Object.} [queryParameters] + * @property {Object.} [expressParams] + * @property {Object.} [queryParams] * @property {Promise[]} pendingPromises */ @@ -142,7 +142,7 @@ class CallHistory { }); calls = calls.filter(({ url, options }) => { - return matcher(createCallLog(url, options, this.config.Request)); + return matcher(createCallLogFromUrlAndOptions(url, options)); }); return calls; diff --git a/packages/core/src/FetchMock.js b/packages/core/src/FetchMock.js index a5d96715..fcd2bd4b 100644 --- a/packages/core/src/FetchMock.js +++ b/packages/core/src/FetchMock.js @@ -73,13 +73,20 @@ const FetchMock = { * @this {FetchMock} * @returns {Promise} */ - fetchHandler(requestInput, requestInit) { + async fetchHandler(requestInput, requestInit) { // TODO move into router - const callLog = requestUtils.createCallLog( - requestInput, - requestInit, - this.config.Request, - ); + let callLog; + if (requestUtils.isRequest(requestInput, this.config.Request)) { + callLog = await requestUtils.createCallLogFromRequest( + requestInput, + requestInit, + ); + } else { + callLog = requestUtils.createCallLogFromUrlAndOptions( + requestInput, + requestInit, + ); + } this.callHistory.recordCall(callLog); const responsePromise = this.router.execute(callLog); diff --git a/packages/core/src/Matchers.js b/packages/core/src/Matchers.js index 0a8145d8..75ee1933 100644 --- a/packages/core/src/Matchers.js +++ b/packages/core/src/Matchers.js @@ -169,7 +169,9 @@ const getBodyMatcher = (route) => { let sentBody; try { - sentBody = JSON.parse(body); + if (typeof body === 'string') { + sentBody = JSON.parse(body); + } } catch (err) {} return ( diff --git a/packages/core/src/RequestUtils.js b/packages/core/src/RequestUtils.js index e6175cf3..78068ed0 100644 --- a/packages/core/src/RequestUtils.js +++ b/packages/core/src/RequestUtils.js @@ -7,7 +7,7 @@ const protocolRelativeUrlRX = new RegExp('^//', 'i'); /** * @typedef DerivedRequestOptions * @property {string} method - * @property {Promise} [body] + * @property {string} [body] * @property {{ [key: string]: string }} [headers] */ @@ -15,18 +15,18 @@ const protocolRelativeUrlRX = new RegExp('^//', 'i'); /** @typedef {import('./CallHistory').CallLog} CallLog */ /** - * - * @param {string} url + * @param {string | string | URL} url * @returns {string} */ export function normalizeUrl(url) { + if (url instanceof URL) { + return url.href; + } if (absoluteUrlRX.test(url)) { - const u = new URL(url); - return u.href; + return new URL(url).href; } if (protocolRelativeUrlRX.test(url)) { - const u = new URL(url, 'http://dummy'); - return u.href; + return new URL(url, 'http://dummy').href; } const u = new URL(url, 'http://dummy'); return u.pathname + u.search; @@ -37,57 +37,29 @@ export function normalizeUrl(url) { * @param {typeof Request} Request * @returns {urlOrRequest is Request} */ -const isRequest = (urlOrRequest, Request) => +export const isRequest = (urlOrRequest, Request) => Request.prototype.isPrototypeOf(urlOrRequest); /** * - * @param {string|Request} urlOrRequest + * @param {string | object} url * @param {RequestInit} options - * @param {typeof Request} Request * @returns {CallLog} */ -export function createCallLog(urlOrRequest, options, Request) { +export function createCallLogFromUrlAndOptions(url, options) { /** @type {Promise[]} */ const pendingPromises = []; - if (isRequest(urlOrRequest, Request)) { - /** @type {NormalizedRequestOptions} */ - const derivedOptions = { - method: urlOrRequest.method, - }; - - try { - derivedOptions.body = urlOrRequest.clone().text(); - } catch (err) {} - - if (urlOrRequest.headers) { - derivedOptions.headers = normalizeHeaders(urlOrRequest.headers); - } - const callLog = { - arguments: [urlOrRequest, options], - url: normalizeUrl(urlOrRequest.url), - options: Object.assign(derivedOptions, options), - request: urlOrRequest, - signal: (options && options.signal) || urlOrRequest.signal, - pendingPromises, - }; - return callLog; - } - if ( - typeof urlOrRequest === 'string' || - /** @type {object} */ (urlOrRequest) instanceof String || - // horrible URL object duck-typing - (typeof urlOrRequest === 'object' && 'href' in urlOrRequest) - ) { + if (typeof url === 'string' || url instanceof String || url instanceof URL) { return { - arguments: [urlOrRequest, options], - url: normalizeUrl(urlOrRequest), + arguments: [url, options], + // @ts-ignore - jsdoc doesn't distinguish between string and String, but typechecker complains + url: normalizeUrl(url), options: options || {}, signal: options && options.signal, pendingPromises, }; } - if (typeof urlOrRequest === 'object') { + if (typeof url === 'object') { throw new TypeError( 'fetch-mock: Unrecognised Request object. Read the Config and Installation sections of the docs', ); @@ -95,6 +67,39 @@ export function createCallLog(urlOrRequest, options, Request) { throw new TypeError('fetch-mock: Invalid arguments passed to fetch'); } } + +/** + * + * @param {Request} request + * @param {RequestInit} options + * @returns {Promise} + */ +export async function createCallLogFromRequest(request, options) { + /** @type {Promise[]} */ + const pendingPromises = []; + /** @type {NormalizedRequestOptions} */ + const derivedOptions = { + method: request.method, + }; + + try { + derivedOptions.body = await request.clone().text(); + } catch (err) {} + + if (request.headers) { + derivedOptions.headers = normalizeHeaders(request.headers); + } + const callLog = { + arguments: [request, options], + url: normalizeUrl(request.url), + options: Object.assign(derivedOptions, options || {}), + request: request, + signal: (options && options.signal) || request.signal, + pendingPromises, + }; + return callLog; +} + /** * @param {string} url * @returns {string} diff --git a/packages/core/src/__tests__/CallHistory.test.js b/packages/core/src/__tests__/CallHistory.test.js index 3b31bcdc..d3ad9b1d 100644 --- a/packages/core/src/__tests__/CallHistory.test.js +++ b/packages/core/src/__tests__/CallHistory.test.js @@ -119,12 +119,12 @@ describe('CallHistory', () => { ); }); - it('when called with Request instance', () => { + it('when called with Request instance', async () => { fm.catch(); const req = new Request('http://a.com/', { method: 'post', }); - fm.fetchHandler(req); + await fm.fetchHandler(req); expect(fm.callHistory.calls()[0]).toEqual( expect.objectContaining({ url: 'http://a.com/', @@ -133,12 +133,12 @@ describe('CallHistory', () => { }), ); }); - it('when called with Request instance and arbitrary option', () => { + it('when called with Request instance and arbitrary option', async () => { fm.catch(); const req = new Request('http://a.com/', { method: 'POST', }); - fm.fetchHandler(req, { arbitraryOption: true }); + await fm.fetchHandler(req, { arbitraryOption: true }); expect(fm.callHistory.calls()[0]).toEqual( expect.objectContaining({ url: 'http://a.com/', diff --git a/packages/core/src/__tests__/Matchers/body.test.js b/packages/core/src/__tests__/Matchers/body.test.js index b5b23d13..adefe18f 100644 --- a/packages/core/src/__tests__/Matchers/body.test.js +++ b/packages/core/src/__tests__/Matchers/body.test.js @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import Route from '../../Route.js'; import Router from '../../Router.js'; -import { createCallLog } from '../../RequestUtils.js'; +import { createCallLogFromRequest } from '../../RequestUtils.js'; describe('body matching', () => { //TODO add a test for matching an asynchronous body it('should not match if no body provided in request', () => { @@ -45,7 +45,7 @@ describe('body matching', () => { Response, }); const router = new Router({ Request, Headers }, { routes: [route] }); - const normalizedRequest = createCallLog( + const normalizedRequest = await createCallLogFromRequest( new Request('http://a.com/', { method: 'POST', body: JSON.stringify({ foo: 'bar' }),