Skip to content

Commit 51ae0b3

Browse files
Uzlopakflakey5
authored andcommitted
fix: add option ignoreTrailingSlash to MockAgent and .intercept() (nodejs#3655)
* fix: mock interceptor should ignore trailing slashes * only normalize path if it is a string * put tests into mock-interceptors.js * make ignoreTrailingSlashes an option of MockAgent * rename to ignoreTrailingSlash * make ignoreTrailingSlashes an option of .intercept * Apply suggestions from code review * add documentation
1 parent 4104180 commit 51ae0b3

File tree

10 files changed

+129
-12
lines changed

10 files changed

+129
-12
lines changed

docs/docs/api/MockAgent.md

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

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

21+
* **ignoreTrailingSlash** `boolean` (optional) - Default: `false` - set the default value for `ignoreTrailingSlash` for interceptors.
22+
2123
### Example - Basic MockAgent instantiation
2224

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

docs/docs/api/MockPool.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Returns: `MockInterceptor` corresponding to the input options.
5858
* **body** `string | RegExp | (body: string) => boolean` - (optional) - a matcher for the HTTP request body.
5959
* **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.
6060
* **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`.
61+
* **ignoreTrailingSlash** `boolean` - (optional) - set to `true` if the matcher should also match by ignoring potential trailing slashes in `MockPoolInterceptOptions.path`.
6162

6263
### Return: `MockInterceptor`
6364

lib/mock/mock-client.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ const {
1010
kOriginalClose,
1111
kOrigin,
1212
kOriginalDispatch,
13-
kConnected
13+
kConnected,
14+
kIgnoreTrailingSlash
1415
} = require('./mock-symbols')
1516
const { MockInterceptor } = require('./mock-interceptor')
1617
const Symbols = require('../core/symbols')
@@ -29,6 +30,7 @@ class MockClient extends Client {
2930

3031
this[kMockAgent] = opts.agent
3132
this[kOrigin] = origin
33+
this[kIgnoreTrailingSlash] = opts.ignoreTrailingSlash ?? false
3234
this[kDispatches] = []
3335
this[kConnected] = 1
3436
this[kOriginalDispatch] = this.dispatch
@@ -46,7 +48,10 @@ class MockClient extends Client {
4648
* Sets up the base interceptor for mocking replies from undici.
4749
*/
4850
intercept (opts) {
49-
return new MockInterceptor(opts, this[kDispatches])
51+
return new MockInterceptor(
52+
opts && { ignoreTrailingSlash: this[kIgnoreTrailingSlash], ...opts },
53+
this[kDispatches]
54+
)
5055
}
5156

5257
async [kClose] () {

lib/mock/mock-interceptor.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ const {
77
kDefaultHeaders,
88
kDefaultTrailers,
99
kContentLength,
10-
kMockDispatch
10+
kMockDispatch,
11+
kIgnoreTrailingSlash
1112
} = require('./mock-symbols')
1213
const { InvalidArgumentError } = require('../core/errors')
1314
const { serializePathWithQuery } = require('../core/util')
@@ -85,6 +86,7 @@ class MockInterceptor {
8586

8687
this[kDispatchKey] = buildKey(opts)
8788
this[kDispatches] = mockDispatches
89+
this[kIgnoreTrailingSlash] = opts.ignoreTrailingSlash ?? false
8890
this[kDefaultHeaders] = {}
8991
this[kDefaultTrailers] = {}
9092
this[kContentLength] = false
@@ -137,7 +139,7 @@ class MockInterceptor {
137139
}
138140

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

@@ -154,7 +156,7 @@ class MockInterceptor {
154156

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

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

169-
const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], { error })
171+
const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], { error }, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] })
170172
return new MockScope(newMockDispatch)
171173
}
172174

lib/mock/mock-pool.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ const {
1010
kOriginalClose,
1111
kOrigin,
1212
kOriginalDispatch,
13-
kConnected
13+
kConnected,
14+
kIgnoreTrailingSlash
1415
} = require('./mock-symbols')
1516
const { MockInterceptor } = require('./mock-interceptor')
1617
const Symbols = require('../core/symbols')
@@ -29,6 +30,7 @@ class MockPool extends Pool {
2930

3031
this[kMockAgent] = opts.agent
3132
this[kOrigin] = origin
33+
this[kIgnoreTrailingSlash] = opts.ignoreTrailingSlash ?? false
3234
this[kDispatches] = []
3335
this[kConnected] = 1
3436
this[kOriginalDispatch] = this.dispatch
@@ -46,7 +48,10 @@ class MockPool extends Pool {
4648
* Sets up the base interceptor for mocking replies from undici.
4749
*/
4850
intercept (opts) {
49-
return new MockInterceptor(opts, this[kDispatches])
51+
return new MockInterceptor(
52+
opts && { ignoreTrailingSlash: this[kIgnoreTrailingSlash], ...opts },
53+
this[kDispatches]
54+
)
5055
}
5156

5257
async [kClose] () {

lib/mock/mock-symbols.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ module.exports = {
2020
kIsMockActive: Symbol('is mock active'),
2121
kNetConnect: Symbol('net connect'),
2222
kGetNetConnect: Symbol('get net connect'),
23-
kConnected: Symbol('connected')
23+
kConnected: Symbol('connected'),
24+
kIgnoreTrailingSlash: Symbol('ignore trailing slash')
2425
}

lib/mock/mock-utils.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,16 @@ function getMockDispatch (mockDispatches, key) {
133133
const basePath = key.query ? serializePathWithQuery(key.path, key.query) : key.path
134134
const resolvedPath = typeof basePath === 'string' ? safeUrl(basePath) : basePath
135135

136+
const resolvedPathWithoutTrailingSlash = removeTrailingSlash(resolvedPath)
137+
136138
// Match path
137-
let matchedMockDispatches = mockDispatches.filter(({ consumed }) => !consumed).filter(({ path }) => matchValue(safeUrl(path), resolvedPath))
139+
let matchedMockDispatches = mockDispatches
140+
.filter(({ consumed }) => !consumed)
141+
.filter(({ path, ignoreTrailingSlash }) => {
142+
return ignoreTrailingSlash
143+
? matchValue(removeTrailingSlash(safeUrl(path)), resolvedPathWithoutTrailingSlash)
144+
: matchValue(safeUrl(path), resolvedPath)
145+
})
138146
if (matchedMockDispatches.length === 0) {
139147
throw new MockNotMatchedError(`Mock dispatch not matched for path '${resolvedPath}'`)
140148
}
@@ -161,8 +169,8 @@ function getMockDispatch (mockDispatches, key) {
161169
return matchedMockDispatches[0]
162170
}
163171

164-
function addMockDispatch (mockDispatches, key, data) {
165-
const baseData = { timesInvoked: 0, times: 1, persist: false, consumed: false }
172+
function addMockDispatch (mockDispatches, key, data, opts) {
173+
const baseData = { timesInvoked: 0, times: 1, persist: false, consumed: false, ...opts }
166174
const replyData = typeof data === 'function' ? { callback: data } : { ...data }
167175
const newMockDispatch = { ...baseData, ...key, pending: true, data: { error: null, ...replyData } }
168176
mockDispatches.push(newMockDispatch)
@@ -181,8 +189,24 @@ function deleteMockDispatch (mockDispatches, key) {
181189
}
182190
}
183191

192+
/**
193+
* @param {string} path Path to remove trailing slash from
194+
*/
195+
function removeTrailingSlash (path) {
196+
while (path.endsWith('/')) {
197+
path = path.slice(0, -1)
198+
}
199+
200+
if (path.length === 0) {
201+
path = '/'
202+
}
203+
204+
return path
205+
}
206+
184207
function buildKey (opts) {
185208
const { path, method, body, headers, query } = opts
209+
186210
return {
187211
path,
188212
method,

test/mock-interceptor-unused-assertions.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ test('returns unused interceptors', t => {
266266
persist: false,
267267
consumed: false,
268268
pending: true,
269+
ignoreTrailingSlash: false,
269270
path: '/',
270271
method: 'GET',
271272
body: undefined,

test/mock-interceptor.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,79 @@ describe('MockInterceptor - replyContentLength', () => {
259259
})
260260
})
261261

262+
describe('https://github.com/nodejs/undici/issues/3649', () => {
263+
[
264+
['/api/some-path', '/api/some-path'],
265+
['/api/some-path/', '/api/some-path'],
266+
['/api/some-path', '/api/some-path/'],
267+
['/api/some-path/', '/api/some-path/'],
268+
['/api/some-path////', '/api/some-path//'],
269+
['', ''],
270+
['/', ''],
271+
['', '/'],
272+
['/', '/']
273+
].forEach(([interceptPath, fetchedPath], index) => {
274+
test(`MockAgent should match with or without trailing slash by setting ignoreTrailingSlash as MockAgent option /${index}`, async (t) => {
275+
t = tspl(t, { plan: 1 })
276+
277+
const mockAgent = new MockAgent({ ignoreTrailingSlash: true })
278+
mockAgent.disableNetConnect()
279+
mockAgent
280+
.get('https://localhost')
281+
.intercept({ path: interceptPath }).reply(200, { ok: true })
282+
283+
const res = await fetch(new URL(fetchedPath, 'https://localhost'), { dispatcher: mockAgent })
284+
285+
t.deepStrictEqual(await res.json(), { ok: true })
286+
})
287+
288+
test(`MockAgent should match with or without trailing slash by setting ignoreTrailingSlash as intercept option /${index}`, async (t) => {
289+
t = tspl(t, { plan: 1 })
290+
291+
const mockAgent = new MockAgent()
292+
mockAgent.disableNetConnect()
293+
mockAgent
294+
.get('https://localhost')
295+
.intercept({ path: interceptPath, ignoreTrailingSlash: true }).reply(200, { ok: true })
296+
297+
const res = await fetch(new URL(fetchedPath, 'https://localhost'), { dispatcher: mockAgent })
298+
299+
t.deepStrictEqual(await res.json(), { ok: true })
300+
})
301+
302+
if (
303+
(interceptPath === fetchedPath && (interceptPath !== '' && fetchedPath !== '')) ||
304+
(interceptPath === '/' && fetchedPath === '')
305+
) {
306+
test(`MockAgent should should match on strict equal cases of paths when ignoreTrailingSlash is not set /${index}`, async (t) => {
307+
t = tspl(t, { plan: 1 })
308+
309+
const mockAgent = new MockAgent()
310+
mockAgent.disableNetConnect()
311+
mockAgent
312+
.get('https://localhost')
313+
.intercept({ path: interceptPath }).reply(200, { ok: true })
314+
315+
const res = await fetch(new URL(fetchedPath, 'https://localhost'), { dispatcher: mockAgent })
316+
317+
t.deepStrictEqual(await res.json(), { ok: true })
318+
})
319+
} else {
320+
test(`MockAgent should should reject on not strict equal cases of paths when ignoreTrailingSlash is not set /${index}`, async (t) => {
321+
t = tspl(t, { plan: 1 })
322+
323+
const mockAgent = new MockAgent()
324+
mockAgent.disableNetConnect()
325+
mockAgent
326+
.get('https://localhost')
327+
.intercept({ path: interceptPath }).reply(200, { ok: true })
328+
329+
t.rejects(fetch(new URL(fetchedPath, 'https://localhost'), { dispatcher: mockAgent }))
330+
})
331+
}
332+
})
333+
})
334+
262335
describe('MockInterceptor - different payloads', () => {
263336
[
264337
// Buffer

types/mock-agent.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,8 @@ declare namespace MockAgent {
4646
export interface Options extends Agent.Options {
4747
/** A custom agent to be encapsulated by the MockAgent. */
4848
agent?: Dispatcher;
49+
50+
/** Ignore trailing slashes in the path */
51+
ignoreTrailingSlash?: boolean;
4952
}
5053
}

0 commit comments

Comments
 (0)