diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index 111a860e9ae..4b42869f71b 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -153,6 +153,11 @@ function getResponseData (data) { return data } else if (data instanceof ArrayBuffer) { return data + } else if (ArrayBuffer.isView(data)) { + // A DataView, or any non-Uint8Array typed array, is a byte container + // rather than a plain object. Buffer.from() cannot read one directly, so + // expose the bytes it covers instead of letting it reach JSON.stringify. + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) } else if (typeof data === 'object') { return JSON.stringify(data) } else if (data) { @@ -225,9 +230,15 @@ function deleteMockDispatch (mockDispatches, key) { } /** - * @param {string} path Path to remove trailing slash from + * @param {string|RegExp|Function} path Path, or path matcher, to remove trailing slash from */ function removeTrailingSlash (path) { + // Registered path matchers may be a RegExp or a function, which have no + // trailing slash to strip; hand those back for matchValue to apply. + if (typeof path !== 'string') { + return path + } + while (path.endsWith('/')) { path = path.slice(0, -1) } diff --git a/test/mock-interceptor.js b/test/mock-interceptor.js index 64fba32bf87..2afe9d7541f 100644 --- a/test/mock-interceptor.js +++ b/test/mock-interceptor.js @@ -511,6 +511,61 @@ describe('https://github.com/nodejs/undici/issues/3649', () => { }) }) +describe('MockInterceptor - ignoreTrailingSlash with non-string path matchers', () => { + [ + ['RegExp', /^\/api\/some-path$/], + ['Function', (path) => path === '/api/some-path'] + ].forEach(([matcherType, path]) => { + ['/api/some-path', '/api/some-path/', '/api/some-path///'].forEach((fetchedPath) => { + test(`a ${matcherType} path matcher matches '${fetchedPath}' as a MockAgent option`, async (t) => { + t.plan(1) + + const mockAgent = new MockAgent({ ignoreTrailingSlash: true }) + mockAgent.disableNetConnect() + t.after(() => mockAgent.close()) + mockAgent + .get('https://localhost') + .intercept({ path }).reply(200, { ok: true }) + + const res = await fetch(new URL(fetchedPath, 'https://localhost'), { dispatcher: mockAgent }) + + t.assert.deepStrictEqual(await res.json(), { ok: true }) + }) + + test(`a ${matcherType} path matcher matches '${fetchedPath}' as an intercept option`, async (t) => { + t.plan(1) + + const mockAgent = new MockAgent() + mockAgent.disableNetConnect() + t.after(() => mockAgent.close()) + mockAgent + .get('https://localhost') + .intercept({ path, ignoreTrailingSlash: true }).reply(200, { ok: true }) + + const res = await fetch(new URL(fetchedPath, 'https://localhost'), { dispatcher: mockAgent }) + + t.assert.deepStrictEqual(await res.json(), { ok: true }) + }) + }) + + test(`a ${matcherType} path matcher still rejects a non-matching path`, async (t) => { + t.plan(1) + + const mockAgent = new MockAgent({ ignoreTrailingSlash: true }) + mockAgent.disableNetConnect() + t.after(() => mockAgent.close()) + mockAgent + .get('https://localhost') + .intercept({ path }).reply(200, { ok: true }) + + await t.assert.rejects( + fetch(new URL('/api/other-path', 'https://localhost'), { dispatcher: mockAgent }), + (err) => err.cause instanceof MockNotMatchedError + ) + }) + }) +}) + describe('MockInterceptor - different payloads', () => { [ // Buffer @@ -537,6 +592,15 @@ describe('MockInterceptor - different payloads', () => { ['bytes', 'string', 'Uint8Array', '{"test":true}', new TextEncoder().encode('{"test":true}')], ['text', 'string', 'string', '{"test":true}', '{"test":true}'], + // DataView + ['arrayBuffer', 'DataView', 'ArrayBuffer', new DataView(new TextEncoder().encode('{"test":true}').buffer), new TextEncoder().encode('{"test":true}').buffer], + ['json', 'DataView', 'Object', new DataView(new TextEncoder().encode('{"test":true}').buffer), { test: true }], + ['bytes', 'DataView', 'Uint8Array', new DataView(new TextEncoder().encode('{"test":true}').buffer), new TextEncoder().encode('{"test":true}')], + ['text', 'DataView', 'string', new DataView(new TextEncoder().encode('{"test":true}').buffer), '{"test":true}'], + + // DataView covering only part of its backing ArrayBuffer + ['text', 'DataView with an offset', 'string', new DataView(new TextEncoder().encode('xx{"test":true}yy').buffer, 2, 13), '{"test":true}'], + // object ['arrayBuffer', 'Object', 'ArrayBuffer', { test: true }, new TextEncoder().encode('{"test":true}').buffer], ['json', 'Object', 'Object', { test: true }, { test: true }], diff --git a/test/mock-utils.js b/test/mock-utils.js index 873ac84014a..67405f0d533 100644 --- a/test/mock-utils.js +++ b/test/mock-utils.js @@ -124,6 +124,44 @@ describe('getMockDispatch', () => { }), new MockNotMatchedError('Mock dispatch not matched for body \'wrong\' on path \'path\'')) }) + test('it should match a RegExp path with ignoreTrailingSlash', (t) => { + t.plan(2) + const dispatch = { + path: /^\/path$/, + method: 'method', + ignoreTrailingSlash: true, + consumed: false + } + + t.assert.deepStrictEqual(getMockDispatch([dispatch], { + path: '/path', + method: 'method' + }), dispatch) + t.assert.deepStrictEqual(getMockDispatch([dispatch], { + path: '/path/', + method: 'method' + }), dispatch) + }) + + test('it should match a function path with ignoreTrailingSlash', (t) => { + t.plan(2) + const dispatch = { + path: (path) => path === '/path', + method: 'method', + ignoreTrailingSlash: true, + consumed: false + } + + t.assert.deepStrictEqual(getMockDispatch([dispatch], { + path: '/path', + method: 'method' + }), dispatch) + t.assert.deepStrictEqual(getMockDispatch([dispatch], { + path: '/path/', + method: 'method' + }), dispatch) + }) + test('it should throw if no dispatch matches headers', (t) => { t.plan(1) const dispatches = [ @@ -176,11 +214,38 @@ describe('getResponseData', () => { t.assert.ok(responseData instanceof ArrayBuffer) }) + test('it should return the bytes of a DataView', (t) => { + t.plan(2) + const responseData = getResponseData(new DataView(new TextEncoder().encode('{"test":true}').buffer)) + t.assert.ok(responseData instanceof Uint8Array) + t.assert.strictEqual(Buffer.from(responseData).toString('utf8'), '{"test":true}') + }) + + test('it should return only the bytes a DataView covers', (t) => { + t.plan(1) + const buffer = new TextEncoder().encode('xx{"test":true}yy').buffer + const responseData = getResponseData(new DataView(buffer, 2, 13)) + t.assert.strictEqual(Buffer.from(responseData).toString('utf8'), '{"test":true}') + }) + + test('it should return the bytes of a typed array that is not a Uint8Array', (t) => { + t.plan(2) + const responseData = getResponseData(new Uint8ClampedArray([1, 2, 3])) + t.assert.ok(responseData instanceof Uint8Array) + t.assert.deepStrictEqual([...responseData], [1, 2, 3]) + }) + test('it should handle undefined', (t) => { t.plan(1) const responseData = getResponseData(undefined) t.assert.strictEqual(responseData, '') }) + + test('it should handle null', (t) => { + t.plan(1) + const responseData = getResponseData(null) + t.assert.strictEqual(responseData, 'null') + }) }) test('getStatusText', (t) => {