mock-interceptor.js 6.7 KB

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