diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cd430c19..8c261c30 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - "packages/core": "0.1.1", + "packages/core": "0.3.0", "packages/fetch-mock": "10.1.0" } diff --git a/docs/docs/@fetch-mock/core/CallHistory.md b/docs/docs/@fetch-mock/core/CallHistory.md index 62a46780..df4922e0 100644 --- a/docs/docs/@fetch-mock/core/CallHistory.md +++ b/docs/docs/@fetch-mock/core/CallHistory.md @@ -10,11 +10,13 @@ sidebar_position: 4 Calls are recorded, and returned, in a standard format with the following properties: +- `[string|Request,Object]` - the original arguments passed in to `fetch` - `{string} url` - The url being fetched - `{NormalizedRequestOptions} options` - The options passed in to the fetch (may be derived from a `Request` if one was used) - `{Request} [request]` - The `Request` passed to fetch, if one was used - `{Route} [route]` - The route used to handle the request - `{Response} [response]` - The `Response` returned to the user +- `{Object.}` - Any express parameters extracted from the `url` - `{Promise[]} pendingPromises` - An internal structure used by the `.flush()` method documented below ## Filtering @@ -74,11 +76,7 @@ Returns a Boolean indicating whether any calls to `fetch` matched the given `fil Returns the `CallLog` for the last call to `fetch` matching the given `filter` and `options`. -### fetchMock.done(routeNames) - -_Note that this function is exposed on the `fetchMock` object, not on `fetchMock.callHistory`_ - -TODO, should callHistory just have access to `routes`... yes probably as these docs are horrible +### .done(routeNames) Returns a Boolean indicating whether `fetch` was called the expected number of times (or has been called at least once if `repeat` is not defined for the route). It does not take into account whether the `fetches` completed successfully. diff --git a/docs/docs/@fetch-mock/core/more-routing-methods.md b/docs/docs/@fetch-mock/core/more-routing-methods.md index 283e01d1..6aa2b42d 100644 --- a/docs/docs/@fetch-mock/core/more-routing-methods.md +++ b/docs/docs/@fetch-mock/core/more-routing-methods.md @@ -95,7 +95,9 @@ If your matcher requires access to the body of the request set this to true; bec `{Function}` -A function which takes a route definition object as input, and returns a function of the signature `(url, options, request) => Boolean`. See the examples below for more detail. The function is passed the fetchMock instance as a second parameter in case you need to access any config. +A function which takes a route definition object as input, and returns a function of the signature `(NormalizedRequest) => Boolean`. See the examples below for more detail. The function is passed the fetchMock instance as a second parameter in case you need to access any config. + +// TODO at time of writing the NormalizedRequest schema is still evolving, so more detailed docs will emerge soon ##### Examples diff --git a/docs/docs/@fetch-mock/core/route/matcher.md b/docs/docs/@fetch-mock/core/route/matcher.md index 21c2bcfe..1e24eec5 100644 --- a/docs/docs/@fetch-mock/core/route/matcher.md +++ b/docs/docs/@fetch-mock/core/route/matcher.md @@ -78,6 +78,10 @@ When the `express:` keyword is used in a string matcher, it can be combined with } ``` +The values of express parameters are made available in the `expressParams` property when +- [Inspecting call history](/fetch-mock/docs/@fetch-mock/core/CallHistory#calllog-schema) +- [Using a function to construct a response](/fetch-mock/docs/@fetch-mock/core/route/response#function) + ## Other matching criteria ### method @@ -116,7 +120,7 @@ Match only requests that have these query parameters set (in any order). Query p Match only requests that send a JSON body with the exact structure and properties as the one provided here. -Note that if matching on body _and_ using `Request` instances in your source code, this forces fetch-mock into an asynchronous flow _before_ it is able to route requests effectively. This means no [inspection methods](#api-inspectionfundamentals) can be used synchronously. You must first either await the fetches to resolve, or `await fetchMock.flush()`. The popular library [Ky](https://github.com/sindresorhus/ky) uses `Request` instances internally, and so also triggers this mode. +Note that if matching on body _and_ using `Request` instances in your source code, this forces fetch-mock into an asynchronous flow _before_ it is able to route requests effectively. This means no [inspection methods](#api-inspectionfundamentals) can be used synchronously. You must first either await the fetches to resolve, or `await fetchMock.callHistory.flush()`. The popular library [Ky](https://github.com/sindresorhus/ky) uses `Request` instances internally, and so also triggers this mode. e.g.`{body: { "key1": "value1", "key2": "value2" }}` @@ -141,7 +145,7 @@ This option can also be [set in the global configuration](/fetch-mock/docs/@fetc For use cases not covered by all the built in matchers, a custom function can be used. It should return `true` to indicate a route should respond to a request. It will be passed the `url` and `options` `fetch` was called with. If `fetch` was called with a `Request` instance, it will be passed `url` and `options` inferred from the `Request` instance, with the original `Request` available as a third argument. -As well as being passed as a standalone argument, it can also be added to the matcher object as the property `{functionMatcher: ...}` when combining with other matchers or options. +As well as being passed as a standalone argument, it can also be added to the matcher object as the property `{matcherFunction: ...}` when combining with other matchers or options. ### Examples diff --git a/docs/docs/@fetch-mock/core/route/response.md b/docs/docs/@fetch-mock/core/route/response.md index 444da0ea..81bd4c15 100644 --- a/docs/docs/@fetch-mock/core/route/response.md +++ b/docs/docs/@fetch-mock/core/route/response.md @@ -76,9 +76,9 @@ A `Promise` that resolves to any of the options documented above e.g. `new Promi `{Function}` -A function that returns any of the options documented above (including `Promise`. The function will be passed the `url` and `options` `fetch` was called with. If `fetch` was called with a `Request` instance, it will be passed `url` and `options` inferred from the `Request` instance, with the original `Request` passed as a third argument. +A function that is passed a [`CallLog`](/fetch-mock/docs/@fetch-mock/core/CallHistory#calllog-schema) and returns any of the options documented above (including `Promise`). ### Examples -- `(url, opts) => opts.headers.Authorization ? 200 : 403` -- `(_, _, request) => request.headers.get('Authorization') ? 200 : 403` +- `({url, options}) => options.headers.Authorization ? 200 : 403` +- `({request}) => request.headers.get('Authorization') ? 200 : 403` diff --git a/package-lock.json b/package-lock.json index e1064d3d..98a65ab7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27216,7 +27216,7 @@ }, "packages/core": { "name": "@fetch-mock/core", - "version": "0.1.1", + "version": "0.3.0", "license": "ISC", "dependencies": { "dequal": "^2.0.3", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 9f0e4b44..e2c6eca8 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## [0.3.0](https://github.com/wheresrhys/fetch-mock/compare/core-v0.2.0...core-v0.3.0) (2024-07-21) + + +### ⚠ BREAKING CHANGES + +* matchers now take normalized requests as input +* renamed func to matcherFunction +* removed support for passing in a matcher under the generic name matcher +* renamed functionMatcher to func + +### refactor + +* matchers now take normalized requests as input ([da9dfe8](https://github.com/wheresrhys/fetch-mock/commit/da9dfe80475f2c95ea9a3652bfe8682ccd4c65fd)) + + +### Features + +* can now access express parameters in responses ([41e2475](https://github.com/wheresrhys/fetch-mock/commit/41e2475d64d909f5fb686f2fe3709243326f2dba)) +* removed support for passing in a matcher under the generic name matcher ([f41d8f9](https://github.com/wheresrhys/fetch-mock/commit/f41d8f909350961e40a4df9dfb4817a3eaba09cd)) +* renamed func to matcherFunction ([e5679a7](https://github.com/wheresrhys/fetch-mock/commit/e5679a72f663d5187d08934aa510951f1d438adc)) +* renamed functionMatcher to func ([4cee629](https://github.com/wheresrhys/fetch-mock/commit/4cee629b36cd618d6d5b1061c15e48aab7047969)) +* response builder function now expects a calllog ([306357d](https://github.com/wheresrhys/fetch-mock/commit/306357db486c9c7aa621f430cd08621420efc724)) + +## [0.2.0](https://github.com/wheresrhys/fetch-mock/compare/core-v0.1.1...core-v0.2.0) (2024-07-20) + + +### ⚠ BREAKING CHANGES + +* removed top level done and flush methods + +### Features + +* removed top level done and flush methods ([49ae6f7](https://github.com/wheresrhys/fetch-mock/commit/49ae6f7671a2ce10f0a31bafd3eb9e1d7ce5cf2d)) + + +### Bug Fixes + +* callhistory created with instance.config, not this.config ([87206e6](https://github.com/wheresrhys/fetch-mock/commit/87206e69e71e1270932fe322c79f0b42cac486c6)) + ## [0.1.1](https://github.com/wheresrhys/fetch-mock/compare/core-v0.1.0...core-v0.1.1) (2024-07-18) diff --git a/packages/core/package.json b/packages/core/package.json index 72430ce1..b8475ec3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,7 +2,7 @@ "name": "@fetch-mock/core", "description": "Utility for creating mock fetch implementation", "exports": "src/index.js", - "version": "0.1.1", + "version": "0.3.0", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" diff --git a/packages/core/src/CallHistory.js b/packages/core/src/CallHistory.js index 045f818e..182a9ad4 100644 --- a/packages/core/src/CallHistory.js +++ b/packages/core/src/CallHistory.js @@ -4,17 +4,22 @@ /** @typedef {import('./RequestUtils').NormalizedRequestOptions} NormalizedRequestOptions */ /** @typedef {import('./Matchers').RouteMatcher} RouteMatcher */ /** @typedef {import('./FetchMock').FetchMockConfig} FetchMockConfig */ -import { normalizeRequest } from './RequestUtils.js'; +import { createCallLog } from './RequestUtils.js'; import { isUrlMatcher } from './Matchers.js'; import Route from './Route.js'; +import Router from './Router.js'; /** * @typedef CallLog + * @property {any[]} arguments * @property {string} url * @property {NormalizedRequestOptions} options * @property {Request} [request] + * @property {AbortSignal} [signal] * @property {Route} [route] * @property {Response} [response] + * @property {Object.} [expressParameters] + * @property {Object.} [queryParameters] * @property {Promise[]} pendingPromises */ @@ -44,11 +49,13 @@ const isMatchedOrUnmatched = (filter) => class CallHistory { /** * @param {FetchMockConfig} globalConfig + * @param {Router} router */ - constructor(globalConfig) { + constructor(globalConfig, router) { /** @type {CallLog[]} */ this.callLogs = []; this.config = globalConfig; + this.router = router; } /** * @@ -123,7 +130,7 @@ class CallHistory { } } else { if (isUrlMatcher(filter)) { - options = { matcher: filter, ...(options || {}) }; + options = { url: filter, ...(options || {}) }; } else { options = { ...filter, ...(options || {}) }; } @@ -135,12 +142,7 @@ class CallHistory { }); calls = calls.filter(({ url, options }) => { - const { - url: normalizedUrl, - options: normalizedOptions, - request, - } = normalizeRequest(url, options, this.config.Request); - return matcher(normalizedUrl, normalizedOptions, request); + return matcher(createCallLog(url, options, this.config.Request)); }); return calls; @@ -163,17 +165,19 @@ class CallHistory { lastCall(filter, options) { return this.calls(filter, options).pop(); } + /** - * - * @param {RouteName[]} [routeNames] - * @param {Route[]} allRoutes + * @param {RouteName|RouteName[]} [routeNames] * @returns {boolean} */ - done(allRoutes, routeNames) { - const routesToCheck = routeNames - ? allRoutes.filter(({ config: { name } }) => routeNames.includes(name)) - : allRoutes; - + done(routeNames) { + let routesToCheck = this.router.routes; + if (routeNames) { + routeNames = Array.isArray(routeNames) ? routeNames : [routeNames]; + routesToCheck = this.router.routes.filter(({ config: { name } }) => + routeNames.includes(name), + ); + } // TODO when checking all routes needs to check against all calls // Can't use array.every because would exit after first failure, which would // break the logging diff --git a/packages/core/src/FetchMock.js b/packages/core/src/FetchMock.js index 76feff83..a5d96715 100644 --- a/packages/core/src/FetchMock.js +++ b/packages/core/src/FetchMock.js @@ -44,18 +44,18 @@ const defaultConfig = { * @property {function(string | Request, RequestInit): Promise} fetchHandler * @property {function(any,any,any): FetchMock} route * @property {function(RouteResponse=): FetchMock} catch - * @property {function(boolean): Promise} flush - * @property {function(RouteName[]=): boolean} done * @property {function(MatcherDefinition):void} defineMatcher * @property {function(object): void} removeRoutes * @property {function():void} clearHistory */ +const defaultRouter = new Router(defaultConfig); + /** @type {FetchMockCore} */ const FetchMock = { config: defaultConfig, - router: new Router(defaultConfig), - callHistory: new CallHistory(defaultConfig), + router: defaultRouter, + callHistory: new CallHistory(defaultConfig, defaultRouter), createInstance() { const instance = Object.create(FetchMock); instance.config = { ...this.config }; @@ -63,7 +63,7 @@ const FetchMock = { routes: [...this.router.routes], fallbackRoute: this.router.fallbackRoute, }); - instance.callHistory = new CallHistory(this.config); + instance.callHistory = new CallHistory(instance.config, instance.router); return instance; }, /** @@ -75,17 +75,15 @@ const FetchMock = { */ fetchHandler(requestInput, requestInit) { // TODO move into router - const normalizedRequest = requestUtils.normalizeRequest( + const callLog = requestUtils.createCallLog( requestInput, requestInit, this.config.Request, ); - /** @type {Promise[]} */ - const pendingPromises = []; - const callLog = { ...normalizedRequest, pendingPromises }; + this.callHistory.recordCall(callLog); - const responsePromise = this.router.execute(callLog, normalizedRequest); - pendingPromises.push(responsePromise); + const responsePromise = this.router.execute(callLog); + callLog.pendingPromises.push(responsePromise); return responsePromise; }, /** @@ -122,23 +120,6 @@ const FetchMock = { defineMatcher(matcher) { Route.defineMatcher(matcher); }, - flush(waitForResponseBody) { - return this.callHistory.flush(waitForResponseBody); - }, - /** - * - * @param {RouteName|RouteName[]} routeNames - * @returns {boolean} - */ - done(routeNames) { - if (!routeNames) { - return this.callHistory.done(this.router.routes); - } - return this.callHistory.done( - this.router.routes, - Array.isArray(routeNames) ? routeNames : [routeNames], - ); - }, removeRoutes(options) { this.router.removeRoutes(options); return this; diff --git a/packages/core/src/Matchers.js b/packages/core/src/Matchers.js index 445bde6e..0a8145d8 100644 --- a/packages/core/src/Matchers.js +++ b/packages/core/src/Matchers.js @@ -1,6 +1,6 @@ //@type-check /** @typedef {import('./Route').RouteConfig} RouteConfig */ -/** @typedef {import('./RequestUtils').NormalizedRequestOptions} NormalizedRequestOptions */ +/** @typedef {import('./CallHistory').CallLog} CallLog */ import glob from 'globrex'; import * as regexparam from 'regexparam'; import querystring from 'querystring'; @@ -30,9 +30,8 @@ export const isUrlMatcher = (matcher) => export const isFunctionMatcher = (matcher) => typeof matcher === 'function'; /** @typedef {string | RegExp | URL} RouteMatcherUrl */ -/** @typedef {function(string): boolean} UrlMatcher */ -/** @typedef {function(string): UrlMatcher} UrlMatcherGenerator */ -/** @typedef {function(string, NormalizedRequestOptions, Request): boolean} RouteMatcherFunction */ +/** @typedef {function(string): RouteMatcherFunction} UrlMatcherGenerator */ +/** @typedef {function(CallLog): boolean} RouteMatcherFunction */ /** @typedef {function(RouteConfig): RouteMatcherFunction} MatcherGenerator */ /** @typedef {RouteMatcherUrl | RouteMatcherFunction} RouteMatcher */ @@ -47,19 +46,41 @@ export const isFunctionMatcher = (matcher) => typeof matcher === 'function'; * @type {Object.} */ const stringMatchers = { - begin: (targetString) => (url) => url.indexOf(targetString) === 0, - end: (targetString) => (url) => - url.substr(-targetString.length) === targetString, + begin: + (targetString) => + ({ url }) => + url.indexOf(targetString) === 0, + end: + (targetString) => + ({ url }) => + url.substr(-targetString.length) === targetString, glob: (targetString) => { const urlRX = glob(targetString); - return (url) => urlRX.regex.test(url); + return ({ url }) => urlRX.regex.test(url); }, express: (targetString) => { const urlRX = regexparam.parse(targetString); - return (url) => urlRX.pattern.test(getPath(url)); + return (callLog) => { + const vals = urlRX.pattern.exec(getPath(callLog.url)); + if (!vals) { + callLog.expressParams = {}; + return false; + } + vals.shift(); + /** @type {Object.} */ + callLog.expressParams = urlRX.keys.reduce( + (map, paramName, i) => + vals[i] ? Object.assign(map, { [paramName]: vals[i] }) : map, + {}, + ); + return true; + }; }, - path: (targetString) => (url) => getPath(url) === targetString, + path: + (targetString) => + ({ url }) => + getPath(url) === targetString, }; /** * @type {MatcherGenerator} @@ -69,7 +90,7 @@ const getHeaderMatcher = ({ headers: expectedHeaders }) => { return; } const expectation = normalizeHeaders(expectedHeaders); - return (url, { headers = {} }) => { + return ({ options: { headers = {} } }) => { // TODO do something to handle multi value headers const lowerCaseHeaders = normalizeHeaders(headers); return Object.keys(expectation).every( @@ -84,7 +105,7 @@ const getMethodMatcher = ({ method: expectedMethod }) => { if (!expectedMethod) { return; } - return (url, { method }) => { + return ({ options: { method } = {} }) => { const actualMethod = method ? method.toLowerCase() : 'get'; return expectedMethod === actualMethod; }; @@ -98,7 +119,7 @@ const getQueryStringMatcher = ({ query: passedQuery }) => { } const expectedQuery = querystring.parse(querystring.stringify(passedQuery)); const keys = Object.keys(expectedQuery); - return (url) => { + return ({ url }) => { const query = querystring.parse(getQuery(url)); return keys.every((key) => { if (Array.isArray(query[key])) { @@ -117,30 +138,21 @@ const getQueryStringMatcher = ({ query: passedQuery }) => { /** * @type {MatcherGenerator} */ -const getParamsMatcher = ({ params: expectedParams, url: matcherUrl }) => { +const getParamsMatcher = ({ params: expectedParams, url }) => { if (!expectedParams) { return; } - if (typeof matcherUrl === 'string') { - if (!/express:/.test(matcherUrl)) { - throw new Error( - 'fetch-mock: matching on params is only possible when using an express: matcher', - ); - } - const expectedKeys = Object.keys(expectedParams); - const re = regexparam.parse(matcherUrl.replace(/^express:/, '')); - return (url) => { - const vals = re.pattern.exec(getPath(url)) || []; - vals.shift(); - /** @type {Object.} */ - const params = re.keys.reduce( - (map, paramName, i) => - vals[i] ? Object.assign(map, { [paramName]: vals[i] }) : map, - {}, - ); - return expectedKeys.every((key) => params[key] === expectedParams[key]); - }; + if (!(typeof url === 'string' && /express:/.test(url))) { + throw new Error( + 'fetch-mock: matching on params is only possible when using an express: matcher', + ); } + const expectedKeys = Object.keys(expectedParams); + return ({ expressParams = {} }) => { + return expectedKeys.every( + (key) => expressParams[key] === expectedParams[key], + ); + }; }; /** * @type {MatcherGenerator} @@ -148,7 +160,7 @@ const getParamsMatcher = ({ params: expectedParams, url: matcherUrl }) => { const getBodyMatcher = (route) => { const { body: expectedBody } = route; - return (url, { body, method = 'get' }) => { + return ({ options: { body, method = 'get' } }) => { if (method.toLowerCase() === 'get') { // GET requests don’t send a body so the body matcher should be ignored for them return true; @@ -185,7 +197,7 @@ const getFullUrlMatcher = (route, matcherUrl, query) => { route.url = expectedUrl; } - return (url) => { + return ({ url }) => { if (query && expectedUrl.indexOf('?')) { return getPath(url) === getPath(expectedUrl); } @@ -196,7 +208,7 @@ const getFullUrlMatcher = (route, matcherUrl, query) => { /** * @type {MatcherGenerator} */ -const getFunctionMatcher = ({ functionMatcher }) => functionMatcher; +const getFunctionMatcher = ({ matcherFunction }) => matcherFunction; /** * @type {MatcherGenerator} */ @@ -208,7 +220,7 @@ const getUrlMatcher = (route) => { } if (matcherUrl instanceof RegExp) { - return (url) => matcherUrl.test(url); + return ({ url }) => matcherUrl.test(url); } if (matcherUrl instanceof URL) { if (matcherUrl.href) { @@ -231,11 +243,11 @@ const getUrlMatcher = (route) => { /** @type {MatcherDefinition[]} */ export const builtInMatchers = [ + { name: 'url', matcher: getUrlMatcher }, { name: 'query', matcher: getQueryStringMatcher }, { name: 'method', matcher: getMethodMatcher }, { name: 'headers', matcher: getHeaderMatcher }, { name: 'params', matcher: getParamsMatcher }, { name: 'body', matcher: getBodyMatcher, usesBody: true }, - { name: 'functionMatcher', matcher: getFunctionMatcher }, - { name: 'url', matcher: getUrlMatcher }, + { name: 'matcherFunction', matcher: getFunctionMatcher }, ]; diff --git a/packages/core/src/RequestUtils.js b/packages/core/src/RequestUtils.js index 65dad0b1..e6175cf3 100644 --- a/packages/core/src/RequestUtils.js +++ b/packages/core/src/RequestUtils.js @@ -12,13 +12,7 @@ const protocolRelativeUrlRX = new RegExp('^//', 'i'); */ /** @typedef {RequestInit | (RequestInit & DerivedRequestOptions) } NormalizedRequestOptions */ -/** - * @typedef NormalizedRequest - * @property {string} url - * @property {NormalizedRequestOptions} options - * @property {Request} [request] - * @property {AbortSignal} [signal] - */ +/** @typedef {import('./CallHistory').CallLog} CallLog */ /** * @@ -51,9 +45,11 @@ const isRequest = (urlOrRequest, Request) => * @param {string|Request} urlOrRequest * @param {RequestInit} options * @param {typeof Request} Request - * @returns {NormalizedRequest} + * @returns {CallLog} */ -export function normalizeRequest(urlOrRequest, options, Request) { +export function createCallLog(urlOrRequest, options, Request) { + /** @type {Promise[]} */ + const pendingPromises = []; if (isRequest(urlOrRequest, Request)) { /** @type {NormalizedRequestOptions} */ const derivedOptions = { @@ -67,13 +63,15 @@ export function normalizeRequest(urlOrRequest, options, Request) { if (urlOrRequest.headers) { derivedOptions.headers = normalizeHeaders(urlOrRequest.headers); } - const normalizedRequestObject = { + const callLog = { + arguments: [urlOrRequest, options], url: normalizeUrl(urlOrRequest.url), options: Object.assign(derivedOptions, options), request: urlOrRequest, signal: (options && options.signal) || urlOrRequest.signal, + pendingPromises, }; - return normalizedRequestObject; + return callLog; } if ( typeof urlOrRequest === 'string' || @@ -82,9 +80,11 @@ export function normalizeRequest(urlOrRequest, options, Request) { (typeof urlOrRequest === 'object' && 'href' in urlOrRequest) ) { return { + arguments: [urlOrRequest, options], url: normalizeUrl(urlOrRequest), - options, + options: options || {}, signal: options && options.signal, + pendingPromises, }; } if (typeof urlOrRequest === 'object') { diff --git a/packages/core/src/Route.js b/packages/core/src/Route.js index d32cf7e6..d2fb3bc3 100644 --- a/packages/core/src/Route.js +++ b/packages/core/src/Route.js @@ -1,8 +1,9 @@ //@type-check -import { builtInMatchers, isUrlMatcher, isFunctionMatcher } from './Matchers'; +import { builtInMatchers } from './Matchers'; import statusTextMap from './StatusTextMap'; /** @typedef {import('./Matchers').RouteMatcher} RouteMatcher */ +/** @typedef {import('./CallHistory').CallLog} CallLog */ /** @typedef {import('./Matchers').RouteMatcherFunction} RouteMatcherFunction */ /** @typedef {import('./Matchers').RouteMatcherUrl} RouteMatcherUrl */ /** @typedef {import('./Matchers').MatcherDefinition} MatcherDefinition */ @@ -28,7 +29,7 @@ import statusTextMap from './StatusTextMap'; /** @typedef {RouteResponseConfig | object} RouteResponseObjectData */ /** @typedef {Response | number| string | RouteResponseObjectData } RouteResponseData */ /** @typedef {Promise} RouteResponsePromise */ -/** @typedef {function(string, RequestInit, Request=): (RouteResponseData|RouteResponsePromise)} RouteResponseFunction */ +/** @typedef {function(CallLog): (RouteResponseData|RouteResponsePromise)} RouteResponseFunction */ /** @typedef {RouteResponseData | RouteResponsePromise | RouteResponseFunction} RouteResponse*/ /** @typedef {string} RouteName */ @@ -41,7 +42,7 @@ import statusTextMap from './StatusTextMap'; * @property {{ [key: string]: string }} [query] * @property {{ [key: string]: string }} [params] * @property {object} [body] - * @property {RouteMatcherFunction} [functionMatcher] + * @property {RouteMatcherFunction} [matcherFunction] * @property {RouteMatcher} [matcher] * @property {RouteMatcherUrl} [url] * @property {RouteResponse | RouteResponseFunction} [response] @@ -134,13 +135,6 @@ class Route { if (this.config.method) { this.config.method = this.config.method.toLowerCase(); } - if (isUrlMatcher(this.config.matcher)) { - this.config.url = this.config.matcher; - delete this.config.matcher; - } - if (isFunctionMatcher(this.config.matcher)) { - this.config.functionMatcher = this.config.matcher; - } } /** * @returns {void} @@ -155,8 +149,8 @@ class Route { })); this.config.usesBody = activeMatchers.some(({ usesBody }) => usesBody); /** @type {RouteMatcherFunction} */ - this.matcher = (url, options = {}, request) => - activeMatchers.every(({ matcher }) => matcher(url, options, request)); + this.matcher = (normalizedRequest) => + activeMatchers.every(({ matcher }) => matcher(normalizedRequest)); } /** * @returns {void} @@ -168,8 +162,8 @@ class Route { } const originalMatcher = this.matcher; let timesLeft = this.config.repeat; - this.matcher = (url, options, request) => { - const match = timesLeft && originalMatcher(url, options, request); + this.matcher = (callLog) => { + const match = timesLeft && originalMatcher(callLog); if (match) { timesLeft--; return true; diff --git a/packages/core/src/Router.js b/packages/core/src/Router.js index 7fed0edb..4bc8a0b3 100644 --- a/packages/core/src/Router.js +++ b/packages/core/src/Router.js @@ -11,7 +11,6 @@ import { isUrlMatcher, isFunctionMatcher } from './Matchers.js'; /** @typedef {import('./Matchers').RouteMatcher} RouteMatcher */ /** @typedef {import('./FetchMock').FetchMockConfig} FetchMockConfig */ /** @typedef {import('./FetchMock')} FetchMock */ -/** @typedef {import('./RequestUtils').NormalizedRequest} NormalizedRequest */ /** @typedef {import('./CallHistory').CallLog} CallLog */ /** @typedef {'body' |'headers' |'throws' |'status' |'redirectUrl' } ResponseConfigProp */ @@ -95,12 +94,10 @@ function shouldSendAsObject(responseInput) { } /** - * @param {RouteResponse} response - * @param {NormalizedRequest} normalizedRequest + * @param {CallLog} callLog * @returns */ -const resolveUntilResponseConfig = async (response, normalizedRequest) => { - const { url, options, request } = normalizedRequest; +const resolveUntilResponseConfig = async (callLog) => { // We want to allow things like // - function returning a Promise for a response // - delaying (using a timeout Promise) a function's execution to generate @@ -109,9 +106,11 @@ const resolveUntilResponseConfig = async (response, normalizedRequest) => { // or vice versa. So to keep it DRY, and flexible, we keep trying until we // have something that looks like neither Promise nor function //eslint-disable-next-line no-constant-condition + let response = callLog.route.config.response; + // eslint-disable-next-line no-constant-condition while (true) { if (typeof response === 'function') { - response = response(url, options, request); + response = response(callLog); } else if (isPromise(response)) { response = await response; // eslint-disable-line no-await-in-loop } else { @@ -121,6 +120,8 @@ const resolveUntilResponseConfig = async (response, normalizedRequest) => { }; export default class Router { + /** @type {Route[]} */ + routes = []; /** * @param {FetchMockConfig} fetchMockConfig * @param {object} [inheritedRoutes] @@ -145,26 +146,24 @@ export default class Router { /** * @param {CallLog} callLog - * @param {NormalizedRequest} normalizedRequest * @returns {Promise} */ - execute(callLog, normalizedRequest) { + execute(callLog) { // TODO make abort vs reject neater return new Promise(async (resolve, reject) => { const { url, options, request, pendingPromises } = callLog; - if (normalizedRequest.signal) { + if (callLog.signal) { const abort = () => { // TODO may need to bring that flushy thing back. // Add a test to combvine flush with abort // done(); reject(new DOMException('The operation was aborted.', 'AbortError')); }; - if (normalizedRequest.signal.aborted) { + if (callLog.signal.aborted) { abort(); } - normalizedRequest.signal.addEventListener('abort', abort); + callLog.signal.addEventListener('abort', abort); } - if (this.needsToReadBody(request)) { options.body = await options.body; } @@ -172,15 +171,13 @@ export default class Router { const routesToTry = this.fallbackRoute ? [...this.routes, this.fallbackRoute] : this.routes; - const route = routesToTry.find((route) => - route.matcher(url, options, request), - ); + const route = routesToTry.find((route) => route.matcher(callLog)); if (route) { try { callLog.route = route; const { response, responseOptions, responseInput } = - await this.generateResponse(route, callLog); + await this.generateResponse(callLog); const observableResponse = this.createObservableResponse( response, responseOptions, @@ -207,16 +204,12 @@ export default class Router { /** * - * @param {Route} route * @param {CallLog} callLog * @returns {Promise<{response: Response, responseOptions: ResponseInit, responseInput: RouteResponseConfig}>} */ // eslint-disable-next-line class-methods-use-this - async generateResponse(route, callLog) { - const responseInput = await resolveUntilResponseConfig( - route.config.response, - callLog, - ); + async generateResponse(callLog) { + const responseInput = await resolveUntilResponseConfig(callLog); // If the response is a pre-made Response, respond with it if (responseInput instanceof Response) { @@ -234,7 +227,7 @@ export default class Router { throw responseConfig.throws; } - return route.constructResponse(responseConfig); + return callLog.route.constructResponse(responseConfig); } /** * @@ -278,8 +271,8 @@ export default class Router { if (typeof response[name] === 'function') { //@ts-ignore return new Proxy(response[name], { - apply: (func, thisArg, args) => { - const result = func.apply(response, args); + apply: (matcherFunction, thisArg, args) => { + const result = matcherFunction.apply(response, args); if (result.then) { pendingPromises.push( result.catch(/** @type {function(): void} */ () => undefined), @@ -318,8 +311,10 @@ export default class Router { addRoute(matcher, response, nameOrOptions) { /** @type {RouteConfig} */ const config = {}; - if (isUrlMatcher(matcher) || isFunctionMatcher(matcher)) { - config.matcher = matcher; + if (isUrlMatcher(matcher)) { + config.url = matcher; + } else if (isFunctionMatcher(matcher)) { + config.matcherFunction = matcher; } else { Object.assign(config, matcher); } @@ -366,14 +361,7 @@ export default class Router { } this.fallbackRoute = new Route({ - matcher: (url, options) => { - if (this.config.warnOnFallback) { - console.warn( - `Unmatched ${(options && options.method) || 'GET'} to ${url}`, - ); // eslint-disable-line - } - return true; - }, + matcherFunction: () => true, response: response || 'ok', ...this.config, }); diff --git a/packages/core/src/__tests__/CallHistory.test.js b/packages/core/src/__tests__/CallHistory.test.js index 889e66dc..3b31bcdc 100644 --- a/packages/core/src/__tests__/CallHistory.test.js +++ b/packages/core/src/__tests__/CallHistory.test.js @@ -231,7 +231,7 @@ describe('CallHistory', () => { }); describe('filtering with a matcher', () => { - //TODO write a test that just makes it clear this is contracted out to Route + // TODO write a test that just makes it clear this is contracted out to Route // spy on route constructor, and then on matcher for that route it('should be able to filter with a url matcher', async () => { fm.catch(); @@ -362,11 +362,11 @@ describe('CallHistory', () => { it('clearHistory() resets count done-ness', async () => { fm = fetchMock.createInstance().route('http://a.com/', 200); await fm.fetchHandler('http://a.com/'); - expect(fm.done()).toBe(true); + expect(fm.callHistory.done()).toBe(true); fm.clearHistory(); - expect(fm.done()).toBe(false); + expect(fm.callHistory.done()).toBe(false); await fm.fetchHandler('http://a.com/'); - expect(fm.done()).toBe(true); + expect(fm.callHistory.done()).toBe(true); }); describe('where number of expected calls is not specified', () => { @@ -379,32 +379,32 @@ describe('CallHistory', () => { }); it('can expect at least one call to have been made to every defined route', () => { - expect(fm.done()).toBe(false); + expect(fm.callHistory.done()).toBe(false); fm.fetchHandler('http://a.com/'); - expect(fm.done()).toBe(false); + expect(fm.callHistory.done()).toBe(false); fm.fetchHandler('http://b.com/'); - expect(fm.done()).toBe(false); + expect(fm.callHistory.done()).toBe(false); fm.fetchHandler('http://c.com/'); - expect(fm.done()).toBe(true); + expect(fm.callHistory.done()).toBe(true); }); it('can expect a named route to be called at least once', () => { - expect(fm.done('a')).toBe(false); - expect(fm.done('b')).toBe(false); + expect(fm.callHistory.done('a')).toBe(false); + expect(fm.callHistory.done('b')).toBe(false); fm.fetchHandler('http://a.com/'); - expect(fm.done('a')).toBe(true); - expect(fm.done('b')).toBe(false); + expect(fm.callHistory.done('a')).toBe(true); + expect(fm.callHistory.done('b')).toBe(false); fm.fetchHandler('http://a.com/'); - expect(fm.done('a')).toBe(true); - expect(fm.done('b')).toBe(false); + expect(fm.callHistory.done('a')).toBe(true); + expect(fm.callHistory.done('b')).toBe(false); }); it('can expect multiple named routes to be called at least once each', () => { - expect(fm.done(['a', 'b'])).toBe(false); + expect(fm.callHistory.done(['a', 'b'])).toBe(false); fm.fetchHandler('http://a.com/'); - expect(fm.done(['a', 'b'])).toBe(false); + expect(fm.callHistory.done(['a', 'b'])).toBe(false); fm.fetchHandler('http://b.com/'); - expect(fm.done(['a', 'b'])).toBe(true); + expect(fm.callHistory.done(['a', 'b'])).toBe(true); }); }); describe('where number of expected calls is specified', () => { @@ -417,32 +417,32 @@ describe('CallHistory', () => { }); it('can expect a named route to be called specified number of times', () => { - expect(fm.done('a')).toBe(false); + expect(fm.callHistory.done('a')).toBe(false); fm.fetchHandler('http://a.com/'); - expect(fm.done('a')).toBe(false); + expect(fm.callHistory.done('a')).toBe(false); fm.fetchHandler('http://a.com/'); - expect(fm.done('a')).toBe(true); + expect(fm.callHistory.done('a')).toBe(true); }); it('can expect multiple named routes to be called specified number of times', () => { - expect(fm.done(['a', 'b'])).toBe(false); + expect(fm.callHistory.done(['a', 'b'])).toBe(false); fm.fetchHandler('http://a.com/'); fm.fetchHandler('http://b.com/'); - expect(fm.done(['a', 'b'])).toBe(false); + expect(fm.callHistory.done(['a', 'b'])).toBe(false); fm.fetchHandler('http://a.com/'); - expect(fm.done(['a', 'b'])).toBe(true); + expect(fm.callHistory.done(['a', 'b'])).toBe(true); }); it('can expect specific number of calls to have been made to every defined route', () => { - expect(fm.done()).toBe(false); + expect(fm.callHistory.done()).toBe(false); fm.fetchHandler('http://a.com/'); fm.fetchHandler('http://b.com/'); fm.fetchHandler('http://c.com/'); - expect(fm.done()).toBe(false); + expect(fm.callHistory.done()).toBe(false); fm.fetchHandler('http://a.com/'); - expect(fm.done()).toBe(false); + expect(fm.callHistory.done()).toBe(false); fm.fetchHandler('http://c.com/'); - expect(fm.done()).toBe(true); + expect(fm.callHistory.done()).toBe(true); }); it('can combine with routes where specific number of calls is unspecified', () => { @@ -452,15 +452,15 @@ describe('CallHistory', () => { .route('http://b.com/', 200, { name: 'b' }) .route('http://c.com/', 200); - expect(fm.done()).toBe(false); + expect(fm.callHistory.done()).toBe(false); fm.fetchHandler('http://a.com/'); fm.fetchHandler('http://b.com/'); fm.fetchHandler('http://c.com/'); - expect(fm.done()).toBe(false); - expect(fm.done(['a', 'b'])).toBe(false); + expect(fm.callHistory.done()).toBe(false); + expect(fm.callHistory.done(['a', 'b'])).toBe(false); fm.fetchHandler('http://a.com/'); - expect(fm.done()).toBe(true); - expect(fm.done(['a', 'b'])).toBe(true); + expect(fm.callHistory.done()).toBe(true); + expect(fm.callHistory.done(['a', 'b'])).toBe(true); }); }); }); diff --git a/packages/core/src/__tests__/FetchMock/flush.test.js b/packages/core/src/__tests__/FetchMock/flush.test.js index 6c7b426a..b5aa22b0 100644 --- a/packages/core/src/__tests__/FetchMock/flush.test.js +++ b/packages/core/src/__tests__/FetchMock/flush.test.js @@ -12,11 +12,11 @@ describe('FetchMockWrapper.js', () => { fm.route('http://one.com/', 200).route('http://two.com/', 200); // no expectation, but if it doesn't work then the promises will hang // or reject and the test will timeout - await fm.flush(); + await fm.callHistory.flush(); fm.fetchHandler('http://one.com'); - await fm.flush(); + await fm.callHistory.flush(); fm.fetchHandler('http://two.com'); - await fm.flush(); + await fm.callHistory.flush(); }); it('should resolve after fetches', async () => { @@ -25,7 +25,7 @@ describe('FetchMockWrapper.js', () => { fm.fetchHandler('http://example').then(() => { data = 'done'; }); - await fm.flush(); + await fm.callHistory.flush(); expect(data).toEqual('done'); }); @@ -38,7 +38,7 @@ describe('FetchMockWrapper.js', () => { data = 'done'; }); - await fm.flush(true); + await fm.callHistory.flush(true); expect(data).toEqual('done'); }); it('should resolve after .json() if waitForResponseMethods option passed', async () => { @@ -50,7 +50,7 @@ describe('FetchMockWrapper.js', () => { data = 'done'; }); - await fm.flush(true); + await fm.callHistory.flush(true); expect(data).toEqual('done'); }); @@ -63,7 +63,7 @@ describe('FetchMockWrapper.js', () => { data = 'done'; }); - await fm.flush(true); + await fm.callHistory.flush(true); expect(data).toEqual('done'); }); }); @@ -80,14 +80,14 @@ describe('FetchMockWrapper.js', () => { setTimeout(() => orderedResults.push('not flush'), 25); - await fm.flush(); + await fm.callHistory.flush(); orderedResults.push('flush'); expect(orderedResults).toEqual(['not flush', 'flush']); }); it('flush resolves on expected error', async () => { fm.route('http://one.com/', { throws: 'Problem in space' }); - await fm.flush(); + await fm.callHistory.flush(); }); }); }); diff --git a/packages/core/src/__tests__/FetchMock/response-construction.test.js b/packages/core/src/__tests__/FetchMock/response-construction.test.js index c4bb5655..95d5deb5 100644 --- a/packages/core/src/__tests__/FetchMock/response-construction.test.js +++ b/packages/core/src/__tests__/FetchMock/response-construction.test.js @@ -197,7 +197,7 @@ describe('response generation', () => { }); it('construct a response based on the request', async () => { - fm.route('*', (url, opts) => url + opts.headers.header); + fm.route('*', ({ url, options }) => url + options.headers.header); const res = await fm.fetchHandler('http://a.com/', { headers: { header: 'val' }, }); @@ -206,7 +206,7 @@ describe('response generation', () => { }); it('construct a response based on a Request instance', async () => { - fm.route('*', (url, opts, request) => request.json().then(({ a }) => a)); + fm.route('*', ({ request }) => request.json().then(({ a }) => a)); const res = await fm.fetchHandler( new fm.config.Request('http://a.com', { body: JSON.stringify({ a: 'b' }), diff --git a/packages/core/src/__tests__/FetchMock/response-negotiation.test.js b/packages/core/src/__tests__/FetchMock/response-negotiation.test.js index 8c6b2509..a930a3e7 100644 --- a/packages/core/src/__tests__/FetchMock/response-negotiation.test.js +++ b/packages/core/src/__tests__/FetchMock/response-negotiation.test.js @@ -9,7 +9,7 @@ describe('response negotiation', () => { }); it('function', async () => { - fm.route('*', (url) => url); + fm.route('*', ({ url }) => url); const res = await fm.fetchHandler('http://a.com/'); expect(res.status).toEqual(200); expect(await res.text()).toEqual('http://a.com/'); @@ -27,7 +27,7 @@ describe('response negotiation', () => { expect(res.status).toEqual(300); }); it('function that returns a Promise for a body', async () => { - fm.route('*', (url) => Promise.resolve(`test: ${url}`)); + fm.route('*', ({ url }) => Promise.resolve(`test: ${url}`)); const res = await fm.fetchHandler('http://a.com/'); expect(res.status).toEqual(200); expect(await res.text()).toEqual('test: http://a.com/'); @@ -36,7 +36,7 @@ describe('response negotiation', () => { it('Promise for a function that returns a response', async () => { fm.route( 'http://a.com/', - Promise.resolve((url) => `test: ${url}`), + Promise.resolve(({ url }) => `test: ${url}`), ); const res = await fm.fetchHandler('http://a.com/'); expect(res.status).toEqual(200); @@ -79,7 +79,7 @@ describe('response negotiation', () => { }); it('pass values to delayed function', async () => { - fm.route('*', (url) => `delayed: ${url}`, { + fm.route('*', ({ url }) => `delayed: ${url}`, { delay: 10, }); const req = fm.fetchHandler('http://a.com/'); @@ -209,7 +209,7 @@ describe('response negotiation', () => { await expectAbortError('http://a.com', { signal: getDelayedAbortController().signal, }); - expect(fm.done()).toBe(true); + expect(fm.callHistory.done()).toBe(true); }); it('will flush even when aborted', async () => { @@ -218,8 +218,8 @@ describe('response negotiation', () => { await expectAbortError('http://a.com', { signal: getDelayedAbortController().signal, }); - await fm.flush(); - expect(fm.done()).toBe(true); + await fm.callHistory.flush(); + expect(fm.callHistory.done()).toBe(true); }); }); }); diff --git a/packages/core/src/__tests__/Matchers/body.test.js b/packages/core/src/__tests__/Matchers/body.test.js index 911434b4..b5b23d13 100644 --- a/packages/core/src/__tests__/Matchers/body.test.js +++ b/packages/core/src/__tests__/Matchers/body.test.js @@ -1,14 +1,18 @@ import { describe, expect, it } from 'vitest'; import Route from '../../Route.js'; - +import Router from '../../Router.js'; +import { createCallLog } 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', () => { const route = new Route({ body: { foo: 'bar' }, response: 200 }); expect( - route.matcher('http://a.com/', { - method: 'POST', + route.matcher({ + url: 'http://a.com/', + options: { + method: 'POST', + }, }), ).toBe(false); }); @@ -17,34 +21,53 @@ describe('body matching', () => { const route = new Route({ body: { foo: 'bar' }, response: 200 }); expect( - route.matcher('http://a.com/', { - method: 'POST', - body: JSON.stringify({ foo: 'bar' }), + route.matcher({ + url: 'http://a.com/', + options: { + method: 'POST', + body: JSON.stringify({ foo: 'bar' }), + }, }), ).toBe(true); }); - it('should match when using Request', () => { - const route = new Route({ body: { foo: 'bar' }, response: 200 }); - - expect( - route.matcher( - new Request('http://a.com/', { - method: 'POST', - body: JSON.stringify({ foo: 'bar' }), - }), - ), - ).toBe(true); + // Note, using Router to test this as normalization of Request to normalizedRequest + // happens in there + // TODO Q: Should it? + // TODO need to split execute into 2?? + // 1. .route() (which can be used to test Routes with normalization applied up front) + // 2. .respond() + it('should match when using Request', async () => { + const route = new Route({ + body: { foo: 'bar' }, + response: 200, + Headers, + Response, + }); + const router = new Router({ Request, Headers }, { routes: [route] }); + const normalizedRequest = createCallLog( + new Request('http://a.com/', { + method: 'POST', + body: JSON.stringify({ foo: 'bar' }), + }), + undefined, + Request, + ); + const response = await router.execute(normalizedRequest, normalizedRequest); + expect(response.status).toBe(200); }); it('should match if body sent matches expected body', () => { const route = new Route({ body: { foo: 'bar' }, response: 200 }); expect( - route.matcher('http://a.com/', { - method: 'POST', - body: JSON.stringify({ foo: 'bar' }), - headers: { 'Content-Type': 'application/json' }, + route.matcher({ + url: 'http://a.com/', + options: { + method: 'POST', + body: JSON.stringify({ foo: 'bar' }), + headers: { 'Content-Type': 'application/json' }, + }, }), ).toBe(true); }); @@ -53,10 +76,13 @@ describe('body matching', () => { const route = new Route({ body: { foo: 'bar' }, response: 200 }); expect( - route.matcher('http://a.com/', { - method: 'POST', - body: JSON.stringify({ foo: 'woah!!!' }), - headers: { 'Content-Type': 'application/json' }, + route.matcher({ + url: 'http://a.com/', + options: { + method: 'POST', + body: JSON.stringify({ foo: 'woah!!!' }), + headers: { 'Content-Type': 'application/json' }, + }, }), ).toBe(false); }); @@ -65,10 +91,13 @@ describe('body matching', () => { const route = new Route({ body: { foo: 'bar' }, response: 200 }); expect( - route.matcher('http://a.com/', { - method: 'POST', - body: new ArrayBuffer(8), - headers: { 'Content-Type': 'application/json' }, + route.matcher({ + url: 'http://a.com/', + options: { + method: 'POST', + body: new ArrayBuffer(8), + headers: { 'Content-Type': 'application/json' }, + }, }), ).toBe(false); }); @@ -84,19 +113,22 @@ describe('body matching', () => { }); expect( - route.matcher('http://a.com/', { - method: 'POST', - body: JSON.stringify({ - baz: 'qux', - foo: 'bar', - }), - headers: { 'Content-Type': 'application/json' }, + route.matcher({ + url: 'http://a.com/', + options: { + method: 'POST', + body: JSON.stringify({ + baz: 'qux', + foo: 'bar', + }), + headers: { 'Content-Type': 'application/json' }, + }, }), ).toBe(true); }); // TODO - I think this shoudl actually throw - it('should ignore the body option matcher if request was GET', () => { + it.skip('should ignore the body option matcher if request was GET', () => { const route = new Route({ body: { foo: 'bar', @@ -106,7 +138,7 @@ describe('body matching', () => { response: 200, }); - expect(route.matcher('http://a.com/')).toBe(true); + expect(route.matcher({ url: 'http://a.com/' })).toBe(true); }); describe('partial body matching', () => { @@ -117,9 +149,12 @@ describe('body matching', () => { response: 200, }); expect( - route.matcher('http://a.com', { - method: 'POST', - body: JSON.stringify({ ham: 'sandwich', egg: 'mayonaise' }), + route.matcher({ + url: 'http://a.com', + options: { + method: 'POST', + body: JSON.stringify({ ham: 'sandwich', egg: 'mayonaise' }), + }, }), ).toBe(true); }); @@ -131,11 +166,14 @@ describe('body matching', () => { response: 200, }); expect( - route.matcher('http://a.com', { - method: 'POST', - body: JSON.stringify({ - meal: { ham: 'sandwich', egg: 'mayonaise' }, - }), + route.matcher({ + url: 'http://a.com', + options: { + method: 'POST', + body: JSON.stringify({ + meal: { ham: 'sandwich', egg: 'mayonaise' }, + }), + }, }), ).toBe(true); }); @@ -147,9 +185,12 @@ describe('body matching', () => { response: 200, }); expect( - route.matcher('http://a.com', { - method: 'POST', - body: JSON.stringify({ meal: { ham: 'sandwich' } }), + route.matcher({ + url: 'http://a.com', + options: { + method: 'POST', + body: JSON.stringify({ meal: { ham: 'sandwich' } }), + }, }), ).toBe(false); }); @@ -161,9 +202,12 @@ describe('body matching', () => { response: 200, }); expect( - route.matcher('http://a.com', { - method: 'POST', - body: JSON.stringify({ ham: [1, 2, 3] }), + route.matcher({ + url: 'http://a.com', + options: { + method: 'POST', + body: JSON.stringify({ ham: [1, 2, 3] }), + }, }), ).toBe(true); }); @@ -175,9 +219,12 @@ describe('body matching', () => { response: 200, }); expect( - route.matcher('http://a.com', { - method: 'POST', - body: JSON.stringify({ ham: [1, 2, 3] }), + route.matcher({ + url: 'http://a.com', + options: { + method: 'POST', + body: JSON.stringify({ ham: [1, 2, 3] }), + }, }), ).toBe(false); }); diff --git a/packages/core/src/__tests__/Matchers/express.test.js b/packages/core/src/__tests__/Matchers/express.test.js index 4973d6db..27d31e9a 100644 --- a/packages/core/src/__tests__/Matchers/express.test.js +++ b/packages/core/src/__tests__/Matchers/express.test.js @@ -4,42 +4,77 @@ import Route from '../../Route.js'; describe('express path parameter matching', () => { it('can match a path parameters', () => { const route = new Route({ - matcher: 'express:/type/:instance', + url: 'express:/type/:instance', response: 200, params: { instance: 'b' }, }); - expect(route.matcher('/')).toBe(false); - expect(route.matcher('/type/a')).toBe(false); - expect(route.matcher('/type/b')).toBe(true); + expect(route.matcher({ url: '/' })).toBe(false); + expect(route.matcher({ url: '/type/a' })).toBe(false); + expect(route.matcher({ url: '/type/b' })).toBe(true); }); it('can match multiple path parameters', () => { const route = new Route({ - matcher: 'express:/:type/:instance', + url: 'express:/:type/:instance', response: 200, params: { instance: 'b', type: 'cat' }, }); - expect(route.matcher('/')).toBe(false); - expect(route.matcher('/dog/a')).toBe(false); - expect(route.matcher('/cat/a')).toBe(false); - expect(route.matcher('/dog/b')).toBe(false); - expect(route.matcher('/cat/b')).toBe(true); + expect(route.matcher({ url: '/' })).toBe(false); + expect(route.matcher({ url: '/dog/a' })).toBe(false); + expect(route.matcher({ url: '/cat/a' })).toBe(false); + expect(route.matcher({ url: '/dog/b' })).toBe(false); + expect(route.matcher({ url: '/cat/b' })).toBe(true); }); it('can match a path parameter on a full url', () => { const route = new Route({ - matcher: 'express:/type/:instance', + url: 'express:/type/:instance', response: 200, params: { instance: 'b' }, }); - expect(route.matcher('http://site.com/')).toBe(false); - expect(route.matcher('http://site.com/type/a')).toBe(false); - expect(route.matcher('http://site.com/type/b')).toBe(true); + expect(route.matcher({ url: 'http://site.com/' })).toBe(false); + expect(route.matcher({ url: 'http://site.com/type/a' })).toBe(false); + expect(route.matcher({ url: 'http://site.com/type/b' })).toBe(true); }); it('can match fully qualified url', () => { - const route = new Route({ matcher: 'express:/apps/:id', response: 200 }); + const route = new Route({ url: 'express:/apps/:id', response: 200 }); - expect(route.matcher('https://api.example.com/apps/abc')).toBe(true); + expect(route.matcher({ url: 'https://api.example.com/apps/abc' })).toBe( + true, + ); + }); + + it('can match based on the existence, not value, of a parameter', () => { + const route = new Route({ + url: 'express:/type/:instance', + response: 200, + }); + expect(route.matcher({ url: '/nottype/a' })).toBe(false); + expect(route.matcher({ url: '/type/a' })).toBe(true); + }); + it('writes parameter values to the callLog', () => { + const route = new Route({ + url: 'express:/type/:instance', + response: 200, + params: { instance: 'b' }, + }); + const callLog = { url: '/type/a' }; + route.matcher(callLog); + expect(callLog.expressParams).toEqual({ instance: 'a' }); + + const callLog2 = { url: '/type/b' }; + route.matcher(callLog2); + expect(callLog2.expressParams).toEqual({ instance: 'b' }); + }); + + it('writes parameter values to the callLog even if not matched on', () => { + const route = new Route({ + url: 'express:/type/:instance', + response: 200, + }); + const callLog = { url: '/type/a' }; + route.matcher(callLog); + expect(callLog.expressParams).toEqual({ instance: 'a' }); }); }); diff --git a/packages/core/src/__tests__/Matchers/function.test.js b/packages/core/src/__tests__/Matchers/function.test.js index 0bc6d4f2..7efc0662 100644 --- a/packages/core/src/__tests__/Matchers/function.test.js +++ b/packages/core/src/__tests__/Matchers/function.test.js @@ -4,58 +4,77 @@ import Route from '../../Route.js'; describe('function matching', () => { it('match using custom function', () => { const route = new Route({ - matcher: (url, opts) => + matcherFunction: ({ url, options }) => url.indexOf('logged-in') > -1 && - opts && - opts.headers && - opts.headers.authorized === true, + options && + options.headers && + options.headers.authorized === true, response: 200, }); expect( - route.matcher('http://a.com/12345', { - headers: { authorized: true }, + route.matcher({ + url: 'http://a.com/12345', + options: { + headers: { authorized: true }, + }, }), ).toBe(false); - expect(route.matcher('http://a.com/logged-in')).toBe(false); + expect(route.matcher({ url: 'http://a.com/logged-in' })).toBe(false); expect( - route.matcher('http://a.com/logged-in', { - headers: { authorized: true }, + route.matcher({ + url: 'http://a.com/logged-in', + options: { + headers: { authorized: true }, + }, }), ).toBe(true); }); it('match using custom function using request body', () => { const route = new Route({ - matcher: (url, opts) => opts.body === 'a string', + matcherFunction: (req) => { + return req.options.body === 'a string'; + }, response: 200, }); - expect(route.matcher('http://a.com/logged-in')).toBe(false); + expect(route.matcher({ url: 'http://a.com/logged-in', options: {} })).toBe( + false, + ); expect( - route.matcher('http://a.com/logged-in', { - method: 'post', - body: 'a string', + route.matcher({ + url: 'http://a.com/logged-in', + options: { + method: 'post', + body: 'a string', + }, }), ).toBe(true); }); it('match using custom function alongside other matchers', () => { const route = new Route({ - matcher: 'end:profile', + url: 'end:profile', response: 200, - functionMatcher: (url, opts) => - opts && opts.headers && opts.headers.authorized === true, + matcherFunction: ({ options }) => + options && options.headers && options.headers.authorized === true, }); - expect(route.matcher('http://a.com/profile')).toBe(false); + expect(route.matcher({ url: 'http://a.com/profile' })).toBe(false); expect( - route.matcher('http://a.com/not', { - headers: { authorized: true }, + route.matcher({ + url: 'http://a.com/not', + options: { + headers: { authorized: true }, + }, }), ).toBe(false); expect( - route.matcher('http://a.com/profile', { - headers: { authorized: true }, + route.matcher({ + url: 'http://a.com/profile', + options: { + headers: { authorized: true }, + }, }), ).toBe(true); }); diff --git a/packages/core/src/__tests__/Matchers/header.js b/packages/core/src/__tests__/Matchers/header.js index a95dc27a..1f64b586 100644 --- a/packages/core/src/__tests__/Matchers/header.js +++ b/packages/core/src/__tests__/Matchers/header.js @@ -9,7 +9,7 @@ describe('header matching', () => { response: 200, }); - expect(route.matcher('http://a.com/')).toBe(true); + expect(route.matcher({ url: 'http://a.com/' })).toBe(true); }); it("not match when headers don't match", () => { @@ -20,8 +20,11 @@ describe('header matching', () => { }); expect( - route.matcher('http://a.com/', { - headers: { a: 'c' }, + route.matcher({ + url: 'http://a.com/', + options: { + headers: { a: 'c' }, + }, }), ).toBe(false); }); @@ -34,8 +37,11 @@ describe('header matching', () => { }); expect( - route.matcher('http://a.com/', { - headers: { a: 'b' }, + route.matcher({ + url: 'http://a.com/', + options: { + headers: { a: 'b' }, + }, }), ).toBe(true); }); @@ -48,8 +54,11 @@ describe('header matching', () => { }); expect( - route.matcher('http://a.com/', { - headers: { A: 'b' }, + route.matcher({ + url: 'http://a.com/', + options: { + headers: { A: 'b' }, + }, }), ).toBe(true); }); @@ -63,8 +72,11 @@ describe('header matching', () => { }); expect( - route.matcher('http://a.com/', { - headers: { a: ['b', 'c'] }, + route.matcher({ + url: 'http://a.com/', + options: { + headers: { a: ['b', 'c'] }, + }, }), ).toBe(true); }); @@ -77,8 +89,11 @@ describe('header matching', () => { }); expect( - route.matcher('http://a.com/', { - headers: { a: ['b', 'c'] }, + route.matcher({ + url: 'http://a.com/', + options: { + headers: { a: ['b', 'c'] }, + }, }), ).toBe(false); }); @@ -91,8 +106,11 @@ describe('header matching', () => { }); expect( - route.matcher('http://a.com/', { - headers: { a: 'b', c: 'd' }, + route.matcher({ + url: 'http://a.com/', + options: { + headers: { a: 'b', c: 'd' }, + }, }), ).toBe(true); }); @@ -105,8 +123,11 @@ describe('header matching', () => { }); expect( - route.matcher('http://a.com/', { - headers: { a: 'b' }, + route.matcher({ + url: 'http://a.com/', + options: { + headers: { a: 'b' }, + }, }), ).toBe(false); }); @@ -119,8 +140,11 @@ describe('header matching', () => { }); expect( - route.matcher('http://a.com/', { - headers: new Headers({ a: 'b' }), + route.matcher({ + url: 'http://a.com/', + options: { + headers: new Headers({ a: 'b' }), + }, }), ).toBe(true); }); @@ -132,10 +156,13 @@ describe('header matching', () => { headers: { a: 'b' }, }); - expect(route.matcher('http://domain.com/person')).toBe(false); + expect(route.matcher({ url: 'http://domain.com/person' })).toBe(false); expect( - route.matcher('http://domain.com/person', { - headers: { a: 'b' }, + route.matcher({ + url: 'http://domain.com/person', + options: { + headers: { a: 'b' }, + }, }), ).toBe(true); }); @@ -162,8 +189,11 @@ describe('header matching', () => { }); expect( - route.matcher('http://a.com', { - headers: new MyHeaders({ a: 'b' }), + route.matcher({ + url: 'http://a.com', + options: { + headers: new MyHeaders({ a: 'b' }), + }, }), ).toBe(true); }); diff --git a/packages/core/src/__tests__/Matchers/method.test.js b/packages/core/src/__tests__/Matchers/method.test.js index 92eb6c8d..d30c8203 100644 --- a/packages/core/src/__tests__/Matchers/method.test.js +++ b/packages/core/src/__tests__/Matchers/method.test.js @@ -4,52 +4,76 @@ import Route from '../../Route.js'; describe('method matching', () => { it('match any method by default', () => { - const route = new Route({ matcher: '*', response: 200 }); + const route = new Route({ url: '*', response: 200 }); - expect(route.matcher('http://a.com/', { method: 'GET' })).toBe(true); - expect(route.matcher('http://a.com/', { method: 'POST' })).toBe(true); + expect( + route.matcher({ url: 'http://a.com/', options: { method: 'GET' } }), + ).toBe(true); + expect( + route.matcher({ url: 'http://a.com/', options: { method: 'POST' } }), + ).toBe(true); }); it('configure an exact method to match', () => { const route = new Route({ method: 'POST', response: 200 }); - expect(route.matcher('http://a.com/', { method: 'GET' })).toBe(false); - expect(route.matcher('http://a.com/', { method: 'POST' })).toBe(true); + expect( + route.matcher({ url: 'http://a.com/', options: { method: 'GET' } }), + ).toBe(false); + expect( + route.matcher({ url: 'http://a.com/', options: { method: 'POST' } }), + ).toBe(true); }); it('match implicit GET', () => { const route = new Route({ method: 'GET', response: 200 }); - expect(route.matcher('http://a.com/')).toBe(true); + expect(route.matcher({ url: 'http://a.com/' })).toBe(true); }); it('be case insensitive', () => { const upperCaseRoute = new Route({ method: 'POST', response: 200 }); const lowerCaseRoute = new Route({ method: 'post', response: 200 }); - expect(upperCaseRoute.matcher('http://a.com/', { method: 'post' })).toBe( - true, - ); - expect(upperCaseRoute.matcher('http://a.com/', { method: 'POST' })).toBe( - true, - ); - expect(lowerCaseRoute.matcher('http://a.com/', { method: 'post' })).toBe( - true, - ); - expect(lowerCaseRoute.matcher('http://a.com/', { method: 'POST' })).toBe( - true, - ); + expect( + upperCaseRoute.matcher({ + url: 'http://a.com/', + options: { method: 'post' }, + }), + ).toBe(true); + expect( + upperCaseRoute.matcher({ + url: 'http://a.com/', + options: { method: 'POST' }, + }), + ).toBe(true); + expect( + lowerCaseRoute.matcher({ + url: 'http://a.com/', + options: { method: 'post' }, + }), + ).toBe(true); + expect( + lowerCaseRoute.matcher({ + url: 'http://a.com/', + options: { method: 'POST' }, + }), + ).toBe(true); }); it('can be used alongside function matchers', () => { const route = new Route({ method: 'POST', - matcher: (url) => /a\.com/.test(url), + matcherFunction: ({ url }) => /a\.com/.test(url), response: 200, }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://b.com', { method: 'POST' })).toBe(false); - expect(route.matcher('http://a.com', { method: 'POST' })).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect( + route.matcher({ url: 'http://b.com', options: { method: 'POST' } }), + ).toBe(false); + expect( + route.matcher({ url: 'http://a.com', options: { method: 'POST' } }), + ).toBe(true); }); }); diff --git a/packages/core/src/__tests__/Matchers/query-string.test.js b/packages/core/src/__tests__/Matchers/query-string.test.js index 127a3741..363c301d 100644 --- a/packages/core/src/__tests__/Matchers/query-string.test.js +++ b/packages/core/src/__tests__/Matchers/query-string.test.js @@ -8,8 +8,8 @@ describe('query string matching', () => { response: 200, }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=b&c=d')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=b&c=d' })).toBe(true); }); it('match a query string against a URL object', () => { @@ -20,7 +20,7 @@ describe('query string matching', () => { const url = new URL('http://a.com/path'); url.searchParams.append('a', 'b'); url.searchParams.append('c', 'd'); - expect(route.matcher(url)).toBe(true); + expect(route.matcher({ url })).toBe(true); }); it('match a query string against a relative path', () => { @@ -29,7 +29,7 @@ describe('query string matching', () => { response: 200, }); const url = '/path?a=b'; - expect(route.matcher(url)).toBe(true); + expect(route.matcher({ url })).toBe(true); }); it('match multiple query strings', () => { @@ -38,10 +38,10 @@ describe('query string matching', () => { response: 200, }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=b')).toBe(false); - expect(route.matcher('http://a.com?a=b&c=d')).toBe(true); - expect(route.matcher('http://a.com?c=d&a=b')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=b&c=d' })).toBe(true); + expect(route.matcher({ url: 'http://a.com?c=d&a=b' })).toBe(true); }); it('ignore irrelevant query strings', () => { @@ -50,7 +50,7 @@ describe('query string matching', () => { response: 200, }); - expect(route.matcher('http://a.com?a=b&c=d&e=f')).toBe(true); + expect(route.matcher({ url: 'http://a.com?a=b&c=d&e=f' })).toBe(true); }); it('match an empty query string', () => { const route = new Route({ @@ -58,8 +58,8 @@ describe('query string matching', () => { response: 200, }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=' })).toBe(true); }); describe('value coercion', () => { @@ -70,8 +70,8 @@ describe('query string matching', () => { }, response: 200, }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=1')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=1' })).toBe(true); }); it('coerce floats to strings and match', () => { @@ -81,8 +81,8 @@ describe('query string matching', () => { }, response: 200, }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=1.2')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=1.2' })).toBe(true); }); it('coerce booleans to strings and match', () => { @@ -99,10 +99,10 @@ describe('query string matching', () => { response: 200, }); - expect(trueRoute.matcher('http://a.com')).toBe(false); - expect(falseRoute.matcher('http://a.com')).toBe(false); - expect(trueRoute.matcher('http://a.com?a=true')).toBe(true); - expect(falseRoute.matcher('http://a.com?b=false')).toBe(true); + expect(trueRoute.matcher({ url: 'http://a.com' })).toBe(false); + expect(falseRoute.matcher({ url: 'http://a.com' })).toBe(false); + expect(trueRoute.matcher({ url: 'http://a.com?a=true' })).toBe(true); + expect(falseRoute.matcher({ url: 'http://a.com?b=false' })).toBe(true); }); it('coerce undefined to an empty string and match', () => { @@ -112,8 +112,8 @@ describe('query string matching', () => { }, response: 200, }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=' })).toBe(true); }); it('coerce null to an empty string and match', () => { @@ -123,8 +123,8 @@ describe('query string matching', () => { }, response: 200, }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=' })).toBe(true); }); it('coerce an object to an empty string and match', () => { @@ -134,8 +134,8 @@ describe('query string matching', () => { }, response: 200, }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=' })).toBe(true); }); it('can match a query string with different value types', () => { @@ -150,9 +150,11 @@ describe('query string matching', () => { }, }); - expect(route.matcher('http://a.com')).toBe(false); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); expect( - route.matcher('http://a.com?t=true&f=false&u=&num=1&arr=a&arr='), + route.matcher({ + url: 'http://a.com?t=true&f=false&u=&num=1&arr=a&arr=', + }), ).toBe(true); }); }); @@ -162,26 +164,26 @@ describe('query string matching', () => { it('match repeated query strings', () => { const route = new Route({ query: { a: ['b', 'c'] }, response: 200 }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=b')).toBe(false); - expect(route.matcher('http://a.com?a=b&a=c')).toBe(true); - expect(route.matcher('http://a.com?a=b&a=c&a=d')).toBe(false); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=b&a=c' })).toBe(true); + expect(route.matcher({ url: 'http://a.com?a=b&a=c&a=d' })).toBe(false); }); it('match repeated query strings in any order', () => { const route = new Route({ query: { a: ['b', 'c'] }, response: 200 }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=b&a=c')).toBe(true); - expect(route.matcher('http://a.com?a=c&a=b')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=b&a=c' })).toBe(true); + expect(route.matcher({ url: 'http://a.com?a=c&a=b' })).toBe(true); }); it('match a query string array of length 1', () => { const route = new Route({ query: { a: ['b'] }, response: 200 }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=b')).toBe(true); - expect(route.matcher('http://a.com?a=b&a=c')).toBe(false); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(true); + expect(route.matcher({ url: 'http://a.com?a=b&a=c' })).toBe(false); }); it('match a repeated query string with an empty value', () => { @@ -190,9 +192,9 @@ describe('query string matching', () => { response: 200, }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=b')).toBe(false); - expect(route.matcher('http://a.com?a=b&a=')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=b&a=' })).toBe(true); }); }); @@ -205,10 +207,10 @@ describe('query string matching', () => { query: { a: 'b' }, }); - expect(route.matcher('http://a.com?c=d')).toBe(false); - expect(route.matcher('http://a.com?a=b')).toBe(false); - expect(route.matcher('http://a.com?c=d&a=b')).toBe(true); - expect(route.matcher('http://a.com?a=b&c=d')).toBe(true); + expect(route.matcher({ url: 'http://a.com?c=d' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?c=d&a=b' })).toBe(true); + expect(route.matcher({ url: 'http://a.com?a=b&c=d' })).toBe(true); }); it('can be used alongside function matchers', () => { @@ -218,8 +220,8 @@ describe('query string matching', () => { query: { a: 'b' }, }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=b')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(true); }); }); }); diff --git a/packages/core/src/__tests__/Matchers/route-config-object.test.js b/packages/core/src/__tests__/Matchers/route-config-object.test.js index b43877c5..b9b7c4cd 100644 --- a/packages/core/src/__tests__/Matchers/route-config-object.test.js +++ b/packages/core/src/__tests__/Matchers/route-config-object.test.js @@ -5,32 +5,38 @@ import Route from '../../Route.js'; // as it's mainly about the shape of optiosn passed into to addRoute describe('matcher object', () => { it('use matcher object with matcher property', () => { - const route = new Route({ matcher: 'http://a.com', response: 200 }); - expect(route.matcher('http://a.com')).toBe(true); + const route = new Route({ url: 'http://a.com', response: 200 }); + expect(route.matcher({ url: 'http://a.com' })).toBe(true); }); it('use matcher object with url property', () => { const route = new Route({ url: 'http://a.com', response: 200 }); - expect(route.matcher('http://a.com')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(true); }); - it('can use matcher and url simultaneously', () => { + it('can use function and url simultaneously', () => { const route = new Route({ url: 'end:path', - matcher: (url, opts) => - opts && opts.headers && opts.headers.authorized === true, + matcherFunction: ({ options }) => + options && options.headers && options.headers.authorized === true, response: 200, }); - expect(route.matcher('http://a.com/path')).toBe(false); + expect(route.matcher({ url: 'http://a.com/path' })).toBe(false); expect( - route.matcher('http://a.com', { - headers: { authorized: true }, + route.matcher({ + url: 'http://a.com', + options: { + headers: { authorized: true }, + }, }), ).toBe(false); expect( - route.matcher('http://a.com/path', { - headers: { authorized: true }, + route.matcher({ + url: 'http://a.com/path', + options: { + headers: { authorized: true }, + }, }), ).toBe(true); }); @@ -38,14 +44,14 @@ describe('matcher object', () => { // TODO this shoudl probably be an error it.skip('if no url provided, match any url', () => { const route = new Route({ response: 200 }); - expect(route.matcher('http://a.com')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(true); }); - //TODO be strionger on discouraging this - it.skip('deprecated message on using functionMatcher (prefer matcher)', () => { + //TODO be stronger on discouraging this + it.skip('deprecated message on using matcherFunction (prefer matcher)', () => { new Route({ url: 'end:profile', - functionMatcher: (url, opts) => + matcherFunction: (url, opts) => opts && opts.headers && opts.headers.authorized === true, response: 200, }); @@ -59,13 +65,19 @@ describe('matcher object', () => { }); expect( - route.matcher('http://a.com', { - headers: { a: 'c' }, + route.matcher({ + url: 'http://a.com', + options: { + headers: { a: 'c' }, + }, }), ).toBe(false); expect( - route.matcher('http://a.com', { - headers: { a: 'b' }, + route.matcher({ + url: 'http://a.com', + options: { + headers: { a: 'b' }, + }, }), ).toBe(true); }); @@ -77,8 +89,8 @@ describe('matcher object', () => { response: 200, }); - expect(route.matcher('http://a.com')).toBe(false); - expect(route.matcher('http://a.com?a=b')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(false); + expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(true); }); it('can match path parameter', () => { @@ -87,37 +99,44 @@ describe('matcher object', () => { params: { var: 'b' }, response: 200, }); - expect(route.matcher('/')).toBe(false); - expect(route.matcher('/type/a')).toBe(false); - expect(route.matcher('/type/b')).toBe(true); + expect(route.matcher({ url: '/' })).toBe(false); + expect(route.matcher({ url: '/type/a' })).toBe(false); + expect(route.matcher({ url: '/type/b' })).toBe(true); }); it('can match method', () => { const route = new Route({ method: 'POST', response: 200 }); - expect(route.matcher('http://a.com', { method: 'GET' })).toBe(false); - expect(route.matcher('http://a.com', { method: 'POST' })).toBe(true); + expect( + route.matcher({ url: 'http://a.com', options: { method: 'GET' } }), + ).toBe(false); + expect( + route.matcher({ url: 'http://a.com', options: { method: 'POST' } }), + ).toBe(true); }); it('can match body', () => { const route = new Route({ body: { foo: 'bar' }, response: 200 }); expect( - route.matcher('http://a.com', { - method: 'POST', + route.matcher({ + url: 'http://a.com', + options: { + method: 'POST', + }, }), ).toBe(false); expect( - route.matcher('http://a.com', { - method: 'POST', - body: JSON.stringify({ foo: 'bar' }), - headers: { 'Content-Type': 'application/json' }, + route.matcher({ + url: 'http://a.com', + options: { + method: 'POST', + body: JSON.stringify({ foo: 'bar' }), + headers: { 'Content-Type': 'application/json' }, + }, }), ).toBe(true); }); - // TODO new tests for how multiple routes that match can be addeed - it.skip('support setting overwrite routes on matcher parameter', () => {}); - it('support setting matchPartialBody on matcher parameter', () => { const route = new Route({ body: { a: 1 }, @@ -125,9 +144,12 @@ describe('matcher object', () => { response: 200, }); expect( - route.matcher('http://a.com', { - method: 'POST', - body: JSON.stringify({ a: 1, b: 2 }), + route.matcher({ + url: 'http://a.com', + options: { + method: 'POST', + body: JSON.stringify({ a: 1, b: 2 }), + }, }), ).toBe(true); }); diff --git a/packages/core/src/__tests__/Matchers/url.test.js b/packages/core/src/__tests__/Matchers/url.test.js index f49bcfa3..938efb4b 100644 --- a/packages/core/src/__tests__/Matchers/url.test.js +++ b/packages/core/src/__tests__/Matchers/url.test.js @@ -3,184 +3,188 @@ import Route from '../../Route.js'; describe('url matching', () => { it('match exact strings', () => { - const route = new Route({ matcher: 'http://a.com/path', response: 200 }); - expect(route.matcher('http://a.com/pat')).toBe(false); - expect(route.matcher('http://a.com/paths')).toBe(false); - expect(route.matcher('http://a.co/path')).toBe(false); - expect(route.matcher('http://a.com/path')).toBe(true); - expect(route.matcher('//a.com/path')).toBe(true); + const route = new Route({ url: 'http://a.com/path', response: 200 }); + expect(route.matcher({ url: 'http://a.com/pat' })).toBe(false); + expect(route.matcher({ url: 'http://a.com/paths' })).toBe(false); + expect(route.matcher({ url: 'http://a.co/path' })).toBe(false); + expect(route.matcher({ url: 'http://a.com/path' })).toBe(true); + expect(route.matcher({ url: '//a.com/path' })).toBe(true); }); it('match string objects', () => { - const route = new Route({ matcher: 'http://a.com/path', response: 200 }); - expect(route.matcher(new String('http://a.com/path'))).toBe(true); // eslint-disable-line no-new-wrappers + const route = new Route({ url: 'http://a.com/path', response: 200 }); + expect(route.matcher({ url: new String('http://a.com/path') })).toBe(true); // eslint-disable-line no-new-wrappers }); it('match exact strings with relative url', () => { - const route = new Route({ matcher: '/path', response: 200 }); - expect(route.matcher('/pat')).toBe(false); - expect(route.matcher('/paths')).toBe(false); - expect(route.matcher('/path')).toBe(true); + const route = new Route({ url: '/path', response: 200 }); + expect(route.matcher({ url: '/pat' })).toBe(false); + expect(route.matcher({ url: '/paths' })).toBe(false); + expect(route.matcher({ url: '/path' })).toBe(true); }); it('match exact string against URL object', () => { - const route = new Route({ matcher: 'http://a.com/path', response: 200 }); + const route = new Route({ url: 'http://a.com/path', response: 200 }); const url = new URL('http://a.com/path'); - expect(route.matcher(url)).toBe(true); + expect(route.matcher({ url })).toBe(true); }); it('match using URL object as matcher', () => { const url = new URL('http://a.com/path'); - const route = new Route({ matcher: url, response: 200 }); - expect(route.matcher('http://a.com/path')).toBe(true); + const route = new Route({ url: url, response: 200 }); + expect(route.matcher({ url: 'http://a.com/path' })).toBe(true); }); it('match begin: keyword', () => { const route = new Route({ - matcher: 'begin:http://a.com/path', + url: 'begin:http://a.com/path', response: 200, }); - expect(route.matcher('http://b.com/path')).toBe(false); - expect(route.matcher('http://a.com/pat')).toBe(false); - expect(route.matcher('http://a.com/path')).toBe(true); - expect(route.matcher('http://a.com/paths')).toBe(true); + expect(route.matcher({ url: 'http://b.com/path' })).toBe(false); + expect(route.matcher({ url: 'http://a.com/pat' })).toBe(false); + expect(route.matcher({ url: 'http://a.com/path' })).toBe(true); + expect(route.matcher({ url: 'http://a.com/paths' })).toBe(true); }); it('match end: keyword', () => { - const route = new Route({ matcher: 'end:com/path', response: 200 }); - expect(route.matcher('http://a.com/paths')).toBe(false); - expect(route.matcher('http://a.com/pat')).toBe(false); - expect(route.matcher('http://a.com/path')).toBe(true); - expect(route.matcher('http://b.com/path')).toBe(true); + const route = new Route({ url: 'end:com/path', response: 200 }); + expect(route.matcher({ url: 'http://a.com/paths' })).toBe(false); + expect(route.matcher({ url: 'http://a.com/pat' })).toBe(false); + expect(route.matcher({ url: 'http://a.com/path' })).toBe(true); + expect(route.matcher({ url: 'http://b.com/path' })).toBe(true); }); it('match glob: keyword', () => { - const route = new Route({ matcher: 'glob:/its/*/*', response: 200 }); - expect(route.matcher('/its/alive')).toBe(false); - expect(route.matcher('/its/a/boy')).toBe(true); - expect(route.matcher('/its/a/girl')).toBe(true); + const route = new Route({ url: 'glob:/its/*/*', response: 200 }); + expect(route.matcher({ url: '/its/alive' })).toBe(false); + expect(route.matcher({ url: '/its/a/boy' })).toBe(true); + expect(route.matcher({ url: '/its/a/girl' })).toBe(true); }); it('match express: keyword', () => { - const route = new Route({ matcher: 'express:/its/:word', response: 200 }); + const route = new Route({ url: 'express:/its/:word', response: 200 }); - expect(route.matcher('/its')).toBe(false); - expect(route.matcher('/its/')).toBe(false); - expect(route.matcher('/its/a/girl')).toBe(false); - expect(route.matcher('/its/alive')).toBe(true); + expect(route.matcher({ url: '/its' })).toBe(false); + expect(route.matcher({ url: '/its/' })).toBe(false); + expect(route.matcher({ url: '/its/a/girl' })).toBe(false); + expect(route.matcher({ url: '/its/alive' })).toBe(true); }); it('match path: keyword', () => { - const route = new Route({ matcher: 'path:/its/:word', response: 200 }); + const route = new Route({ url: 'path:/its/:word', response: 200 }); - expect(route.matcher('/its/boy')).toBe(false); - expect(route.matcher('/its/:word/still')).toBe(false); - expect(route.matcher('/its/:word')).toBe(true); - expect(route.matcher('/its/:word?brain=false')).toBe(true); + expect(route.matcher({ url: '/its/boy' })).toBe(false); + expect(route.matcher({ url: '/its/:word/still' })).toBe(false); + expect(route.matcher({ url: '/its/:word' })).toBe(true); + expect(route.matcher({ url: '/its/:word?brain=false' })).toBe(true); }); it('match wildcard string', () => { - const route = new Route({ matcher: '*', response: 200 }); + const route = new Route({ url: '*', response: 200 }); - expect(route.matcher('http://a.com')).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(true); }); it('match regular expressions', () => { const rx = /http\:\/\/a\.com\/\d+/; - const route = new Route({ matcher: rx, response: 200 }); + const route = new Route({ url: rx, response: 200 }); - expect(route.matcher('http://a.com/')).toBe(false); - expect(route.matcher('http://a.com/abcde')).toBe(false); - expect(route.matcher('http://a.com/12345')).toBe(true); + expect(route.matcher({ url: 'http://a.com/' })).toBe(false); + expect(route.matcher({ url: 'http://a.com/abcde' })).toBe(false); + expect(route.matcher({ url: 'http://a.com/12345' })).toBe(true); }); it('match relative urls', () => { - const route = new Route({ matcher: '/a.com/', response: 200 }); - expect(route.matcher('/a.com/')).toBe(true); + const route = new Route({ url: '/a.com/', response: 200 }); + expect(route.matcher({ url: '/a.com/' })).toBe(true); }); it('match relative urls with dots', () => { - const route = new Route({ matcher: '/it.at/there/', response: 200 }); - expect(route.matcher('/it.at/not/../there/')).toBe(true); - expect(route.matcher('./it.at/there/')).toBe(true); + const route = new Route({ url: '/it.at/there/', response: 200 }); + expect(route.matcher({ url: '/it.at/not/../there/' })).toBe(true); + expect(route.matcher({ url: './it.at/there/' })).toBe(true); }); it('match absolute urls with dots', () => { - const route = new Route({ matcher: 'http://it.at/there/', response: 200 }); - expect(route.matcher('http://it.at/not/../there/')).toBe(true); + const route = new Route({ url: 'http://it.at/there/', response: 200 }); + expect(route.matcher({ url: 'http://it.at/not/../there/' })).toBe(true); }); describe('host normalisation', () => { it('match exact pathless urls regardless of trailing slash', () => { - const route = new Route({ matcher: 'http://a.com/', response: 200 }); + const route = new Route({ url: 'http://a.com/', response: 200 }); - expect(route.matcher('http://a.com/')).toBe(true); - expect(route.matcher('http://a.com')).toBe(true); + expect(route.matcher({ url: 'http://a.com/' })).toBe(true); + expect(route.matcher({ url: 'http://a.com' })).toBe(true); - const route2 = new Route({ matcher: 'http://b.com', response: 200 }); - expect(route2.matcher('http://b.com/')).toBe(true); - expect(route2.matcher('http://b.com')).toBe(true); + const route2 = new Route({ url: 'http://b.com', response: 200 }); + expect(route2.matcher({ url: 'http://b.com/' })).toBe(true); + expect(route2.matcher({ url: 'http://b.com' })).toBe(true); }); }); describe('data: URLs', () => { it('match exact strings', () => { const route = new Route({ - matcher: 'data:text/plain,path', + url: 'data:text/plain,path', response: 200, }); - expect(route.matcher('data:text/plain,pat')).toBe(false); - expect(route.matcher('data:text/plain,paths')).toBe(false); - expect(route.matcher('data:text/html,path')).toBe(false); - expect(route.matcher('data:text/plain,path')).toBe(true); + expect(route.matcher({ url: 'data:text/plain,pat' })).toBe(false); + expect(route.matcher({ url: 'data:text/plain,paths' })).toBe(false); + expect(route.matcher({ url: 'data:text/html,path' })).toBe(false); + expect(route.matcher({ url: 'data:text/plain,path' })).toBe(true); }); it('match exact string against URL object', () => { const route = new Route({ - matcher: 'data:text/plain,path', + url: 'data:text/plain,path', response: 200, }); const url = new URL('data:text/plain,path'); - expect(route.matcher(url)).toBe(true); + expect(route.matcher({ url })).toBe(true); }); it('match using URL object as matcher', () => { const url = new URL('data:text/plain,path'); - const route = new Route({ matcher: url, response: 200 }); - expect(route.matcher('data:text/plain,path')).toBe(true); + const route = new Route({ url: url, response: 200 }); + expect(route.matcher({ url: 'data:text/plain,path' })).toBe(true); }); it('match begin: keyword', () => { const route = new Route({ - matcher: 'begin:data:text/plain', + url: 'begin:data:text/plain', response: 200, }); - expect(route.matcher('http://a.com/path')).toBe(false); - expect(route.matcher('data:text/html,path')).toBe(false); - expect(route.matcher('data:text/plain,path')).toBe(true); - expect(route.matcher('data:text/plain;base64,cGF0aA')).toBe(true); + expect(route.matcher({ url: 'http://a.com/path' })).toBe(false); + expect(route.matcher({ url: 'data:text/html,path' })).toBe(false); + expect(route.matcher({ url: 'data:text/plain,path' })).toBe(true); + expect(route.matcher({ url: 'data:text/plain;base64,cGF0aA' })).toBe( + true, + ); }); it('match end: keyword', () => { - const route = new Route({ matcher: 'end:sky', response: 200 }); - expect(route.matcher('data:text/plain,blue lake')).toBe(false); - expect(route.matcher('data:text/plain,blue sky research')).toBe(false); - expect(route.matcher('data:text/plain,blue sky')).toBe(true); - expect(route.matcher('data:text/plain,grey sky')).toBe(true); + const route = new Route({ url: 'end:sky', response: 200 }); + expect(route.matcher({ url: 'data:text/plain,blue lake' })).toBe(false); + expect(route.matcher({ url: 'data:text/plain,blue sky research' })).toBe( + false, + ); + expect(route.matcher({ url: 'data:text/plain,blue sky' })).toBe(true); + expect(route.matcher({ url: 'data:text/plain,grey sky' })).toBe(true); }); it('match glob: keyword', () => { - const route = new Route({ matcher: 'glob:data:* sky', response: 200 }); - expect(route.matcher('data:text/plain,blue lake')).toBe(false); - expect(route.matcher('data:text/plain,blue sky')).toBe(true); - expect(route.matcher('data:text/plain,grey sky')).toBe(true); + const route = new Route({ url: 'glob:data:* sky', response: 200 }); + expect(route.matcher({ url: 'data:text/plain,blue lake' })).toBe(false); + expect(route.matcher({ url: 'data:text/plain,blue sky' })).toBe(true); + expect(route.matcher({ url: 'data:text/plain,grey sky' })).toBe(true); }); it('match wildcard string', () => { - const route = new Route({ matcher: '*', response: 200 }); - expect(route.matcher('data:text/plain,path')).toBe(true); + const route = new Route({ url: '*', response: 200 }); + expect(route.matcher({ url: 'data:text/plain,path' })).toBe(true); }); it('match regular expressions', () => { const rx = /data\:text\/plain,\d+/; - const route = new Route({ matcher: rx, response: 200 }); - expect(route.matcher('data:text/html,12345')).toBe(false); - expect(route.matcher('data:text/plain,path')).toBe(false); - expect(route.matcher('data:text/plain,12345')).toBe(true); + const route = new Route({ url: rx, response: 200 }); + expect(route.matcher({ url: 'data:text/html,12345' })).toBe(false); + expect(route.matcher({ url: 'data:text/plain,path' })).toBe(false); + expect(route.matcher({ url: 'data:text/plain,12345' })).toBe(true); }); }); }); diff --git a/packages/core/src/__tests__/router-integration.test.js b/packages/core/src/__tests__/router-integration.test.js index bf6a430d..b892e874 100644 --- a/packages/core/src/__tests__/router-integration.test.js +++ b/packages/core/src/__tests__/router-integration.test.js @@ -15,7 +15,7 @@ describe('Router', () => { it('match using custom function with Request', async () => { const fm = fetchMock.createInstance(); - fm.route((url, options) => { + fm.route(({ url, options }) => { return url.indexOf('logged-in') > -1 && options.headers.authorized; }, 200); @@ -37,10 +37,7 @@ describe('Router', () => { const valueToSet = propertyToCheck === 'credentials' ? 'include' : false; const fm = fetchMock.createInstance(); - fm.route( - (url, options, request) => request[propertyToCheck] === valueToSet, - 200, - ); + fm.route(({ request }) => request[propertyToCheck] === valueToSet, 200); await expect( fm.fetchHandler(new Request('http://a.com/logged-in')), @@ -59,7 +56,10 @@ describe('Router', () => { const fm = fetchMock.createInstance(); fm.defineMatcher({ name: 'syncMatcher', - matcher: (route) => (url) => url.indexOf(route.syncMatcher) > -1, + matcher: + (route) => + ({ url }) => + url.indexOf(route.syncMatcher) > -1, }); fm.route( { @@ -77,8 +77,10 @@ describe('Router', () => { const fm = fetchMock.createInstance(); fm.defineMatcher({ name: 'bodyMatcher', - matcher: (route) => (url, options) => - JSON.parse(options.body)[route.bodyMatcher] === true, + matcher: + (route) => + ({ options }) => + JSON.parse(options.body)[route.bodyMatcher] === true, usesBody: true, }); fm.route( @@ -115,8 +117,10 @@ describe('Router', () => { const fm = fetchMock.createInstance(); fm.defineMatcher({ name: 'asyncBodyMatcher', - matcher: (route) => (url, options) => - JSON.parse(options.body)[route.asyncBodyMatcher] === true, + matcher: + (route) => + ({ options }) => + JSON.parse(options.body)[route.asyncBodyMatcher] === true, }); fm.route( {