From 1cdb1d42f3e671b611bb4af8df4a969253d30348 Mon Sep 17 00:00:00 2001 From: Blephy Date: Tue, 28 Jan 2025 23:42:58 +0100 Subject: [PATCH] feat: add AND and OR operator to filterCalls class method --- docs/docs/api/MockAgent.md | 21 ++- docs/docs/api/MockCallHistory.md | 192 ++++++++++++++++++++ docs/docs/api/MockCallHistoryLog.md | 43 +++++ docs/docs/best-practices/mocking-request.md | 4 + lib/mock/mock-call-history.js | 59 ++++-- test/mock-call-history.js | 86 ++++++++- types/index.d.ts | 4 + 7 files changed, 383 insertions(+), 26 deletions(-) create mode 100644 docs/docs/api/MockCallHistory.md create mode 100644 docs/docs/api/MockCallHistoryLog.md diff --git a/docs/docs/api/MockAgent.md b/docs/docs/api/MockAgent.md index 7d671ecb507..4788880edae 100644 --- a/docs/docs/api/MockAgent.md +++ b/docs/docs/api/MockAgent.md @@ -592,7 +592,7 @@ client.intercept({ path: '/', method: 'GET' }).reply(200, 'hi !').registerCallHi await request('http://example.com') // intercepted await request('http://example.com', { method: 'POST', body: JSON.stringify({ data: 'hello' }), headers: { 'content-type': 'application/json' }}) -mockAgent.getCallHistory('my-specific-history-name').calls() +mockAgent.getCallHistory('my-specific-history-name')?.calls() // Returns [ // MockCallHistoryLog { // body: undefined, @@ -663,13 +663,14 @@ const mockAgent = new MockAgent() const mockAgentHistory = mockAgent.getCallHistory() -mockAgentHistory.calls() // returns an array of MockCallHistoryLogs -mockAgentHistory.firstCall() // returns the first MockCallHistoryLogs or undefined -mockAgentHistory.lastCall() // returns the last MockCallHistoryLogs or undefined -mockAgentHistory.nthCall(3) // returns the third MockCallHistoryLogs or undefined -mockAgentHistory.filterCalls({ path: '/endpoint', hash: '#hash-value' }) // returns an Array of MockCallHistoryLogs WHERE path === /endpoint OR hash === #hash-value -mockAgentHistory.filterCalls(/"data": "{}"/) // returns an Array of MockCallHistoryLogs where any value match regexp -mockAgentHistory.filterCalls('application/json') // returns an Array of MockCallHistoryLogs where any value === 'application/json' -mockAgentHistory.filterCalls((log) => log.path === '/endpoint') // returns an Array of MockCallHistoryLogs when given function returns true -mockAgentHistory.clear() // clear the history +mockAgentHistory?.calls() // returns an array of MockCallHistoryLogs +mockAgentHistory?.firstCall() // returns the first MockCallHistoryLogs or undefined +mockAgentHistory?.lastCall() // returns the last MockCallHistoryLogs or undefined +mockAgentHistory?.nthCall(3) // returns the third MockCallHistoryLogs or undefined +mockAgentHistory?.filterCalls({ path: '/endpoint', hash: '#hash-value' }) // returns an Array of MockCallHistoryLogs WHERE path === /endpoint OR hash === #hash-value +mockAgentHistory?.filterCalls({ path: '/endpoint', hash: '#hash-value' }, { operator: 'AND' }) // returns an Array of MockCallHistoryLogs WHERE path === /endpoint AND hash === #hash-value +mockAgentHistory?.filterCalls(/"data": "{}"/) // returns an Array of MockCallHistoryLogs where any value match regexp +mockAgentHistory?.filterCalls('application/json') // returns an Array of MockCallHistoryLogs where any value === 'application/json' +mockAgentHistory?.filterCalls((log) => log.path === '/endpoint') // returns an Array of MockCallHistoryLogs when given function returns true +mockAgentHistory?.clear() // clear the history ``` diff --git a/docs/docs/api/MockCallHistory.md b/docs/docs/api/MockCallHistory.md new file mode 100644 index 00000000000..163b2a2db58 --- /dev/null +++ b/docs/docs/api/MockCallHistory.md @@ -0,0 +1,192 @@ +# Class: MockCallHistory + +Access to an instance with : + +```js +const mockAgent = new MockAgent({ enableCallHistory: true }) +mockAgent.getCallHistory() +// or +const mockAgent = new MockAgent() +mockAgent.enableMockHistory() +mockAgent.getCallHistory() +// or + +const mockAgent = new MockAgent() +const mockClient = mockAgent.get('http://localhost:3000') +mockClient + .intercept({ path: '/' }) + .reply(200, 'hello') + .registerCallHistory('my-custom-history') +mockAgent.getCallHistory('my-custom-history') +``` + +## class methods + +### clear + +Clear all MockCallHistoryLog registered + +```js +mockAgent.getCallHistory()?.clear() // clear only mockAgent history +mockAgent.getCallHistory('my-custom-history')?.clear() // clear only 'my-custom-history' history +``` + +### calls + +Get all MockCallHistoryLog registered as an array + +```js +mockAgent.getCallHistory()?.calls() +``` + +### firstCall + +Get the first MockCallHistoryLog registered or undefined + +```js +mockAgent.getCallHistory()?.firstCall() +``` + +### lastCall + +Get the last MockCallHistoryLog registered or undefined + +```js +mockAgent.getCallHistory()?.lastCall() +``` + +### nthCall + +Get the nth MockCallHistoryLog registered or undefined + +```js +mockAgent.getCallHistory()?.nthCall(3) // the third MockCallHistoryLog registered +``` + +### filterCallsByProtocol + +Filter MockCallHistoryLog by protocol. + +> more details for the first parameter can be found [here](#filter-parameter) + +```js +mockAgent.getCallHistory()?.filterCallsByProtocol(/https/) +mockAgent.getCallHistory()?.filterCallsByProtocol('https:') +``` + +### filterCallsByHost + +Filter MockCallHistoryLog by host. + +> more details for the first parameter can be found [here](#filter-parameter) + +```js +mockAgent.getCallHistory()?.filterCallsByHost(/localhost/) +mockAgent.getCallHistory()?.filterCallsByHost('localhost:3000') +``` + +### filterCallsByPort + +Filter MockCallHistoryLog by port. + +> more details for the first parameter can be found [here](#filter-parameter) + +```js +mockAgent.getCallHistory()?.filterCallsByPort(/3000/) +mockAgent.getCallHistory()?.filterCallsByPort('3000') +mockAgent.getCallHistory()?.filterCallsByPort('') +``` + +### filterCallsByOrigin + +Filter MockCallHistoryLog by origin. + +> more details for the first parameter can be found [here](#filter-parameter) + +```js +mockAgent.getCallHistory()?.filterCallsByOrigin(/http:\/\/localhost:3000/) +mockAgent.getCallHistory()?.filterCallsByOrigin('http://localhost:3000') +``` + +### filterCallsByPath + +Filter MockCallHistoryLog by path. + +> more details for the first parameter can be found [here](#filter-parameter) + +```js +mockAgent.getCallHistory()?.filterCallsByPath(/api\/v1\/graphql/) +mockAgent.getCallHistory()?.filterCallsByPath('/api/v1/graphql') +``` + +### filterCallsByHash + +Filter MockCallHistoryLog by hash. + +> more details for the first parameter can be found [here](#filter-parameter) + +```js +mockAgent.getCallHistory()?.filterCallsByPath(/hash/) +mockAgent.getCallHistory()?.filterCallsByPath('#hash') +``` + +### filterCallsByFullUrl + +Filter MockCallHistoryLog by fullUrl. fullUrl contains protocol, host, port, path, hash, and query params + +> more details for the first parameter can be found [here](#filter-parameter) + +```js +mockAgent.getCallHistory()?.filterCallsByFullUrl(/https:\/\/localhost:3000\/\?query=value#hash/) +mockAgent.getCallHistory()?.filterCallsByFullUrl('https://localhost:3000/?query=value#hash') +``` + +### filterCallsByMethod + +Filter MockCallHistoryLog by method. + +> more details for the first parameter can be found [here](#filter-parameter) + +```js +mockAgent.getCallHistory()?.filterCallsByMethod(/POST/) +mockAgent.getCallHistory()?.filterCallsByMethod('POST') +``` + +### filterCalls + +This class method is a meta function / alias to apply complex filtering in one way. + +Parameters : + +- criteria : this first parameter. a function, regexp or object. + - function : filter MockCallHistoryLog when the function returns false + - regexp : filter MockCallHistoryLog when the regexp does not match on MockCallHistoryLog.toString() ([see](./MockCallHistoryLog.md#to-string)) + - object : an object with MockCallHistoryLog properties as keys to apply multiple filters. each values are a [filter parameter](#filter-parameter) +- options : the second parameter. an object. + - options.operator : `'AND'` or `'OR'` (default `'OR'`). Used only if criteria is an object. see below + +```js +mockAgent.getCallHistory()?.filterCalls((log) => log.hash === value && log.headers?.['authorization'] !== undefined) +mockAgent.getCallHistory()?.filterCalls(/"data": "{ "errors": "wrong body" }"/) + +// returns MockCallHistoryLog which have +// - a hash containing my-hash +// - OR +// - a path equal to /endpoint +mockAgent.getCallHistory()?.filterCalls({ hash: /my-hash/, path: '/endpoint' }) + +// returns MockCallHistoryLog which have +// - a hash containing my-hash +// - AND +// - a path equal to /endpoint +mockAgent.getCallHistory()?.filterCalls({ hash: /my-hash/, path: '/endpoint' }, { operator: 'AND' }) +``` + +## filter parameter + +Can be : + +- string. filtered if `value !== parameterValue` +- null. filtered if `value !== parameterValue` +- undefined. filtered if `value !== parameterValue` +- regexp. filtered if `!parameterValue.test(value)` diff --git a/docs/docs/api/MockCallHistoryLog.md b/docs/docs/api/MockCallHistoryLog.md new file mode 100644 index 00000000000..e7d9e4ca51e --- /dev/null +++ b/docs/docs/api/MockCallHistoryLog.md @@ -0,0 +1,43 @@ +# Class: MockCallHistoryLog + +Access to an instance with : + +```js +const mockAgent = new MockAgent({ enableCallHistory: true }) +mockAgent.getCallHistory()?.firstCall() +``` + +## class properties + +- body `mockAgent.getCallHistory()?.firstCall()?.body` +- headers `mockAgent.getCallHistory()?.firstCall()?.headers` an object +- method `mockAgent.getCallHistory()?.firstCall()?.method` a string +- fullUrl `mockAgent.getCallHistory()?.firstCall()?.fullUrl` a string containing the protocol, origin, path, query and hash +- origin `mockAgent.getCallHistory()?.firstCall()?.origin` a string containing the protocol and the host +- headers `mockAgent.getCallHistory()?.firstCall()?.headers` an object +- path `mockAgent.getCallHistory()?.firstCall()?.path` a string always starting with `/` +- searchParams `mockAgent.getCallHistory()?.firstCall()?.searchParams` an object +- protocol `mockAgent.getCallHistory()?.firstCall()?.protocol` a string (`https:`) +- host `mockAgent.getCallHistory()?.firstCall()?.host` a string +- port `mockAgent.getCallHistory()?.firstCall()?.port` an empty string or a string containing numbers +- hash `mockAgent.getCallHistory()?.firstCall()?.hash` an empty string or a string starting with `#` + +## class methods + +### toMap + +Returns a Map instance + +```js +mockAgent.getCallHistory()?.firstCall()?.toMap().get('hash') +// #hash +``` + +### toString + +Returns a a string computed with any class property name and value pair + +```js +mockAgent.getCallHistory()?.firstCall()?.toString() +// protocol->https:|host->localhost:4000|port->4000|origin->https://localhost:4000|path->/endpoint|hash->#here|searchParams->{"query":"value"}|fullUrl->https://localhost:4000/endpoint?query=value#here|method->PUT|body->"{ "data": "hello" }"|headers->{"content-type":"application/json"} +``` diff --git a/docs/docs/best-practices/mocking-request.md b/docs/docs/best-practices/mocking-request.md index 0a37da3610c..0c4484fdc35 100644 --- a/docs/docs/best-practices/mocking-request.md +++ b/docs/docs/best-practices/mocking-request.md @@ -145,6 +145,10 @@ Calling `mockAgent.close()` will automatically clear and delete every call histo Explore other MockAgent functionality [here](/docs/docs/api/MockAgent.md) +Explore other MockCallHistory functionality [here](/docs/docs/api/MockCallHistory.md) + +Explore other MockCallHistoryLog functionality [here](/docs/docs/api/MockCallHistoryLog.md) + ## Debug Mock Value When the interceptor and the request options are not the same, undici will automatically make a real HTTP request. To prevent real requests from being made, use `mockAgent.disableNetConnect()`: diff --git a/lib/mock/mock-call-history.js b/lib/mock/mock-call-history.js index 345b0dc105a..c3c3efe8820 100644 --- a/lib/mock/mock-call-history.js +++ b/lib/mock/mock-call-history.js @@ -10,6 +10,37 @@ const { } = require('./mock-symbols') const { InvalidArgumentError } = require('../core/errors') +function handleFilterCallsWithOptions (criteria, options, handler, store) { + switch (options.operator) { + case 'OR': + store.push(...handler(criteria)) + + return store + case 'AND': + return handler.call({ logs: store }, criteria) + default: + // guard -- should never happens because buildAndValidateFilterCallsOptions is called before + throw new InvalidArgumentError('options.operator must to be a case insensitive string equal to \'OR\' or \'AND\'') + } +} + +function buildAndValidateFilterCallsOptions (options = {}) { + const finalOptions = {} + + if ('operator' in options) { + if (typeof options.operator !== 'string' || (options.operator.toUpperCase() !== 'OR' && options.operator.toUpperCase() !== 'AND')) { + throw new InvalidArgumentError('options.operator must to be a case insensitive string equal to \'OR\' or \'AND\'') + } + + return { + ...finalOptions, + operator: options.operator.toUpperCase() + } + } + + return finalOptions +} + function makeFilterCalls (parameterName) { return (parameterValue) => { if (typeof parameterValue === 'string' || parameterValue == null) { @@ -160,15 +191,13 @@ class MockCallHistory { return this.logs.at(number - 1) } - filterCalls (criteria) { + filterCalls (criteria, options) { // perf if (this.logs.length === 0) { return this.logs } if (typeof criteria === 'function') { - return this.logs.filter((log) => { - return criteria(log) - }) + return this.logs.filter(criteria) } if (criteria instanceof RegExp) { return this.logs.filter((log) => { @@ -181,30 +210,32 @@ class MockCallHistory { return this.logs } - const maybeDuplicatedLogsFiltered = [] + const finalOptions = { operator: 'OR', ...buildAndValidateFilterCallsOptions(options) } + + let maybeDuplicatedLogsFiltered = [] if ('protocol' in criteria) { - maybeDuplicatedLogsFiltered.push(...this.filterCallsByProtocol(criteria.protocol)) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.protocol, finalOptions, this.filterCallsByProtocol, maybeDuplicatedLogsFiltered) } if ('host' in criteria) { - maybeDuplicatedLogsFiltered.push(...this.filterCallsByHost(criteria.host)) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.host, finalOptions, this.filterCallsByHost, maybeDuplicatedLogsFiltered) } if ('port' in criteria) { - maybeDuplicatedLogsFiltered.push(...this.filterCallsByPort(criteria.port)) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.port, finalOptions, this.filterCallsByPort, maybeDuplicatedLogsFiltered) } if ('origin' in criteria) { - maybeDuplicatedLogsFiltered.push(...this.filterCallsByOrigin(criteria.origin)) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.origin, finalOptions, this.filterCallsByOrigin, maybeDuplicatedLogsFiltered) } if ('path' in criteria) { - maybeDuplicatedLogsFiltered.push(...this.filterCallsByPath(criteria.path)) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.path, finalOptions, this.filterCallsByPath, maybeDuplicatedLogsFiltered) } if ('hash' in criteria) { - maybeDuplicatedLogsFiltered.push(...this.filterCallsByHash(criteria.hash)) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.hash, finalOptions, this.filterCallsByHash, maybeDuplicatedLogsFiltered) } if ('fullUrl' in criteria) { - maybeDuplicatedLogsFiltered.push(...this.filterCallsByFullUrl(criteria.fullUrl)) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.fullUrl, finalOptions, this.filterCallsByFullUrl, maybeDuplicatedLogsFiltered) } if ('method' in criteria) { - maybeDuplicatedLogsFiltered.push(...this.filterCallsByMethod(criteria.method)) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.method, finalOptions, this.filterCallsByMethod, maybeDuplicatedLogsFiltered) } const uniqLogsFiltered = [...new Set(maybeDuplicatedLogsFiltered)] @@ -212,7 +243,7 @@ class MockCallHistory { return uniqLogsFiltered } - throw new InvalidArgumentError('criteria parameter should be one of string, function, regexp, or object') + throw new InvalidArgumentError('criteria parameter should be one of function, regexp, or object') } filterCallsByProtocol = makeFilterCalls.call(this, 'protocol') diff --git a/test/mock-call-history.js b/test/mock-call-history.js index ea8781c9a54..68a8cc1d672 100644 --- a/test/mock-call-history.js +++ b/test/mock-call-history.js @@ -220,7 +220,7 @@ describe('MockCallHistory - nthCall', () => { }) }) -describe('MockCallHistory - filterCalls', () => { +describe('MockCallHistory - filterCalls without options', () => { test('should filter logs with a function', t => { t = tspl(t, { plan: 2 }) after(MockCallHistory[kMockCallHistoryDeleteAll]) @@ -357,7 +357,7 @@ describe('MockCallHistory - filterCalls', () => { t.strictEqual(filtered.length, 2) }) - test('should filter multiple time logs with an object', t => { + test('should use "OR" operator', t => { t = tspl(t, { plan: 1 }) after(MockCallHistory[kMockCallHistoryDeleteAll]) @@ -409,3 +409,85 @@ describe('MockCallHistory - filterCalls', () => { t.throws(() => mockCallHistoryHello.filterCalls(3), new InvalidArgumentError('criteria parameter should be one of string, function, regexp, or object')) }) }) + +describe('MockCallHistory - filterCalls with options', () => { + test('should throw if options.operator is not a valid string', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + + t.throws(() => mockCallHistoryHello.filterCalls({ path: '/' }, { operator: 'wrong' }), new InvalidArgumentError('options.operator must to be a case insensitive string equal to \'OR\' or \'AND\'')) + }) + + test('should not throw if options.operator is "or"', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + + t.doesNotThrow(() => mockCallHistoryHello.filterCalls({ path: '/' }, { operator: 'or' })) + }) + + test('should not throw if options.operator is "and"', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + + t.doesNotThrow(() => mockCallHistoryHello.filterCalls({ path: '/' }, { operator: 'and' })) + }) + + test('should use "OR" operator if options is an empty object', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/foo', origin: 'http://localhost:4000' }) + + const filtered = mockCallHistoryHello.filterCalls({ path: '/' }, {}) + + t.strictEqual(filtered.length, 1) + }) + + test('should use "AND" operator correctly', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:5000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/foo', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/foo', origin: 'http://localhost:5000' }) + + const filtered = mockCallHistoryHello.filterCalls({ path: '/', port: '4000' }, { operator: 'AND' }) + + t.strictEqual(filtered.length, 2) + }) + + test('should use "AND" operator with a lot of filters', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/#hello', origin: 'http://localhost:1000', method: 'GET' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/#hello', origin: 'http://localhost:1000', method: 'GET' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/#hello', origin: 'http://localhost:1000', method: 'DELETE' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/#hello', origin: 'http://localhost:1000', method: 'POST' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/#hello', origin: 'http://localhost:1000', method: 'PUT' }) + + const filtered = mockCallHistoryHello.filterCalls({ path: '/', port: '1000', host: /localhost/, method: /(POST|PUT)/ }, { operator: 'AND' }) + + t.strictEqual(filtered.length, 2) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index c473915abf1..78c773dc5f7 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -57,6 +57,10 @@ declare namespace Undici { const MockAgent: typeof import('./mock-agent').default const MockCallHistory: typeof import('./mock-call-history').MockCallHistory const MockCallHistoryLog: typeof import('./mock-call-history').MockCallHistoryLog + const FilterCallsParameter: typeof import('./mock-call-history').FilterCallsParameter + const FilterCallFunctionCriteria: typeof import('./mock-call-history').FilterCallFunctionCriteria + const FilterCallsObjectCriteria: typeof import('./mock-call-history').FilterCallsObjectCriteria + const MockCallHistoryLogProperties: typeof import('./mock-call-history').MockCallHistoryLogProperties const mockErrors: typeof import('./mock-errors').default const fetch: typeof import('./fetch').fetch const Headers: typeof import('./fetch').Headers