From 652213058258ac8b1777a7714cd81a7bd14cb785 Mon Sep 17 00:00:00 2001 From: Blephy Date: Sat, 25 Jan 2025 10:57:55 +0100 Subject: [PATCH 01/23] feat: add mock call histories --- lib/mock/mock-agent.js | 21 +++++++++- lib/mock/mock-call-history.js | 78 +++++++++++++++++++++++++++++++++++ lib/mock/mock-interceptor.js | 23 ++++++++++- lib/mock/mock-symbols.js | 4 +- lib/mock/mock-utils.js | 5 ++- types/mock-agent.d.ts | 4 ++ types/mock-call-history.d.ts | 24 +++++++++++ 7 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 lib/mock/mock-call-history.js create mode 100644 types/mock-call-history.d.ts diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 6ee68570539..2b673a678c0 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -11,7 +11,8 @@ const { kNetConnect, kGetNetConnect, kOptions, - kFactory + kFactory, + kMockCallHistory } = require('./mock-symbols') const MockClient = require('./mock-client') const MockPool = require('./mock-pool') @@ -19,6 +20,7 @@ const { matchValue, buildMockOptions } = require('./mock-utils') const { InvalidArgumentError, UndiciError } = require('../core/errors') const Dispatcher = require('../dispatcher/dispatcher') const PendingInterceptorsFormatter = require('./pending-interceptors-formatter') +const { MockCallHistory } = require('./mock-call-history') class MockAgent extends Dispatcher { constructor (opts) { @@ -36,6 +38,7 @@ class MockAgent extends Dispatcher { this[kClients] = agent[kClients] this[kOptions] = buildMockOptions(opts) + this[kMockCallHistory] = new MockCallHistory(kMockCallHistory) } get (origin) { @@ -51,6 +54,10 @@ class MockAgent extends Dispatcher { dispatch (opts, handler) { // Call MockAgent.get to perform additional setup before dispatching as normal this.get(opts.origin) + + // add call history log even on non intercepted call + this[kMockCallHistory]._add(opts) + return this[kAgent].dispatch(opts, handler) } @@ -85,6 +92,18 @@ class MockAgent extends Dispatcher { this[kNetConnect] = false } + getCallHistory (name) { + if (name == null) { + return this[kMockCallHistory] + } + + return MockCallHistory.GetByName(name) + } + + clearAllCallHistory () { + MockCallHistory.ClearAll() + } + // This is required to bypass issues caused by using global symbols - see: // https://github.com/nodejs/undici/issues/1447 get isMockActive () { diff --git a/lib/mock/mock-call-history.js b/lib/mock/mock-call-history.js new file mode 100644 index 00000000000..a467aff56ea --- /dev/null +++ b/lib/mock/mock-call-history.js @@ -0,0 +1,78 @@ +class MockHistoryLog { + constructor (requestInit = {}) { + this.body = requestInit.body + this.headers = requestInit.headers + this.origin = requestInit.origin + this.method = requestInit.method + this.path = requestInit.path + this.query = requestInit.query + } + + get url () { + const url = new URL(this.path, this.origin) + + if (url.search.length !== 0) { + return url.toString() + } + + url.search = new URLSearchParams(this.query).toString() + + return url.toString() + } +} + +class MockCallHistory { + static AllMockCallHistory = new Map() + + logs = [] + + constructor (name) { + this.name = name + + MockCallHistory.AllMockCallHistory.set(this.name, this) + } + + static GetByName (name) { + return MockCallHistory.AllMockCallHistory.get(name) + } + + static Delete (name) { + MockCallHistory.AllMockCallHistory.delete(name) + } + + static ClearAll () { + for (const [ + , + callHistory + ] of MockCallHistory.AllMockCallHistory.entries()) { + callHistory.clear() + } + } + + calls () { + return this.logs + } + + lastCall () { + return this.logs.at(-1) + } + + nthCall (number) { + return this.logs.at(number) + } + + clear () { + this.logs = [] + } + + _add (requestInit) { + const log = new MockHistoryLog(requestInit) + + this.logs.push(log) + + return log + } +} + +module.exports.MockCallHistory = MockCallHistory +module.exports.MockHistoryLog = MockHistoryLog diff --git a/lib/mock/mock-interceptor.js b/lib/mock/mock-interceptor.js index 1ea7aac486d..fe45b25c03c 100644 --- a/lib/mock/mock-interceptor.js +++ b/lib/mock/mock-interceptor.js @@ -8,10 +8,12 @@ const { kDefaultTrailers, kContentLength, kMockDispatch, - kIgnoreTrailingSlash + kIgnoreTrailingSlash, + kMockCallHistory } = require('./mock-symbols') const { InvalidArgumentError } = require('../core/errors') const { serializePathWithQuery } = require('../core/util') +const { MockCallHistory } = require('./mock-call-history') /** * Defines the scope API for an interceptor reply @@ -52,6 +54,25 @@ class MockScope { this[kMockDispatch].times = repeatTimes return this } + + /** + * Register call history to be able to make assertion on + */ + registerCallHistory (name) { + if (typeof name !== 'string' || name.length === 0) { + throw new InvalidArgumentError('name must be a populated string') + } + + if (MockCallHistory.GetByName(name) !== undefined) { + throw new InvalidArgumentError(`a CallHistory with name ${name} already exist`) + } + + // we want to be able to access history even if mockDispatch are deleted + this[kMockCallHistory] = new MockCallHistory(name) + this[kMockDispatch][kMockCallHistory] = this[kMockCallHistory] + + return this + } } /** diff --git a/lib/mock/mock-symbols.js b/lib/mock/mock-symbols.js index 492edddabf5..c70b57415a6 100644 --- a/lib/mock/mock-symbols.js +++ b/lib/mock/mock-symbols.js @@ -21,5 +21,7 @@ module.exports = { kNetConnect: Symbol('net connect'), kGetNetConnect: Symbol('get net connect'), kConnected: Symbol('connected'), - kIgnoreTrailingSlash: Symbol('ignore trailing slash') + kIgnoreTrailingSlash: Symbol('ignore trailing slash'), + kMockCallHistory: Symbol('mock call history'), + kGlobalMockCallHistory: Symbol('global mock call history') } diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index b19aaaf8e11..84bd2779a5b 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -6,7 +6,8 @@ const { kMockAgent, kOriginalDispatch, kOrigin, - kGetNetConnect + kGetNetConnect, + kMockCallHistory } = require('./mock-symbols') const { serializePathWithQuery } = require('../core/util') const { STATUS_CODES } = require('node:http') @@ -260,6 +261,8 @@ function mockDispatch (opts, handler) { mockDispatch.timesInvoked++ + mockDispatch[kMockCallHistory]._add(opts) + // Here's where we resolve a callback if a callback is present for the dispatch data. if (mockDispatch.data.callback) { mockDispatch.data = { ...mockDispatch.data, ...mockDispatch.data.callback(opts) } diff --git a/types/mock-agent.d.ts b/types/mock-agent.d.ts index e92c7bea860..0ecf228ff30 100644 --- a/types/mock-agent.d.ts +++ b/types/mock-agent.d.ts @@ -2,6 +2,7 @@ import Agent from './agent' import Dispatcher from './dispatcher' import { Interceptable, MockInterceptor } from './mock-interceptor' import MockDispatch = MockInterceptor.MockDispatch +import { MockCallHistory } from './mock-call-history' export default MockAgent @@ -29,6 +30,9 @@ declare class MockAgent boolean)): void + getCallHistory (): MockCallHistory + getCallHistory (name: string): MockCallHistory | undefined + clearAllCallHistory (): void /** Causes all requests to throw when requests are not matched in a MockAgent intercept. */ disableNetConnect (): void pendingInterceptors (): PendingInterceptor[] diff --git a/types/mock-call-history.d.ts b/types/mock-call-history.d.ts new file mode 100644 index 00000000000..98fbd601bb0 --- /dev/null +++ b/types/mock-call-history.d.ts @@ -0,0 +1,24 @@ +import Dispatcher from './dispatcher' + +declare class MockHistoryLog { + constructor (requestInit: Dispatcher.DispatchOptions) + body: Dispatcher.DispatchOptions['body'] | undefined + headers: Dispatcher.DispatchOptions['headers'] | undefined + origin: Dispatcher.DispatchOptions['origin'] | undefined + method: Dispatcher.DispatchOptions['method'] | undefined + path: Dispatcher.DispatchOptions['path'] | undefined + query: Dispatcher.DispatchOptions['query'] | undefined +} + +declare class MockCallHistory { + constructor (name: string) + + static GetByName (name: string): MockCallHistory | undefined + + calls (): Array + lastCall (): MockHistoryLog | undefined + nthCall (position: number): MockHistoryLog | undefined + clear (): void +} + +export { MockHistoryLog, MockCallHistory } From 5a9f7ae1419fecff75a8d8d41f588afbd61a1ea6 Mon Sep 17 00:00:00 2001 From: Blephy Date: Sun, 26 Jan 2025 23:52:15 +0100 Subject: [PATCH 02/23] test: add mock call history specs --- lib/mock/mock-agent.js | 20 +- lib/mock/mock-call-history.js | 77 +++++--- lib/mock/mock-utils.js | 6 +- package.json | 4 +- test/mock-agent.js | 351 ++++++++++++++++++++++++++++++++++ types/mock-agent.d.ts | 3 +- types/mock-call-history.d.ts | 43 +++-- types/mock-interceptor.d.ts | 2 + 8 files changed, 459 insertions(+), 47 deletions(-) diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 2b673a678c0..1e3ee42f503 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -12,7 +12,8 @@ const { kGetNetConnect, kOptions, kFactory, - kMockCallHistory + kMockCallHistory, + kGlobalMockCallHistory } = require('./mock-symbols') const MockClient = require('./mock-client') const MockPool = require('./mock-pool') @@ -30,7 +31,7 @@ class MockAgent extends Dispatcher { this[kIsMockActive] = true // Instantiate Agent and encapsulate - if ((opts?.agent && typeof opts.agent.dispatch !== 'function')) { + if (opts?.agent && typeof opts.agent.dispatch !== 'function') { throw new InvalidArgumentError('Argument opts.agent must implement Agent') } const agent = opts?.agent ? opts.agent : new Agent(opts) @@ -38,7 +39,7 @@ class MockAgent extends Dispatcher { this[kClients] = agent[kClients] this[kOptions] = buildMockOptions(opts) - this[kMockCallHistory] = new MockCallHistory(kMockCallHistory) + this[kMockCallHistory] = new MockCallHistory(kGlobalMockCallHistory) } get (origin) { @@ -55,8 +56,14 @@ class MockAgent extends Dispatcher { // Call MockAgent.get to perform additional setup before dispatching as normal this.get(opts.origin) - // add call history log even on non intercepted call - this[kMockCallHistory]._add(opts) + // guard if mockAgent.close (which delete all history) was called before a dispatch by inadvertency + // using MockCallHistory.GetByName instead of this[kMockCallHistory] because this[kMockCallHistory] would then be still populated + if (MockCallHistory.GetByName(kGlobalMockCallHistory) === undefined) { + this[kMockCallHistory] = new MockCallHistory(kGlobalMockCallHistory) + } + + // add call history log even on non intercepted and intercepted calls (every call) + this[kMockCallHistory]._addCallHistoryLog(opts) return this[kAgent].dispatch(opts, handler) } @@ -64,6 +71,7 @@ class MockAgent extends Dispatcher { async close () { await this[kAgent].close() this[kClients].clear() + MockCallHistory.DeleteAll() } deactivate () { @@ -94,7 +102,7 @@ class MockAgent extends Dispatcher { getCallHistory (name) { if (name == null) { - return this[kMockCallHistory] + return MockCallHistory.GetByName(kGlobalMockCallHistory) } return MockCallHistory.GetByName(name) diff --git a/lib/mock/mock-call-history.js b/lib/mock/mock-call-history.js index a467aff56ea..a9ddac362a9 100644 --- a/lib/mock/mock-call-history.js +++ b/lib/mock/mock-call-history.js @@ -1,23 +1,55 @@ -class MockHistoryLog { +const computingError = 'error occurred when computing MockCallHistoryLog.url' + +function computeUrlWithMaybeSearchParameters (requestInit) { + // path can contains query url parameters + // or query can contains query url parameters + try { + const url = new URL(requestInit.path, requestInit.origin) + + // requestInit.path contains query url parameters + // requestInit.query is then undefined + if (url.search.length !== 0) { + return url + } + + // requestInit.query can be populated here + url.search = new URLSearchParams(requestInit.query).toString() + + return url + } catch { + // should never happens + return computingError + } +} + +class MockCallHistoryLog { constructor (requestInit = {}) { this.body = requestInit.body this.headers = requestInit.headers - this.origin = requestInit.origin this.method = requestInit.method - this.path = requestInit.path - this.query = requestInit.query - } - - get url () { - const url = new URL(this.path, this.origin) + this.origin = requestInit.origin - if (url.search.length !== 0) { - return url.toString() + const url = computeUrlWithMaybeSearchParameters(requestInit) + + this.fullUrl = url.toString() + + if (url instanceof URL) { + this.path = url.pathname + this.searchParams = Object.fromEntries(url.searchParams) + this.protocol = url.protocol + this.host = url.host + this.port = url.port + } else { + this.path = computingError + this.searchParams = computingError + this.protocol = computingError + this.host = computingError + this.port = computingError } + } - url.search = new URLSearchParams(this.query).toString() - - return url.toString() + _setPending (pending) { + this.pending = pending } } @@ -36,19 +68,16 @@ class MockCallHistory { return MockCallHistory.AllMockCallHistory.get(name) } - static Delete (name) { - MockCallHistory.AllMockCallHistory.delete(name) - } - static ClearAll () { - for (const [ - , - callHistory - ] of MockCallHistory.AllMockCallHistory.entries()) { + for (const callHistory of MockCallHistory.AllMockCallHistory.values()) { callHistory.clear() } } + static DeleteAll () { + MockCallHistory.AllMockCallHistory.clear() + } + calls () { return this.logs } @@ -65,8 +94,8 @@ class MockCallHistory { this.logs = [] } - _add (requestInit) { - const log = new MockHistoryLog(requestInit) + _addCallHistoryLog (requestInit) { + const log = new MockCallHistoryLog(requestInit) this.logs.push(log) @@ -75,4 +104,4 @@ class MockCallHistory { } module.exports.MockCallHistory = MockCallHistory -module.exports.MockHistoryLog = MockHistoryLog +module.exports.MockCallHistoryLog = MockCallHistoryLog diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index 84bd2779a5b..c2bc1cdf806 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -261,7 +261,11 @@ function mockDispatch (opts, handler) { mockDispatch.timesInvoked++ - mockDispatch[kMockCallHistory]._add(opts) + // guard if mockAgent.close (which delete all history) was called before a dispatch by inadvertency + if (mockDispatch[kMockCallHistory] !== undefined) { + // add call history log even on intercepted calls when mockScope.registerCallHistory was called + mockDispatch[kMockCallHistory]._addCallHistoryLog(opts) + } // Here's where we resolve a callback if a callback is present for the dispatch data. if (mockDispatch.data.callback) { diff --git a/package.json b/package.json index e90a4dbfbf6..11a293384b1 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "test:unit": "borp --expose-gc -p \"test/*.js\"", "test:node-fetch": "borp -p \"test/node-fetch/**/*.js\"", "test:node-test": "borp -p \"test/node-test/**/*.js\"", + "test:amoi": "borp -p \"test/mock-agent.js\"", "test:tdd": "borp --expose-gc -p \"test/*.js\"", "test:tdd:node-test": "borp -p \"test/node-test/**/*.js\" -w", "test:typescript": "tsd && tsc test/imports/undici-import.ts --typeRoots ./types --noEmit && tsc ./types/*.d.ts --noEmit --typeRoots ./types", @@ -145,5 +146,6 @@ "testMatch": [ "/test/jest/**" ] - } + }, + "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" } diff --git a/test/mock-agent.js b/test/mock-agent.js index d6fa744049d..53f848d0484 100644 --- a/test/mock-agent.js +++ b/test/mock-agent.js @@ -14,6 +14,7 @@ const { kAgent } = require('../lib/mock/mock-symbols') const Dispatcher = require('../lib/dispatcher/dispatcher') const { MockNotMatchedError } = require('../lib/mock/mock-errors') const { fetch } = require('..') +const { MockCallHistory } = require('../lib/mock/mock-call-history') describe('MockAgent - constructor', () => { test('sets up mock agent', t => { @@ -772,6 +773,332 @@ test('MockAgent - should persist requests', async (t) => { } }) +test('MockAgent - getCallHistory with no name parameter should return the global call history', async (t) => { + t = tspl(t, { plan: 2 }) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + after(() => mockAgent.close()) + + const mockClient = mockAgent.get('http://localhost:9999') + mockClient.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo') + + t.strictEqual(MockCallHistory.AllMockCallHistory.size, 1) + t.ok(mockAgent.getCallHistory() instanceof MockCallHistory) +}) + +test('MockAgent - getCallHistory with name parameter should return the named call history', async (t) => { + t = tspl(t, { plan: 2 }) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + after(() => mockAgent.close()) + + const mockClient = mockAgent.get('http://localhost:9999') + mockClient.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo').registerCallHistory('my-history') + + t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) + t.ok(mockAgent.getCallHistory('my-history') instanceof MockCallHistory) +}) + +test('MockAgent - getCallHistory with name parameter should return undefined when unknown name history', async (t) => { + t = tspl(t, { plan: 2 }) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + after(() => mockAgent.close()) + + const mockClient = mockAgent.get('http://localhost:9999') + mockClient.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo').registerCallHistory('my-history') + + t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) + t.strictEqual(mockAgent.getCallHistory('no-exist'), undefined) +}) + +test('MockAgent - getCallHistory with name parameter should throw when a named history with the name parameter already exist', async (t) => { + t = tspl(t, { plan: 1 }) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + after(() => mockAgent.close()) + + const mockClient = mockAgent.get('http://localhost:9999') + mockClient.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo').registerCallHistory('my-history') + + t.throws(() => mockClient.intercept({ + path: '/bar', + method: 'POST' + }).reply(200, 'bar').registerCallHistory('my-history'), new InvalidArgumentError('a CallHistory with name my-history already exist')) +}) + +test('MockAgent - getCallHistory with no name parameter with request should return the global call history with history log', async (t) => { + t = tspl(t, { plan: 10 }) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + after(() => mockAgent.close()) + + const baseUrl = 'http://localhost:9999' + const mockClient = mockAgent.get(baseUrl) + mockClient.intercept({ + path: /^\/foo/, + method: 'POST' + }).reply(200, 'foo') + + t.strictEqual(MockCallHistory.AllMockCallHistory.size, 1) + t.ok(mockAgent.getCallHistory().calls().length === 0) + + const path = '/foo' + const url = new URL(path, baseUrl) + const method = 'POST' + const body = { data: 'value' } + const query = { a: 1 } + const headers = { authorization: 'Bearer token' } + + await request(url, { method, query, body: JSON.stringify(body), headers }) + + t.ok(mockAgent.getCallHistory().calls().length === 1) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.body, JSON.stringify(body)) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.headers, headers) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.method, method) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.origin, baseUrl) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.path, path) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.fullUrl, `${url.toString()}?${new URLSearchParams(query).toString()}`) + t.deepStrictEqual(mockAgent.getCallHistory().lastCall()?.searchParams, { a: '1' }) +}) + +test('MockAgent - getCallHistory with no name parameter with fetch should return the global call history with history log', async (t) => { + t = tspl(t, { plan: 10 }) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + after(() => mockAgent.close()) + + const baseUrl = 'http://localhost:9999' + const mockClient = mockAgent.get(baseUrl) + mockClient.intercept({ + path: /^\/foo/, + method: 'POST' + }).reply(200, 'foo') + + t.strictEqual(MockCallHistory.AllMockCallHistory.size, 1) + t.ok(mockAgent.getCallHistory().calls().length === 0) + + const path = '/foo' + const url = new URL(path, baseUrl) + const method = 'POST' + const body = { data: 'value' } + const query = { a: 1 } + url.search = new URLSearchParams(query) + const headers = { authorization: 'Bearer token', 'content-type': 'application/json' } + + await fetch(url, { method, query, body: JSON.stringify(body), headers }) + + t.ok(mockAgent.getCallHistory().calls().length === 1) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.body, JSON.stringify(body)) + t.deepStrictEqual(mockAgent.getCallHistory().lastCall()?.headers, { + ...headers, + 'accept-encoding': 'gzip, deflate', + 'content-length': '16', + 'content-type': 'application/json', + 'accept-language': '*', + 'sec-fetch-mode': 'cors', + 'user-agent': 'undici', + accept: '*/*' + }) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.method, method) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.origin, baseUrl) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.path, url.pathname) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.fullUrl, url.toString()) + t.deepStrictEqual(mockAgent.getCallHistory().lastCall()?.searchParams, { a: '1' }) +}) + +test('MockAgent - getCallHistory with name parameter should return the intercepted call history with history log', async (t) => { + t = tspl(t, { plan: 10 }) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + after(() => mockAgent.close()) + + const baseUrl = 'http://localhost:9999' + const historyName = 'my-history' + const mockClient = mockAgent.get(baseUrl) + mockClient.intercept({ + path: /^\/foo/, + method: 'POST' + }).reply(200, 'foo').registerCallHistory(historyName) + + t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) + t.ok(mockAgent.getCallHistory().calls().length === 0) + + const path = '/foo' + const url = new URL(path, baseUrl) + const method = 'POST' + const body = { data: 'value' } + const query = { a: 1 } + const headers = { authorization: 'Bearer token' } + + await request(url, { method, query, body: JSON.stringify(body), headers }) + + t.ok(mockAgent.getCallHistory().calls().length === 1) + t.strictEqual(mockAgent.getCallHistory(historyName)?.lastCall()?.body, JSON.stringify(body)) + t.strictEqual(mockAgent.getCallHistory(historyName)?.lastCall()?.headers, headers) + t.strictEqual(mockAgent.getCallHistory(historyName)?.lastCall()?.method, method) + t.strictEqual(mockAgent.getCallHistory(historyName)?.lastCall()?.origin, baseUrl) + t.strictEqual(mockAgent.getCallHistory(historyName)?.lastCall()?.path, path) + t.strictEqual(mockAgent.getCallHistory(historyName)?.lastCall()?.fullUrl, `${url.toString()}?${new URLSearchParams(query).toString()}`) + t.deepStrictEqual(mockAgent.getCallHistory(historyName)?.lastCall()?.searchParams, { a: '1' }) +}) + +test('MockAgent - getCallHistory with fetch with a minimal configuration should register call history log', async (t) => { + t = tspl(t, { plan: 11 }) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + after(() => mockAgent.close()) + + const baseUrl = 'http://localhost:9999' + const mockClient = mockAgent.get(baseUrl) + mockClient.intercept({ + path: '/' + }).reply(200, 'foo') + + const path = '/' + const url = new URL(path, baseUrl) + + await fetch(url) + + t.ok(mockAgent.getCallHistory().calls().length === 1) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.body, null) + t.deepStrictEqual(mockAgent.getCallHistory().lastCall()?.headers, { + 'accept-encoding': 'gzip, deflate', + 'accept-language': '*', + 'sec-fetch-mode': 'cors', + 'user-agent': 'undici', + accept: '*/*' + }) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.method, 'GET') + t.strictEqual(mockAgent.getCallHistory().lastCall()?.origin, baseUrl) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.path, path) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.fullUrl, baseUrl + path) + t.deepStrictEqual(mockAgent.getCallHistory().lastCall()?.searchParams, {}) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.host, 'localhost:9999') + t.strictEqual(mockAgent.getCallHistory().lastCall()?.port, '9999') + t.strictEqual(mockAgent.getCallHistory().lastCall()?.protocol, 'http:') +}) + +test('MockAgent - getCallHistory with request with a minimal configuration should register call history log', async (t) => { + t = tspl(t, { plan: 11 }) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + after(() => mockAgent.close()) + + const baseUrl = 'http://localhost:9999' + const mockClient = mockAgent.get(baseUrl) + mockClient.intercept({ + path: '/' + }).reply(200, 'foo') + + const path = '/' + const url = new URL(path, baseUrl) + + await request(url) + + t.ok(mockAgent.getCallHistory().calls().length === 1) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.body, undefined) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.headers, undefined) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.method, 'GET') + t.strictEqual(mockAgent.getCallHistory().lastCall()?.origin, baseUrl) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.path, path) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.fullUrl, baseUrl + path) + t.deepStrictEqual(mockAgent.getCallHistory().lastCall()?.searchParams, {}) + t.strictEqual(mockAgent.getCallHistory().lastCall()?.host, 'localhost:9999') + t.strictEqual(mockAgent.getCallHistory().lastCall()?.port, '9999') + t.strictEqual(mockAgent.getCallHistory().lastCall()?.protocol, 'http:') +}) + +test('MockAgent - getCallHistory should register logs on non intercepted call', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + after(() => server.close()) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + const secondeBaseUrl = 'http://localhost:8' + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + after(() => mockAgent.close()) + + const mockClient = mockAgent.get(secondeBaseUrl) + mockClient.intercept({ + path: '/' + }).reply(200, 'foo').registerCallHistory('second-history') + + await request(baseUrl) + await request(secondeBaseUrl) + + t.ok(mockAgent.getCallHistory().calls().length === 2) + t.ok(mockAgent.getCallHistory('second-history').calls().length === 1) +}) + +test('MockAgent - clearAllCallHistory should clear all call histories', async (t) => { + t = tspl(t, { plan: 6 }) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + after(() => mockAgent.close()) + + const baseUrl = 'http://localhost:9999' + const historyName = 'my-history' + const mockClient = mockAgent.get(baseUrl) + mockClient.intercept({ + path: /^\/foo/, + method: 'POST' + }).reply(200, 'foo').registerCallHistory(historyName).persist() + + t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) + t.ok(mockAgent.getCallHistory().calls().length === 0) + + const path = '/foo' + const url = new URL(path, baseUrl) + const method = 'POST' + const body = { data: 'value' } + const query = { a: 1 } + const headers = { authorization: 'Bearer token' } + + await request(url, { method, query, body: JSON.stringify(body), headers }) + await request(url, { method, query, body: JSON.stringify(body), headers }) + await request(url, { method, query, body: JSON.stringify(body), headers }) + await request(url, { method, query, body: JSON.stringify(body), headers }) + + t.ok(mockAgent.getCallHistory().calls().length === 4) + t.ok(mockAgent.getCallHistory(historyName)?.calls().length === 4) + + mockAgent.clearAllCallHistory() + + t.ok(mockAgent.getCallHistory().calls().length === 0) + t.ok(mockAgent.getCallHistory(historyName)?.calls().length === 0) +}) + test('MockAgent - handle persists with delayed requests', async (t) => { t = tspl(t, { plan: 4 }) @@ -910,6 +1237,30 @@ test('MockAgent - close removes all registered mock clients', async (t) => { } }) +test('MockAgent - close removes all registered mock call history', async (t) => { + t = tspl(t, { plan: 6 }) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + + const mockClient = mockAgent.get('http://localhost:9999') + + mockClient.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo').registerCallHistory('my-history') + + t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) + t.ok(mockAgent.getCallHistory() instanceof MockCallHistory) + t.ok(mockAgent.getCallHistory('my-history') instanceof MockCallHistory) + + await mockAgent.close() + + t.strictEqual(MockCallHistory.AllMockCallHistory.size, 0) + t.strictEqual(mockAgent.getCallHistory(), undefined) + t.strictEqual(mockAgent.getCallHistory('my-history'), undefined) +}) + test('MockAgent - close removes all registered mock pools', async (t) => { t = tspl(t, { plan: 2 }) diff --git a/types/mock-agent.d.ts b/types/mock-agent.d.ts index 0ecf228ff30..5890c52bcf1 100644 --- a/types/mock-agent.d.ts +++ b/types/mock-agent.d.ts @@ -30,10 +30,11 @@ declare class MockAgent boolean)): void + /** get call history. If a name is provided, it returns the history registered previously with registerCallHistory on a MockScope instance. If not, it returns the global call history of the MockAgent. */ getCallHistory (): MockCallHistory getCallHistory (name: string): MockCallHistory | undefined + /** clear every call history. Any MockCallHistoryLog will be deleted on every MockCallHistory */ clearAllCallHistory (): void - /** Causes all requests to throw when requests are not matched in a MockAgent intercept. */ disableNetConnect (): void pendingInterceptors (): PendingInterceptor[] assertNoPendingInterceptors (options?: { diff --git a/types/mock-call-history.d.ts b/types/mock-call-history.d.ts index 98fbd601bb0..1d513da2585 100644 --- a/types/mock-call-history.d.ts +++ b/types/mock-call-history.d.ts @@ -1,24 +1,39 @@ import Dispatcher from './dispatcher' -declare class MockHistoryLog { +declare class MockCallHistoryLog { constructor (requestInit: Dispatcher.DispatchOptions) - body: Dispatcher.DispatchOptions['body'] | undefined - headers: Dispatcher.DispatchOptions['headers'] | undefined - origin: Dispatcher.DispatchOptions['origin'] | undefined - method: Dispatcher.DispatchOptions['method'] | undefined - path: Dispatcher.DispatchOptions['path'] | undefined - query: Dispatcher.DispatchOptions['query'] | undefined + /** request's body */ + body: Dispatcher.DispatchOptions['body'] + /** request's headers */ + headers: Dispatcher.DispatchOptions['headers'] + /** request's origin. ie. https://localhost:3000. */ + origin: string + /** request's method. */ + method: Dispatcher.DispatchOptions['method'] + /** the full url requested. */ + fullUrl: string + /** path. never contains searchParams. */ + path: string + /** search params. */ + searchParams: Record + /** protocol used. */ + protocol: string + /** request's host. ie. 'https:' or 'http:' etc... */ + host: string + /** request's port. */ + port: string } declare class MockCallHistory { constructor (name: string) - - static GetByName (name: string): MockCallHistory | undefined - - calls (): Array - lastCall (): MockHistoryLog | undefined - nthCall (position: number): MockHistoryLog | undefined + /** returns an array of MockCallHistoryLog. */ + calls (): Array + /** returns the last MockCallHistoryLog. */ + lastCall (): MockCallHistoryLog | undefined + /** returns the nth MockCallHistoryLog. */ + nthCall (position: number): MockCallHistoryLog | undefined + /** clear all MockCallHistoryLog on this MockCallHistory. */ clear (): void } -export { MockHistoryLog, MockCallHistory } +export { MockCallHistoryLog, MockCallHistory } diff --git a/types/mock-interceptor.d.ts b/types/mock-interceptor.d.ts index 418db413e5d..46efb4cfe3f 100644 --- a/types/mock-interceptor.d.ts +++ b/types/mock-interceptor.d.ts @@ -11,6 +11,8 @@ declare class MockScope { persist (): MockScope /** Define a reply for a set amount of matching requests. */ times (repeatTimes: number): MockScope + /** Register a specific MockCallHistory within this scope. */ + registerCallHistory (name: string | Symbol): MockScope } /** The interceptor for a Mock. */ From 4273de96628d165b978c82de177432f8daa085af Mon Sep 17 00:00:00 2001 From: Blephy Date: Mon, 27 Jan 2025 00:05:13 +0100 Subject: [PATCH 03/23] chore: resolve discussion --- index.js | 3 +++ lib/mock/mock-agent.js | 19 +++++++++++-------- lib/mock/mock-call-history.js | 21 +++++++++++++-------- lib/mock/mock-symbols.js | 6 +++++- lib/mock/mock-utils.js | 5 +++-- 5 files changed, 35 insertions(+), 19 deletions(-) diff --git a/index.js b/index.js index f31e10e9114..873f5643565 100644 --- a/index.js +++ b/index.js @@ -14,6 +14,7 @@ const { InvalidArgumentError } = errors const api = require('./lib/api') const buildConnector = require('./lib/core/connect') const MockClient = require('./lib/mock/mock-client') +const { MockCallHistory, MockCallHistoryLog } = require('./lib/mock/mock-call-history') const MockAgent = require('./lib/mock/mock-agent') const MockPool = require('./lib/mock/mock-pool') const mockErrors = require('./lib/mock/mock-errors') @@ -169,6 +170,8 @@ module.exports.connect = makeDispatcher(api.connect) module.exports.upgrade = makeDispatcher(api.upgrade) module.exports.MockClient = MockClient +module.exports.MockCallHistory = MockCallHistory +module.exports.MockCallHistoryLog = MockCallHistoryLog module.exports.MockPool = MockPool module.exports.MockAgent = MockAgent module.exports.mockErrors = mockErrors diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 1e3ee42f503..72764da0eaa 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -13,7 +13,10 @@ const { kOptions, kFactory, kMockCallHistory, - kGlobalMockCallHistory + kMockAgentMockCallHistory, + kMockCallHistoryGetByName, + kMockCallHistoryClearAll, + kMockCallHistoryDeleteAll } = require('./mock-symbols') const MockClient = require('./mock-client') const MockPool = require('./mock-pool') @@ -39,7 +42,7 @@ class MockAgent extends Dispatcher { this[kClients] = agent[kClients] this[kOptions] = buildMockOptions(opts) - this[kMockCallHistory] = new MockCallHistory(kGlobalMockCallHistory) + this[kMockCallHistory] = new MockCallHistory(kMockAgentMockCallHistory) } get (origin) { @@ -58,8 +61,8 @@ class MockAgent extends Dispatcher { // guard if mockAgent.close (which delete all history) was called before a dispatch by inadvertency // using MockCallHistory.GetByName instead of this[kMockCallHistory] because this[kMockCallHistory] would then be still populated - if (MockCallHistory.GetByName(kGlobalMockCallHistory) === undefined) { - this[kMockCallHistory] = new MockCallHistory(kGlobalMockCallHistory) + if (MockCallHistory[kMockCallHistoryGetByName](kMockAgentMockCallHistory) === undefined) { + this[kMockCallHistory] = new MockCallHistory(kMockAgentMockCallHistory) } // add call history log even on non intercepted and intercepted calls (every call) @@ -71,7 +74,7 @@ class MockAgent extends Dispatcher { async close () { await this[kAgent].close() this[kClients].clear() - MockCallHistory.DeleteAll() + MockCallHistory[kMockCallHistoryDeleteAll]() } deactivate () { @@ -102,14 +105,14 @@ class MockAgent extends Dispatcher { getCallHistory (name) { if (name == null) { - return MockCallHistory.GetByName(kGlobalMockCallHistory) + return MockCallHistory[kMockCallHistoryGetByName](kMockAgentMockCallHistory) } - return MockCallHistory.GetByName(name) + return MockCallHistory[kMockCallHistoryGetByName](name) } clearAllCallHistory () { - MockCallHistory.ClearAll() + MockCallHistory[kMockCallHistoryClearAll]() } // This is required to bypass issues caused by using global symbols - see: diff --git a/lib/mock/mock-call-history.js b/lib/mock/mock-call-history.js index a9ddac362a9..1b762d9c347 100644 --- a/lib/mock/mock-call-history.js +++ b/lib/mock/mock-call-history.js @@ -1,3 +1,12 @@ +'use strict' + +const { + kMockCallHistoryAddLog, + kMockCallHistoryGetByName, + kMockCallHistoryClearAll, + kMockCallHistoryDeleteAll +} = require('./mock-symbols') + const computingError = 'error occurred when computing MockCallHistoryLog.url' function computeUrlWithMaybeSearchParameters (requestInit) { @@ -47,10 +56,6 @@ class MockCallHistoryLog { this.port = computingError } } - - _setPending (pending) { - this.pending = pending - } } class MockCallHistory { @@ -64,17 +69,17 @@ class MockCallHistory { MockCallHistory.AllMockCallHistory.set(this.name, this) } - static GetByName (name) { + static [kMockCallHistoryGetByName] (name) { return MockCallHistory.AllMockCallHistory.get(name) } - static ClearAll () { + static [kMockCallHistoryClearAll] () { for (const callHistory of MockCallHistory.AllMockCallHistory.values()) { callHistory.clear() } } - static DeleteAll () { + static [kMockCallHistoryDeleteAll] () { MockCallHistory.AllMockCallHistory.clear() } @@ -94,7 +99,7 @@ class MockCallHistory { this.logs = [] } - _addCallHistoryLog (requestInit) { + [kMockCallHistoryAddLog] (requestInit) { const log = new MockCallHistoryLog(requestInit) this.logs.push(log) diff --git a/lib/mock/mock-symbols.js b/lib/mock/mock-symbols.js index c70b57415a6..1a046e3192e 100644 --- a/lib/mock/mock-symbols.js +++ b/lib/mock/mock-symbols.js @@ -23,5 +23,9 @@ module.exports = { kConnected: Symbol('connected'), kIgnoreTrailingSlash: Symbol('ignore trailing slash'), kMockCallHistory: Symbol('mock call history'), - kGlobalMockCallHistory: Symbol('global mock call history') + kMockAgentMockCallHistory: Symbol('mock agent mock call history'), + kMockCallHistoryAddLog: Symbol('add mock call history log'), + kMockCallHistoryGetByName: Symbol('mock call history get by name'), + kMockCallHistoryClearAll: Symbol('mock call history clear all'), + kMockCallHistoryDeleteAll: Symbol('mock call history delete all') } diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index c2bc1cdf806..77850d6c9fc 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -7,7 +7,8 @@ const { kOriginalDispatch, kOrigin, kGetNetConnect, - kMockCallHistory + kMockCallHistory, + kMockCallHistoryAddLog } = require('./mock-symbols') const { serializePathWithQuery } = require('../core/util') const { STATUS_CODES } = require('node:http') @@ -264,7 +265,7 @@ function mockDispatch (opts, handler) { // guard if mockAgent.close (which delete all history) was called before a dispatch by inadvertency if (mockDispatch[kMockCallHistory] !== undefined) { // add call history log even on intercepted calls when mockScope.registerCallHistory was called - mockDispatch[kMockCallHistory]._addCallHistoryLog(opts) + mockDispatch[kMockCallHistory][kMockCallHistoryAddLog](opts) } // Here's where we resolve a callback if a callback is present for the dispatch data. From 8f82ec0082b787047642c18b37000839761e794b Mon Sep 17 00:00:00 2001 From: Blephy Date: Mon, 27 Jan 2025 00:12:07 +0100 Subject: [PATCH 04/23] chore: own review --- lib/mock/mock-agent.js | 7 ++++--- lib/mock/mock-interceptor.js | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 72764da0eaa..9ab473f15cf 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -16,7 +16,8 @@ const { kMockAgentMockCallHistory, kMockCallHistoryGetByName, kMockCallHistoryClearAll, - kMockCallHistoryDeleteAll + kMockCallHistoryDeleteAll, + kMockCallHistoryAddLog } = require('./mock-symbols') const MockClient = require('./mock-client') const MockPool = require('./mock-pool') @@ -60,13 +61,13 @@ class MockAgent extends Dispatcher { this.get(opts.origin) // guard if mockAgent.close (which delete all history) was called before a dispatch by inadvertency - // using MockCallHistory.GetByName instead of this[kMockCallHistory] because this[kMockCallHistory] would then be still populated + // using MockCallHistory[kMockCallHistoryGetByName] instead of this[kMockCallHistory] because this[kMockCallHistory] would then be still populated if (MockCallHistory[kMockCallHistoryGetByName](kMockAgentMockCallHistory) === undefined) { this[kMockCallHistory] = new MockCallHistory(kMockAgentMockCallHistory) } // add call history log even on non intercepted and intercepted calls (every call) - this[kMockCallHistory]._addCallHistoryLog(opts) + this[kMockCallHistory][kMockCallHistoryAddLog](opts) return this[kAgent].dispatch(opts, handler) } diff --git a/lib/mock/mock-interceptor.js b/lib/mock/mock-interceptor.js index fe45b25c03c..5d9b3087191 100644 --- a/lib/mock/mock-interceptor.js +++ b/lib/mock/mock-interceptor.js @@ -9,7 +9,8 @@ const { kContentLength, kMockDispatch, kIgnoreTrailingSlash, - kMockCallHistory + kMockCallHistory, + kMockCallHistoryGetByName } = require('./mock-symbols') const { InvalidArgumentError } = require('../core/errors') const { serializePathWithQuery } = require('../core/util') @@ -63,7 +64,7 @@ class MockScope { throw new InvalidArgumentError('name must be a populated string') } - if (MockCallHistory.GetByName(name) !== undefined) { + if (MockCallHistory[kMockCallHistoryGetByName](name) !== undefined) { throw new InvalidArgumentError(`a CallHistory with name ${name} already exist`) } From 1134004ad4acdf2762c70aa5c8933f71d1a1d8b1 Mon Sep 17 00:00:00 2001 From: Blephy Date: Mon, 27 Jan 2025 00:13:20 +0100 Subject: [PATCH 05/23] chore: delete unused package.json addition --- package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/package.json b/package.json index 11a293384b1..e90a4dbfbf6 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,6 @@ "test:unit": "borp --expose-gc -p \"test/*.js\"", "test:node-fetch": "borp -p \"test/node-fetch/**/*.js\"", "test:node-test": "borp -p \"test/node-test/**/*.js\"", - "test:amoi": "borp -p \"test/mock-agent.js\"", "test:tdd": "borp --expose-gc -p \"test/*.js\"", "test:tdd:node-test": "borp -p \"test/node-test/**/*.js\" -w", "test:typescript": "tsd && tsc test/imports/undici-import.ts --typeRoots ./types --noEmit && tsc ./types/*.d.ts --noEmit --typeRoots ./types", @@ -146,6 +145,5 @@ "testMatch": [ "/test/jest/**" ] - }, - "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" + } } From 1b5b344e56d88d6bd30cd24f316f89a8fb1a94f6 Mon Sep 17 00:00:00 2001 From: Blephy Date: Mon, 27 Jan 2025 00:19:01 +0100 Subject: [PATCH 06/23] feat: add firstCall class method on MockCallHistory --- lib/mock/mock-call-history.js | 4 ++++ types/mock-call-history.d.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/lib/mock/mock-call-history.js b/lib/mock/mock-call-history.js index 1b762d9c347..5e5e5d22e4b 100644 --- a/lib/mock/mock-call-history.js +++ b/lib/mock/mock-call-history.js @@ -87,6 +87,10 @@ class MockCallHistory { return this.logs } + firstCall () { + return this.logs.at(0) + } + lastCall () { return this.logs.at(-1) } diff --git a/types/mock-call-history.d.ts b/types/mock-call-history.d.ts index 1d513da2585..7280278bfe7 100644 --- a/types/mock-call-history.d.ts +++ b/types/mock-call-history.d.ts @@ -28,6 +28,8 @@ declare class MockCallHistory { constructor (name: string) /** returns an array of MockCallHistoryLog. */ calls (): Array + /** returns the first MockCallHistoryLog */ + firstCall (): MockCallHistoryLog | undefined /** returns the last MockCallHistoryLog. */ lastCall (): MockCallHistoryLog | undefined /** returns the nth MockCallHistoryLog. */ From a0e046b5cd5ac438b63ba6b85db74b9115cbe20b Mon Sep 17 00:00:00 2001 From: Blephy Date: Mon, 27 Jan 2025 00:26:22 +0100 Subject: [PATCH 07/23] test: fix test plan --- test/mock-agent.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/mock-agent.js b/test/mock-agent.js index 53f848d0484..8de562569e6 100644 --- a/test/mock-agent.js +++ b/test/mock-agent.js @@ -1031,7 +1031,7 @@ test('MockAgent - getCallHistory with request with a minimal configuration shoul }) test('MockAgent - getCallHistory should register logs on non intercepted call', async (t) => { - t = tspl(t, { plan: 2 }) + t = tspl(t, { plan: 4 }) const server = createServer((req, res) => { res.setHeader('content-type', 'text/plain') @@ -1057,7 +1057,9 @@ test('MockAgent - getCallHistory should register logs on non intercepted call', await request(secondeBaseUrl) t.ok(mockAgent.getCallHistory().calls().length === 2) - t.ok(mockAgent.getCallHistory('second-history').calls().length === 1) + t.ok(mockAgent.getCallHistory().firstCall()?.origin === baseUrl) + t.ok(mockAgent.getCallHistory('second-history')?.calls().length === 1) + t.ok(mockAgent.getCallHistory('second-history')?.firstCall()?.origin === secondeBaseUrl) }) test('MockAgent - clearAllCallHistory should clear all call histories', async (t) => { From 4a13b931b088138acd02ca0a42aad23954625899 Mon Sep 17 00:00:00 2001 From: Blephy Date: Mon, 27 Jan 2025 01:26:02 +0100 Subject: [PATCH 08/23] docs: update documentation --- docs/docs/api/MockAgent.md | 124 ++++++++++++++++++++ docs/docs/best-practices/mocking-request.md | 67 +++++++++++ lib/mock/mock-call-history.js | 14 ++- 3 files changed, 204 insertions(+), 1 deletion(-) diff --git a/docs/docs/api/MockAgent.md b/docs/docs/api/MockAgent.md index 70d479ac618..3f5d9bf5575 100644 --- a/docs/docs/api/MockAgent.md +++ b/docs/docs/api/MockAgent.md @@ -179,7 +179,9 @@ for await (const data of result2.body) { console.log('data', data.toString('utf8')) // data hello } ``` + #### Example - Mock different requests within the same file + ```js const { MockAgent, setGlobalDispatcher } = require('undici'); const agent = new MockAgent(); @@ -540,3 +542,125 @@ agent.assertNoPendingInterceptors() // │ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ // └─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ ``` + +#### Example - access call history on MockAgent + +By default, every call made within a MockAgent have their request configuration historied + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +await request('http://example.com', { query: { item: 1 }}) + +mockAgent.getCallHistory().firstCall() +// Returns +// MockCallHistoryLog { +// body: undefined, +// headers: undefined, +// method: 'GET', +// origin: 'http://example.com', +// fullUrl: 'http://example.com/?item=1', +// path: '/', +// searchParams: { item: '1' }, +// protocol: 'http:', +// host: 'example.com', +// port: '' +// } +``` + +#### Example - access call history on intercepted client + +You can use `registerCallHistory` to register a specific MockCallHistory instance while you are intercepting request. This is useful to have an history already filtered. Note that `getCallHistory()` will still register every request configuration. + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const client = mockAgent.get('http://example.com') + +client.intercept({ path: '/', method: 'GET' }).reply(200, 'hi !').registerCallHistory('my-specific-history-name') + +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() +// Returns [ +// MockCallHistoryLog { +// body: undefined, +// headers: undefined, +// method: 'GET', +// origin: 'http://example.com', +// fullUrl: 'http://example.com/', +// path: '/', +// searchParams: {}, +// protocol: 'http:', +// host: 'example.com', +// port: '' +// } +// ] + +mockAgent.getCallHistory().calls() +// Returns [ +// MockCallHistoryLog { +// body: undefined, +// headers: undefined, +// method: 'GET', +// origin: 'http://example.com', +// fullUrl: 'http://example.com/', +// path: '/', +// searchParams: {}, +// protocol: 'http:', +// host: 'example.com', +// port: '' +// }, +// MockCallHistoryLog { +// body: "{ "data": "hello" }", +// headers: { 'content-type': 'application/json' }, +// method: 'POST', +// origin: 'http://example.com', +// fullUrl: 'http://example.com/', +// path: '/', +// searchParams: {}, +// protocol: 'http:', +// host: 'example.com', +// port: '' +// } +// ] +``` + +#### Example - clear call history + +Clear all call history registered : + +```js +const mockAgent = new MockAgent() + +mockAgent.clearAllCallHistory() +``` + +Clear only one call history : + +```js +const mockAgent = new MockAgent() + +mockAgent.getCallHistory().clear() +mockAgent.getCallHistory('my-history')?.clear() +``` + +#### Example - call history instance class method + +```js +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 +``` diff --git a/docs/docs/best-practices/mocking-request.md b/docs/docs/best-practices/mocking-request.md index 68831931ae8..95cdb124be2 100644 --- a/docs/docs/best-practices/mocking-request.md +++ b/docs/docs/best-practices/mocking-request.md @@ -75,6 +75,73 @@ assert.deepEqual(badRequest, { message: 'bank account not found' }) Explore other MockAgent functionality [here](/docs/docs/api/MockAgent.md) +## Access agent history + +Using a MockAgent also allows you to make assertions on the configuration used to make your http calls in your application. + +Here is an example : + +```js +// index.test.mjs +import { strict as assert } from 'assert' +import { MockAgent, setGlobalDispatcher, fetch } from 'undici' +import { app } from './app.mjs' + +// given an application server running on http://localhost:3000 +await app.start() + +const mockAgent = new MockAgent() + +setGlobalDispatcher(mockAgent) + +// this call is made (not intercepted) +await fetch(`http://localhost:3000/endpoint?query='hello'`, { + method: 'POST', + headers: { 'content-type': 'application/json' } + body: JSON.stringify({ data: '' }) +}) + +// access to the call history of the MockAgent (which register every call made intercepted or not) +assert.ok(mockAgent.getCallHistory().calls().length === 1) +assert.strictEqual(mockAgent.getCallHistory().firstCall()?.fullUrl, `http://localhost:3000/endpoint?query='hello'`) +assert.strictEqual(mockAgent.getCallHistory().firstCall()?.body, JSON.stringify({ data: '' })) +assert.deepStrictEqual(mockAgent.getCallHistory().firstCall()?.searchParams, { query: 'hello' }) +assert.strictEqual(mockAgent.getCallHistory().firstCall()?.port, '3000') +assert.strictEqual(mockAgent.getCallHistory().firstCall()?.host, 'localhost:3000') +assert.strictEqual(mockAgent.getCallHistory().firstCall()?.method, 'POST') +assert.strictEqual(mockAgent.getCallHistory().firstCall()?.path, '/endpoint') +assert.deepStrictEqual(mockAgent.getCallHistory().firstCall()?.headers, { 'content-type': 'application/json' }) + +// register a specific call history for a given interceptor (useful to filter call within a particular interceptor) +const mockPool = mockAgent.get('http://localhost:3000'); + +// we intercept a call and we register a specific MockCallHistory +mockPool.intercept({ + path: '/second-endpoint', +}).reply(200, 'hello').registerCallHistory('second-endpoint-history') + +assert.ok(mockAgent.getCallHistory().calls().length === 2) // MockAgent call history has registered the call too +assert.ok(mockAgent.getCallHistory('second-endpoint-history')?.calls().length === 1) +assert.strictEqual(mockAgent.getCallHistory('second-endpoint-history')?.firstCall()?.path, '/second-endpoint') +assert.strictEqual(mockAgent.getCallHistory('second-endpoint-history')?.firstCall()?.method, 'GET') + +// clearing all call history + +mockAgent.clearAllCallHistory() + +assert.ok(mockAgent.getCallHistory().calls().length === 0) +assert.ok(mockAgent.getCallHistory('second-endpoint-history')?.calls().length === 0) + +// clearing a particular history + +mockAgent.getCallHistory().clear() // second-endpoint-history will not be cleared +mockAgent.getCallHistory('second-endpoint-history').clear() // it is not cleared +``` + +Calling `mockAgent.close()` will automatically clear and delete every call history for you. + +Explore other MockAgent functionality [here](/docs/docs/api/MockAgent.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 5e5e5d22e4b..85a241080a4 100644 --- a/lib/mock/mock-call-history.js +++ b/lib/mock/mock-call-history.js @@ -6,6 +6,7 @@ const { kMockCallHistoryClearAll, kMockCallHistoryDeleteAll } = require('./mock-symbols') +const { InvalidArgumentError } = require('../core/errors') const computingError = 'error occurred when computing MockCallHistoryLog.url' @@ -96,7 +97,18 @@ class MockCallHistory { } nthCall (number) { - return this.logs.at(number) + if (typeof number !== 'number') { + throw new InvalidArgumentError('nthCall must be called with a number') + } + if (!Number.isInteger(number)) { + throw new InvalidArgumentError('nthCall must be called with an integer') + } + if (Math.sign(number) !== 1) { + throw new InvalidArgumentError('nthCall must be called with a positive value. use firstCall or lastCall instead') + } + + // non zero based index. this is more human readable + return this.logs.at(number - 1) } clear () { From 45b147edb0a84c40b0d352ed69677ac09966b4cd Mon Sep 17 00:00:00 2001 From: Blephy Date: Mon, 27 Jan 2025 14:50:57 +0100 Subject: [PATCH 09/23] feat: add filter function utils --- lib/mock/mock-call-history.js | 126 +++++++++++++++ test/mock-call-history-log.js | 100 ++++++++++++ test/mock-call-history.js | 297 ++++++++++++++++++++++++++++++++++ types/index.d.ts | 1 + types/mock-call-history.d.ts | 73 +++++++-- 5 files changed, 582 insertions(+), 15 deletions(-) create mode 100644 test/mock-call-history-log.js create mode 100644 test/mock-call-history.js diff --git a/lib/mock/mock-call-history.js b/lib/mock/mock-call-history.js index 85a241080a4..7518159240c 100644 --- a/lib/mock/mock-call-history.js +++ b/lib/mock/mock-call-history.js @@ -10,6 +10,25 @@ const { InvalidArgumentError } = require('../core/errors') const computingError = 'error occurred when computing MockCallHistoryLog.url' +function makeFilterCalls (parameterName) { + return (parameterValue) => { + if (typeof parameterValue !== 'string' && !(parameterValue instanceof RegExp) && parameterValue != null) { + throw new InvalidArgumentError(`${parameterName} parameter should be one of string, regexp, undefined or null`) + } + if (typeof parameterValue === 'string' || parameterValue == null) { + return this.logs.filter((log) => { + return log[parameterName] === parameterValue + }) + } + if (parameterValue instanceof RegExp) { + return this.logs.filter((log) => { + return parameterValue.test(log[parameterName]) + }) + } + + return [] + } +} function computeUrlWithMaybeSearchParameters (requestInit) { // path can contains query url parameters // or query can contains query url parameters @@ -49,14 +68,50 @@ class MockCallHistoryLog { this.protocol = url.protocol this.host = url.host this.port = url.port + this.hash = url.hash } else { + // guard if new URL or new URLSearchParams failed. Should never happens, request initialization will fail before this.path = computingError this.searchParams = computingError this.protocol = computingError this.host = computingError this.port = computingError + this.hash = computingError } } + + toMap () { + return new Map([ + ['protocol', this.protocol], + ['host', this.host], + ['port', this.port], + ['origin', this.origin], + ['path', this.path], + ['hash', this.hash], + ['searchParams', this.searchParams], + ['fullUrl', this.fullUrl], + ['method', this.method], + ['body', this.body], + ['headers', this.headers]] + ) + } + + toString () { + let result = '' + + this.toMap().forEach((value, key) => { + if (typeof value === 'string' || value === undefined || value === null) { + result = `${result}${key}->${value}|` + } + if ((typeof value === 'object' && value !== null) || Array.isArray(value)) { + result = `${result}${key}->${JSON.stringify(value)}|` + } + // maybe miss something for non Record / Array headers and searchParams here + }) + + // delete last suffix + return result.slice(0, -1) + } } class MockCallHistory { @@ -111,6 +166,77 @@ class MockCallHistory { return this.logs.at(number - 1) } + filterCalls (criteria) { + // perf + if (this.logs.length === 0) { + return this.logs + } + if (typeof criteria === 'function') { + return this.logs.filter((log) => { + return criteria(log) + }) + } + if (criteria instanceof RegExp) { + return this.logs.filter((log) => { + return criteria.test(log.toString()) + }) + } + if (typeof criteria === 'object' && criteria !== null) { + // no criteria - returning all logs + if (Object.keys(criteria).length === 0) { + return this.logs + } + + const maybeDuplicatedLogsFiltered = [] + if ('protocol' in criteria) { + maybeDuplicatedLogsFiltered.push(...this.filterCallsByProtocol(criteria.protocol)) + } + if ('host' in criteria) { + maybeDuplicatedLogsFiltered.push(...this.filterCallsByHost(criteria.host)) + } + if ('port' in criteria) { + maybeDuplicatedLogsFiltered.push(...this.filterCallsByPort(criteria.port)) + } + if ('origin' in criteria) { + maybeDuplicatedLogsFiltered.push(...this.filterCallsByOrigin(criteria.origin)) + } + if ('path' in criteria) { + maybeDuplicatedLogsFiltered.push(...this.filterCallsByPath(criteria.path)) + } + if ('hash' in criteria) { + maybeDuplicatedLogsFiltered.push(...this.filterCallsByHash(criteria.hash)) + } + if ('fullUrl' in criteria) { + maybeDuplicatedLogsFiltered.push(...this.filterCallsByFullUrl(criteria.fullUrl)) + } + if ('method' in criteria) { + maybeDuplicatedLogsFiltered.push(...this.filterCallsByMethod(criteria.method)) + } + + const uniqLogsFiltered = [...new Set(maybeDuplicatedLogsFiltered)] + + return uniqLogsFiltered + } + + throw new InvalidArgumentError('criteria parameter should be one of string, function, regexp, or object') + } + + filterCallsByProtocol = makeFilterCalls.call(this, 'protocol') + + filterCallsByHost = makeFilterCalls.call(this, 'host') + + filterCallsByPort = makeFilterCalls.call(this, 'port') + + filterCallsByOrigin = makeFilterCalls.call(this, 'origin') + + filterCallsByPath = makeFilterCalls.call(this, 'path') + + filterCallsByHash = makeFilterCalls.call(this, 'hash') + + filterCallsByFullUrl = makeFilterCalls.call(this, 'fullUrl') + + filterCallsByMethod = makeFilterCalls.call(this, 'method') + clear () { this.logs = [] } diff --git a/test/mock-call-history-log.js b/test/mock-call-history-log.js new file mode 100644 index 00000000000..95e09143502 --- /dev/null +++ b/test/mock-call-history-log.js @@ -0,0 +1,100 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, describe } = require('node:test') +const { MockCallHistoryLog } = require('../lib/mock/mock-call-history') + +describe('MockCallHistoryLog - constructor', () => { + function assertConsistent (t, mockCallHistoryLog) { + t.strictEqual(mockCallHistoryLog.body, null) + t.strictEqual(mockCallHistoryLog.headers, undefined) + t.deepStrictEqual(mockCallHistoryLog.searchParams, { query: 'value' }) + t.strictEqual(mockCallHistoryLog.method, 'PUT') + t.strictEqual(mockCallHistoryLog.origin, 'https://localhost:4000') + t.strictEqual(mockCallHistoryLog.path, '/endpoint') + t.strictEqual(mockCallHistoryLog.hash, '#here') + t.strictEqual(mockCallHistoryLog.protocol, 'https:') + t.strictEqual(mockCallHistoryLog.host, 'localhost:4000') + t.strictEqual(mockCallHistoryLog.port, '4000') + } + + test('should not throw when requestInit is not set', t => { + t = tspl(t, { plan: 1 }) + t.doesNotThrow(() => new MockCallHistoryLog()) + }) + + test('should populate class properties with query in path', t => { + t = tspl(t, { plan: 10 }) + + const mockCallHistoryLog = new MockCallHistoryLog({ + body: null, + headers: undefined, + method: 'PUT', + origin: 'https://localhost:4000', + path: '/endpoint?query=value#here' + }) + + assertConsistent(t, mockCallHistoryLog) + }) + + test('should populate class properties with query in argument', t => { + t = tspl(t, { plan: 10 }) + + const mockCallHistoryLog = new MockCallHistoryLog({ + body: null, + headers: undefined, + method: 'PUT', + origin: 'https://localhost:4000', + path: '/endpoint#here', + query: { query: 'value' } + }) + + assertConsistent(t, mockCallHistoryLog) + }) +}) + +describe('MockCallHistoryLog - toMap', () => { + test('should return a Map of eleven element', t => { + t = tspl(t, { plan: 1 }) + + const mockCallHistoryLog = new MockCallHistoryLog({ + body: '"{}"', + headers: { 'content-type': 'application/json' }, + method: 'PUT', + origin: 'https://localhost:4000', + path: '/endpoint?query=value#here' + }) + + t.strictEqual(mockCallHistoryLog.toMap().size, 11) + }) +}) + +describe('MockCallHistoryLog - toString', () => { + test('should return a string with all property', t => { + t = tspl(t, { plan: 1 }) + + const mockCallHistoryLog = new MockCallHistoryLog({ + body: '"{ "data": "hello" }"', + headers: { 'content-type': 'application/json' }, + method: 'PUT', + origin: 'https://localhost:4000', + path: '/endpoint?query=value#here' + }) + + t.strictEqual(mockCallHistoryLog.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"}') + }) + + test('should return a string when headers is an Array of string Array', t => { + t = tspl(t, { plan: 1 }) + + const mockCallHistoryLog = new MockCallHistoryLog({ + body: '"{ "data": "hello" }"', + headers: ['content-type', ['application/json', 'application/xml']], + method: 'PUT', + origin: 'https://localhost:4000', + path: '/endpoint?query=value#here' + }) + + t.strictEqual(mockCallHistoryLog.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","application/xml"]]') + }) +}) diff --git a/test/mock-call-history.js b/test/mock-call-history.js new file mode 100644 index 00000000000..3769a1f6f85 --- /dev/null +++ b/test/mock-call-history.js @@ -0,0 +1,297 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, describe, after } = require('node:test') +const { MockCallHistory } = require('../lib/mock/mock-call-history') +const { kMockCallHistoryDeleteAll, kMockCallHistoryAddLog, kMockCallHistoryClearAll } = require('../lib/mock/mock-symbols') +const { InvalidArgumentError } = require('../lib/core/errors') + +describe('MockCallHistory - constructor', () => { + test('should returns a MockCallHistory', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistory = new MockCallHistory('hello') + + t.ok(mockCallHistory instanceof MockCallHistory) + }) + + test('should populate static class property', t => { + t = tspl(t, { plan: 3 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + t.strictEqual(MockCallHistory.AllMockCallHistory.size, 0) + + const mockCallHistory = new MockCallHistory('hello') + + t.strictEqual(MockCallHistory.AllMockCallHistory.size, 1) + t.strictEqual(MockCallHistory.AllMockCallHistory.get('hello'), mockCallHistory) + }) +}) + +describe('MockCallHistory - ClearAll', () => { + test('should clear all call history', t => { + t = tspl(t, { plan: 6 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + const mockCallHistoryWorld = new MockCallHistory('world') + + mockCallHistoryWorld[kMockCallHistoryAddLog]({}) + mockCallHistoryHello[kMockCallHistoryAddLog]({}) + mockCallHistoryHello[kMockCallHistoryAddLog]({}) + + t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) + t.strictEqual(mockCallHistoryWorld.calls().length, 1) + t.strictEqual(mockCallHistoryHello.calls().length, 2) + + MockCallHistory[kMockCallHistoryClearAll]() + + t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) + t.strictEqual(mockCallHistoryWorld.calls().length, 0) + t.strictEqual(mockCallHistoryHello.calls().length, 0) + }) +}) + +describe('MockCallHistory - calls', () => { + test('should returns every logs', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({}) + mockCallHistoryHello[kMockCallHistoryAddLog]({}) + + t.strictEqual(mockCallHistoryHello.calls().length, 2) + }) +}) + +describe('MockCallHistory - calls', () => { + test('should returns every logs', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({}) + mockCallHistoryHello[kMockCallHistoryAddLog]({}) + + t.strictEqual(mockCallHistoryHello.calls().length, 2) + }) + + test('should returns empty array when no logs', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + t.ok(mockCallHistoryHello.calls() instanceof Array) + }) +}) + +describe('MockCallHistory - firstCall', () => { + test('should returns the first log registered', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'http://localhost:4000' }) + + t.strictEqual(mockCallHistoryHello.firstCall()?.path, '/') + }) + + test('should returns undefined when no logs', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + t.strictEqual(mockCallHistoryHello.firstCall(), undefined) + }) +}) + +describe('MockCallHistory - lastCall', () => { + test('should returns the first log registered', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'http://localhost:4000' }) + + t.strictEqual(mockCallHistoryHello.lastCall()?.path, '/noop') + }) + + test('should returns undefined when no logs', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + t.strictEqual(mockCallHistoryHello.lastCall(), undefined) + }) +}) + +describe('MockCallHistory - nthCall', () => { + test('should returns the nth log registered', t => { + t = tspl(t, { plan: 2 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'http://localhost:4000' }) + + t.strictEqual(mockCallHistoryHello.nthCall(1)?.path, '/') + t.strictEqual(mockCallHistoryHello.nthCall(2)?.path, '/noop') + }) + + test('should returns undefined when no logs', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + t.strictEqual(mockCallHistoryHello.nthCall(3), undefined) + }) + + test('should throw if index is not a number', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + t.throws(() => mockCallHistoryHello.nthCall('noop'), new InvalidArgumentError('nthCall must be called with a number')) + }) + + test('should throw if index is not an integer', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + t.throws(() => mockCallHistoryHello.nthCall(1.3), new InvalidArgumentError('nthCall must be called with an integer')) + }) + + test('should throw if index is equal to zero', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + t.throws(() => mockCallHistoryHello.nthCall(0), new InvalidArgumentError('nthCall must be called with a positive value. use firstCall or lastCall instead')) + }) + + test('should throw if index is negative', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + t.throws(() => mockCallHistoryHello.nthCall(-1), new InvalidArgumentError('nthCall must be called with a positive value. use firstCall or lastCall instead')) + }) +}) + +describe('MockCallHistory - filterCalls', () => { + test('should filter logs with a function', t => { + t = tspl(t, { plan: 2 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'http://localhost:4000' }) + + const filtered = mockCallHistoryHello.filterCalls((log) => log.path === '/noop') + + t.strictEqual(filtered?.[0]?.path, '/noop') + t.strictEqual(filtered.length, 1) + }) + + test('should filter logs with a regexp', t => { + t = tspl(t, { plan: 2 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'https://localhost:4000' }) + + const filtered = mockCallHistoryHello.filterCalls(/https:\/\//) + + t.strictEqual(filtered?.[0]?.path, '/noop') + t.strictEqual(filtered.length, 1) + }) + + test('should filter logs with an object', t => { + t = tspl(t, { plan: 2 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/yes', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'https://localhost:4000' }) + + const filtered = mockCallHistoryHello.filterCalls({ protocol: 'https:' }) + + t.strictEqual(filtered?.[0]?.path, '/noop') + t.strictEqual(filtered.length, 1) + }) + + test('should filter multiple time logs with an 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: '/yes', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'https://localhost:4000' }) + + const filtered = mockCallHistoryHello.filterCalls({ protocol: 'https:', path: /^\/$/ }) + + t.strictEqual(filtered.length, 2) + }) + + test('should returns no duplicated logs', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/yes', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'https://localhost:4000' }) + + const filtered = mockCallHistoryHello.filterCalls({ protocol: 'https:', origin: /localhost/ }) + + t.strictEqual(filtered.length, 3) + }) + + test('should throw if criteria is typeof number', 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: 3 }), new InvalidArgumentError('path parameter should be one of string, regexp, undefined or null')) + }) + + test('should throw if criteria is not a function, regexp, nor object', 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(3), new InvalidArgumentError('criteria parameter should be one of string, function, regexp, or object')) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 3174b324200..62e03f549f5 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -55,6 +55,7 @@ declare namespace Undici { const MockClient: typeof import('./mock-client').default const MockPool: typeof import('./mock-pool').default const MockAgent: typeof import('./mock-agent').default + const MockCallHistory: typeof import('./mock-call-history') const mockErrors: typeof import('./mock-errors').default const fetch: typeof import('./fetch').fetch const Headers: typeof import('./fetch').Headers diff --git a/types/mock-call-history.d.ts b/types/mock-call-history.d.ts index 7280278bfe7..23fe92e254b 100644 --- a/types/mock-call-history.d.ts +++ b/types/mock-call-history.d.ts @@ -1,29 +1,54 @@ import Dispatcher from './dispatcher' +type MockCallHistoryLogProperties = 'protocol' | 'host' | 'port' | 'origin' | 'path' | 'hash' | 'fullUrl' | 'method' | 'searchParams' | 'body' | 'headers' + declare class MockCallHistoryLog { constructor (requestInit: Dispatcher.DispatchOptions) - /** request's body */ - body: Dispatcher.DispatchOptions['body'] - /** request's headers */ - headers: Dispatcher.DispatchOptions['headers'] - /** request's origin. ie. https://localhost:3000. */ - origin: string - /** request's method. */ - method: Dispatcher.DispatchOptions['method'] - /** the full url requested. */ - fullUrl: string - /** path. never contains searchParams. */ - path: string - /** search params. */ - searchParams: Record /** protocol used. */ protocol: string /** request's host. ie. 'https:' or 'http:' etc... */ host: string /** request's port. */ port: string + /** request's origin. ie. https://localhost:3000. */ + origin: string + /** path. never contains searchParams. */ + path: string + /** request's hash. */ + hash: string + /** the full url requested. */ + fullUrl: string + /** request's method. */ + method: Dispatcher.DispatchOptions['method'] + /** search params. */ + searchParams: Record + /** request's body */ + body: Dispatcher.DispatchOptions['body'] + /** request's headers */ + headers: Dispatcher.DispatchOptions['headers'] + + /** return an Map of property / value pair */ + toMap (): Map + + /** return a string computed with all properties value */ + toString (): string } +interface FilterCallsObjectCriteria extends Record { + protocol?: FilterCallsParameter; + host?: FilterCallsParameter; + port?: FilterCallsParameter; + origin?: FilterCallsParameter; + path?: FilterCallsParameter; + hash?: FilterCallsParameter; + fullUrl?: FilterCallsParameter; + method?: FilterCallsParameter; +} + +type FilterCallFunctionCriteria = (log: MockCallHistoryLog) => boolean + +type FilterCallsParameter = string | RegExp | undefined | null + declare class MockCallHistory { constructor (name: string) /** returns an array of MockCallHistoryLog. */ @@ -34,8 +59,26 @@ declare class MockCallHistory { lastCall (): MockCallHistoryLog | undefined /** returns the nth MockCallHistoryLog. */ nthCall (position: number): MockCallHistoryLog | undefined + /** return all MockCallHistoryLog matching any of criteria given. */ + filterCalls (criteria: FilterCallsObjectCriteria | FilterCallFunctionCriteria | RegExp): Array + /** return all MockCallHistoryLog matching the given protocol. if a string is given, it is matched with includes */ + filterCallsByProtocol (protocol: FilterCallsParameter): Array + /** return all MockCallHistoryLog matching the given host. if a string is given, it is matched with includes */ + filterCallsByHost (host: FilterCallsParameter): Array + /** return all MockCallHistoryLog matching the given port. if a string is given, it is matched with includes */ + filterCallsByPort (port: FilterCallsParameter): Array + /** return all MockCallHistoryLog matching the given origin. if a string is given, it is matched with includes */ + filterCallsByOrigin (origin: FilterCallsParameter): Array + /** return all MockCallHistoryLog matching the given path. if a string is given, it is matched with includes */ + filterCallsByPath (path: FilterCallsParameter): Array + /** return all MockCallHistoryLog matching the given hash. if a string is given, it is matched with includes */ + filterCallsByHash (hash: FilterCallsParameter): Array + /** return all MockCallHistoryLog matching the given fullUrl. if a string is given, it is matched with includes */ + filterCallsByFullUrl (fullUrl: FilterCallsParameter): Array + /** return all MockCallHistoryLog matching the given method. if a string is given, it is matched with includes */ + filterCallsByMethod (method: FilterCallsParameter): Array /** clear all MockCallHistoryLog on this MockCallHistory. */ clear (): void } -export { MockCallHistoryLog, MockCallHistory } +export { MockCallHistoryLog, MockCallHistory, FilterCallsObjectCriteria, FilterCallFunctionCriteria, FilterCallsParameter, MockCallHistoryLogProperties } From 74bbe05812cf0448dabd846ed3937fa466c10d43 Mon Sep 17 00:00:00 2001 From: Blephy Date: Mon, 27 Jan 2025 14:53:50 +0100 Subject: [PATCH 10/23] chore: own review --- types/mock-agent.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/types/mock-agent.d.ts b/types/mock-agent.d.ts index 5890c52bcf1..bcca7e43a42 100644 --- a/types/mock-agent.d.ts +++ b/types/mock-agent.d.ts @@ -35,6 +35,7 @@ declare class MockAgent Date: Tue, 28 Jan 2025 10:48:52 +0100 Subject: [PATCH 11/23] docs: use node: protocol in example --- docs/docs/best-practices/mocking-request.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/best-practices/mocking-request.md b/docs/docs/best-practices/mocking-request.md index 95cdb124be2..60e7bcc4b00 100644 --- a/docs/docs/best-practices/mocking-request.md +++ b/docs/docs/best-practices/mocking-request.md @@ -29,7 +29,7 @@ And this is what the test file looks like: ```js // index.test.mjs -import { strict as assert } from 'assert' +import { strict as assert } from 'node:assert' import { MockAgent, setGlobalDispatcher, } from 'undici' import { bankTransfer } from './bank.mjs' @@ -83,7 +83,7 @@ Here is an example : ```js // index.test.mjs -import { strict as assert } from 'assert' +import { strict as assert } from 'node:assert' import { MockAgent, setGlobalDispatcher, fetch } from 'undici' import { app } from './app.mjs' From 5121e8ae5627ab2584020095753140baa5e85b6f Mon Sep 17 00:00:00 2001 From: Blephy Date: Tue, 28 Jan 2025 20:21:28 +0100 Subject: [PATCH 12/23] chore: refactor and add tests --- docs/docs/api/MockAgent.md | 23 +- docs/docs/best-practices/mocking-request.md | 39 ++-- lib/mock/mock-agent.js | 55 +++-- lib/mock/mock-call-history.js | 66 +++--- lib/mock/mock-interceptor.js | 12 +- lib/mock/mock-symbols.js | 11 +- lib/mock/mock-utils.js | 18 +- test/mock-agent.js | 227 +++++++++++++------- test/mock-call-history-log.js | 12 +- test/mock-call-history.js | 164 +++++++++++--- types/index.d.ts | 3 +- types/mock-agent.d.ts | 15 +- types/mock-call-history.d.ts | 4 +- 13 files changed, 448 insertions(+), 201 deletions(-) diff --git a/docs/docs/api/MockAgent.md b/docs/docs/api/MockAgent.md index 3f5d9bf5575..7d671ecb507 100644 --- a/docs/docs/api/MockAgent.md +++ b/docs/docs/api/MockAgent.md @@ -545,17 +545,19 @@ agent.assertNoPendingInterceptors() #### Example - access call history on MockAgent -By default, every call made within a MockAgent have their request configuration historied +You can register every call made within a MockAgent to be able to retrieve the body, headers and so on. + +This is not enabled by default. ```js import { MockAgent, setGlobalDispatcher, request } from 'undici' -const mockAgent = new MockAgent() +const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) await request('http://example.com', { query: { item: 1 }}) -mockAgent.getCallHistory().firstCall() +mockAgent.getCallHistory()?.firstCall() // Returns // MockCallHistoryLog { // body: undefined, @@ -573,12 +575,14 @@ mockAgent.getCallHistory().firstCall() #### Example - access call history on intercepted client -You can use `registerCallHistory` to register a specific MockCallHistory instance while you are intercepting request. This is useful to have an history already filtered. Note that `getCallHistory()` will still register every request configuration. +You can use `registerCallHistory` to register a specific MockCallHistory instance while you are intercepting request. This is useful to have an history already filtered. Note that `getCallHistory()` will still register every request configuration if you previously enable call history. + +> using registerCallHistory with a disabled MockAgent will still register an history on the intercepted request. ```js import { MockAgent, setGlobalDispatcher, request } from 'undici' -const mockAgent = new MockAgent() +const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) const client = mockAgent.get('http://example.com') @@ -604,7 +608,7 @@ mockAgent.getCallHistory('my-specific-history-name').calls() // } // ] -mockAgent.getCallHistory().calls() +mockAgent.getCallHistory()?.calls() // Returns [ // MockCallHistoryLog { // body: undefined, @@ -648,7 +652,7 @@ Clear only one call history : ```js const mockAgent = new MockAgent() -mockAgent.getCallHistory().clear() +mockAgent.getCallHistory()?.clear() mockAgent.getCallHistory('my-history')?.clear() ``` @@ -663,4 +667,9 @@ 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 ``` diff --git a/docs/docs/best-practices/mocking-request.md b/docs/docs/best-practices/mocking-request.md index 60e7bcc4b00..0a37da3610c 100644 --- a/docs/docs/best-practices/mocking-request.md +++ b/docs/docs/best-practices/mocking-request.md @@ -77,7 +77,7 @@ Explore other MockAgent functionality [here](/docs/docs/api/MockAgent.md) ## Access agent history -Using a MockAgent also allows you to make assertions on the configuration used to make your http calls in your application. +Using a MockAgent also allows you to make assertions on the configuration used to make your request in your application. Here is an example : @@ -90,7 +90,10 @@ import { app } from './app.mjs' // given an application server running on http://localhost:3000 await app.start() -const mockAgent = new MockAgent() +// enable call history at instantiation +const mockAgent = new MockAgent({ enableCallHistory: true }) +// or after instantiation +mockAgent.enableCallHistory() setGlobalDispatcher(mockAgent) @@ -102,15 +105,15 @@ await fetch(`http://localhost:3000/endpoint?query='hello'`, { }) // access to the call history of the MockAgent (which register every call made intercepted or not) -assert.ok(mockAgent.getCallHistory().calls().length === 1) -assert.strictEqual(mockAgent.getCallHistory().firstCall()?.fullUrl, `http://localhost:3000/endpoint?query='hello'`) -assert.strictEqual(mockAgent.getCallHistory().firstCall()?.body, JSON.stringify({ data: '' })) -assert.deepStrictEqual(mockAgent.getCallHistory().firstCall()?.searchParams, { query: 'hello' }) -assert.strictEqual(mockAgent.getCallHistory().firstCall()?.port, '3000') -assert.strictEqual(mockAgent.getCallHistory().firstCall()?.host, 'localhost:3000') -assert.strictEqual(mockAgent.getCallHistory().firstCall()?.method, 'POST') -assert.strictEqual(mockAgent.getCallHistory().firstCall()?.path, '/endpoint') -assert.deepStrictEqual(mockAgent.getCallHistory().firstCall()?.headers, { 'content-type': 'application/json' }) +assert.ok(mockAgent.getCallHistory()?.calls().length === 1) +assert.strictEqual(mockAgent.getCallHistory()?.firstCall()?.fullUrl, `http://localhost:3000/endpoint?query='hello'`) +assert.strictEqual(mockAgent.getCallHistory()?.firstCall()?.body, JSON.stringify({ data: '' })) +assert.deepStrictEqual(mockAgent.getCallHistory()?.firstCall()?.searchParams, { query: 'hello' }) +assert.strictEqual(mockAgent.getCallHistory()?.firstCall()?.port, '3000') +assert.strictEqual(mockAgent.getCallHistory()?.firstCall()?.host, 'localhost:3000') +assert.strictEqual(mockAgent.getCallHistory()?.firstCall()?.method, 'POST') +assert.strictEqual(mockAgent.getCallHistory()?.firstCall()?.path, '/endpoint') +assert.deepStrictEqual(mockAgent.getCallHistory()?.firstCall()?.headers, { 'content-type': 'application/json' }) // register a specific call history for a given interceptor (useful to filter call within a particular interceptor) const mockPool = mockAgent.get('http://localhost:3000'); @@ -118,24 +121,24 @@ const mockPool = mockAgent.get('http://localhost:3000'); // we intercept a call and we register a specific MockCallHistory mockPool.intercept({ path: '/second-endpoint', -}).reply(200, 'hello').registerCallHistory('second-endpoint-history') +}) +.reply(200, 'hello') +.registerCallHistory('second-endpoint-history') -assert.ok(mockAgent.getCallHistory().calls().length === 2) // MockAgent call history has registered the call too +assert.ok(mockAgent.getCallHistory()?.calls().length === 2) // MockAgent call history has registered the call too assert.ok(mockAgent.getCallHistory('second-endpoint-history')?.calls().length === 1) assert.strictEqual(mockAgent.getCallHistory('second-endpoint-history')?.firstCall()?.path, '/second-endpoint') assert.strictEqual(mockAgent.getCallHistory('second-endpoint-history')?.firstCall()?.method, 'GET') // clearing all call history - mockAgent.clearAllCallHistory() -assert.ok(mockAgent.getCallHistory().calls().length === 0) +assert.ok(mockAgent.getCallHistory()?.calls().length === 0) assert.ok(mockAgent.getCallHistory('second-endpoint-history')?.calls().length === 0) // clearing a particular history - -mockAgent.getCallHistory().clear() // second-endpoint-history will not be cleared -mockAgent.getCallHistory('second-endpoint-history').clear() // it is not cleared +mockAgent.getCallHistory()?.clear() // second-endpoint-history will not be cleared +mockAgent.getCallHistory('second-endpoint-history').clear() // second-endpoint-history is now cleared ``` Calling `mockAgent.close()` will automatically clear and delete every call history for you. diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 9ab473f15cf..f3c94a4ae76 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -12,11 +12,14 @@ const { kGetNetConnect, kOptions, kFactory, - kMockCallHistory, - kMockAgentMockCallHistory, + kMockAgentMockCallHistoryName, + kMockAgentRegisterCallHistory, + kMockAgentIsCallHistoryEnabled, + kMockAgentAddCallHistoryLog, kMockCallHistoryGetByName, kMockCallHistoryClearAll, kMockCallHistoryDeleteAll, + kMockCallHistoryCreate, kMockCallHistoryAddLog } = require('./mock-symbols') const MockClient = require('./mock-client') @@ -33,6 +36,7 @@ class MockAgent extends Dispatcher { this[kNetConnect] = true this[kIsMockActive] = true + this[kMockAgentIsCallHistoryEnabled] = Boolean(opts?.enableCallHistory) // Instantiate Agent and encapsulate if (opts?.agent && typeof opts.agent.dispatch !== 'function') { @@ -43,7 +47,10 @@ class MockAgent extends Dispatcher { this[kClients] = agent[kClients] this[kOptions] = buildMockOptions(opts) - this[kMockCallHistory] = new MockCallHistory(kMockAgentMockCallHistory) + + if (this[kMockAgentIsCallHistoryEnabled]) { + this[kMockAgentRegisterCallHistory]() + } } get (origin) { @@ -60,22 +67,15 @@ class MockAgent extends Dispatcher { // Call MockAgent.get to perform additional setup before dispatching as normal this.get(opts.origin) - // guard if mockAgent.close (which delete all history) was called before a dispatch by inadvertency - // using MockCallHistory[kMockCallHistoryGetByName] instead of this[kMockCallHistory] because this[kMockCallHistory] would then be still populated - if (MockCallHistory[kMockCallHistoryGetByName](kMockAgentMockCallHistory) === undefined) { - this[kMockCallHistory] = new MockCallHistory(kMockAgentMockCallHistory) - } - - // add call history log even on non intercepted and intercepted calls (every call) - this[kMockCallHistory][kMockCallHistoryAddLog](opts) + this[kMockAgentAddCallHistoryLog](opts) return this[kAgent].dispatch(opts, handler) } async close () { + MockCallHistory[kMockCallHistoryDeleteAll]() await this[kAgent].close() this[kClients].clear() - MockCallHistory[kMockCallHistoryDeleteAll]() } deactivate () { @@ -104,9 +104,21 @@ class MockAgent extends Dispatcher { this[kNetConnect] = false } + enableCallHistory () { + this[kMockAgentIsCallHistoryEnabled] = true + + return this + } + + disableCallHistory () { + this[kMockAgentIsCallHistoryEnabled] = false + + return this + } + getCallHistory (name) { if (name == null) { - return MockCallHistory[kMockCallHistoryGetByName](kMockAgentMockCallHistory) + return MockCallHistory[kMockCallHistoryGetByName](kMockAgentMockCallHistoryName) } return MockCallHistory[kMockCallHistoryGetByName](name) @@ -122,6 +134,23 @@ class MockAgent extends Dispatcher { return this[kIsMockActive] } + [kMockAgentRegisterCallHistory] () { + MockCallHistory[kMockCallHistoryCreate](kMockAgentMockCallHistoryName) + } + + [kMockAgentAddCallHistoryLog] (opts) { + if (this[kMockAgentIsCallHistoryEnabled]) { + // guard if mockAgent.close() (which delete all history) was called before a dispatch by inadvertency + const maybeMockAgentCallHistory = this.getCallHistory() + if (maybeMockAgentCallHistory === undefined) { + this[kMockAgentRegisterCallHistory]() + } + + // add call history log on every call (intercepted or not) + this.getCallHistory()[kMockCallHistoryAddLog](opts) + } + } + [kMockAgentSet] (origin, dispatcher) { this[kClients].set(origin, dispatcher) } diff --git a/lib/mock/mock-call-history.js b/lib/mock/mock-call-history.js index 7518159240c..345b0dc105a 100644 --- a/lib/mock/mock-call-history.js +++ b/lib/mock/mock-call-history.js @@ -4,17 +4,14 @@ const { kMockCallHistoryAddLog, kMockCallHistoryGetByName, kMockCallHistoryClearAll, - kMockCallHistoryDeleteAll + kMockCallHistoryDeleteAll, + kMockCallHistoryCreate, + kMockCallHistoryAllMockCallHistoryInstances } = require('./mock-symbols') const { InvalidArgumentError } = require('../core/errors') -const computingError = 'error occurred when computing MockCallHistoryLog.url' - function makeFilterCalls (parameterName) { return (parameterValue) => { - if (typeof parameterValue !== 'string' && !(parameterValue instanceof RegExp) && parameterValue != null) { - throw new InvalidArgumentError(`${parameterName} parameter should be one of string, regexp, undefined or null`) - } if (typeof parameterValue === 'string' || parameterValue == null) { return this.logs.filter((log) => { return log[parameterName] === parameterValue @@ -26,7 +23,7 @@ function makeFilterCalls (parameterName) { }) } - return [] + throw new InvalidArgumentError(`${parameterName} parameter should be one of string, regexp, undefined or null`) } } function computeUrlWithMaybeSearchParameters (requestInit) { @@ -45,9 +42,8 @@ function computeUrlWithMaybeSearchParameters (requestInit) { url.search = new URLSearchParams(requestInit.query).toString() return url - } catch { - // should never happens - return computingError + } catch (error) { + throw new InvalidArgumentError('An error occurred when computing MockCallHistoryLog.url', { cause: error }) } } @@ -56,28 +52,17 @@ class MockCallHistoryLog { this.body = requestInit.body this.headers = requestInit.headers this.method = requestInit.method - this.origin = requestInit.origin const url = computeUrlWithMaybeSearchParameters(requestInit) this.fullUrl = url.toString() - - if (url instanceof URL) { - this.path = url.pathname - this.searchParams = Object.fromEntries(url.searchParams) - this.protocol = url.protocol - this.host = url.host - this.port = url.port - this.hash = url.hash - } else { - // guard if new URL or new URLSearchParams failed. Should never happens, request initialization will fail before - this.path = computingError - this.searchParams = computingError - this.protocol = computingError - this.host = computingError - this.port = computingError - this.hash = computingError - } + this.origin = url.origin + this.path = url.pathname + this.searchParams = Object.fromEntries(url.searchParams) + this.protocol = url.protocol + this.host = url.host + this.port = url.port + this.hash = url.hash } toMap () { @@ -97,46 +82,55 @@ class MockCallHistoryLog { } toString () { + const options = { betweenKeyValueSeparator: '->', betweenPairSeparator: '|' } let result = '' this.toMap().forEach((value, key) => { if (typeof value === 'string' || value === undefined || value === null) { - result = `${result}${key}->${value}|` + result = `${result}${key}${options.betweenKeyValueSeparator}${value}${options.betweenPairSeparator}` } if ((typeof value === 'object' && value !== null) || Array.isArray(value)) { - result = `${result}${key}->${JSON.stringify(value)}|` + result = `${result}${key}${options.betweenKeyValueSeparator}${JSON.stringify(value)}${options.betweenPairSeparator}` } // maybe miss something for non Record / Array headers and searchParams here }) - // delete last suffix + // delete last betweenPairSeparator return result.slice(0, -1) } } class MockCallHistory { - static AllMockCallHistory = new Map() + static [kMockCallHistoryAllMockCallHistoryInstances] = new Map() logs = [] constructor (name) { this.name = name - MockCallHistory.AllMockCallHistory.set(this.name, this) + MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].set(this.name, this) + } + + static [kMockCallHistoryCreate] (name) { + if (MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].has(name)) { + return MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].get(name) + } + + return new MockCallHistory(name) } static [kMockCallHistoryGetByName] (name) { - return MockCallHistory.AllMockCallHistory.get(name) + return MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].get(name) } static [kMockCallHistoryClearAll] () { - for (const callHistory of MockCallHistory.AllMockCallHistory.values()) { + for (const callHistory of MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].values()) { callHistory.clear() } } static [kMockCallHistoryDeleteAll] () { - MockCallHistory.AllMockCallHistory.clear() + MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].clear() } calls () { diff --git a/lib/mock/mock-interceptor.js b/lib/mock/mock-interceptor.js index 5d9b3087191..6b1c5d5f6a9 100644 --- a/lib/mock/mock-interceptor.js +++ b/lib/mock/mock-interceptor.js @@ -9,8 +9,8 @@ const { kContentLength, kMockDispatch, kIgnoreTrailingSlash, - kMockCallHistory, - kMockCallHistoryGetByName + kMockCallHistoryCreate, + kMockDispatchCallHistoryName } = require('./mock-symbols') const { InvalidArgumentError } = require('../core/errors') const { serializePathWithQuery } = require('../core/util') @@ -64,13 +64,9 @@ class MockScope { throw new InvalidArgumentError('name must be a populated string') } - if (MockCallHistory[kMockCallHistoryGetByName](name) !== undefined) { - throw new InvalidArgumentError(`a CallHistory with name ${name} already exist`) - } + MockCallHistory[kMockCallHistoryCreate](name) - // we want to be able to access history even if mockDispatch are deleted - this[kMockCallHistory] = new MockCallHistory(name) - this[kMockDispatch][kMockCallHistory] = this[kMockCallHistory] + this[kMockDispatch][kMockDispatchCallHistoryName] = name return this } diff --git a/lib/mock/mock-symbols.js b/lib/mock/mock-symbols.js index 1a046e3192e..eb44566ccca 100644 --- a/lib/mock/mock-symbols.js +++ b/lib/mock/mock-symbols.js @@ -13,6 +13,7 @@ module.exports = { kMockAgentSet: Symbol('mock agent set'), kMockAgentGet: Symbol('mock agent get'), kMockDispatch: Symbol('mock dispatch'), + kMockDispatchCallHistoryName: Symbol('mock dispatch call history name'), kClose: Symbol('close'), kOriginalClose: Symbol('original agent close'), kOriginalDispatch: Symbol('original dispatch'), @@ -22,9 +23,13 @@ module.exports = { kGetNetConnect: Symbol('get net connect'), kConnected: Symbol('connected'), kIgnoreTrailingSlash: Symbol('ignore trailing slash'), - kMockCallHistory: Symbol('mock call history'), - kMockAgentMockCallHistory: Symbol('mock agent mock call history'), - kMockCallHistoryAddLog: Symbol('add mock call history log'), + kMockAgentMockCallHistoryName: Symbol('mock agent mock call history name'), + kMockAgentRegisterCallHistory: Symbol('mock agent register mock call history'), + kMockAgentAddCallHistoryLog: Symbol('mock agent add call history log'), + kMockAgentIsCallHistoryEnabled: Symbol('mock agent is call history enabled'), + kMockCallHistoryAddLog: Symbol('mock call history add log'), + kMockCallHistoryAllMockCallHistoryInstances: Symbol('mock call history all instances'), + kMockCallHistoryCreate: Symbol('mock call history create'), kMockCallHistoryGetByName: Symbol('mock call history get by name'), kMockCallHistoryClearAll: Symbol('mock call history clear all'), kMockCallHistoryDeleteAll: Symbol('mock call history delete all') diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index 77850d6c9fc..41cf80bba08 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -7,7 +7,8 @@ const { kOriginalDispatch, kOrigin, kGetNetConnect, - kMockCallHistory, + kMockDispatchCallHistoryName, + kMockCallHistoryGetByName, kMockCallHistoryAddLog } = require('./mock-symbols') const { serializePathWithQuery } = require('../core/util') @@ -17,6 +18,7 @@ const { isPromise } } = require('node:util') +const { MockCallHistory } = require('./mock-call-history') function matchValue (match, value) { if (typeof match === 'string') { @@ -262,10 +264,16 @@ function mockDispatch (opts, handler) { mockDispatch.timesInvoked++ - // guard if mockAgent.close (which delete all history) was called before a dispatch by inadvertency - if (mockDispatch[kMockCallHistory] !== undefined) { - // add call history log even on intercepted calls when mockScope.registerCallHistory was called - mockDispatch[kMockCallHistory][kMockCallHistoryAddLog](opts) + const maybeCallHistoryName = mockDispatch[kMockDispatchCallHistoryName] + // a named call history was registered + if (maybeCallHistoryName !== undefined) { + const namedCallHistory = MockCallHistory[kMockCallHistoryGetByName](mockDispatch[kMockDispatchCallHistoryName]) + + // guard if mockAgent.close() was called (which delete all call history) + if (namedCallHistory !== undefined) { + // add call history log on intercepted call + namedCallHistory[kMockCallHistoryAddLog](opts) + } } // Here's where we resolve a callback if a callback is present for the dispatch data. diff --git a/test/mock-agent.js b/test/mock-agent.js index 8de562569e6..49344d074d4 100644 --- a/test/mock-agent.js +++ b/test/mock-agent.js @@ -10,7 +10,7 @@ const { kClients, kConnected } = require('../lib/core/symbols') const { InvalidArgumentError, ClientDestroyedError } = require('../lib/core/errors') const MockClient = require('../lib/mock/mock-client') const MockPool = require('../lib/mock/mock-pool') -const { kAgent } = require('../lib/mock/mock-symbols') +const { kAgent, kMockAgentIsCallHistoryEnabled, kMockCallHistoryAllMockCallHistoryInstances } = require('../lib/mock/mock-symbols') const Dispatcher = require('../lib/dispatcher/dispatcher') const { MockNotMatchedError } = require('../lib/mock/mock-errors') const { fetch } = require('..') @@ -48,6 +48,78 @@ describe('MockAgent - constructor', () => { t.strictEqual(mockAgent[kAgent], agent) }) + + test('should disable call history by default', t => { + t = tspl(t, { plan: 2 }) + const agent = new Agent() + after(() => agent.close()) + const mockAgent = new MockAgent() + + t.strictEqual(mockAgent[kMockAgentIsCallHistoryEnabled], false) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 0) + }) + + test('should enable call history if option is true', t => { + t = tspl(t, { plan: 2 }) + const agent = new Agent() + after(() => agent.close()) + const mockAgent = new MockAgent({ enableCallHistory: true }) + + t.strictEqual(mockAgent[kMockAgentIsCallHistoryEnabled], true) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 1) + }) +}) + +describe('MockAgent - enableCallHistory', t => { + test('should enable call history and add call history log', async (t) => { + t = tspl(t, { plan: 2 }) + + const mockAgent = new MockAgent() + setGlobalDispatcher(mockAgent) + after(() => mockAgent.close()) + + const mockClient = mockAgent.get('http://localhost:9999') + mockClient.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo').persist() + + await fetch('http://localhost:9999/foo') + + t.strictEqual(mockAgent.getCallHistory()?.calls()?.length, 0) + + mockAgent.enableCallHistory() + + await request('http://localhost:9999/foo') + + t.strictEqual(mockAgent.getCallHistory()?.calls()?.length, 1) + }) +}) + +describe('MockAgent - disableCallHistory', t => { + test('should disable call history and not add call history log', async (t) => { + t = tspl(t, { plan: 2 }) + + const mockAgent = new MockAgent({ enableCallHistory: true }) + setGlobalDispatcher(mockAgent) + after(() => mockAgent.close()) + + const mockClient = mockAgent.get('http://localhost:9999') + mockClient.intercept({ + path: '/foo', + method: 'GET' + }).reply(200, 'foo').persist() + + await request('http://localhost:9999/foo') + + t.strictEqual(mockAgent.getCallHistory()?.calls()?.length, 1) + + mockAgent.disableCallHistory() + + await request('http://localhost:9999/foo') + + t.strictEqual(mockAgent.getCallHistory()?.calls()?.length, 1) + }) }) describe('MockAgent - get', t => { @@ -773,10 +845,10 @@ test('MockAgent - should persist requests', async (t) => { } }) -test('MockAgent - getCallHistory with no name parameter should return the global call history', async (t) => { +test('MockAgent - getCallHistory with no name parameter should return the agent call history', async (t) => { t = tspl(t, { plan: 2 }) - const mockAgent = new MockAgent() + const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) after(() => mockAgent.close()) @@ -786,14 +858,14 @@ test('MockAgent - getCallHistory with no name parameter should return the global method: 'GET' }).reply(200, 'foo') - t.strictEqual(MockCallHistory.AllMockCallHistory.size, 1) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 1) t.ok(mockAgent.getCallHistory() instanceof MockCallHistory) }) test('MockAgent - getCallHistory with name parameter should return the named call history', async (t) => { t = tspl(t, { plan: 2 }) - const mockAgent = new MockAgent() + const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) after(() => mockAgent.close()) @@ -803,14 +875,14 @@ test('MockAgent - getCallHistory with name parameter should return the named cal method: 'GET' }).reply(200, 'foo').registerCallHistory('my-history') - t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 2) t.ok(mockAgent.getCallHistory('my-history') instanceof MockCallHistory) }) test('MockAgent - getCallHistory with name parameter should return undefined when unknown name history', async (t) => { t = tspl(t, { plan: 2 }) - const mockAgent = new MockAgent() + const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) after(() => mockAgent.close()) @@ -820,33 +892,40 @@ test('MockAgent - getCallHistory with name parameter should return undefined whe method: 'GET' }).reply(200, 'foo').registerCallHistory('my-history') - t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 2) t.strictEqual(mockAgent.getCallHistory('no-exist'), undefined) }) -test('MockAgent - getCallHistory with name parameter should throw when a named history with the name parameter already exist', async (t) => { +test('MockAgent - getCallHistory with name parameter when history already exist should return the same history', async (t) => { t = tspl(t, { plan: 1 }) - const mockAgent = new MockAgent() + const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) after(() => mockAgent.close()) const mockClient = mockAgent.get('http://localhost:9999') + const historyName = 'my-history' mockClient.intercept({ path: '/foo', method: 'GET' - }).reply(200, 'foo').registerCallHistory('my-history') + }).reply(200, 'foo').registerCallHistory(historyName) + + await request('http://localhost:9999/foo') - t.throws(() => mockClient.intercept({ + mockClient.intercept({ path: '/bar', method: 'POST' - }).reply(200, 'bar').registerCallHistory('my-history'), new InvalidArgumentError('a CallHistory with name my-history already exist')) + }).reply(200, 'bar').registerCallHistory(historyName) + + await request('http://localhost:9999/bar', { method: 'POST' }) + + t.strictEqual(mockAgent.getCallHistory(historyName)?.calls().length, 2) }) test('MockAgent - getCallHistory with no name parameter with request should return the global call history with history log', async (t) => { t = tspl(t, { plan: 10 }) - const mockAgent = new MockAgent() + const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) after(() => mockAgent.close()) @@ -857,8 +936,8 @@ test('MockAgent - getCallHistory with no name parameter with request should retu method: 'POST' }).reply(200, 'foo') - t.strictEqual(MockCallHistory.AllMockCallHistory.size, 1) - t.ok(mockAgent.getCallHistory().calls().length === 0) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 1) + t.ok(mockAgent.getCallHistory()?.calls().length === 0) const path = '/foo' const url = new URL(path, baseUrl) @@ -869,20 +948,20 @@ test('MockAgent - getCallHistory with no name parameter with request should retu await request(url, { method, query, body: JSON.stringify(body), headers }) - t.ok(mockAgent.getCallHistory().calls().length === 1) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.body, JSON.stringify(body)) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.headers, headers) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.method, method) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.origin, baseUrl) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.path, path) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.fullUrl, `${url.toString()}?${new URLSearchParams(query).toString()}`) - t.deepStrictEqual(mockAgent.getCallHistory().lastCall()?.searchParams, { a: '1' }) + t.ok(mockAgent.getCallHistory()?.calls().length === 1) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.body, JSON.stringify(body)) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.headers, headers) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.method, method) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.origin, baseUrl) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.path, path) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.fullUrl, `${url.toString()}?${new URLSearchParams(query).toString()}`) + t.deepStrictEqual(mockAgent.getCallHistory()?.lastCall()?.searchParams, { a: '1' }) }) test('MockAgent - getCallHistory with no name parameter with fetch should return the global call history with history log', async (t) => { t = tspl(t, { plan: 10 }) - const mockAgent = new MockAgent() + const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) after(() => mockAgent.close()) @@ -893,8 +972,8 @@ test('MockAgent - getCallHistory with no name parameter with fetch should return method: 'POST' }).reply(200, 'foo') - t.strictEqual(MockCallHistory.AllMockCallHistory.size, 1) - t.ok(mockAgent.getCallHistory().calls().length === 0) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 1) + t.ok(mockAgent.getCallHistory()?.calls().length === 0) const path = '/foo' const url = new URL(path, baseUrl) @@ -906,9 +985,9 @@ test('MockAgent - getCallHistory with no name parameter with fetch should return await fetch(url, { method, query, body: JSON.stringify(body), headers }) - t.ok(mockAgent.getCallHistory().calls().length === 1) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.body, JSON.stringify(body)) - t.deepStrictEqual(mockAgent.getCallHistory().lastCall()?.headers, { + t.ok(mockAgent.getCallHistory()?.calls().length === 1) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.body, JSON.stringify(body)) + t.deepStrictEqual(mockAgent.getCallHistory()?.lastCall()?.headers, { ...headers, 'accept-encoding': 'gzip, deflate', 'content-length': '16', @@ -918,17 +997,17 @@ test('MockAgent - getCallHistory with no name parameter with fetch should return 'user-agent': 'undici', accept: '*/*' }) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.method, method) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.origin, baseUrl) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.path, url.pathname) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.fullUrl, url.toString()) - t.deepStrictEqual(mockAgent.getCallHistory().lastCall()?.searchParams, { a: '1' }) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.method, method) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.origin, baseUrl) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.path, url.pathname) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.fullUrl, url.toString()) + t.deepStrictEqual(mockAgent.getCallHistory()?.lastCall()?.searchParams, { a: '1' }) }) test('MockAgent - getCallHistory with name parameter should return the intercepted call history with history log', async (t) => { t = tspl(t, { plan: 10 }) - const mockAgent = new MockAgent() + const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) after(() => mockAgent.close()) @@ -940,8 +1019,8 @@ test('MockAgent - getCallHistory with name parameter should return the intercept method: 'POST' }).reply(200, 'foo').registerCallHistory(historyName) - t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) - t.ok(mockAgent.getCallHistory().calls().length === 0) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 2) + t.ok(mockAgent.getCallHistory()?.calls().length === 0) const path = '/foo' const url = new URL(path, baseUrl) @@ -952,7 +1031,7 @@ test('MockAgent - getCallHistory with name parameter should return the intercept await request(url, { method, query, body: JSON.stringify(body), headers }) - t.ok(mockAgent.getCallHistory().calls().length === 1) + t.ok(mockAgent.getCallHistory()?.calls().length === 1) t.strictEqual(mockAgent.getCallHistory(historyName)?.lastCall()?.body, JSON.stringify(body)) t.strictEqual(mockAgent.getCallHistory(historyName)?.lastCall()?.headers, headers) t.strictEqual(mockAgent.getCallHistory(historyName)?.lastCall()?.method, method) @@ -965,7 +1044,7 @@ test('MockAgent - getCallHistory with name parameter should return the intercept test('MockAgent - getCallHistory with fetch with a minimal configuration should register call history log', async (t) => { t = tspl(t, { plan: 11 }) - const mockAgent = new MockAgent() + const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) after(() => mockAgent.close()) @@ -980,29 +1059,29 @@ test('MockAgent - getCallHistory with fetch with a minimal configuration should await fetch(url) - t.ok(mockAgent.getCallHistory().calls().length === 1) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.body, null) - t.deepStrictEqual(mockAgent.getCallHistory().lastCall()?.headers, { + t.ok(mockAgent.getCallHistory()?.calls().length === 1) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.body, null) + t.deepStrictEqual(mockAgent.getCallHistory()?.lastCall()?.headers, { 'accept-encoding': 'gzip, deflate', 'accept-language': '*', 'sec-fetch-mode': 'cors', 'user-agent': 'undici', accept: '*/*' }) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.method, 'GET') - t.strictEqual(mockAgent.getCallHistory().lastCall()?.origin, baseUrl) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.path, path) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.fullUrl, baseUrl + path) - t.deepStrictEqual(mockAgent.getCallHistory().lastCall()?.searchParams, {}) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.host, 'localhost:9999') - t.strictEqual(mockAgent.getCallHistory().lastCall()?.port, '9999') - t.strictEqual(mockAgent.getCallHistory().lastCall()?.protocol, 'http:') + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.method, 'GET') + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.origin, baseUrl) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.path, path) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.fullUrl, baseUrl + path) + t.deepStrictEqual(mockAgent.getCallHistory()?.lastCall()?.searchParams, {}) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.host, 'localhost:9999') + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.port, '9999') + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.protocol, 'http:') }) test('MockAgent - getCallHistory with request with a minimal configuration should register call history log', async (t) => { t = tspl(t, { plan: 11 }) - const mockAgent = new MockAgent() + const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) after(() => mockAgent.close()) @@ -1017,17 +1096,17 @@ test('MockAgent - getCallHistory with request with a minimal configuration shoul await request(url) - t.ok(mockAgent.getCallHistory().calls().length === 1) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.body, undefined) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.headers, undefined) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.method, 'GET') - t.strictEqual(mockAgent.getCallHistory().lastCall()?.origin, baseUrl) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.path, path) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.fullUrl, baseUrl + path) - t.deepStrictEqual(mockAgent.getCallHistory().lastCall()?.searchParams, {}) - t.strictEqual(mockAgent.getCallHistory().lastCall()?.host, 'localhost:9999') - t.strictEqual(mockAgent.getCallHistory().lastCall()?.port, '9999') - t.strictEqual(mockAgent.getCallHistory().lastCall()?.protocol, 'http:') + t.ok(mockAgent.getCallHistory()?.calls().length === 1) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.body, undefined) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.headers, undefined) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.method, 'GET') + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.origin, baseUrl) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.path, path) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.fullUrl, baseUrl + path) + t.deepStrictEqual(mockAgent.getCallHistory()?.lastCall()?.searchParams, {}) + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.host, 'localhost:9999') + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.port, '9999') + t.strictEqual(mockAgent.getCallHistory()?.lastCall()?.protocol, 'http:') }) test('MockAgent - getCallHistory should register logs on non intercepted call', async (t) => { @@ -1044,7 +1123,7 @@ test('MockAgent - getCallHistory should register logs on non intercepted call', const baseUrl = `http://localhost:${server.address().port}` const secondeBaseUrl = 'http://localhost:8' - const mockAgent = new MockAgent() + const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) after(() => mockAgent.close()) @@ -1056,8 +1135,8 @@ test('MockAgent - getCallHistory should register logs on non intercepted call', await request(baseUrl) await request(secondeBaseUrl) - t.ok(mockAgent.getCallHistory().calls().length === 2) - t.ok(mockAgent.getCallHistory().firstCall()?.origin === baseUrl) + t.ok(mockAgent.getCallHistory()?.calls().length === 2) + t.ok(mockAgent.getCallHistory()?.firstCall()?.origin === baseUrl) t.ok(mockAgent.getCallHistory('second-history')?.calls().length === 1) t.ok(mockAgent.getCallHistory('second-history')?.firstCall()?.origin === secondeBaseUrl) }) @@ -1065,7 +1144,7 @@ test('MockAgent - getCallHistory should register logs on non intercepted call', test('MockAgent - clearAllCallHistory should clear all call histories', async (t) => { t = tspl(t, { plan: 6 }) - const mockAgent = new MockAgent() + const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) after(() => mockAgent.close()) @@ -1077,8 +1156,8 @@ test('MockAgent - clearAllCallHistory should clear all call histories', async (t method: 'POST' }).reply(200, 'foo').registerCallHistory(historyName).persist() - t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) - t.ok(mockAgent.getCallHistory().calls().length === 0) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 2) + t.ok(mockAgent.getCallHistory()?.calls().length === 0) const path = '/foo' const url = new URL(path, baseUrl) @@ -1092,12 +1171,12 @@ test('MockAgent - clearAllCallHistory should clear all call histories', async (t await request(url, { method, query, body: JSON.stringify(body), headers }) await request(url, { method, query, body: JSON.stringify(body), headers }) - t.ok(mockAgent.getCallHistory().calls().length === 4) + t.ok(mockAgent.getCallHistory()?.calls().length === 4) t.ok(mockAgent.getCallHistory(historyName)?.calls().length === 4) mockAgent.clearAllCallHistory() - t.ok(mockAgent.getCallHistory().calls().length === 0) + t.ok(mockAgent.getCallHistory()?.calls().length === 0) t.ok(mockAgent.getCallHistory(historyName)?.calls().length === 0) }) @@ -1242,7 +1321,7 @@ test('MockAgent - close removes all registered mock clients', async (t) => { test('MockAgent - close removes all registered mock call history', async (t) => { t = tspl(t, { plan: 6 }) - const mockAgent = new MockAgent() + const mockAgent = new MockAgent({ enableCallHistory: true }) setGlobalDispatcher(mockAgent) const mockClient = mockAgent.get('http://localhost:9999') @@ -1252,13 +1331,13 @@ test('MockAgent - close removes all registered mock call history', async (t) => method: 'GET' }).reply(200, 'foo').registerCallHistory('my-history') - t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 2) t.ok(mockAgent.getCallHistory() instanceof MockCallHistory) t.ok(mockAgent.getCallHistory('my-history') instanceof MockCallHistory) await mockAgent.close() - t.strictEqual(MockCallHistory.AllMockCallHistory.size, 0) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 0) t.strictEqual(mockAgent.getCallHistory(), undefined) t.strictEqual(mockAgent.getCallHistory('my-history'), undefined) }) diff --git a/test/mock-call-history-log.js b/test/mock-call-history-log.js index 95e09143502..5a7c9de8c0a 100644 --- a/test/mock-call-history-log.js +++ b/test/mock-call-history-log.js @@ -3,6 +3,7 @@ const { tspl } = require('@matteo.collina/tspl') const { test, describe } = require('node:test') const { MockCallHistoryLog } = require('../lib/mock/mock-call-history') +const { InvalidArgumentError } = require('../lib/core/errors') describe('MockCallHistoryLog - constructor', () => { function assertConsistent (t, mockCallHistoryLog) { @@ -18,11 +19,6 @@ describe('MockCallHistoryLog - constructor', () => { t.strictEqual(mockCallHistoryLog.port, '4000') } - test('should not throw when requestInit is not set', t => { - t = tspl(t, { plan: 1 }) - t.doesNotThrow(() => new MockCallHistoryLog()) - }) - test('should populate class properties with query in path', t => { t = tspl(t, { plan: 10 }) @@ -51,6 +47,12 @@ describe('MockCallHistoryLog - constructor', () => { assertConsistent(t, mockCallHistoryLog) }) + + test('should throw when url computing failed', t => { + t = tspl(t, { plan: 1 }) + + t.throws(() => new MockCallHistoryLog({}), new InvalidArgumentError('An error occurred when computing MockCallHistoryLog.url')) + }) }) describe('MockCallHistoryLog - toMap', () => { diff --git a/test/mock-call-history.js b/test/mock-call-history.js index 3769a1f6f85..ea8781c9a54 100644 --- a/test/mock-call-history.js +++ b/test/mock-call-history.js @@ -3,7 +3,7 @@ const { tspl } = require('@matteo.collina/tspl') const { test, describe, after } = require('node:test') const { MockCallHistory } = require('../lib/mock/mock-call-history') -const { kMockCallHistoryDeleteAll, kMockCallHistoryAddLog, kMockCallHistoryClearAll } = require('../lib/mock/mock-symbols') +const { kMockCallHistoryDeleteAll, kMockCallHistoryCreate, kMockCallHistoryAddLog, kMockCallHistoryClearAll, kMockCallHistoryAllMockCallHistoryInstances } = require('../lib/mock/mock-symbols') const { InvalidArgumentError } = require('../lib/core/errors') describe('MockCallHistory - constructor', () => { @@ -20,50 +20,74 @@ describe('MockCallHistory - constructor', () => { t = tspl(t, { plan: 3 }) after(MockCallHistory[kMockCallHistoryDeleteAll]) - t.strictEqual(MockCallHistory.AllMockCallHistory.size, 0) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 0) const mockCallHistory = new MockCallHistory('hello') - t.strictEqual(MockCallHistory.AllMockCallHistory.size, 1) - t.strictEqual(MockCallHistory.AllMockCallHistory.get('hello'), mockCallHistory) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 1) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].get('hello'), mockCallHistory) }) }) -describe('MockCallHistory - ClearAll', () => { - test('should clear all call history', t => { - t = tspl(t, { plan: 6 }) +describe('MockCallHistory - Create', () => { + test('should returns a MockCallHistory if named history is not present', t => { + t = tspl(t, { plan: 1 }) after(MockCallHistory[kMockCallHistoryDeleteAll]) - const mockCallHistoryHello = new MockCallHistory('hello') - const mockCallHistoryWorld = new MockCallHistory('world') + const mockCallHistory = MockCallHistory[kMockCallHistoryCreate]('hello') - mockCallHistoryWorld[kMockCallHistoryAddLog]({}) - mockCallHistoryHello[kMockCallHistoryAddLog]({}) - mockCallHistoryHello[kMockCallHistoryAddLog]({}) + t.ok(mockCallHistory instanceof MockCallHistory) + }) - t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) - t.strictEqual(mockCallHistoryWorld.calls().length, 1) - t.strictEqual(mockCallHistoryHello.calls().length, 2) + test('should populate static class property', t => { + t = tspl(t, { plan: 3 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) - MockCallHistory[kMockCallHistoryClearAll]() + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 0) + + const mockCallHistory = new MockCallHistory('hello') + + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 1) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].get('hello'), mockCallHistory) + }) +}) + +describe('MockCallHistory - add log', () => { + test('should add a log', t => { + t = tspl(t, { plan: 2 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') - t.strictEqual(MockCallHistory.AllMockCallHistory.size, 2) - t.strictEqual(mockCallHistoryWorld.calls().length, 0) t.strictEqual(mockCallHistoryHello.calls().length, 0) + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'https://localhost:4000' }) + + t.strictEqual(mockCallHistoryHello.calls().length, 1) }) }) -describe('MockCallHistory - calls', () => { - test('should returns every logs', t => { - t = tspl(t, { plan: 1 }) +describe('MockCallHistory - ClearAll', () => { + test('should clear all call history', t => { + t = tspl(t, { plan: 6 }) after(MockCallHistory[kMockCallHistoryDeleteAll]) const mockCallHistoryHello = new MockCallHistory('hello') + const mockCallHistoryWorld = new MockCallHistory('world') - mockCallHistoryHello[kMockCallHistoryAddLog]({}) - mockCallHistoryHello[kMockCallHistoryAddLog]({}) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'https://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'https://localhost:4000' }) + mockCallHistoryWorld[kMockCallHistoryAddLog]({ path: '/', origin: 'https://localhost:4000' }) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 2) + t.strictEqual(mockCallHistoryWorld.calls().length, 1) t.strictEqual(mockCallHistoryHello.calls().length, 2) + + MockCallHistory[kMockCallHistoryClearAll]() + + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 2) + t.strictEqual(mockCallHistoryWorld.calls().length, 0) + t.strictEqual(mockCallHistoryHello.calls().length, 0) }) }) @@ -74,8 +98,8 @@ describe('MockCallHistory - calls', () => { const mockCallHistoryHello = new MockCallHistory('hello') - mockCallHistoryHello[kMockCallHistoryAddLog]({}) - mockCallHistoryHello[kMockCallHistoryAddLog]({}) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'https://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'https://localhost:4000' }) t.strictEqual(mockCallHistoryHello.calls().length, 2) }) @@ -243,6 +267,96 @@ describe('MockCallHistory - filterCalls', () => { t.strictEqual(filtered.length, 1) }) + test('should returns every logs with 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: '/yes', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'https://localhost:4000' }) + + const filtered = mockCallHistoryHello.filterCalls({}) + + t.strictEqual(filtered.length, 3) + }) + + test('should filter logs with an object with host property', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/yes', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'https://127.0.0.1:4000' }) + + const filtered = mockCallHistoryHello.filterCalls({ host: /localhost/ }) + + t.strictEqual(filtered.length, 2) + }) + + test('should filter logs with an object with port property', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:1000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/yes', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'https://127.0.0.1:4000' }) + + const filtered = mockCallHistoryHello.filterCalls({ port: '1000' }) + + t.strictEqual(filtered.length, 1) + }) + + test('should filter logs with an object with hash property', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/#hello', origin: 'http://localhost:1000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/yes', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'https://127.0.0.1:4000' }) + + const filtered = mockCallHistoryHello.filterCalls({ hash: '#hello' }) + + t.strictEqual(filtered.length, 1) + }) + + test('should filter logs with an object with fullUrl property', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '#hello', origin: 'http://localhost:1000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/yes', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'https://127.0.0.1:4000' }) + + const filtered = mockCallHistoryHello.filterCalls({ fullUrl: 'http://localhost:1000/#hello' }) + + t.strictEqual(filtered.length, 1) + }) + + test('should filter logs with an object with method property', t => { + t = tspl(t, { plan: 1 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:1000', method: 'POST' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/yes', origin: 'http://localhost:4000', method: 'GET' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'https://127.0.0.1:4000', method: 'PUT' }) + + const filtered = mockCallHistoryHello.filterCalls({ method: /(PUT|GET)/ }) + + t.strictEqual(filtered.length, 2) + }) + test('should filter multiple time logs with an object', t => { t = tspl(t, { plan: 1 }) after(MockCallHistory[kMockCallHistoryDeleteAll]) diff --git a/types/index.d.ts b/types/index.d.ts index 62e03f549f5..c473915abf1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -55,7 +55,8 @@ declare namespace Undici { const MockClient: typeof import('./mock-client').default const MockPool: typeof import('./mock-pool').default const MockAgent: typeof import('./mock-agent').default - const MockCallHistory: typeof import('./mock-call-history') + const MockCallHistory: typeof import('./mock-call-history').MockCallHistory + const MockCallHistoryLog: typeof import('./mock-call-history').MockCallHistoryLog const mockErrors: typeof import('./mock-errors').default const fetch: typeof import('./fetch').fetch const Headers: typeof import('./fetch').Headers diff --git a/types/mock-agent.d.ts b/types/mock-agent.d.ts index bcca7e43a42..0760521b40a 100644 --- a/types/mock-agent.d.ts +++ b/types/mock-agent.d.ts @@ -30,13 +30,17 @@ declare class MockAgent boolean)): void - /** get call history. If a name is provided, it returns the history registered previously with registerCallHistory on a MockScope instance. If not, it returns the global call history of the MockAgent. */ - getCallHistory (): MockCallHistory + /** Causes all requests to throw when requests are not matched in a MockAgent intercept. */ + disableNetConnect (): void + /** get call history. If a name is provided, it returns the history registered previously with MockScope.registerCallHistory. If not, it returns the MockAgent call history. */ + getCallHistory (): MockCallHistory | undefined getCallHistory (name: string): MockCallHistory | undefined /** clear every call history. Any MockCallHistoryLog will be deleted on every MockCallHistory */ clearAllCallHistory (): void - /** Causes all requests to throw when requests are not matched in a MockAgent intercept. */ - disableNetConnect (): void + /** Enable call history. Any subsequence calls will then be registered. Note that this has no effect on MockCallHistory registered with MockScope.registerCallHistory */ + enableCallHistory (): void + /** Disable call history. Any subsequence calls will then not be registered. Note that this has no effect on MockCallHistory registered with MockScope.registerCallHistory */ + disableCallHistory (): void pendingInterceptors (): PendingInterceptor[] assertNoPendingInterceptors (options?: { pendingInterceptorsFormatter?: PendingInterceptorsFormatter; @@ -55,5 +59,8 @@ declare namespace MockAgent { /** Ignore trailing slashes in the path */ ignoreTrailingSlash?: boolean; + + /** Enable call history. you can either call MockAgent.enableCallHistory(). default false */ + enableCallHistory?: boolean } } diff --git a/types/mock-call-history.d.ts b/types/mock-call-history.d.ts index 23fe92e254b..abc2deb571b 100644 --- a/types/mock-call-history.d.ts +++ b/types/mock-call-history.d.ts @@ -27,10 +27,10 @@ declare class MockCallHistoryLog { /** request's headers */ headers: Dispatcher.DispatchOptions['headers'] - /** return an Map of property / value pair */ + /** returns an Map of property / value pair */ toMap (): Map - /** return a string computed with all properties value */ + /** returns a string computed with all key value pair */ toString (): string } From 04ffecc0004e351742ebad0c58ce9fd7147cfc0c Mon Sep 17 00:00:00 2001 From: Blephy Date: Tue, 28 Jan 2025 20:25:54 +0100 Subject: [PATCH 13/23] fix: CodeQL sniffing --- package.json | 3 ++- test/mock-agent.js | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index e90a4dbfbf6..e37bd39f4fd 100644 --- a/package.json +++ b/package.json @@ -145,5 +145,6 @@ "testMatch": [ "/test/jest/**" ] - } + }, + "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" } diff --git a/test/mock-agent.js b/test/mock-agent.js index 49344d074d4..98e0abdddaf 100644 --- a/test/mock-agent.js +++ b/test/mock-agent.js @@ -944,7 +944,7 @@ test('MockAgent - getCallHistory with no name parameter with request should retu const method = 'POST' const body = { data: 'value' } const query = { a: 1 } - const headers = { authorization: 'Bearer token' } + const headers = { 'content-type': 'application/json' } await request(url, { method, query, body: JSON.stringify(body), headers }) @@ -1027,7 +1027,7 @@ test('MockAgent - getCallHistory with name parameter should return the intercept const method = 'POST' const body = { data: 'value' } const query = { a: 1 } - const headers = { authorization: 'Bearer token' } + const headers = { 'content-type': 'application/json' } await request(url, { method, query, body: JSON.stringify(body), headers }) @@ -1164,7 +1164,7 @@ test('MockAgent - clearAllCallHistory should clear all call histories', async (t const method = 'POST' const body = { data: 'value' } const query = { a: 1 } - const headers = { authorization: 'Bearer token' } + const headers = { 'content-type': 'application/json' } await request(url, { method, query, body: JSON.stringify(body), headers }) await request(url, { method, query, body: JSON.stringify(body), headers }) From 101d1084332f56c271af0c7af7a62873dc7672eb Mon Sep 17 00:00:00 2001 From: Blephy Date: Tue, 28 Jan 2025 20:32:27 +0100 Subject: [PATCH 14/23] chore: own review --- lib/mock/mock-utils.js | 2 +- package.json | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index 41cf80bba08..df9c8626c0a 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -267,7 +267,7 @@ function mockDispatch (opts, handler) { const maybeCallHistoryName = mockDispatch[kMockDispatchCallHistoryName] // a named call history was registered if (maybeCallHistoryName !== undefined) { - const namedCallHistory = MockCallHistory[kMockCallHistoryGetByName](mockDispatch[kMockDispatchCallHistoryName]) + const namedCallHistory = MockCallHistory[kMockCallHistoryGetByName](maybeCallHistoryName) // guard if mockAgent.close() was called (which delete all call history) if (namedCallHistory !== undefined) { diff --git a/package.json b/package.json index e37bd39f4fd..e90a4dbfbf6 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,5 @@ "testMatch": [ "/test/jest/**" ] - }, - "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" + } } From 1cdb1d42f3e671b611bb4af8df4a969253d30348 Mon Sep 17 00:00:00 2001 From: Blephy Date: Tue, 28 Jan 2025 23:42:58 +0100 Subject: [PATCH 15/23] 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 From 4e049d3a7f6264dc58fdf6645be9c257aa34a5c9 Mon Sep 17 00:00:00 2001 From: Blephy Date: Tue, 28 Jan 2025 23:49:31 +0100 Subject: [PATCH 16/23] chore: own review --- docs/docs/api/MockCallHistory.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/docs/api/MockCallHistory.md b/docs/docs/api/MockCallHistory.md index 163b2a2db58..7b18b4c1daf 100644 --- a/docs/docs/api/MockCallHistory.md +++ b/docs/docs/api/MockCallHistory.md @@ -31,6 +31,8 @@ mockAgent.getCallHistory()?.clear() // clear only mockAgent history mockAgent.getCallHistory('my-custom-history')?.clear() // clear only 'my-custom-history' history ``` +> to clear all registered MockCallHistory, use `mockAgent.clearAllCallHistory()`. This is automatically done when calling `mockAgent.close()` + ### calls Get all MockCallHistoryLog registered as an array @@ -154,11 +156,11 @@ mockAgent.getCallHistory()?.filterCallsByMethod('POST') ### filterCalls -This class method is a meta function / alias to apply complex filtering in one way. +This class method is a meta function / alias to apply complex filtering in a single way. Parameters : -- criteria : this first parameter. a function, regexp or object. +- criteria : the 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) @@ -186,7 +188,7 @@ mockAgent.getCallHistory()?.filterCalls({ hash: /my-hash/, path: '/endpoint' }, Can be : -- string. filtered if `value !== parameterValue` -- null. filtered if `value !== parameterValue` -- undefined. filtered if `value !== parameterValue` -- regexp. filtered if `!parameterValue.test(value)` +- string. MockCallHistoryLog filtered if `value !== parameterValue` +- null. MockCallHistoryLog filtered if `value !== parameterValue` +- undefined. MockCallHistoryLog filtered if `value !== parameterValue` +- regexp. MockCallHistoryLog filtered if `!parameterValue.test(value)` From 3467de634082ef74a7539b31b2f5bd00f18748b7 Mon Sep 17 00:00:00 2001 From: Blephy Date: Wed, 29 Jan 2025 00:21:19 +0100 Subject: [PATCH 17/23] fix: ts types --- docs/docs/api/MockCallHistoryLog.md | 4 +- types/index.d.ts | 7 +--- types/mock-call-history.d.ts | 58 +++++++++++++++++------------ 3 files changed, 38 insertions(+), 31 deletions(-) diff --git a/docs/docs/api/MockCallHistoryLog.md b/docs/docs/api/MockCallHistoryLog.md index e7d9e4ca51e..3d38bdd29d5 100644 --- a/docs/docs/api/MockCallHistoryLog.md +++ b/docs/docs/api/MockCallHistoryLog.md @@ -29,13 +29,13 @@ mockAgent.getCallHistory()?.firstCall() Returns a Map instance ```js -mockAgent.getCallHistory()?.firstCall()?.toMap().get('hash') +mockAgent.getCallHistory()?.firstCall()?.toMap()?.get('hash') // #hash ``` ### toString -Returns a a string computed with any class property name and value pair +Returns a string computed with any class property name and value pair ```js mockAgent.getCallHistory()?.firstCall()?.toString() diff --git a/types/index.d.ts b/types/index.d.ts index 78c773dc5f7..bfc724e831a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -12,6 +12,7 @@ import Agent from './agent' import MockClient from './mock-client' import MockPool from './mock-pool' import MockAgent from './mock-agent' +import { MockCallHistory, MockCallHistoryLog } from './mock-call-history' import mockErrors from './mock-errors' import ProxyAgent from './proxy-agent' import EnvHttpProxyAgent from './env-http-proxy-agent' @@ -31,7 +32,7 @@ export * from './content-type' export * from './cache' export { Interceptable } from './mock-interceptor' -export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, interceptors, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent, EnvHttpProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent } +export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, interceptors, MockClient, MockPool, MockAgent, MockCallHistory, MockCallHistoryLog, mockErrors, ProxyAgent, EnvHttpProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent } export default Undici declare namespace Undici { @@ -57,10 +58,6 @@ 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 diff --git a/types/mock-call-history.d.ts b/types/mock-call-history.d.ts index abc2deb571b..7126a181188 100644 --- a/types/mock-call-history.d.ts +++ b/types/mock-call-history.d.ts @@ -1,7 +1,11 @@ import Dispatcher from './dispatcher' -type MockCallHistoryLogProperties = 'protocol' | 'host' | 'port' | 'origin' | 'path' | 'hash' | 'fullUrl' | 'method' | 'searchParams' | 'body' | 'headers' +declare namespace MockCallHistoryLog { + /** request's configuration properties */ + export type MockCallHistoryLogProperties = 'protocol' | 'host' | 'port' | 'origin' | 'path' | 'hash' | 'fullUrl' | 'method' | 'searchParams' | 'body' | 'headers' +} +/** a log reflecting request configuration */ declare class MockCallHistoryLog { constructor (requestInit: Dispatcher.DispatchOptions) /** protocol used. */ @@ -28,27 +32,33 @@ declare class MockCallHistoryLog { headers: Dispatcher.DispatchOptions['headers'] /** returns an Map of property / value pair */ - toMap (): Map + toMap (): Map /** returns a string computed with all key value pair */ toString (): string } -interface FilterCallsObjectCriteria extends Record { - protocol?: FilterCallsParameter; - host?: FilterCallsParameter; - port?: FilterCallsParameter; - origin?: FilterCallsParameter; - path?: FilterCallsParameter; - hash?: FilterCallsParameter; - fullUrl?: FilterCallsParameter; - method?: FilterCallsParameter; -} +declare namespace MockCallHistory { + /** a function to be executed for filtering MockCallHistoryLog */ + export type FilterCallsFunctionCriteria = (log: MockCallHistoryLog) => boolean -type FilterCallFunctionCriteria = (log: MockCallHistoryLog) => boolean + /** parameter to filter MockCallHistoryLog */ + export type FilterCallsParameter = string | RegExp | undefined | null -type FilterCallsParameter = string | RegExp | undefined | null + /** an object to execute multiple filtering at once */ + export interface FilterCallsObjectCriteria extends Record { + protocol?: FilterCallsParameter; + host?: FilterCallsParameter; + port?: FilterCallsParameter; + origin?: FilterCallsParameter; + path?: FilterCallsParameter; + hash?: FilterCallsParameter; + fullUrl?: FilterCallsParameter; + method?: FilterCallsParameter; + } +} +/** a call history to track requests configuration */ declare class MockCallHistory { constructor (name: string) /** returns an array of MockCallHistoryLog. */ @@ -60,25 +70,25 @@ declare class MockCallHistory { /** returns the nth MockCallHistoryLog. */ nthCall (position: number): MockCallHistoryLog | undefined /** return all MockCallHistoryLog matching any of criteria given. */ - filterCalls (criteria: FilterCallsObjectCriteria | FilterCallFunctionCriteria | RegExp): Array + filterCalls (criteria: MockCallHistory.FilterCallsObjectCriteria | MockCallHistory.FilterCallsFunctionCriteria | RegExp): Array /** return all MockCallHistoryLog matching the given protocol. if a string is given, it is matched with includes */ - filterCallsByProtocol (protocol: FilterCallsParameter): Array + filterCallsByProtocol (protocol: MockCallHistory.FilterCallsParameter): Array /** return all MockCallHistoryLog matching the given host. if a string is given, it is matched with includes */ - filterCallsByHost (host: FilterCallsParameter): Array + filterCallsByHost (host: MockCallHistory.FilterCallsParameter): Array /** return all MockCallHistoryLog matching the given port. if a string is given, it is matched with includes */ - filterCallsByPort (port: FilterCallsParameter): Array + filterCallsByPort (port: MockCallHistory.FilterCallsParameter): Array /** return all MockCallHistoryLog matching the given origin. if a string is given, it is matched with includes */ - filterCallsByOrigin (origin: FilterCallsParameter): Array + filterCallsByOrigin (origin: MockCallHistory.FilterCallsParameter): Array /** return all MockCallHistoryLog matching the given path. if a string is given, it is matched with includes */ - filterCallsByPath (path: FilterCallsParameter): Array + filterCallsByPath (path: MockCallHistory.FilterCallsParameter): Array /** return all MockCallHistoryLog matching the given hash. if a string is given, it is matched with includes */ - filterCallsByHash (hash: FilterCallsParameter): Array + filterCallsByHash (hash: MockCallHistory.FilterCallsParameter): Array /** return all MockCallHistoryLog matching the given fullUrl. if a string is given, it is matched with includes */ - filterCallsByFullUrl (fullUrl: FilterCallsParameter): Array + filterCallsByFullUrl (fullUrl: MockCallHistory.FilterCallsParameter): Array /** return all MockCallHistoryLog matching the given method. if a string is given, it is matched with includes */ - filterCallsByMethod (method: FilterCallsParameter): Array + filterCallsByMethod (method: MockCallHistory.FilterCallsParameter): Array /** clear all MockCallHistoryLog on this MockCallHistory. */ clear (): void } -export { MockCallHistoryLog, MockCallHistory, FilterCallsObjectCriteria, FilterCallFunctionCriteria, FilterCallsParameter, MockCallHistoryLogProperties } +export { MockCallHistoryLog, MockCallHistory } From 5ca878cb276d381190024f397a3629ce5d0509d9 Mon Sep 17 00:00:00 2001 From: Blephy Date: Wed, 29 Jan 2025 00:25:42 +0100 Subject: [PATCH 18/23] fix: ts types --- types/mock-agent.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/types/mock-agent.d.ts b/types/mock-agent.d.ts index 0760521b40a..3b1ec2aaed0 100644 --- a/types/mock-agent.d.ts +++ b/types/mock-agent.d.ts @@ -38,9 +38,9 @@ declare class MockAgent Date: Wed, 29 Jan 2025 00:51:03 +0100 Subject: [PATCH 19/23] docs: fix url in documentation website --- docs/docs/api/MockCallHistory.md | 25 +++++++++++++------------ docs/docsify/sidebar.md | 2 ++ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/docs/api/MockCallHistory.md b/docs/docs/api/MockCallHistory.md index 7b18b4c1daf..9415ee43dd7 100644 --- a/docs/docs/api/MockCallHistory.md +++ b/docs/docs/api/MockCallHistory.md @@ -5,12 +5,13 @@ Access to an instance with : ```js const mockAgent = new MockAgent({ enableCallHistory: true }) mockAgent.getCallHistory() + // or const mockAgent = new MockAgent() mockAgent.enableMockHistory() mockAgent.getCallHistory() -// or +// or const mockAgent = new MockAgent() const mockClient = mockAgent.get('http://localhost:3000') mockClient @@ -69,7 +70,7 @@ mockAgent.getCallHistory()?.nthCall(3) // the third MockCallHistoryLog registere Filter MockCallHistoryLog by protocol. -> more details for the first parameter can be found [here](#filter-parameter) +> more details for the first parameter can be found [here](/docs/docs/api/MockCallHistory.md#filter-parameter) ```js mockAgent.getCallHistory()?.filterCallsByProtocol(/https/) @@ -80,7 +81,7 @@ mockAgent.getCallHistory()?.filterCallsByProtocol('https:') Filter MockCallHistoryLog by host. -> more details for the first parameter can be found [here](#filter-parameter) +> more details for the first parameter can be found [here](/docs/docs/api/MockCallHistory.md#filter-parameter) ```js mockAgent.getCallHistory()?.filterCallsByHost(/localhost/) @@ -91,7 +92,7 @@ mockAgent.getCallHistory()?.filterCallsByHost('localhost:3000') Filter MockCallHistoryLog by port. -> more details for the first parameter can be found [here](#filter-parameter) +> more details for the first parameter can be found [here](/docs/docs/api/MockCallHistory.md#filter-parameter) ```js mockAgent.getCallHistory()?.filterCallsByPort(/3000/) @@ -103,7 +104,7 @@ mockAgent.getCallHistory()?.filterCallsByPort('') Filter MockCallHistoryLog by origin. -> more details for the first parameter can be found [here](#filter-parameter) +> more details for the first parameter can be found [here](/docs/docs/api/MockCallHistory.md#filter-parameter) ```js mockAgent.getCallHistory()?.filterCallsByOrigin(/http:\/\/localhost:3000/) @@ -114,7 +115,7 @@ mockAgent.getCallHistory()?.filterCallsByOrigin('http://localhost:3000') Filter MockCallHistoryLog by path. -> more details for the first parameter can be found [here](#filter-parameter) +> more details for the first parameter can be found [here](/docs/docs/api/MockCallHistory.md#filter-parameter) ```js mockAgent.getCallHistory()?.filterCallsByPath(/api\/v1\/graphql/) @@ -125,7 +126,7 @@ mockAgent.getCallHistory()?.filterCallsByPath('/api/v1/graphql') Filter MockCallHistoryLog by hash. -> more details for the first parameter can be found [here](#filter-parameter) +> more details for the first parameter can be found [here](/docs/docs/api/MockCallHistory.md#filter-parameter) ```js mockAgent.getCallHistory()?.filterCallsByPath(/hash/) @@ -136,7 +137,7 @@ mockAgent.getCallHistory()?.filterCallsByPath('#hash') 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) +> more details for the first parameter can be found [here](/docs/docs/api/MockCallHistory.md#filter-parameter) ```js mockAgent.getCallHistory()?.filterCallsByFullUrl(/https:\/\/localhost:3000\/\?query=value#hash/) @@ -147,7 +148,7 @@ mockAgent.getCallHistory()?.filterCallsByFullUrl('https://localhost:3000/?query= Filter MockCallHistoryLog by method. -> more details for the first parameter can be found [here](#filter-parameter) +> more details for the first parameter can be found [here](/docs/docs/api/MockCallHistory.md#filter-parameter) ```js mockAgent.getCallHistory()?.filterCallsByMethod(/POST/) @@ -163,7 +164,7 @@ Parameters : - criteria : the 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) + - object : an object with MockCallHistoryLog properties as keys to apply multiple filters. each values are a [filter parameter](/docs/docs/api/MockCallHistory.md#filter-parameter) - options : the second parameter. an object. - options.operator : `'AND'` or `'OR'` (default `'OR'`). Used only if criteria is an object. see below @@ -171,13 +172,13 @@ Parameters : mockAgent.getCallHistory()?.filterCalls((log) => log.hash === value && log.headers?.['authorization'] !== undefined) mockAgent.getCallHistory()?.filterCalls(/"data": "{ "errors": "wrong body" }"/) -// returns MockCallHistoryLog which have +// returns an Array of MockCallHistoryLog which all have // - a hash containing my-hash // - OR // - a path equal to /endpoint mockAgent.getCallHistory()?.filterCalls({ hash: /my-hash/, path: '/endpoint' }) -// returns MockCallHistoryLog which have +// returns an Array of MockCallHistoryLog which all have // - a hash containing my-hash // - AND // - a path equal to /endpoint diff --git a/docs/docsify/sidebar.md b/docs/docsify/sidebar.md index 4a4ae6742b3..adc27b32fe9 100644 --- a/docs/docsify/sidebar.md +++ b/docs/docsify/sidebar.md @@ -17,6 +17,8 @@ * [MockClient](/docs/api/MockClient.md "Undici API - MockClient") * [MockPool](/docs/api/MockPool.md "Undici API - MockPool") * [MockAgent](/docs/api/MockAgent.md "Undici API - MockAgent") + * [MockCallHistory](/docs/api/MockCallHistory.md "Undici API - MockCallHistory") + * [MockCallHistoryLog](/docs/api/MockCallHistoryLog.md "Undici API - MockCallHistoryLog") * [MockErrors](/docs/api/MockErrors.md "Undici API - MockErrors") * [API Lifecycle](/docs/api/api-lifecycle.md "Undici API - Lifecycle") * [Diagnostics Channel Support](/docs/api/DiagnosticsChannel.md "Diagnostics Channel Support") From 200d8dc32e08370bda07effc654d602ffe3ef99c Mon Sep 17 00:00:00 2001 From: Blephy Date: Wed, 29 Jan 2025 18:20:18 +0100 Subject: [PATCH 20/23] feat: make MockCallHistory iterable --- docs/docs/api/MockCallHistory.md | 11 ++++++++++ lib/mock/mock-call-history.js | 6 ++++++ package.json | 2 +- test/imports/undici-import.ts | 21 ++++++++++++++++++- test/mock-call-history.js | 36 ++++++++++++++++++++++++++++++-- types/mock-call-history.d.ts | 2 ++ 6 files changed, 74 insertions(+), 4 deletions(-) diff --git a/docs/docs/api/MockCallHistory.md b/docs/docs/api/MockCallHistory.md index 9415ee43dd7..0c6c0aae878 100644 --- a/docs/docs/api/MockCallHistory.md +++ b/docs/docs/api/MockCallHistory.md @@ -21,6 +21,17 @@ mockClient mockAgent.getCallHistory('my-custom-history') ``` +a MockCallHistory instance implements a **Symbol.iterator** letting you iterate on registered logs : + +```ts +for (const log of mockAgent.getCallHistory('my-custom-history')) { + //... +} + +const array: Array = [...mockAgent.getCallHistory('my-custom-history')] +const set: Set = new Set(mockAgent.getCallHistory('my-custom-history')) +``` + ## class methods ### clear diff --git a/lib/mock/mock-call-history.js b/lib/mock/mock-call-history.js index c3c3efe8820..2cc4325a621 100644 --- a/lib/mock/mock-call-history.js +++ b/lib/mock/mock-call-history.js @@ -273,6 +273,12 @@ class MockCallHistory { return log } + + * [Symbol.iterator] () { + for (const log of this.calls()) { + yield log + } + } } module.exports.MockCallHistory = MockCallHistory diff --git a/package.json b/package.json index e90a4dbfbf6..4ecfcecd059 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "test:node-test": "borp -p \"test/node-test/**/*.js\"", "test:tdd": "borp --expose-gc -p \"test/*.js\"", "test:tdd:node-test": "borp -p \"test/node-test/**/*.js\" -w", - "test:typescript": "tsd && tsc test/imports/undici-import.ts --typeRoots ./types --noEmit && tsc ./types/*.d.ts --noEmit --typeRoots ./types", + "test:typescript": "tsd && tsc test/imports/undici-import.ts --downlevelIteration --typeRoots ./types --noEmit && tsc ./types/*.d.ts --noEmit --typeRoots ./types", "test:webidl": "borp -p \"test/webidl/*.js\"", "test:websocket": "borp -p \"test/websocket/*.js\"", "test:websocket:autobahn": "node test/autobahn/client.js", diff --git a/test/imports/undici-import.ts b/test/imports/undici-import.ts index 8be95cdd009..f3c12270772 100644 --- a/test/imports/undici-import.ts +++ b/test/imports/undici-import.ts @@ -1,5 +1,6 @@ import { expectType } from 'tsd' -import { Dispatcher, interceptors, request } from '../../' +import { Dispatcher, interceptors, MockCallHistory, MockCallHistoryLog, request } from '../../' +import { kMockCallHistoryAddLog, kMockCallHistoryDeleteAll } from '../../lib/mock/mock-symbols' async function exampleCode () { const retry = interceptors.retry() @@ -13,4 +14,22 @@ async function exampleCode () { await request('http://localhost:3000/foo') } +function checkMockCallHistoryIterator () { + const mockCallHistory = new MockCallHistory('hello') + // @ts-ignore -- not relevant here + mockCallHistory[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000', method: 'GET' }) + // @ts-ignore -- not relevant here + mockCallHistory[kMockCallHistoryAddLog]({ path: '/endpoint', origin: 'http://localhost:4000', method: 'GET' }) + + expectType>([...mockCallHistory]) + + for (const log of mockCallHistory) { + expectType(log) + } + + // @ts-ignore -- not relevant here + MockCallHistory[kMockCallHistoryDeleteAll]() +} + exampleCode() +checkMockCallHistoryIterator() diff --git a/test/mock-call-history.js b/test/mock-call-history.js index 68a8cc1d672..67ff8b8c00b 100644 --- a/test/mock-call-history.js +++ b/test/mock-call-history.js @@ -2,7 +2,7 @@ const { tspl } = require('@matteo.collina/tspl') const { test, describe, after } = require('node:test') -const { MockCallHistory } = require('../lib/mock/mock-call-history') +const { MockCallHistory, MockCallHistoryLog } = require('../lib/mock/mock-call-history') const { kMockCallHistoryDeleteAll, kMockCallHistoryCreate, kMockCallHistoryAddLog, kMockCallHistoryClearAll, kMockCallHistoryAllMockCallHistoryInstances } = require('../lib/mock/mock-symbols') const { InvalidArgumentError } = require('../lib/core/errors') @@ -220,6 +220,38 @@ describe('MockCallHistory - nthCall', () => { }) }) +describe('MockCallHistory - iterator', () => { + test('should permit to iterate over logs with for..of', t => { + t = tspl(t, { plan: 4 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'http://localhost:4000' }) + + for (const log of mockCallHistoryHello) { + t.ok(log instanceof MockCallHistoryLog) + t.ok(typeof log.path === 'string') + } + }) + + test('should permit to iterate over logs with spread operator', t => { + t = tspl(t, { plan: 2 }) + after(MockCallHistory[kMockCallHistoryDeleteAll]) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/noop', origin: 'http://localhost:4000' }) + + const logs = [...mockCallHistoryHello] + + t.ok(logs.every((log) => log instanceof MockCallHistoryLog)) + t.strictEqual(logs.length, 2) + }) +}) + describe('MockCallHistory - filterCalls without options', () => { test('should filter logs with a function', t => { t = tspl(t, { plan: 2 }) @@ -406,7 +438,7 @@ describe('MockCallHistory - filterCalls without options', () => { mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000' }) - t.throws(() => mockCallHistoryHello.filterCalls(3), new InvalidArgumentError('criteria parameter should be one of string, function, regexp, or object')) + t.throws(() => mockCallHistoryHello.filterCalls(3), new InvalidArgumentError('criteria parameter should be one of function, regexp, or object')) }) }) diff --git a/types/mock-call-history.d.ts b/types/mock-call-history.d.ts index 7126a181188..9d14e530f70 100644 --- a/types/mock-call-history.d.ts +++ b/types/mock-call-history.d.ts @@ -89,6 +89,8 @@ declare class MockCallHistory { filterCallsByMethod (method: MockCallHistory.FilterCallsParameter): Array /** clear all MockCallHistoryLog on this MockCallHistory. */ clear (): void + /** use it with for..of loop or spread operator */ + [Symbol.iterator]: () => Generator } export { MockCallHistoryLog, MockCallHistory } From 7d9c6793f79ddf1231980823e47061a378a21107 Mon Sep 17 00:00:00 2001 From: Blephy Date: Thu, 30 Jan 2025 12:26:45 +0100 Subject: [PATCH 21/23] fix: throw if enableCallHistory is not a boolean --- lib/mock/mock-agent.js | 8 +++++--- lib/mock/mock-utils.js | 10 ++++++++-- test/mock-agent.js | 23 ++++++++++++++++++----- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index f3c94a4ae76..fd34d576d12 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -24,7 +24,7 @@ const { } = require('./mock-symbols') const MockClient = require('./mock-client') const MockPool = require('./mock-pool') -const { matchValue, buildMockOptions } = require('./mock-utils') +const { matchValue, buildAndValidateMockOptions } = require('./mock-utils') const { InvalidArgumentError, UndiciError } = require('../core/errors') const Dispatcher = require('../dispatcher/dispatcher') const PendingInterceptorsFormatter = require('./pending-interceptors-formatter') @@ -34,9 +34,11 @@ class MockAgent extends Dispatcher { constructor (opts) { super(opts) + const mockOptions = buildAndValidateMockOptions(opts) + this[kNetConnect] = true this[kIsMockActive] = true - this[kMockAgentIsCallHistoryEnabled] = Boolean(opts?.enableCallHistory) + this[kMockAgentIsCallHistoryEnabled] = mockOptions?.enableCallHistory ?? false // Instantiate Agent and encapsulate if (opts?.agent && typeof opts.agent.dispatch !== 'function') { @@ -46,7 +48,7 @@ class MockAgent extends Dispatcher { this[kAgent] = agent this[kClients] = agent[kClients] - this[kOptions] = buildMockOptions(opts) + this[kOptions] = mockOptions if (this[kMockAgentIsCallHistoryEnabled]) { this[kMockAgentRegisterCallHistory]() diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index df9c8626c0a..d5b931acd6b 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -19,6 +19,7 @@ const { } } = require('node:util') const { MockCallHistory } = require('./mock-call-history') +const { InvalidArgumentError } = require('../core/errors') function matchValue (match, value) { if (typeof match === 'string') { @@ -381,9 +382,14 @@ function checkNetConnect (netConnect, origin) { return false } -function buildMockOptions (opts) { +function buildAndValidateMockOptions (opts) { if (opts) { const { agent, ...mockOptions } = opts + + if ('enableCallHistory' in mockOptions && typeof mockOptions.enableCallHistory !== 'boolean') { + throw new InvalidArgumentError('options.enableCallHistory must to be a boolean') + } + return mockOptions } } @@ -401,7 +407,7 @@ module.exports = { mockDispatch, buildMockDispatch, checkNetConnect, - buildMockOptions, + buildAndValidateMockOptions, getHeaderByName, buildHeadersFromArray } diff --git a/test/mock-agent.js b/test/mock-agent.js index 98e0abdddaf..44f663151f7 100644 --- a/test/mock-agent.js +++ b/test/mock-agent.js @@ -51,9 +51,8 @@ describe('MockAgent - constructor', () => { test('should disable call history by default', t => { t = tspl(t, { plan: 2 }) - const agent = new Agent() - after(() => agent.close()) const mockAgent = new MockAgent() + after(() => mockAgent.close()) t.strictEqual(mockAgent[kMockAgentIsCallHistoryEnabled], false) t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 0) @@ -61,13 +60,27 @@ describe('MockAgent - constructor', () => { test('should enable call history if option is true', t => { t = tspl(t, { plan: 2 }) - const agent = new Agent() - after(() => agent.close()) const mockAgent = new MockAgent({ enableCallHistory: true }) + after(() => mockAgent.close()) t.strictEqual(mockAgent[kMockAgentIsCallHistoryEnabled], true) t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 1) }) + + test('should disable call history if option is false', t => { + t = tspl(t, { plan: 2 }) + after(() => mockAgent.close()) + const mockAgent = new MockAgent({ enableCallHistory: false }) + + t.strictEqual(mockAgent[kMockAgentIsCallHistoryEnabled], false) + t.strictEqual(MockCallHistory[kMockCallHistoryAllMockCallHistoryInstances].size, 0) + }) + + test('should throw if enableCallHistory option is not a boolean', t => { + t = tspl(t, { plan: 1 }) + + t.throws(() => new MockAgent({ enableCallHistory: 'hello' }), new InvalidArgumentError('options.enableCallHistory must to be a boolean')) + }) }) describe('MockAgent - enableCallHistory', t => { @@ -86,7 +99,7 @@ describe('MockAgent - enableCallHistory', t => { await fetch('http://localhost:9999/foo') - t.strictEqual(mockAgent.getCallHistory()?.calls()?.length, 0) + t.strictEqual(mockAgent.getCallHistory()?.calls()?.length, undefined) mockAgent.enableCallHistory() From 78bb6b259b36312a589b9415f6cfd9ae8b1d9412 Mon Sep 17 00:00:00 2001 From: Blephy Date: Fri, 31 Jan 2025 14:07:07 +0100 Subject: [PATCH 22/23] fix: ts type definitions and specs --- package.json | 2 +- test/imports/undici-import.ts | 21 +--------- test/types/mock-call-history.test-d.ts | 58 ++++++++++++++++++++++++++ types/mock-call-history.d.ts | 31 ++++++++++---- 4 files changed, 83 insertions(+), 29 deletions(-) create mode 100644 test/types/mock-call-history.test-d.ts diff --git a/package.json b/package.json index 4ecfcecd059..e90a4dbfbf6 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "test:node-test": "borp -p \"test/node-test/**/*.js\"", "test:tdd": "borp --expose-gc -p \"test/*.js\"", "test:tdd:node-test": "borp -p \"test/node-test/**/*.js\" -w", - "test:typescript": "tsd && tsc test/imports/undici-import.ts --downlevelIteration --typeRoots ./types --noEmit && tsc ./types/*.d.ts --noEmit --typeRoots ./types", + "test:typescript": "tsd && tsc test/imports/undici-import.ts --typeRoots ./types --noEmit && tsc ./types/*.d.ts --noEmit --typeRoots ./types", "test:webidl": "borp -p \"test/webidl/*.js\"", "test:websocket": "borp -p \"test/websocket/*.js\"", "test:websocket:autobahn": "node test/autobahn/client.js", diff --git a/test/imports/undici-import.ts b/test/imports/undici-import.ts index f3c12270772..8be95cdd009 100644 --- a/test/imports/undici-import.ts +++ b/test/imports/undici-import.ts @@ -1,6 +1,5 @@ import { expectType } from 'tsd' -import { Dispatcher, interceptors, MockCallHistory, MockCallHistoryLog, request } from '../../' -import { kMockCallHistoryAddLog, kMockCallHistoryDeleteAll } from '../../lib/mock/mock-symbols' +import { Dispatcher, interceptors, request } from '../../' async function exampleCode () { const retry = interceptors.retry() @@ -14,22 +13,4 @@ async function exampleCode () { await request('http://localhost:3000/foo') } -function checkMockCallHistoryIterator () { - const mockCallHistory = new MockCallHistory('hello') - // @ts-ignore -- not relevant here - mockCallHistory[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000', method: 'GET' }) - // @ts-ignore -- not relevant here - mockCallHistory[kMockCallHistoryAddLog]({ path: '/endpoint', origin: 'http://localhost:4000', method: 'GET' }) - - expectType>([...mockCallHistory]) - - for (const log of mockCallHistory) { - expectType(log) - } - - // @ts-ignore -- not relevant here - MockCallHistory[kMockCallHistoryDeleteAll]() -} - exampleCode() -checkMockCallHistoryIterator() diff --git a/test/types/mock-call-history.test-d.ts b/test/types/mock-call-history.test-d.ts new file mode 100644 index 00000000000..12bc9cad857 --- /dev/null +++ b/test/types/mock-call-history.test-d.ts @@ -0,0 +1,58 @@ +import { expectType } from 'tsd' +import { MockAgent, MockCallHistory, MockCallHistoryLog } from '../..' +import { MockScope } from '../../types/mock-interceptor' + +{ + const mockAgent = new MockAgent() + expectType(mockAgent.getCallHistory()) + expectType(mockAgent.getCallHistory('hello')) + expectType(mockAgent.clearAllCallHistory()) + expectType(mockAgent.enableCallHistory()) + expectType(mockAgent.disableCallHistory()) + expectType>(mockAgent.get('http://localhost:3000').intercept({ path: '/' }).reply(200, 'hello').registerCallHistory('local-history')) +} + +{ + const mockAgent = new MockAgent() + expectType(mockAgent.getCallHistory()?.firstCall()) + expectType(mockAgent.getCallHistory()?.lastCall()) + expectType(mockAgent.getCallHistory()?.nthCall(1)) + expectType | undefined>(mockAgent.getCallHistory()?.calls()) + expectType | undefined>(mockAgent.getCallHistory()?.filterCallsByFullUrl('')) + expectType | undefined>(mockAgent.getCallHistory()?.filterCallsByHash('')) + expectType | undefined>(mockAgent.getCallHistory()?.filterCallsByHost('')) + expectType | undefined>(mockAgent.getCallHistory()?.filterCallsByMethod('')) + expectType | undefined>(mockAgent.getCallHistory()?.filterCallsByOrigin('')) + expectType | undefined>(mockAgent.getCallHistory()?.filterCallsByPath('')) + expectType | undefined>(mockAgent.getCallHistory()?.filterCallsByPort('')) + expectType | undefined>(mockAgent.getCallHistory()?.filterCallsByProtocol('')) + expectType | undefined>(mockAgent.getCallHistory()?.filterCalls((log) => log.path === '/')) + expectType | undefined>(mockAgent.getCallHistory()?.filterCalls(/path->\//)) + expectType | undefined>(mockAgent.getCallHistory()?.filterCalls({ method: 'POST' })) + expectType | undefined>(mockAgent.getCallHistory()?.filterCalls({ method: 'POST' }, { operator: 'AND' })) + + const callHistory = mockAgent.getCallHistory() + + if (callHistory !== undefined) { + expectType>([...callHistory]) + expectType>(new Set(callHistory)) + + for (const log of callHistory) { + expectType(log) + expectType(log.body) + expectType(log.fullUrl) + expectType(log.hash) + expectType> | null | undefined>(log.headers) + expectType(log.host) + expectType(log.method) + expectType(log.origin) + expectType(log.path) + expectType(log.port) + expectType(log.protocol) + expectType>(log.searchParams) + expectType | null | undefined>>(log.toMap()) + expectType(log.toString()) + } + } + expectType(mockAgent.getCallHistory()?.clear()) +} diff --git a/types/mock-call-history.d.ts b/types/mock-call-history.d.ts index 9d14e530f70..df07fa0dca0 100644 --- a/types/mock-call-history.d.ts +++ b/types/mock-call-history.d.ts @@ -8,9 +8,9 @@ declare namespace MockCallHistoryLog { /** a log reflecting request configuration */ declare class MockCallHistoryLog { constructor (requestInit: Dispatcher.DispatchOptions) - /** protocol used. */ + /** protocol used. ie. 'https:' or 'http:' etc... */ protocol: string - /** request's host. ie. 'https:' or 'http:' etc... */ + /** request's host. */ host: string /** request's port. */ port: string @@ -23,22 +23,29 @@ declare class MockCallHistoryLog { /** the full url requested. */ fullUrl: string /** request's method. */ - method: Dispatcher.DispatchOptions['method'] + method: string /** search params. */ searchParams: Record /** request's body */ - body: Dispatcher.DispatchOptions['body'] + body: string | null | undefined /** request's headers */ - headers: Dispatcher.DispatchOptions['headers'] + headers: Record | null | undefined /** returns an Map of property / value pair */ - toMap (): Map + toMap (): Map | null | undefined> /** returns a string computed with all key value pair */ toString (): string } declare namespace MockCallHistory { + export type FilterCallsOperator = 'AND' | 'OR' + + /** modify the filtering behavior */ + export interface FilterCallsOptions { + /** the operator to apply when filtering. 'OR' will adds any MockCallHistoryLog matching any criteria given. 'AND' will adds only MockCallHistoryLog matching every criteria given. (default 'OR') */ + operator?: FilterCallsOperator | Lowercase + } /** a function to be executed for filtering MockCallHistoryLog */ export type FilterCallsFunctionCriteria = (log: MockCallHistoryLog) => boolean @@ -47,13 +54,21 @@ declare namespace MockCallHistory { /** an object to execute multiple filtering at once */ export interface FilterCallsObjectCriteria extends Record { + /** filter by request protocol. ie https: */ protocol?: FilterCallsParameter; + /** filter by request host. */ host?: FilterCallsParameter; + /** filter by request port. */ port?: FilterCallsParameter; + /** filter by request origin. */ origin?: FilterCallsParameter; + /** filter by request path. */ path?: FilterCallsParameter; + /** filter by request hash. */ hash?: FilterCallsParameter; + /** filter by request fullUrl. */ fullUrl?: FilterCallsParameter; + /** filter by request method. */ method?: FilterCallsParameter; } } @@ -69,8 +84,8 @@ declare class MockCallHistory { lastCall (): MockCallHistoryLog | undefined /** returns the nth MockCallHistoryLog. */ nthCall (position: number): MockCallHistoryLog | undefined - /** return all MockCallHistoryLog matching any of criteria given. */ - filterCalls (criteria: MockCallHistory.FilterCallsObjectCriteria | MockCallHistory.FilterCallsFunctionCriteria | RegExp): Array + /** return all MockCallHistoryLog matching any of criteria given. if an object is used with multiple properties, you can change the operator to apply during filtering on options */ + filterCalls (criteria: MockCallHistory.FilterCallsFunctionCriteria | MockCallHistory.FilterCallsObjectCriteria | RegExp, options?: MockCallHistory.FilterCallsOptions): Array /** return all MockCallHistoryLog matching the given protocol. if a string is given, it is matched with includes */ filterCallsByProtocol (protocol: MockCallHistory.FilterCallsParameter): Array /** return all MockCallHistoryLog matching the given host. if a string is given, it is matched with includes */ From d56770480eafb76c28e7b098d182e0db796219a1 Mon Sep 17 00:00:00 2001 From: Blephy Date: Fri, 7 Feb 2025 13:25:13 +0100 Subject: [PATCH 23/23] test: fix tsd tests --- test/types/mock-call-history.test-d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/types/mock-call-history.test-d.ts b/test/types/mock-call-history.test-d.ts index 12bc9cad857..83c20051fab 100644 --- a/test/types/mock-call-history.test-d.ts +++ b/test/types/mock-call-history.test-d.ts @@ -54,5 +54,5 @@ import { MockScope } from '../../types/mock-interceptor' expectType(log.toString()) } } - expectType(mockAgent.getCallHistory()?.clear()) + expectType(mockAgent.getCallHistory()?.clear()) }