api.tsx 8.5 KB

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