api.tsx 8.2 KB

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