api.tsx 7.4 KB

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