mock-interceptor.js 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. 'use strict'
  2. const { getResponseData, buildKey, addMockDispatch } = require('./mock-utils')
  3. const {
  4. kDispatches,
  5. kDispatchKey,
  6. kDefaultHeaders,
  7. kDefaultTrailers,
  8. kContentLength,
  9. kMockDispatch,
  10. kIgnoreTrailingSlash
  11. } = require('./mock-symbols')
  12. const { InvalidArgumentError } = require('../core/errors')
  13. const { serializePathWithQuery } = require('../core/util')
  14. /**
  15. * Defines the scope API for an interceptor reply
  16. */
  17. class MockScope {
  18. constructor (mockDispatch) {
  19. this[kMockDispatch] = mockDispatch
  20. }
  21. /**
  22. * Delay a reply by a set amount in ms.
  23. */
  24. delay (waitInMs) {
  25. if (typeof waitInMs !== 'number' || !Number.isInteger(waitInMs) || waitInMs <= 0) {
  26. throw new InvalidArgumentError('waitInMs must be a valid integer > 0')
  27. }
  28. this[kMockDispatch].delay = waitInMs
  29. return this
  30. }
  31. /**
  32. * For a defined reply, never mark as consumed.
  33. */
  34. persist () {
  35. this[kMockDispatch].persist = true
  36. return this
  37. }
  38. /**
  39. * Allow one to define a reply for a set amount of matching requests.
  40. */
  41. times (repeatTimes) {
  42. if (typeof repeatTimes !== 'number' || !Number.isInteger(repeatTimes) || repeatTimes <= 0) {
  43. throw new InvalidArgumentError('repeatTimes must be a valid integer > 0')
  44. }
  45. this[kMockDispatch].times = repeatTimes
  46. return this
  47. }
  48. }
  49. /**
  50. * Defines an interceptor for a Mock
  51. */
  52. class MockInterceptor {
  53. constructor (opts, mockDispatches) {
  54. if (typeof opts !== 'object') {
  55. throw new InvalidArgumentError('opts must be an object')
  56. }
  57. if (typeof opts.path === 'undefined') {
  58. throw new InvalidArgumentError('opts.path must be defined')
  59. }
  60. if (typeof opts.method === 'undefined') {
  61. opts.method = 'GET'
  62. }
  63. // See https://github.com/nodejs/undici/issues/1245
  64. // As per RFC 3986, clients are not supposed to send URI
  65. // fragments to servers when they retrieve a document,
  66. if (typeof opts.path === 'string') {
  67. if (opts.query) {
  68. opts.path = serializePathWithQuery(opts.path, opts.query)
  69. } else {
  70. // Matches https://github.com/nodejs/undici/blob/main/lib/web/fetch/index.js#L1811
  71. const parsedURL = new URL(opts.path, 'data://')
  72. opts.path = parsedURL.pathname + parsedURL.search
  73. }
  74. }
  75. if (typeof opts.method === 'string') {
  76. opts.method = opts.method.toUpperCase()
  77. }
  78. this[kDispatchKey] = buildKey(opts)
  79. this[kDispatches] = mockDispatches
  80. this[kIgnoreTrailingSlash] = opts.ignoreTrailingSlash ?? false
  81. this[kDefaultHeaders] = {}
  82. this[kDefaultTrailers] = {}
  83. this[kContentLength] = false
  84. }
  85. createMockScopeDispatchData ({ statusCode, data, responseOptions }) {
  86. const responseData = getResponseData(data)
  87. const contentLength = this[kContentLength] ? { 'content-length': responseData.length } : {}
  88. const headers = { ...this[kDefaultHeaders], ...contentLength, ...responseOptions.headers }
  89. const trailers = { ...this[kDefaultTrailers], ...responseOptions.trailers }
  90. return { statusCode, data, headers, trailers }
  91. }
  92. validateReplyParameters (replyParameters) {
  93. if (typeof replyParameters.statusCode === 'undefined') {
  94. throw new InvalidArgumentError('statusCode must be defined')
  95. }
  96. if (typeof replyParameters.responseOptions !== 'object' || replyParameters.responseOptions === null) {
  97. throw new InvalidArgumentError('responseOptions must be an object')
  98. }
  99. }
  100. /**
  101. * Mock an undici request with a defined reply.
  102. */
  103. reply (replyOptionsCallbackOrStatusCode) {
  104. // Values of reply aren't available right now as they
  105. // can only be available when the reply callback is invoked.
  106. if (typeof replyOptionsCallbackOrStatusCode === 'function') {
  107. // We'll first wrap the provided callback in another function,
  108. // this function will properly resolve the data from the callback
  109. // when invoked.
  110. const wrappedDefaultsCallback = (opts) => {
  111. // Our reply options callback contains the parameter for statusCode, data and options.
  112. const resolvedData = replyOptionsCallbackOrStatusCode(opts)
  113. // Check if it is in the right format
  114. if (typeof resolvedData !== 'object' || resolvedData === null) {
  115. throw new InvalidArgumentError('reply options callback must return an object')
  116. }
  117. const replyParameters = { data: '', responseOptions: {}, ...resolvedData }
  118. this.validateReplyParameters(replyParameters)
  119. // Since the values can be obtained immediately we return them
  120. // from this higher order function that will be resolved later.
  121. return {
  122. ...this.createMockScopeDispatchData(replyParameters)
  123. }
  124. }
  125. // Add usual dispatch data, but this time set the data parameter to function that will eventually provide data.
  126. const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], wrappedDefaultsCallback, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] })
  127. return new MockScope(newMockDispatch)
  128. }
  129. // We can have either one or three parameters, if we get here,
  130. // we should have 1-3 parameters. So we spread the arguments of
  131. // this function to obtain the parameters, since replyData will always
  132. // just be the statusCode.
  133. const replyParameters = {
  134. statusCode: replyOptionsCallbackOrStatusCode,
  135. data: arguments[1] === undefined ? '' : arguments[1],
  136. responseOptions: arguments[2] === undefined ? {} : arguments[2]
  137. }
  138. this.validateReplyParameters(replyParameters)
  139. // Send in-already provided data like usual
  140. const dispatchData = this.createMockScopeDispatchData(replyParameters)
  141. const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], dispatchData, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] })
  142. return new MockScope(newMockDispatch)
  143. }
  144. /**
  145. * Mock an undici request with a defined error.
  146. */
  147. replyWithError (error) {
  148. if (typeof error === 'undefined') {
  149. throw new InvalidArgumentError('error must be defined')
  150. }
  151. const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], { error }, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] })
  152. return new MockScope(newMockDispatch)
  153. }
  154. /**
  155. * Set default reply headers on the interceptor for subsequent replies
  156. */
  157. defaultReplyHeaders (headers) {
  158. if (typeof headers === 'undefined') {
  159. throw new InvalidArgumentError('headers must be defined')
  160. }
  161. this[kDefaultHeaders] = headers
  162. return this
  163. }
  164. /**
  165. * Set default reply trailers on the interceptor for subsequent replies
  166. */
  167. defaultReplyTrailers (trailers) {
  168. if (typeof trailers === 'undefined') {
  169. throw new InvalidArgumentError('trailers must be defined')
  170. }
  171. this[kDefaultTrailers] = trailers
  172. return this
  173. }
  174. /**
  175. * Set reply content length header for replies on the interceptor
  176. */
  177. replyContentLength () {
  178. this[kContentLength] = true
  179. return this
  180. }
  181. }
  182. module.exports.MockInterceptor = MockInterceptor
  183. module.exports.MockScope = MockScope