api.tsx 8.5 KB

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