mock-utils.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. 'use strict'
  2. const { MockNotMatchedError } = require('./mock-errors')
  3. const {
  4. kDispatches,
  5. kMockAgent,
  6. kOriginalDispatch,
  7. kOrigin,
  8. kGetNetConnect
  9. } = require('./mock-symbols')
  10. const { serializePathWithQuery } = require('../core/util')
  11. const { STATUS_CODES } = require('node:http')
  12. const {
  13. types: {
  14. isPromise
  15. }
  16. } = require('node:util')
  17. const { InvalidArgumentError } = require('../core/errors')
  18. function matchValue (match, value) {
  19. if (typeof match === 'string') {
  20. return match === value
  21. }
  22. if (match instanceof RegExp) {
  23. return match.test(value)
  24. }
  25. if (typeof match === 'function') {
  26. return match(value) === true
  27. }
  28. return false
  29. }
  30. function lowerCaseEntries (headers) {
  31. return Object.fromEntries(
  32. Object.entries(headers).map(([headerName, headerValue]) => {
  33. return [headerName.toLocaleLowerCase(), headerValue]
  34. })
  35. )
  36. }
  37. /**
  38. * @param {import('../../index').Headers|string[]|Record<string, string>} headers
  39. * @param {string} key
  40. */
  41. function getHeaderByName (headers, key) {
  42. if (Array.isArray(headers)) {
  43. for (let i = 0; i < headers.length; i += 2) {
  44. if (headers[i].toLocaleLowerCase() === key.toLocaleLowerCase()) {
  45. return headers[i + 1]
  46. }
  47. }
  48. return undefined
  49. } else if (typeof headers.get === 'function') {
  50. return headers.get(key)
  51. } else {
  52. return lowerCaseEntries(headers)[key.toLocaleLowerCase()]
  53. }
  54. }
  55. /** @param {string[]} headers */
  56. function buildHeadersFromArray (headers) { // fetch HeadersList
  57. const clone = headers.slice()
  58. const entries = []
  59. for (let index = 0; index < clone.length; index += 2) {
  60. entries.push([clone[index], clone[index + 1]])
  61. }
  62. return Object.fromEntries(entries)
  63. }
  64. function matchHeaders (mockDispatch, headers) {
  65. if (typeof mockDispatch.headers === 'function') {
  66. if (Array.isArray(headers)) { // fetch HeadersList
  67. headers = buildHeadersFromArray(headers)
  68. }
  69. return mockDispatch.headers(headers ? lowerCaseEntries(headers) : {})
  70. }
  71. if (typeof mockDispatch.headers === 'undefined') {
  72. return true
  73. }
  74. if (typeof headers !== 'object' || typeof mockDispatch.headers !== 'object') {
  75. return false
  76. }
  77. for (const [matchHeaderName, matchHeaderValue] of Object.entries(mockDispatch.headers)) {
  78. const headerValue = getHeaderByName(headers, matchHeaderName)
  79. if (!matchValue(matchHeaderValue, headerValue)) {
  80. return false
  81. }
  82. }
  83. return true
  84. }
  85. function normalizeSearchParams (query) {
  86. if (typeof query !== 'string') {
  87. return query
  88. }
  89. const originalQp = new URLSearchParams(query)
  90. const normalizedQp = new URLSearchParams()
  91. for (let [key, value] of originalQp.entries()) {
  92. key = key.replace('[]', '')
  93. const valueRepresentsString = /^(['"]).*\1$/.test(value)
  94. if (valueRepresentsString) {
  95. normalizedQp.append(key, value)
  96. continue
  97. }
  98. if (value.includes(',')) {
  99. const values = value.split(',')
  100. for (const v of values) {
  101. normalizedQp.append(key, v)
  102. }
  103. continue
  104. }
  105. normalizedQp.append(key, value)
  106. }
  107. return normalizedQp
  108. }
  109. function safeUrl (path) {
  110. if (typeof path !== 'string') {
  111. return path
  112. }
  113. const pathSegments = path.split('?', 3)
  114. if (pathSegments.length !== 2) {
  115. return path
  116. }
  117. const qp = new URLSearchParams(pathSegments.pop())
  118. qp.sort()
  119. return [...pathSegments, qp.toString()].join('?')
  120. }
  121. function matchKey (mockDispatch, { path, method, body, headers }) {
  122. const pathMatch = matchValue(mockDispatch.path, path)
  123. const methodMatch = matchValue(mockDispatch.method, method)
  124. const bodyMatch = typeof mockDispatch.body !== 'undefined' ? matchValue(mockDispatch.body, body) : true
  125. const headersMatch = matchHeaders(mockDispatch, headers)
  126. return pathMatch && methodMatch && bodyMatch && headersMatch
  127. }
  128. function getResponseData (data) {
  129. if (Buffer.isBuffer(data)) {
  130. return data
  131. } else if (data instanceof Uint8Array) {
  132. return data
  133. } else if (data instanceof ArrayBuffer) {
  134. return data
  135. } else if (typeof data === 'object') {
  136. return JSON.stringify(data)
  137. } else if (data) {
  138. return data.toString()
  139. } else {
  140. return ''
  141. }
  142. }
  143. function getMockDispatch (mockDispatches, key) {
  144. const basePath = key.query ? serializePathWithQuery(key.path, key.query) : key.path
  145. const resolvedPath = typeof basePath === 'string' ? safeUrl(basePath) : basePath
  146. const resolvedPathWithoutTrailingSlash = removeTrailingSlash(resolvedPath)
  147. // Match path
  148. let matchedMockDispatches = mockDispatches
  149. .filter(({ consumed }) => !consumed)
  150. .filter(({ path, ignoreTrailingSlash }) => {
  151. return ignoreTrailingSlash
  152. ? matchValue(removeTrailingSlash(safeUrl(path)), resolvedPathWithoutTrailingSlash)
  153. : matchValue(safeUrl(path), resolvedPath)
  154. })
  155. if (matchedMockDispatches.length === 0) {
  156. throw new MockNotMatchedError(`Mock dispatch not matched for path '${resolvedPath}'`)
  157. }
  158. // Match method
  159. matchedMockDispatches = matchedMockDispatches.filter(({ method }) => matchValue(method, key.method))
  160. if (matchedMockDispatches.length === 0) {
  161. throw new MockNotMatchedError(`Mock dispatch not matched for method '${key.method}' on path '${resolvedPath}'`)
  162. }
  163. // Match body
  164. matchedMockDispatches = matchedMockDispatches.filter(({ body }) => typeof body !== 'undefined' ? matchValue(body, key.body) : true)
  165. if (matchedMockDispatches.length === 0) {
  166. throw new MockNotMatchedError(`Mock dispatch not matched for body '${key.body}' on path '${resolvedPath}'`)
  167. }
  168. // Match headers
  169. matchedMockDispatches = matchedMockDispatches.filter((mockDispatch) => matchHeaders(mockDispatch, key.headers))
  170. if (matchedMockDispatches.length === 0) {
  171. const headers = typeof key.headers === 'object' ? JSON.stringify(key.headers) : key.headers
  172. throw new MockNotMatchedError(`Mock dispatch not matched for headers '${headers}' on path '${resolvedPath}'`)
  173. }
  174. return matchedMockDispatches[0]
  175. }
  176. function addMockDispatch (mockDispatches, key, data, opts) {
  177. const baseData = { timesInvoked: 0, times: 1, persist: false, consumed: false, ...opts }
  178. const replyData = typeof data === 'function' ? { callback: data } : { ...data }
  179. const newMockDispatch = { ...baseData, ...key, pending: true, data: { error: null, ...replyData } }
  180. mockDispatches.push(newMockDispatch)
  181. return newMockDispatch
  182. }
  183. function deleteMockDispatch (mockDispatches, key) {
  184. const index = mockDispatches.findIndex(dispatch => {
  185. if (!dispatch.consumed) {
  186. return false
  187. }
  188. return matchKey(dispatch, key)
  189. })
  190. if (index !== -1) {
  191. mockDispatches.splice(index, 1)
  192. }
  193. }
  194. /**
  195. * @param {string} path Path to remove trailing slash from
  196. */
  197. function removeTrailingSlash (path) {
  198. while (path.endsWith('/')) {
  199. path = path.slice(0, -1)
  200. }
  201. if (path.length === 0) {
  202. path = '/'
  203. }
  204. return path
  205. }
  206. function buildKey (opts) {
  207. const { path, method, body, headers, query } = opts
  208. return {
  209. path,
  210. method,
  211. body,
  212. headers,
  213. query
  214. }
  215. }
  216. function generateKeyValues (data) {
  217. const keys = Object.keys(data)
  218. const result = []
  219. for (let i = 0; i < keys.length; ++i) {
  220. const key = keys[i]
  221. const value = data[key]
  222. const name = Buffer.from(`${key}`)
  223. if (Array.isArray(value)) {
  224. for (let j = 0; j < value.length; ++j) {
  225. result.push(name, Buffer.from(`${value[j]}`))
  226. }
  227. } else {
  228. result.push(name, Buffer.from(`${value}`))
  229. }
  230. }
  231. return result
  232. }
  233. /**
  234. * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
  235. * @param {number} statusCode
  236. */
  237. function getStatusText (statusCode) {
  238. return STATUS_CODES[statusCode] || 'unknown'
  239. }
  240. async function getResponse (body) {
  241. const buffers = []
  242. for await (const data of body) {
  243. buffers.push(data)
  244. }
  245. return Buffer.concat(buffers).toString('utf8')
  246. }
  247. /**
  248. * Mock dispatch function used to simulate undici dispatches
  249. */
  250. function mockDispatch (opts, handler) {
  251. // Get mock dispatch from built key
  252. const key = buildKey(opts)
  253. const mockDispatch = getMockDispatch(this[kDispatches], key)
  254. mockDispatch.timesInvoked++
  255. // Here's where we resolve a callback if a callback is present for the dispatch data.
  256. if (mockDispatch.data.callback) {
  257. mockDispatch.data = { ...mockDispatch.data, ...mockDispatch.data.callback(opts) }
  258. }
  259. // Parse mockDispatch data
  260. const { data: { statusCode, data, headers, trailers, error }, delay, persist } = mockDispatch
  261. const { timesInvoked, times } = mockDispatch
  262. // If it's used up and not persistent, mark as consumed
  263. mockDispatch.consumed = !persist && timesInvoked >= times
  264. mockDispatch.pending = timesInvoked < times
  265. // If specified, trigger dispatch error
  266. if (error !== null) {
  267. deleteMockDispatch(this[kDispatches], key)
  268. handler.onError(error)
  269. return true
  270. }
  271. // Handle the request with a delay if necessary
  272. if (typeof delay === 'number' && delay > 0) {
  273. setTimeout(() => {
  274. handleReply(this[kDispatches])
  275. }, delay)
  276. } else {
  277. handleReply(this[kDispatches])
  278. }
  279. function handleReply (mockDispatches, _data = data) {
  280. // fetch's HeadersList is a 1D string array
  281. const optsHeaders = Array.isArray(opts.headers)
  282. ? buildHeadersFromArray(opts.headers)
  283. : opts.headers
  284. const body = typeof _data === 'function'
  285. ? _data({ ...opts, headers: optsHeaders })
  286. : _data
  287. // util.types.isPromise is likely needed for jest.
  288. if (isPromise(body)) {
  289. // If handleReply is asynchronous, throwing an error
  290. // in the callback will reject the promise, rather than
  291. // synchronously throw the error, which breaks some tests.
  292. // Rather, we wait for the callback to resolve if it is a
  293. // promise, and then re-run handleReply with the new body.
  294. body.then((newData) => handleReply(mockDispatches, newData))
  295. return
  296. }
  297. const responseData = getResponseData(body)
  298. const responseHeaders = generateKeyValues(headers)
  299. const responseTrailers = generateKeyValues(trailers)
  300. handler.onConnect?.(err => handler.onError(err), null)
  301. handler.onHeaders?.(statusCode, responseHeaders, resume, getStatusText(statusCode))
  302. handler.onData?.(Buffer.from(responseData))
  303. handler.onComplete?.(responseTrailers)
  304. deleteMockDispatch(mockDispatches, key)
  305. }
  306. function resume () {}
  307. return true
  308. }
  309. function buildMockDispatch () {
  310. const agent = this[kMockAgent]
  311. const origin = this[kOrigin]
  312. const originalDispatch = this[kOriginalDispatch]
  313. return function dispatch (opts, handler) {
  314. if (agent.isMockActive) {
  315. try {
  316. mockDispatch.call(this, opts, handler)
  317. } catch (error) {
  318. if (error instanceof MockNotMatchedError) {
  319. const netConnect = agent[kGetNetConnect]()
  320. if (netConnect === false) {
  321. throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect disabled)`)
  322. }
  323. if (checkNetConnect(netConnect, origin)) {
  324. originalDispatch.call(this, opts, handler)
  325. } else {
  326. throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect is not enabled for this origin)`)
  327. }
  328. } else {
  329. throw error
  330. }
  331. }
  332. } else {
  333. originalDispatch.call(this, opts, handler)
  334. }
  335. }
  336. }
  337. function checkNetConnect (netConnect, origin) {
  338. const url = new URL(origin)
  339. if (netConnect === true) {
  340. return true
  341. } else if (Array.isArray(netConnect) && netConnect.some((matcher) => matchValue(matcher, url.host))) {
  342. return true
  343. }
  344. return false
  345. }
  346. function buildAndValidateMockOptions (opts) {
  347. if (opts) {
  348. const { agent, ...mockOptions } = opts
  349. if ('enableCallHistory' in mockOptions && typeof mockOptions.enableCallHistory !== 'boolean') {
  350. throw new InvalidArgumentError('options.enableCallHistory must to be a boolean')
  351. }
  352. if ('acceptNonStandardSearchParameters' in mockOptions && typeof mockOptions.acceptNonStandardSearchParameters !== 'boolean') {
  353. throw new InvalidArgumentError('options.acceptNonStandardSearchParameters must to be a boolean')
  354. }
  355. return mockOptions
  356. }
  357. }
  358. module.exports = {
  359. getResponseData,
  360. getMockDispatch,
  361. addMockDispatch,
  362. deleteMockDispatch,
  363. buildKey,
  364. generateKeyValues,
  365. matchValue,
  366. getResponse,
  367. getStatusText,
  368. mockDispatch,
  369. buildMockDispatch,
  370. checkNetConnect,
  371. buildAndValidateMockOptions,
  372. getHeaderByName,
  373. buildHeadersFromArray,
  374. normalizeSearchParams
  375. }