mock-utils.js 14 KB

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