Skip to content

Commit 0daba93

Browse files
feat: add new acceptNonStandardSearchParameters MockAgent option (#4148)
Co-authored-by: Carlos Fuentes <[email protected]>
1 parent 259d5f8 commit 0daba93

8 files changed

Lines changed: 190 additions & 6 deletions

File tree

docs/docs/api/MockAgent.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Extends: [`AgentOptions`](/docs/docs/api/Agent.md#parameter-agentoptions)
2020

2121
* **ignoreTrailingSlash** `boolean` (optional) - Default: `false` - set the default value for `ignoreTrailingSlash` for interceptors.
2222

23+
* **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&param[]=2&param[]=3`) and multi-value items which values are comma separated (e.g. `param=1,2,3`).
24+
2325
### Example - Basic MockAgent instantiation
2426

2527
This will instantiate the MockAgent. It will not do anything until registered as the agent to use with requests and mock interceptions are added.

lib/mock/mock-agent.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@ const {
1616
kMockAgentIsCallHistoryEnabled,
1717
kMockAgentAddCallHistoryLog,
1818
kMockAgentMockCallHistoryInstance,
19+
kMockAgentAcceptsNonStandardSearchParameters,
1920
kMockCallHistoryAddLog
2021
} = require('./mock-symbols')
2122
const MockClient = require('./mock-client')
2223
const MockPool = require('./mock-pool')
23-
const { matchValue, buildAndValidateMockOptions } = require('./mock-utils')
24+
const { matchValue, normalizeSearchParams, buildAndValidateMockOptions } = require('./mock-utils')
2425
const { InvalidArgumentError, UndiciError } = require('../core/errors')
2526
const Dispatcher = require('../dispatcher/dispatcher')
2627
const PendingInterceptorsFormatter = require('./pending-interceptors-formatter')
@@ -35,6 +36,7 @@ class MockAgent extends Dispatcher {
3536
this[kNetConnect] = true
3637
this[kIsMockActive] = true
3738
this[kMockAgentIsCallHistoryEnabled] = mockOptions?.enableCallHistory ?? false
39+
this[kMockAgentAcceptsNonStandardSearchParameters] = mockOptions?.acceptNonStandardSearchParameters ?? false
3840

3941
// Instantiate Agent and encapsulate
4042
if (opts?.agent && typeof opts.agent.dispatch !== 'function') {
@@ -67,7 +69,17 @@ class MockAgent extends Dispatcher {
6769

6870
this[kMockAgentAddCallHistoryLog](opts)
6971

70-
return this[kAgent].dispatch(opts, handler)
72+
const acceptNonStandardSearchParameters = this[kMockAgentAcceptsNonStandardSearchParameters]
73+
74+
const dispatchOpts = { ...opts }
75+
76+
if (acceptNonStandardSearchParameters && dispatchOpts.path) {
77+
const [path, searchParams] = dispatchOpts.path.split('?')
78+
const normalizedSearchParams = normalizeSearchParams(searchParams, acceptNonStandardSearchParameters)
79+
dispatchOpts.path = `${path}?${normalizedSearchParams}`
80+
}
81+
82+
return this[kAgent].dispatch(dispatchOpts, handler)
7183
}
7284

7385
async close () {

lib/mock/mock-symbols.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@ module.exports = {
2626
kMockAgentRegisterCallHistory: Symbol('mock agent register mock call history'),
2727
kMockAgentAddCallHistoryLog: Symbol('mock agent add call history log'),
2828
kMockAgentIsCallHistoryEnabled: Symbol('mock agent is call history enabled'),
29+
kMockAgentAcceptsNonStandardSearchParameters: Symbol('mock agent accepts non standard search parameters'),
2930
kMockCallHistoryAddLog: Symbol('mock call history add log')
3031
}

lib/mock/mock-utils.js

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,42 @@ function matchHeaders (mockDispatch, headers) {
9292
return true
9393
}
9494

95+
function normalizeSearchParams (query) {
96+
if (typeof query !== 'string') {
97+
return query
98+
}
99+
100+
const originalQp = new URLSearchParams(query)
101+
const normalizedQp = new URLSearchParams()
102+
103+
for (let [key, value] of originalQp.entries()) {
104+
key = key.replace('[]', '')
105+
106+
const valueRepresentsString = /^(['"]).*\1$/.test(value)
107+
if (valueRepresentsString) {
108+
normalizedQp.append(key, value)
109+
continue
110+
}
111+
112+
if (value.includes(',')) {
113+
const values = value.split(',')
114+
for (const v of values) {
115+
normalizedQp.append(key, v)
116+
}
117+
continue
118+
}
119+
120+
normalizedQp.append(key, value)
121+
}
122+
123+
return normalizedQp
124+
}
125+
95126
function safeUrl (path) {
96127
if (typeof path !== 'string') {
97128
return path
98129
}
99-
100130
const pathSegments = path.split('?', 3)
101-
102131
if (pathSegments.length !== 2) {
103132
return path
104133
}
@@ -376,6 +405,10 @@ function buildAndValidateMockOptions (opts) {
376405
throw new InvalidArgumentError('options.enableCallHistory must to be a boolean')
377406
}
378407

408+
if ('acceptNonStandardSearchParameters' in mockOptions && typeof mockOptions.acceptNonStandardSearchParameters !== 'boolean') {
409+
throw new InvalidArgumentError('options.acceptNonStandardSearchParameters must to be a boolean')
410+
}
411+
379412
return mockOptions
380413
}
381414
}
@@ -395,5 +428,6 @@ module.exports = {
395428
checkNetConnect,
396429
buildAndValidateMockOptions,
397430
getHeaderByName,
398-
buildHeadersFromArray
431+
buildHeadersFromArray,
432+
normalizeSearchParams
399433
}

test/mock-agent.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2888,3 +2888,95 @@ test('MockAgent - headers should be array of strings (fetch)', async (t) => {
28882888

28892889
t.deepStrictEqual(response.headers.getSetCookie(), ['foo=bar', 'bar=baz', 'baz=qux'])
28902890
})
2891+
2892+
// https://github.com/nodejs/undici/issues/4146
2893+
;[
2894+
'/foo?array=item1&array=item2',
2895+
'/foo?array[]=item1&array[]=item2',
2896+
'/foo?array=item1,item2'
2897+
].forEach(path => {
2898+
test(`MockAgent - should accept non-standard multi value search parameters when acceptNonStandardSearchParameters is true "${path}"`, async (t) => {
2899+
t = tspl(t, { plan: 4 })
2900+
2901+
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
2902+
res.setHeader('content-type', 'text/plain')
2903+
res.end('should not be called')
2904+
t.fail('should not be called')
2905+
t.end()
2906+
})
2907+
after(() => server.close())
2908+
2909+
await promisify(server.listen.bind(server))(0)
2910+
2911+
const baseUrl = `http://localhost:${server.address().port}`
2912+
2913+
const mockAgent = new MockAgent({ acceptNonStandardSearchParameters: true })
2914+
after(() => mockAgent.close())
2915+
const mockPool = mockAgent.get(baseUrl)
2916+
2917+
mockPool.intercept({
2918+
path: '/foo',
2919+
method: 'GET',
2920+
query: {
2921+
array: ['item1', 'item2']
2922+
}
2923+
}).reply(200, { foo: 'bar' }, {
2924+
headers: { 'content-type': 'application/json' },
2925+
trailers: { 'Content-MD5': 'test' }
2926+
})
2927+
2928+
const { statusCode, headers, trailers, body } = await mockAgent.request({
2929+
origin: baseUrl,
2930+
path,
2931+
method: 'GET'
2932+
})
2933+
t.strictEqual(statusCode, 200)
2934+
t.strictEqual(headers['content-type'], 'application/json')
2935+
t.deepStrictEqual(trailers, { 'content-md5': 'test' })
2936+
2937+
const jsonResponse = JSON.parse(await getResponse(body))
2938+
t.deepStrictEqual(jsonResponse, {
2939+
foo: 'bar'
2940+
})
2941+
})
2942+
})
2943+
2944+
test('MockAgent - should not accept non-standard search parameters when acceptNonStandardSearchParameters is false (default)', async (t) => {
2945+
t = tspl(t, { plan: 2 })
2946+
2947+
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
2948+
res.setHeader('content-type', 'text/plain')
2949+
res.end('(non-intercepted) response from server')
2950+
})
2951+
after(() => server.close())
2952+
2953+
await promisify(server.listen.bind(server))(0)
2954+
2955+
const baseUrl = `http://localhost:${server.address().port}`
2956+
2957+
const mockAgent = new MockAgent()
2958+
after(() => mockAgent.close())
2959+
const mockPool = mockAgent.get(baseUrl)
2960+
2961+
mockPool.intercept({
2962+
path: '/foo',
2963+
method: 'GET',
2964+
query: {
2965+
array: ['item1', 'item2']
2966+
}
2967+
}).reply(200, { foo: 'bar' }, {
2968+
headers: { 'content-type': 'application/json' },
2969+
trailers: { 'Content-MD5': 'test' }
2970+
})
2971+
2972+
const { statusCode, body } =
2973+
await mockAgent.request({
2974+
origin: baseUrl,
2975+
path: '/foo?array[]=item1&array[]=item2',
2976+
method: 'GET'
2977+
})
2978+
t.strictEqual(statusCode, 200)
2979+
2980+
const textResponse = await getResponse(body)
2981+
t.strictEqual(textResponse, '(non-intercepted) response from server')
2982+
})

