Spaces:
Runtime error
Runtime error
const { MockNotMatchedError } = require('./mock-errors') | |
const { | |
kDispatches, | |
kMockAgent, | |
kOriginalDispatch, | |
kOrigin, | |
kGetNetConnect | |
} = require('./mock-symbols') | |
const { buildURL, nop } = require('../core/util') | |
const { STATUS_CODES } = require('http') | |
const { | |
types: { | |
isPromise | |
} | |
} = require('util') | |
function matchValue (match, value) { | |
if (typeof match === 'string') { | |
return match === value | |
} | |
if (match instanceof RegExp) { | |
return match.test(value) | |
} | |
if (typeof match === 'function') { | |
return match(value) === true | |
} | |
return false | |
} | |
function lowerCaseEntries (headers) { | |
return Object.fromEntries( | |
Object.entries(headers).map(([headerName, headerValue]) => { | |
return [headerName.toLocaleLowerCase(), headerValue] | |
}) | |
) | |
} | |
/** | |
* @param {import('../../index').Headers|string[]|Record<string, string>} headers | |
* @param {string} key | |
*/ | |
function getHeaderByName (headers, key) { | |
if (Array.isArray(headers)) { | |
for (let i = 0; i < headers.length; i += 2) { | |
if (headers[i].toLocaleLowerCase() === key.toLocaleLowerCase()) { | |
return headers[i + 1] | |
} | |
} | |
return undefined | |
} else if (typeof headers.get === 'function') { | |
return headers.get(key) | |
} else { | |
return lowerCaseEntries(headers)[key.toLocaleLowerCase()] | |
} | |
} | |
/** @param {string[]} headers */ | |
function buildHeadersFromArray (headers) { // fetch HeadersList | |
const clone = headers.slice() | |
const entries = [] | |
for (let index = 0; index < clone.length; index += 2) { | |
entries.push([clone[index], clone[index + 1]]) | |
} | |
return Object.fromEntries(entries) | |
} | |
function matchHeaders (mockDispatch, headers) { | |
if (typeof mockDispatch.headers === 'function') { | |
if (Array.isArray(headers)) { // fetch HeadersList | |
headers = buildHeadersFromArray(headers) | |
} | |
return mockDispatch.headers(headers ? lowerCaseEntries(headers) : {}) | |
} | |
if (typeof mockDispatch.headers === 'undefined') { | |
return true | |
} | |
if (typeof headers !== 'object' || typeof mockDispatch.headers !== 'object') { | |
return false | |
} | |
for (const [matchHeaderName, matchHeaderValue] of Object.entries(mockDispatch.headers)) { | |
const headerValue = getHeaderByName(headers, matchHeaderName) | |
if (!matchValue(matchHeaderValue, headerValue)) { | |
return false | |
} | |
} | |
return true | |
} | |
function safeUrl (path) { | |
if (typeof path !== 'string') { | |
return path | |
} | |
const pathSegments = path.split('?') | |
if (pathSegments.length !== 2) { | |
return path | |
} | |
const qp = new URLSearchParams(pathSegments.pop()) | |
qp.sort() | |
return [...pathSegments, qp.toString()].join('?') | |
} | |
function matchKey (mockDispatch, { path, method, body, headers }) { | |
const pathMatch = matchValue(mockDispatch.path, path) | |
const methodMatch = matchValue(mockDispatch.method, method) | |
const bodyMatch = typeof mockDispatch.body !== 'undefined' ? matchValue(mockDispatch.body, body) : true | |
const headersMatch = matchHeaders(mockDispatch, headers) | |
return pathMatch && methodMatch && bodyMatch && headersMatch | |
} | |
function getResponseData (data) { | |
if (Buffer.isBuffer(data)) { | |
return data | |
} else if (typeof data === 'object') { | |
return JSON.stringify(data) | |
} else { | |
return data.toString() | |
} | |
} | |
function getMockDispatch (mockDispatches, key) { | |
const basePath = key.query ? buildURL(key.path, key.query) : key.path | |
const resolvedPath = typeof basePath === 'string' ? safeUrl(basePath) : basePath | |
// Match path | |
let matchedMockDispatches = mockDispatches.filter(({ consumed }) => !consumed).filter(({ path }) => matchValue(safeUrl(path), resolvedPath)) | |
if (matchedMockDispatches.length === 0) { | |
throw new MockNotMatchedError(`Mock dispatch not matched for path '${resolvedPath}'`) | |
} | |
// Match method | |
matchedMockDispatches = matchedMockDispatches.filter(({ method }) => matchValue(method, key.method)) | |
if (matchedMockDispatches.length === 0) { | |
throw new MockNotMatchedError(`Mock dispatch not matched for method '${key.method}'`) | |
} | |
// Match body | |
matchedMockDispatches = matchedMockDispatches.filter(({ body }) => typeof body !== 'undefined' ? matchValue(body, key.body) : true) | |
if (matchedMockDispatches.length === 0) { | |
throw new MockNotMatchedError(`Mock dispatch not matched for body '${key.body}'`) | |
} | |
// Match headers | |
matchedMockDispatches = matchedMockDispatches.filter((mockDispatch) => matchHeaders(mockDispatch, key.headers)) | |
if (matchedMockDispatches.length === 0) { | |
throw new MockNotMatchedError(`Mock dispatch not matched for headers '${typeof key.headers === 'object' ? JSON.stringify(key.headers) : key.headers}'`) | |
} | |
return matchedMockDispatches[0] | |
} | |
function addMockDispatch (mockDispatches, key, data) { | |
const baseData = { timesInvoked: 0, times: 1, persist: false, consumed: false } | |
const replyData = typeof data === 'function' ? { callback: data } : { ...data } | |
const newMockDispatch = { ...baseData, ...key, pending: true, data: { error: null, ...replyData } } | |
mockDispatches.push(newMockDispatch) | |
return newMockDispatch | |
} | |
function deleteMockDispatch (mockDispatches, key) { | |
const index = mockDispatches.findIndex(dispatch => { | |
if (!dispatch.consumed) { | |
return false | |
} | |
return matchKey(dispatch, key) | |
}) | |
if (index !== -1) { | |
mockDispatches.splice(index, 1) | |
} | |
} | |
function buildKey (opts) { | |
const { path, method, body, headers, query } = opts | |
return { | |
path, | |
method, | |
body, | |
headers, | |
query | |
} | |
} | |
function generateKeyValues (data) { | |
return Object.entries(data).reduce((keyValuePairs, [key, value]) => [ | |
...keyValuePairs, | |
Buffer.from(`${key}`), | |
Array.isArray(value) ? value.map(x => Buffer.from(`${x}`)) : Buffer.from(`${value}`) | |
], []) | |
} | |
/** | |
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status | |
* @param {number} statusCode | |
*/ | |
function getStatusText (statusCode) { | |
return STATUS_CODES[statusCode] || 'unknown' | |
} | |
async function getResponse (body) { | |
const buffers = [] | |
for await (const data of body) { | |
buffers.push(data) | |
} | |
return Buffer.concat(buffers).toString('utf8') | |
} | |
/** | |
* Mock dispatch function used to simulate undici dispatches | |
*/ | |
function mockDispatch (opts, handler) { | |
// Get mock dispatch from built key | |
const key = buildKey(opts) | |
const mockDispatch = getMockDispatch(this[kDispatches], key) | |
mockDispatch.timesInvoked++ | |
// Here's where we resolve a callback if a callback is present for the dispatch data. | |
if (mockDispatch.data.callback) { | |
mockDispatch.data = { ...mockDispatch.data, ...mockDispatch.data.callback(opts) } | |
} | |
// Parse mockDispatch data | |
const { data: { statusCode, data, headers, trailers, error }, delay, persist } = mockDispatch | |
const { timesInvoked, times } = mockDispatch | |
// If it's used up and not persistent, mark as consumed | |
mockDispatch.consumed = !persist && timesInvoked >= times | |
mockDispatch.pending = timesInvoked < times | |
// If specified, trigger dispatch error | |
if (error !== null) { | |
deleteMockDispatch(this[kDispatches], key) | |
handler.onError(error) | |
return true | |
} | |
// Handle the request with a delay if necessary | |
if (typeof delay === 'number' && delay > 0) { | |
setTimeout(() => { | |
handleReply(this[kDispatches]) | |
}, delay) | |
} else { | |
handleReply(this[kDispatches]) | |
} | |
function handleReply (mockDispatches, _data = data) { | |
// fetch's HeadersList is a 1D string array | |
const optsHeaders = Array.isArray(opts.headers) | |
? buildHeadersFromArray(opts.headers) | |
: opts.headers | |
const body = typeof _data === 'function' | |
? _data({ ...opts, headers: optsHeaders }) | |
: _data | |
// util.types.isPromise is likely needed for jest. | |
if (isPromise(body)) { | |
// If handleReply is asynchronous, throwing an error | |
// in the callback will reject the promise, rather than | |
// synchronously throw the error, which breaks some tests. | |
// Rather, we wait for the callback to resolve if it is a | |
// promise, and then re-run handleReply with the new body. | |
body.then((newData) => handleReply(mockDispatches, newData)) | |
return | |
} | |
const responseData = getResponseData(body) | |
const responseHeaders = generateKeyValues(headers) | |
const responseTrailers = generateKeyValues(trailers) | |
handler.abort = nop | |
handler.onHeaders(statusCode, responseHeaders, resume, getStatusText(statusCode)) | |
handler.onData(Buffer.from(responseData)) | |
handler.onComplete(responseTrailers) | |
deleteMockDispatch(mockDispatches, key) | |
} | |
function resume () {} | |
return true | |
} | |
function buildMockDispatch () { | |
const agent = this[kMockAgent] | |
const origin = this[kOrigin] | |
const originalDispatch = this[kOriginalDispatch] | |
return function dispatch (opts, handler) { | |
if (agent.isMockActive) { | |
try { | |
mockDispatch.call(this, opts, handler) | |
} catch (error) { | |
if (error instanceof MockNotMatchedError) { | |
const netConnect = agent[kGetNetConnect]() | |
if (netConnect === false) { | |
throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect disabled)`) | |
} | |
if (checkNetConnect(netConnect, origin)) { | |
originalDispatch.call(this, opts, handler) | |
} else { | |
throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect is not enabled for this origin)`) | |
} | |
} else { | |
throw error | |
} | |
} | |
} else { | |
originalDispatch.call(this, opts, handler) | |
} | |
} | |
} | |
function checkNetConnect (netConnect, origin) { | |
const url = new URL(origin) | |
if (netConnect === true) { | |
return true | |
} else if (Array.isArray(netConnect) && netConnect.some((matcher) => matchValue(matcher, url.host))) { | |
return true | |
} | |
return false | |
} | |
function buildMockOptions (opts) { | |
if (opts) { | |
const { agent, ...mockOptions } = opts | |
return mockOptions | |
} | |
} | |
module.exports = { | |
getResponseData, | |
getMockDispatch, | |
addMockDispatch, | |
deleteMockDispatch, | |
buildKey, | |
generateKeyValues, | |
matchValue, | |
getResponse, | |
getStatusText, | |
mockDispatch, | |
buildMockDispatch, | |
checkNetConnect, | |
buildMockOptions, | |
getHeaderByName | |
} | |