From 36356b18275f49f07fc51acddb0950ca02f51743 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Fri, 4 Apr 2025 20:02:27 +0100 Subject: [PATCH] feat: add new `acceptNonStandardSearchParameters` MockAgent option add a new `acceptNonStandardSearchParameters` option to make instances of `MockAgent` accept search parameters specified using non-standard syntaxes such as multi-value items specified with `[]` (e.g. `param[]=1¶m[]=2¶m[]=3`) and multi-value items which values are comma separated (e.g. `param=1,2,3`) Co-authored-by: Carlos Fuentes --- docs/docs/api/MockAgent.md | 2 + lib/mock/mock-agent.js | 16 +++++- lib/mock/mock-symbols.js | 1 + lib/mock/mock-utils.js | 40 ++++++++++++-- test/mock-agent.js | 92 +++++++++++++++++++++++++++++++++ test/mock-utils.js | 33 +++++++++++- test/types/mock-agent.test-d.ts | 9 ++++ types/mock-agent.d.ts | 3 ++ 8 files changed, 190 insertions(+), 6 deletions(-) diff --git a/docs/docs/api/MockAgent.md b/docs/docs/api/MockAgent.md index e61c2080370..b4ce8106bb0 100644 --- a/docs/docs/api/MockAgent.md +++ b/docs/docs/api/MockAgent.md @@ -20,6 +20,8 @@ Extends: [`AgentOptions`](/docs/docs/api/Agent.md#parameter-agentoptions) * **ignoreTrailingSlash** `boolean` (optional) - Default: `false` - set the default value for `ignoreTrailingSlash` for interceptors. +* **acceptNonStandardSearchParameters** `boolean` (optional) - Default: `false` - set to `true` if the matcher should also accept non standard search parameters such as multi-value items specified with `[]` (e.g. `param[]=1¶m[]=2¶m[]=3`) and multi-value items which values are comma separated (e.g. `param=1,2,3`). + ### Example - Basic MockAgent instantiation This will instantiate the MockAgent. It will not do anything until registered as the agent to use with requests and mock interceptions are added. diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 3bff4092db1..53c041002c8 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -16,11 +16,12 @@ const { kMockAgentIsCallHistoryEnabled, kMockAgentAddCallHistoryLog, kMockAgentMockCallHistoryInstance, + kMockAgentAcceptsNonStandardSearchParameters, kMockCallHistoryAddLog } = require('./mock-symbols') const MockClient = require('./mock-client') const MockPool = require('./mock-pool') -const { matchValue, buildAndValidateMockOptions } = require('./mock-utils') +const { matchValue, normalizeSearchParams, buildAndValidateMockOptions } = require('./mock-utils') const { InvalidArgumentError, UndiciError } = require('../core/errors') const Dispatcher = require('../dispatcher/dispatcher') const PendingInterceptorsFormatter = require('./pending-interceptors-formatter') @@ -35,6 +36,7 @@ class MockAgent extends Dispatcher { this[kNetConnect] = true this[kIsMockActive] = true this[kMockAgentIsCallHistoryEnabled] = mockOptions?.enableCallHistory ?? false + this[kMockAgentAcceptsNonStandardSearchParameters] = mockOptions?.acceptNonStandardSearchParameters ?? false // Instantiate Agent and encapsulate if (opts?.agent && typeof opts.agent.dispatch !== 'function') { @@ -67,7 +69,17 @@ class MockAgent extends Dispatcher { this[kMockAgentAddCallHistoryLog](opts) - return this[kAgent].dispatch(opts, handler) + const acceptNonStandardSearchParameters = this[kMockAgentAcceptsNonStandardSearchParameters] + + const dispatchOpts = { ...opts } + + if (acceptNonStandardSearchParameters && dispatchOpts.path) { + const [path, searchParams] = dispatchOpts.path.split('?') + const normalizedSearchParams = normalizeSearchParams(searchParams, acceptNonStandardSearchParameters) + dispatchOpts.path = `${path}?${normalizedSearchParams}` + } + + return this[kAgent].dispatch(dispatchOpts, handler) } async close () { diff --git a/lib/mock/mock-symbols.js b/lib/mock/mock-symbols.js index 811002cccf2..940dbe6e3f8 100644 --- a/lib/mock/mock-symbols.js +++ b/lib/mock/mock-symbols.js @@ -26,5 +26,6 @@ module.exports = { kMockAgentRegisterCallHistory: Symbol('mock agent register mock call history'), kMockAgentAddCallHistoryLog: Symbol('mock agent add call history log'), kMockAgentIsCallHistoryEnabled: Symbol('mock agent is call history enabled'), + kMockAgentAcceptsNonStandardSearchParameters: Symbol('mock agent accepts non standard search parameters'), kMockCallHistoryAddLog: Symbol('mock call history add log') } diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index a41825459a4..822d45d153f 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -92,13 +92,42 @@ function matchHeaders (mockDispatch, headers) { return true } +function normalizeSearchParams (query) { + if (typeof query !== 'string') { + return query + } + + const originalQp = new URLSearchParams(query) + const normalizedQp = new URLSearchParams() + + for (let [key, value] of originalQp.entries()) { + key = key.replace('[]', '') + + const valueRepresentsString = /^(['"]).*\1$/.test(value) + if (valueRepresentsString) { + normalizedQp.append(key, value) + continue + } + + if (value.includes(',')) { + const values = value.split(',') + for (const v of values) { + normalizedQp.append(key, v) + } + continue + } + + normalizedQp.append(key, value) + } + + return normalizedQp +} + function safeUrl (path) { if (typeof path !== 'string') { return path } - const pathSegments = path.split('?', 3) - if (pathSegments.length !== 2) { return path } @@ -376,6 +405,10 @@ function buildAndValidateMockOptions (opts) { throw new InvalidArgumentError('options.enableCallHistory must to be a boolean') } + if ('acceptNonStandardSearchParameters' in mockOptions && typeof mockOptions.acceptNonStandardSearchParameters !== 'boolean') { + throw new InvalidArgumentError('options.acceptNonStandardSearchParameters must to be a boolean') + } + return mockOptions } } @@ -395,5 +428,6 @@ module.exports = { checkNetConnect, buildAndValidateMockOptions, getHeaderByName, - buildHeadersFromArray + buildHeadersFromArray, + normalizeSearchParams } diff --git a/test/mock-agent.js b/test/mock-agent.js index 5f6488f04eb..bc8ce2d6671 100644 --- a/test/mock-agent.js +++ b/test/mock-agent.js @@ -2888,3 +2888,95 @@ test('MockAgent - headers should be array of strings (fetch)', async (t) => { t.deepStrictEqual(response.headers.getSetCookie(), ['foo=bar', 'bar=baz', 'baz=qux']) }) + +// https://github.com/nodejs/undici/issues/4146 +;[ + '/foo?array=item1&array=item2', + '/foo?array[]=item1&array[]=item2', + '/foo?array=item1,item2' +].forEach(path => { + test(`MockAgent - should accept non-standard multi value search parameters when acceptNonStandardSearchParameters is true "${path}"`, async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('should not be called') + t.fail('should not be called') + t.end() + }) + after(() => server.close()) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent({ acceptNonStandardSearchParameters: true }) + after(() => mockAgent.close()) + const mockPool = mockAgent.get(baseUrl) + + mockPool.intercept({ + path: '/foo', + method: 'GET', + query: { + array: ['item1', 'item2'] + } + }).reply(200, { foo: 'bar' }, { + headers: { 'content-type': 'application/json' }, + trailers: { 'Content-MD5': 'test' } + }) + + const { statusCode, headers, trailers, body } = await mockAgent.request({ + origin: baseUrl, + path, + method: 'GET' + }) + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'application/json') + t.deepStrictEqual(trailers, { 'content-md5': 'test' }) + + const jsonResponse = JSON.parse(await getResponse(body)) + t.deepStrictEqual(jsonResponse, { + foo: 'bar' + }) + }) +}) + +test('MockAgent - should not accept non-standard search parameters when acceptNonStandardSearchParameters is false (default)', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.setHeader('content-type', 'text/plain') + res.end('(non-intercepted) response from server') + }) + after(() => server.close()) + + await promisify(server.listen.bind(server))(0) + + const baseUrl = `http://localhost:${server.address().port}` + + const mockAgent = new MockAgent() + after(() => mockAgent.close()) + const mockPool = mockAgent.get(baseUrl) + + mockPool.intercept({ + path: '/foo', + method: 'GET', + query: { + array: ['item1', 'item2'] + } + }).reply(200, { foo: 'bar' }, { + headers: { 'content-type': 'application/json' }, + trailers: { 'Content-MD5': 'test' } + }) + + const { statusCode, body } = + await mockAgent.request({ + origin: baseUrl, + path: '/foo?array[]=item1&array[]=item2', + method: 'GET' + }) + t.strictEqual(statusCode, 200) + + const textResponse = await getResponse(body) + t.strictEqual(textResponse, '(non-intercepted) response from server') +}) diff --git a/test/mock-utils.js b/test/mock-utils.js index 2d0044b60dc..3ecf246c5ac 100644 --- a/test/mock-utils.js +++ b/test/mock-utils.js @@ -9,7 +9,8 @@ const { getResponseData, getStatusText, getHeaderByName, - buildHeadersFromArray + buildHeadersFromArray, + normalizeSearchParams } = require('../lib/mock/mock-utils') test('deleteMockDispatch - should do nothing if not able to find mock dispatch', (t) => { @@ -242,3 +243,33 @@ describe('buildHeadersFromArray', () => { t.strictEqual(headers.key, 'value') }) }) + +describe('normalizeQueryParams', () => { + test('it should handle basic cases', (t) => { + t = tspl(t, { plan: 4 }) + + t.deepStrictEqual(normalizeSearchParams('').toString(), '') + t.deepStrictEqual(normalizeSearchParams('a').toString(), 'a=') + t.deepStrictEqual(normalizeSearchParams('b=2&c=3&a=1').toString(), 'b=2&c=3&a=1') + t.deepStrictEqual(normalizeSearchParams('lang=en_EN&id=123').toString(), 'lang=en_EN&id=123') + }) + + // https://github.com/nodejs/undici/issues/4146 + test('it should handle multiple values set using different syntaxes', (t) => { + t = tspl(t, { plan: 3 }) + + t.deepStrictEqual(normalizeSearchParams('a=1&a=2&a=3').toString(), 'a=1&a=2&a=3') + t.deepStrictEqual(normalizeSearchParams('a[]=1&a[]=2&a[]=3').toString(), 'a=1&a=2&a=3') + t.deepStrictEqual(normalizeSearchParams('a=1,2,3').toString(), 'a=1&a=2&a=3') + }) + + test('should handle edge case scenarios', (t) => { + t = tspl(t, { plan: 4 }) + + t.deepStrictEqual(normalizeSearchParams('a="b[]"').toString(), `a=${encodeURIComponent('"b[]"')}`) + t.deepStrictEqual(normalizeSearchParams('a="1,2,3"').toString(), `a=${encodeURIComponent('"1,2,3"')}`) + const encodedSingleQuote = '%27' + t.deepStrictEqual(normalizeSearchParams("a='b[]'").toString(), `a=${encodedSingleQuote}${encodeURIComponent('b[]')}${encodedSingleQuote}`) + t.deepStrictEqual(normalizeSearchParams("a='1,2,3'").toString(), `a=${encodedSingleQuote}${encodeURIComponent('1,2,3')}${encodedSingleQuote}`) + }) +}) diff --git a/test/types/mock-agent.test-d.ts b/test/types/mock-agent.test-d.ts index 91962e4a8a0..fc4ddd6221b 100644 --- a/test/types/mock-agent.test-d.ts +++ b/test/types/mock-agent.test-d.ts @@ -95,3 +95,12 @@ expectType(new MockAgent({ expectType(new MockAgent({ agent: new RetryAgent(new Agent()) })) +expectType(new MockAgent({ + acceptNonStandardSearchParameters: true +})) +expectType(new MockAgent({ + acceptNonStandardSearchParameters: false +})) +expectType(new MockAgent({ + acceptNonStandardSearchParameters: undefined +})) diff --git a/types/mock-agent.d.ts b/types/mock-agent.d.ts index 0b8321298da..330926be191 100644 --- a/types/mock-agent.d.ts +++ b/types/mock-agent.d.ts @@ -59,6 +59,9 @@ declare namespace MockAgent { /** Ignore trailing slashes in the path */ ignoreTrailingSlash?: boolean; + /** Accept URLs with search parameters using non standard syntaxes. default false */ + acceptNonStandardSearchParameters?: boolean; + /** Enable call history. you can either call MockAgent.enableCallHistory(). default false */ enableCallHistory?: boolean }