Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: add option ignoreTrailingSlash to MockAgent and .intercept() #3655

Merged
merged 9 commits into from
Oct 4, 2024
2 changes: 2 additions & 0 deletions docs/docs/api/MockAgent.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Extends: [`AgentOptions`](Agent.md#parameter-agentoptions)

* **agent** `Agent` (optional) - Default: `new Agent([options])` - a custom agent encapsulated by the MockAgent.

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

### 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
1 change: 1 addition & 0 deletions docs/docs/api/MockPool.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Returns: `MockInterceptor` corresponding to the input options.
* **body** `string | RegExp | (body: string) => boolean` - (optional) - a matcher for the HTTP request body.
* **headers** `Record<string, string | RegExp | (body: string) => boolean`> - (optional) - a matcher for the HTTP request headers. To be intercepted, a request must match all defined headers. Extra headers not defined here may (or may not) be included in the request and do not affect the interception in any way.
* **query** `Record<string, any> | null` - (optional) - a matcher for the HTTP request query string params. Only applies when a `string` was provided for `MockPoolInterceptOptions.path`.
* **ignoreTrailingSlash** `boolean` - (optional) - set to `true` if the matcher should also match by ignoring potential trailing slashes in `MockPoolInterceptOptions.path`.

### Return: `MockInterceptor`

Expand Down
9 changes: 7 additions & 2 deletions lib/mock/mock-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ const {
kOriginalClose,
kOrigin,
kOriginalDispatch,
kConnected
kConnected,
kIgnoreTrailingSlash
} = require('./mock-symbols')
const { MockInterceptor } = require('./mock-interceptor')
const Symbols = require('../core/symbols')
Expand All @@ -29,6 +30,7 @@ class MockClient extends Client {

this[kMockAgent] = opts.agent
this[kOrigin] = origin
this[kIgnoreTrailingSlash] = opts.ignoreTrailingSlash ?? false
this[kDispatches] = []
this[kConnected] = 1
this[kOriginalDispatch] = this.dispatch
Expand All @@ -46,7 +48,10 @@ class MockClient extends Client {
* Sets up the base interceptor for mocking replies from undici.
*/
intercept (opts) {
return new MockInterceptor(opts, this[kDispatches])
return new MockInterceptor(
opts && { ignoreTrailingSlash: this[kIgnoreTrailingSlash], ...opts },
this[kDispatches]
)
}

async [kClose] () {
Expand Down
10 changes: 6 additions & 4 deletions lib/mock/mock-interceptor.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const {
kDefaultHeaders,
kDefaultTrailers,
kContentLength,
kMockDispatch
kMockDispatch,
kIgnoreTrailingSlash
} = require('./mock-symbols')
const { InvalidArgumentError } = require('../core/errors')
const { serializePathWithQuery } = require('../core/util')
Expand Down Expand Up @@ -85,6 +86,7 @@ class MockInterceptor {

this[kDispatchKey] = buildKey(opts)
this[kDispatches] = mockDispatches
this[kIgnoreTrailingSlash] = opts.ignoreTrailingSlash ?? false
this[kDefaultHeaders] = {}
this[kDefaultTrailers] = {}
this[kContentLength] = false
Expand Down Expand Up @@ -137,7 +139,7 @@ class MockInterceptor {
}

// Add usual dispatch data, but this time set the data parameter to function that will eventually provide data.
const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], wrappedDefaultsCallback)
const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], wrappedDefaultsCallback, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] })
return new MockScope(newMockDispatch)
}

Expand All @@ -154,7 +156,7 @@ class MockInterceptor {

// Send in-already provided data like usual
const dispatchData = this.createMockScopeDispatchData(replyParameters)
const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], dispatchData)
const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], dispatchData, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] })
return new MockScope(newMockDispatch)
}

Expand All @@ -166,7 +168,7 @@ class MockInterceptor {
throw new InvalidArgumentError('error must be defined')
}

const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], { error })
const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], { error }, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] })
return new MockScope(newMockDispatch)
}

