api.tsx 7.2 KB

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