api.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import isEqual from 'lodash/isEqual';
  2. import * as ApiNamespace from 'sentry/api';
  3. const RealApi: typeof ApiNamespace = jest.requireActual('sentry/api');
  4. export class Request {}
  5. export const initApiClientErrorHandling = RealApi.initApiClientErrorHandling;
  6. export const hasProjectBeenRenamed = RealApi.hasProjectBeenRenamed;
  7. const respond = (asyncDelay: AsyncDelay, fn?: Function, ...args: any[]): void => {
  8. if (!fn) {
  9. return;
  10. }
  11. if (asyncDelay !== undefined) {
  12. setTimeout(() => fn(...args), asyncDelay);
  13. return;
  14. }
  15. fn(...args);
  16. };
  17. type FunctionCallback<Args extends any[] = any[]> = (...args: Args) => void;
  18. /**
  19. * Callables for matching requests based on arbitrary conditions.
  20. */
  21. interface MatchCallable {
  22. (url: string, options: ApiNamespace.RequestOptions): boolean;
  23. }
  24. type AsyncDelay = undefined | number;
  25. interface ResponseType extends ApiNamespace.ResponseMeta {
  26. body: any;
  27. callCount: 0;
  28. headers: Record<string, string>;
  29. match: MatchCallable[];
  30. method: string;
  31. statusCode: number;
  32. url: string;
  33. /**
  34. * Whether to return mocked api responses directly, or with a setTimeout delay.
  35. *
  36. * Set to `null` to disable the async delay
  37. * Set to a `number` which will be the amount of time (ms) for the delay
  38. *
  39. * This will override `MockApiClient.asyncDelay` for this request.
  40. */
  41. asyncDelay?: AsyncDelay;
  42. }
  43. type MockResponse = [resp: ResponseType, mock: jest.Mock];
  44. /**
  45. * Compare two records. `want` is all the entries we want to have the same value in `check`
  46. */
  47. function compareRecord(want: Record<string, any>, check: Record<string, any>): boolean {
  48. for (const entry of Object.entries(want)) {
  49. const [key, value] = entry;
  50. if (!isEqual(check[key], value)) {
  51. return false;
  52. }
  53. }
  54. return true;
  55. }
  56. afterEach(() => {
  57. // if any errors are caught we console.error them
  58. const errors = Object.values(Client.errors);
  59. if (errors.length > 0) {
  60. for (const err of errors) {
  61. // eslint-disable-next-line no-console
  62. console.error(err);
  63. }
  64. Client.errors = {};
  65. }
  66. });
  67. class Client implements ApiNamespace.Client {
  68. static mockResponses: MockResponse[] = [];
  69. /**
  70. * Whether to return mocked api responses directly, or with a setTimeout delay.
  71. *
  72. * Set to `null` to disable the async delay
  73. * Set to a `number` which will be the amount of time (ms) for the delay
  74. *
  75. * This is the global/default value. `addMockResponse` can override per request.
  76. */
  77. static asyncDelay: AsyncDelay = undefined;
  78. static clearMockResponses() {
  79. Client.mockResponses = [];
  80. }
  81. /**
  82. * Create a query string match callable.
  83. *
  84. * Only keys/values defined in `query` are checked.
  85. */
  86. static matchQuery(query: Record<string, any>): MatchCallable {
  87. const queryMatcher: MatchCallable = (_url, options) => {
  88. return compareRecord(query, options.query ?? {});
  89. };
  90. return queryMatcher;
  91. }
  92. /**
  93. * Create a data match callable.
  94. *
  95. * Only keys/values defined in `data` are checked.
  96. */
  97. static matchData(data: Record<string, any>): MatchCallable {
  98. const dataMatcher: MatchCallable = (_url, options) => {
  99. return compareRecord(data, options.data ?? {});
  100. };
  101. return dataMatcher;
  102. }
  103. // Returns a jest mock that represents Client.request calls
  104. static addMockResponse(response: Partial<ResponseType>) {
  105. const mock = jest.fn();
  106. Client.mockResponses.unshift([
  107. {
  108. url: '',
  109. status: 200,
  110. statusCode: 200,
  111. statusText: 'OK',
  112. responseText: '',
  113. responseJSON: '',
  114. body: '',
  115. method: 'GET',
  116. callCount: 0,
  117. match: [],
  118. ...response,
  119. asyncDelay: response.asyncDelay ?? Client.asyncDelay,
  120. headers: response.headers ?? {},
  121. getResponseHeader: (key: string) => response.headers?.[key] ?? null,
  122. },
  123. mock,
  124. ]);
  125. return mock;
  126. }
  127. static findMockResponse(url: string, options: Readonly<ApiNamespace.RequestOptions>) {
  128. return Client.mockResponses.find(([response]) => {
  129. if (url !== response.url) {
  130. return false;
  131. }
  132. if ((options.method || 'GET') !== response.method) {
  133. return false;
  134. }
  135. return response.match.every(matcher => matcher(url, options));
  136. });
  137. }
  138. activeRequests: Record<string, ApiNamespace.Request> = {};
  139. baseUrl = '';
  140. uniqueId() {
  141. return '123';
  142. }
  143. /**
  144. * In the real client, this clears in-flight responses. It's NOT
  145. * clearMockResponses. You probably don't want to call this from a test.
  146. */
  147. clear() {
  148. Object.values(this.activeRequests).forEach(r => r.cancel());
  149. }
  150. wrapCallback<T extends any[]>(
  151. _id: string,
  152. func: FunctionCallback<T> | undefined,
  153. _cleanup: boolean = false
  154. ) {
  155. const asyncDelay = Client.asyncDelay;
  156. return (...args: T) => {
  157. // @ts-expect-error
  158. if (RealApi.hasProjectBeenRenamed(...args)) {
  159. return;
  160. }
  161. respond(asyncDelay, func, ...args);
  162. };
  163. }
  164. requestPromise(
  165. path: string,
  166. {
  167. includeAllArgs,
  168. ...options
  169. }: {includeAllArgs?: boolean} & Readonly<ApiNamespace.RequestOptions> = {}
  170. ): any {
  171. return new Promise((resolve, reject) => {
  172. this.request(path, {
  173. ...options,
  174. success: (data, ...args) => {
  175. includeAllArgs ? resolve([data, ...args]) : resolve(data);
  176. },
  177. error: (error, ..._args) => {
  178. reject(error);
  179. },
  180. });
  181. });
  182. }
  183. static errors: Record<string, Error> = {};
  184. // XXX(ts): We type the return type for requestPromise and request as `any`. Typically these woul
  185. request(url: string, options: Readonly<ApiNamespace.RequestOptions> = {}): any {
  186. const [response, mock] = Client.findMockResponse(url, options) || [
  187. undefined,
  188. undefined,
  189. ];
  190. if (!response || !mock) {
  191. const methodAndUrl = `${options.method || 'GET'} ${url}`;
  192. // Endpoints need to be mocked
  193. const err = new Error(`No mocked response found for request: ${methodAndUrl}`);
  194. // Mutate stack to drop frames since test file so that we know where in the test
  195. // this needs to be mocked
  196. const lines = err.stack?.split('\n');
  197. const startIndex = lines?.findIndex(line => line.includes('.spec.'));
  198. err.stack = ['\n', lines?.[0], ...(lines?.slice(startIndex) ?? [])].join('\n');
  199. // Throwing an error here does not do what we want it to do....
  200. // Because we are mocking an API client, we generally catch errors to show
  201. // user-friendly error messages, this means in tests this error gets gobbled
  202. // up and developer frustration ensues.
  203. // We track the errors on a static member and warn afterEach test.
  204. Client.errors[methodAndUrl] = err;
  205. } else {
  206. // has mocked response
  207. // mock gets returned when we add a mock response, will represent calls to api.request
  208. mock(url, options);
  209. const body =
  210. typeof response.body === 'function' ? response.body(url, options) : response.body;
  211. if (![200, 202].includes(response.statusCode)) {
  212. response.callCount++;
  213. const errorResponse = Object.assign(
  214. {
  215. status: response.statusCode,
  216. responseText: JSON.stringify(body),
  217. responseJSON: body,
  218. },
  219. {
  220. overrideMimeType: () => {},
  221. abort: () => {},
  222. then: () => {},
  223. error: () => {},
  224. },
  225. new XMLHttpRequest()
  226. );
  227. this.handleRequestError(
  228. {
  229. id: '1234',
  230. path: url,
  231. requestOptions: options,
  232. },
  233. errorResponse as any,
  234. 'error',
  235. 'error'
  236. );
  237. } else {
  238. response.callCount++;
  239. respond(
  240. response.asyncDelay,
  241. options.success,
  242. body,
  243. {},
  244. {
  245. getResponseHeader: (key: string) => response.headers[key],
  246. statusCode: response.statusCode,
  247. status: response.statusCode,
  248. }
  249. );
  250. }
  251. }
  252. respond(response?.asyncDelay, options.complete);
  253. }
  254. handleRequestError = RealApi.Client.prototype.handleRequestError;
  255. }
  256. export {Client};