Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/docs/api/MockAgent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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&param[]=2&param[]=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.
Expand Down
16 changes: 14 additions & 2 deletions lib/mock/mock-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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') {
Expand Down Expand Up @@ -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 () {
Expand Down
1 change: 1 addition & 0 deletions lib/mock/mock-symbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
40 changes: 37 additions & 3 deletions lib/mock/mock-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
}
Expand All @@ -395,5 +428,6 @@ module.exports = {
checkNetConnect,
buildAndValidateMockOptions,
getHeaderByName,
buildHeadersFromArray
buildHeadersFromArray,
normalizeSearchParams
}
92 changes: 92 additions & 0 deletions test/mock-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
33 changes: 32 additions & 1 deletion test/mock-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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}`)
})
})
9 changes: 9 additions & 0 deletions test/types/mock-agent.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,12 @@ expectType<MockAgent>(new MockAgent({
expectType<MockAgent>(new MockAgent({
agent: new RetryAgent(new Agent())
}))
expectType<MockAgent>(new MockAgent({
acceptNonStandardSearchParameters: true
}))
expectType<MockAgent>(new MockAgent({
acceptNonStandardSearchParameters: false
}))
expectType<MockAgent>(new MockAgent({
acceptNonStandardSearchParameters: undefined
}))
3 changes: 3 additions & 0 deletions types/mock-agent.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
dario-piotrowicz marked this conversation as resolved.

/** Enable call history. you can either call MockAgent.enableCallHistory(). default false */
enableCallHistory?: boolean
}
Expand Down
Loading