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