api.tsx 7.4 KB

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