test/mock-utils.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ const {
99
getResponseData,
1010
getStatusText,
1111
getHeaderByName,
12-
buildHeadersFromArray
12+
buildHeadersFromArray,
13+
normalizeSearchParams
1314
} = require('../lib/mock/mock-utils')
1415

1516
test('deleteMockDispatch - should do nothing if not able to find mock dispatch', (t) => {
@@ -242,3 +243,33 @@ describe('buildHeadersFromArray', () => {
242243
t.strictEqual(headers.key, 'value')
243244
})
244245
})
246+
247+
describe('normalizeQueryParams', () => {
248+
test('it should handle basic cases', (t) => {
249+
t = tspl(t, { plan: 4 })
250+
251+
t.deepStrictEqual(normalizeSearchParams('').toString(), '')
252+
t.deepStrictEqual(normalizeSearchParams('a').toString(), 'a=')
253+
t.deepStrictEqual(normalizeSearchParams('b=2&c=3&a=1').toString(), 'b=2&c=3&a=1')
254+
t.deepStrictEqual(normalizeSearchParams('lang=en_EN&id=123').toString(), 'lang=en_EN&id=123')
255+
})
256+
257+
// https://github.com/nodejs/undici/issues/4146
258+
test('it should handle multiple values set using different syntaxes', (t) => {
259+
t = tspl(t, { plan: 3 })
260+
261+
t.deepStrictEqual(normalizeSearchParams('a=1&a=2&a=3').toString(), 'a=1&a=2&a=3')
262+
t.deepStrictEqual(normalizeSearchParams('a[]=1&a[]=2&a[]=3').toString(), 'a=1&a=2&a=3')
263+
t.deepStrictEqual(normalizeSearchParams('a=1,2,3').toString(), 'a=1&a=2&a=3')
264+
})
265+
266+
test('should handle edge case scenarios', (t) => {
267+
t = tspl(t, { plan: 4 })
268+
269+
t.deepStrictEqual(normalizeSearchParams('a="b[]"').toString(), `a=${encodeURIComponent('"b[]"')}`)
270+
t.deepStrictEqual(normalizeSearchParams('a="1,2,3"').toString(), `a=${encodeURIComponent('"1,2,3"')}`)
271+
const encodedSingleQuote = '%27'
272+
t.deepStrictEqual(normalizeSearchParams("a='b[]'").toString(), `a=${encodedSingleQuote}${encodeURIComponent('b[]')}${encodedSingleQuote}`)
273+
t.deepStrictEqual(normalizeSearchParams("a='1,2,3'").toString(), `a=${encodedSingleQuote}${encodeURIComponent('1,2,3')}${encodedSingleQuote}`)
274+
})
275+
})

test/types/mock-agent.test-d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,12 @@ expectType<MockAgent>(new MockAgent({
9595
expectType<MockAgent>(new MockAgent({
9696
agent: new RetryAgent(new Agent())
9797
}))
98+
expectType<MockAgent>(new MockAgent({
99+
acceptNonStandardSearchParameters: true
100+
}))
101+
expectType<MockAgent>(new MockAgent({
102+
acceptNonStandardSearchParameters: false
103+
}))
104+
expectType<MockAgent>(new MockAgent({
105+
acceptNonStandardSearchParameters: undefined
106+
}))

types/mock-agent.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ declare namespace MockAgent {
5959
/** Ignore trailing slashes in the path */
6060
ignoreTrailingSlash?: boolean;
6161

62+
/** Accept URLs with search parameters using non standard syntaxes. default false */
63+
acceptNonStandardSearchParameters?: boolean;
64+
6265
/** Enable call history. you can either call MockAgent.enableCallHistory(). default false */
6366
enableCallHistory?: boolean
6467
}

0 commit comments

Comments
 (0)