api.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import * as ImportedClient from 'app/api';
  2. const RealClient: typeof ImportedClient = jest.requireActual('app/api');
  3. export class Request {}
  4. export const initApiClientErrorHandling = RealClient.initApiClientErrorHandling;
  5. const respond = (isAsync: boolean, fn, ...args): void => {
  6. if (fn) {
  7. if (isAsync) {
  8. setTimeout(() => fn(...args), 1);
  9. } else {
  10. fn(...args);
  11. }
  12. }
  13. };
  14. const DEFAULT_MOCK_RESPONSE_OPTIONS = {
  15. predicate: () => true,
  16. };
  17. type ResponseType = JQueryXHR & {
  18. url: string;
  19. statusCode: number;
  20. method: string;
  21. callCount: 0;
  22. body: any;
  23. headers: {[key: string]: string};
  24. };
  25. class Client {
  26. static mockResponses: Array<
  27. [
  28. ResponseType,
  29. jest.Mock,
  30. (url: string, options: Readonly<ImportedClient.RequestOptions>) => boolean
  31. ]
  32. > = [];
  33. static clearMockResponses() {
  34. Client.mockResponses = [];
  35. }
  36. // Returns a jest mock that represents Client.request calls
  37. static addMockResponse(response, options = DEFAULT_MOCK_RESPONSE_OPTIONS) {
  38. const mock = jest.fn();
  39. Client.mockResponses.unshift([
  40. {
  41. statusCode: 200,
  42. body: '',
  43. method: 'GET',
  44. callCount: 0,
  45. ...response,
  46. headers: response.headers || {},
  47. },
  48. mock,
  49. options.predicate,
  50. ]);
  51. return mock;
  52. }
  53. static findMockResponse(url: string, options: Readonly<ImportedClient.RequestOptions>) {
  54. return Client.mockResponses.find(([response, _mock, predicate]) => {
  55. const matchesURL = url === response.url;
  56. const matchesMethod = (options.method || 'GET') === response.method;
  57. const matchesPredicate = predicate(url, options);
  58. return matchesURL && matchesMethod && matchesPredicate;
  59. });
  60. }
  61. uniqueId() {
  62. return '123';
  63. }
  64. // In the real client, this clears in-flight responses. It's NOT clearMockResponses. You probably don't want to call this from a test.
  65. clear() {}
  66. static mockAsync = false;
  67. wrapCallback(_id, error) {
  68. return (...args) => {
  69. // @ts-expect-error
  70. if (RealClient.hasProjectBeenRenamed(...args)) {
  71. return;
  72. }
  73. respond(Client.mockAsync, error, ...args);
  74. };
  75. }
  76. requestPromise(
  77. path,
  78. {
  79. includeAllArgs,
  80. ...options
  81. }: {includeAllArgs?: boolean} & Readonly<ImportedClient.RequestOptions> = {}
  82. ) {
  83. return new Promise((resolve, reject) => {
  84. this.request(path, {
  85. ...options,
  86. success: (data, ...args) => {
  87. includeAllArgs ? resolve([data, ...args]) : resolve(data);
  88. },
  89. error: (error, ..._args) => {
  90. reject(error);
  91. },
  92. });
  93. });
  94. }
  95. request(url, options: Readonly<ImportedClient.RequestOptions> = {}) {
  96. const [response, mock] = Client.findMockResponse(url, options) || [
  97. undefined,
  98. undefined,
  99. ];
  100. if (!response || !mock) {
  101. // Endpoints need to be mocked
  102. const err = new Error(
  103. `No mocked response found for request: ${options.method || 'GET'} ${url}`
  104. );
  105. // Mutate stack to drop frames since test file so that we know where in the test
  106. // this needs to be mocked
  107. const lines = err.stack?.split('\n');
  108. const startIndex = lines?.findIndex(line => line.includes('tests/js/spec'));
  109. err.stack = ['\n', lines?.[0], ...(lines?.slice(startIndex) ?? [])].join('\n');
  110. // Throwing an error here does not do what we want it to do....
  111. // Because we are mocking an API client, we generally catch errors to show
  112. // user-friendly error messages, this means in tests this error gets gobbled
  113. // up and developer frustration ensues. Use `setTimeout` to get around this
  114. setTimeout(() => {
  115. throw err;
  116. });
  117. } else {
  118. // has mocked response
  119. // mock gets returned when we add a mock response, will represent calls to api.request
  120. mock(url, options);
  121. const body =
  122. typeof response.body === 'function' ? response.body(url, options) : response.body;
  123. if (response.statusCode !== 200) {
  124. response.callCount++;
  125. const errorResponse = Object.assign(
  126. {
  127. status: response.statusCode,
  128. responseText: JSON.stringify(body),
  129. responseJSON: body,
  130. },
  131. {
  132. overrideMimeType: () => {},
  133. abort: () => {},
  134. then: () => {},
  135. error: () => {},
  136. },
  137. new XMLHttpRequest()
  138. );
  139. this.handleRequestError(
  140. {
  141. id: '1234',
  142. path: url,
  143. requestOptions: options,
  144. },
  145. errorResponse as any,
  146. 'error',
  147. 'error'
  148. );
  149. } else {
  150. response.callCount++;
  151. respond(
  152. Client.mockAsync,
  153. options.success,
  154. body,
  155. {},
  156. {
  157. getResponseHeader: key => response.headers[key],
  158. }
  159. );
  160. }
  161. }
  162. respond(Client.mockAsync, options.complete);
  163. }
  164. handleRequestError = RealClient.Client.prototype.handleRequestError;
  165. }
  166. export {Client};