Expand Down
9 changes: 7 additions & 2 deletions lib/mock/mock-pool.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ const {
kOriginalClose,
kOrigin,
kOriginalDispatch,
kConnected
kConnected,
kIgnoreTrailingSlash
} = require('./mock-symbols')
const { MockInterceptor } = require('./mock-interceptor')
const Symbols = require('../core/symbols')
Expand All @@ -29,6 +30,7 @@ class MockPool extends Pool {

this[kMockAgent] = opts.agent
this[kOrigin] = origin
this[kIgnoreTrailingSlash] = opts.ignoreTrailingSlash ?? false
this[kDispatches] = []
this[kConnected] = 1
this[kOriginalDispatch] = this.dispatch
Expand All @@ -46,7 +48,10 @@ class MockPool extends Pool {
* Sets up the base interceptor for mocking replies from undici.
*/
intercept (opts) {
return new MockInterceptor(opts, this[kDispatches])
return new MockInterceptor(
opts && { ignoreTrailingSlash: this[kIgnoreTrailingSlash], ...opts },
this[kDispatches]
)
}

async [kClose] () {
Expand Down
3 changes: 2 additions & 1 deletion lib/mock/mock-symbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ module.exports = {
kIsMockActive: Symbol('is mock active'),
kNetConnect: Symbol('net connect'),
kGetNetConnect: Symbol('get net connect'),
kConnected: Symbol('connected')
kConnected: Symbol('connected'),
kIgnoreTrailingSlash: Symbol('ignore trailing slash')
}
30 changes: 27 additions & 3 deletions lib/mock/mock-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,16 @@ function getMockDispatch (mockDispatches, key) {
const basePath = key.query ? serializePathWithQuery(key.path, key.query) : key.path
const resolvedPath = typeof basePath === 'string' ? safeUrl(basePath) : basePath

const resolvedPathWithoutTrailingSlash = removeTrailingSlash(resolvedPath)

// Match path
let matchedMockDispatches = mockDispatches.filter(({ consumed }) => !consumed).filter(({ path }) => matchValue(safeUrl(path), resolvedPath))
let matchedMockDispatches = mockDispatches
.filter(({ consumed }) => !consumed)
.filter(({ path, ignoreTrailingSlash }) => {
return ignoreTrailingSlash
? matchValue(removeTrailingSlash(safeUrl(path)), resolvedPathWithoutTrailingSlash)
: matchValue(safeUrl(path), resolvedPath)
})
if (matchedMockDispatches.length === 0) {
throw new MockNotMatchedError(`Mock dispatch not matched for path '${resolvedPath}'`)
}
Expand All @@ -161,8 +169,8 @@ function getMockDispatch (mockDispatches, key) {
return matchedMockDispatches[0]
}

function addMockDispatch (mockDispatches, key, data) {
const baseData = { timesInvoked: 0, times: 1, persist: false, consumed: false }
function addMockDispatch (mockDispatches, key, data, opts) {
const baseData = { timesInvoked: 0, times: 1, persist: false, consumed: false, ...opts }
const replyData = typeof data === 'function' ? { callback: data } : { ...data }
const newMockDispatch = { ...baseData, ...key, pending: true, data: { error: null, ...replyData } }
mockDispatches.push(newMockDispatch)
Expand All @@ -181,8 +189,24 @@ function deleteMockDispatch (mockDispatches, key) {
}
}

/**
* @param {string} path Path to remove trailing slash from
*/
function removeTrailingSlash (path) {
while (path.endsWith('/')) {
path = path.slice(0, -1)
}

if (path.length === 0) {
path = '/'
}

return path
}

function buildKey (opts) {
const { path, method, body, headers, query } = opts

return {
path,
method,
Expand Down
1 change: 1 addition & 0 deletions test/mock-interceptor-unused-assertions.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ test('returns unused interceptors', t => {
persist: false,
consumed: false,
pending: true,
ignoreTrailingSlash: false,
path: '/',
method: 'GET',
body: undefined,
Expand Down
73 changes: 73 additions & 0 deletions test/mock-interceptor.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,79 @@ describe('MockInterceptor - replyContentLength', () => {
})
})

describe('https://github.com/nodejs/undici/issues/3649', () => {
[
['/api/some-path', '/api/some-path'],
['/api/some-path/', '/api/some-path'],
['/api/some-path', '/api/some-path/'],
['/api/some-path/', '/api/some-path/'],
['/api/some-path////', '/api/some-path//'],
['', ''],
['/', ''],
['', '/'],
['/', '/']
].forEach(([interceptPath, fetchedPath], index) => {
test(`MockAgent should match with or without trailing slash by setting ignoreTrailingSlash as MockAgent option /${index}`, async (t) => {
t = tspl(t, { plan: 1 })

const mockAgent = new MockAgent({ ignoreTrailingSlash: true })
mockAgent.disableNetConnect()
mockAgent
.get('https://localhost')
.intercept({ path: interceptPath }).reply(200, { ok: true })

const res = await fetch(new URL(fetchedPath, 'https://localhost'), { dispatcher: mockAgent })

t.deepStrictEqual(await res.json(), { ok: true })
})

test(`MockAgent should match with or without trailing slash by setting ignoreTrailingSlash as intercept option /${index}`, async (t) => {
t = tspl(t, { plan: 1 })

const mockAgent = new MockAgent()
mockAgent.disableNetConnect()
mockAgent
.get('https://localhost')
.intercept({ path: interceptPath, ignoreTrailingSlash: true }).reply(200, { ok: true })

const res = await fetch(new URL(fetchedPath, 'https://localhost'), { dispatcher: mockAgent })

t.deepStrictEqual(await res.json(), { ok: true })
})

if (
(interceptPath === fetchedPath && (interceptPath !== '' && fetchedPath !== '')) ||
(interceptPath === '/' && fetchedPath === '')
) {
test(`MockAgent should should match on strict equal cases of paths when ignoreTrailingSlash is not set /${index}`, async (t) => {
t = tspl(t, { plan: 1 })

const mockAgent = new MockAgent()
mockAgent.disableNetConnect()
mockAgent
.get('https://localhost')
.intercept({ path: interceptPath }).reply(200, { ok: true })

const res = await fetch(new URL(fetchedPath, 'https://localhost'), { dispatcher: mockAgent })

t.deepStrictEqual(await res.json(), { ok: true })
})
} else {
test(`MockAgent should should reject on not strict equal cases of paths when ignoreTrailingSlash is not set /${index}`, async (t) => {
t = tspl(t, { plan: 1 })

const mockAgent = new MockAgent()
mockAgent.disableNetConnect()
mockAgent
.get('https://localhost')
.intercept({ path: interceptPath }).reply(200, { ok: true })

t.rejects(fetch(new URL(fetchedPath, 'https://localhost'), { dispatcher: mockAgent }))
})
}
})
})

describe('MockInterceptor - different payloads', () => {
[
// Buffer
Expand Down
3 changes: 3 additions & 0 deletions types/mock-agent.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,8 @@ declare namespace MockAgent {
export interface Options extends Agent.Options {
/** A custom agent to be encapsulated by the MockAgent. */
agent?: Dispatcher;

/** Ignore trailing slashes in the path */
ignoreTrailingSlash?: boolean;
}
}
Loading