mock-utils.js 11 KB

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