import isEqual from 'lodash/isEqual'; import * as ApiNamespace from 'sentry/api'; const RealApi: typeof ApiNamespace = jest.requireActual('sentry/api'); export class Request {} export const initApiClientErrorHandling = RealApi.initApiClientErrorHandling; export const hasProjectBeenRenamed = RealApi.hasProjectBeenRenamed; const respond = (asyncDelay: AsyncDelay, fn?: Function, ...args: any[]): void => { if (!fn) { return; } if (asyncDelay !== undefined) { setTimeout(() => fn(...args), asyncDelay); return; } fn(...args); }; type FunctionCallback = (...args: Args) => void; /** * Callables for matching requests based on arbitrary conditions. */ interface MatchCallable { (url: string, options: ApiNamespace.RequestOptions): boolean; } type AsyncDelay = undefined | number; interface ResponseType extends ApiNamespace.ResponseMeta { body: any; callCount: 0; headers: Record; match: MatchCallable[]; method: string; statusCode: number; url: string; /** * Whether to return mocked api responses directly, or with a setTimeout delay. * * Set to `null` to disable the async delay * Set to a `number` which will be the amount of time (ms) for the delay * * This will override `MockApiClient.asyncDelay` for this request. */ asyncDelay?: AsyncDelay; } type MockResponse = [resp: ResponseType, mock: jest.Mock]; /** * Compare two records. `want` is all the entries we want to have the same value in `check` */ function compareRecord(want: Record, check: Record): boolean { for (const entry of Object.entries(want)) { const [key, value] = entry; if (!isEqual(check[key], value)) { return false; } } return true; } afterEach(() => { // if any errors are caught we console.error them const errors = Object.values(Client.errors); if (errors.length > 0) { for (const err of errors) { // eslint-disable-next-line no-console console.error(err); } Client.errors = {}; } }); class Client implements ApiNamespace.Client { activeRequests: Record = {}; baseUrl = ''; // uses the default client json headers. Sadly, we cannot refernce the real client // because it will cause a circular dependency and explode, hence the copy/paste headers = { Accept: 'application/json; charset=utf-8', 'Content-Type': 'application/json', }; static mockResponses: MockResponse[] = []; /** * Whether to return mocked api responses directly, or with a setTimeout delay. * * Set to `null` to disable the async delay * Set to a `number` which will be the amount of time (ms) for the delay * * This is the global/default value. `addMockResponse` can override per request. */ static asyncDelay: AsyncDelay = undefined; static clearMockResponses() { Client.mockResponses = []; } /** * Create a query string match callable. * * Only keys/values defined in `query` are checked. */ static matchQuery(query: Record): MatchCallable { const queryMatcher: MatchCallable = (_url, options) => { return compareRecord(query, options.query ?? {}); }; return queryMatcher; } /** * Create a data match callable. * * Only keys/values defined in `data` are checked. */ static matchData(data: Record): MatchCallable { const dataMatcher: MatchCallable = (_url, options) => { return compareRecord(data, options.data ?? {}); }; return dataMatcher; } // Returns a jest mock that represents Client.request calls static addMockResponse(response: Partial) { const mock = jest.fn(); Client.mockResponses.unshift([ { url: '', status: 200, statusCode: 200, statusText: 'OK', responseText: '', responseJSON: '', body: '', method: 'GET', callCount: 0, match: [], ...response, asyncDelay: response.asyncDelay ?? Client.asyncDelay, headers: response.headers ?? {}, getResponseHeader: (key: string) => response.headers?.[key] ?? null, }, mock, ]); return mock; } static findMockResponse(url: string, options: Readonly) { return Client.mockResponses.find(([response]) => { if (url !== response.url) { return false; } if ((options.method || 'GET') !== response.method) { return false; } return response.match.every(matcher => matcher(url, options)); }); } uniqueId() { return '123'; } /** * In the real client, this clears in-flight responses. It's NOT * clearMockResponses. You probably don't want to call this from a test. */ clear() { Object.values(this.activeRequests).forEach(r => r.cancel()); } wrapCallback( _id: string, func: FunctionCallback | undefined, _cleanup: boolean = false ) { const asyncDelay = Client.asyncDelay; return (...args: T) => { // @ts-expect-error if (RealApi.hasProjectBeenRenamed(...args)) { return; } respond(asyncDelay, func, ...args); }; } requestPromise( path: string, { includeAllArgs, ...options }: {includeAllArgs?: boolean} & Readonly = {} ): any { return new Promise((resolve, reject) => { this.request(path, { ...options, success: (data, ...args) => { includeAllArgs ? resolve([data, ...args]) : resolve(data); }, error: (error, ..._args) => { reject(error); }, }); }); } static errors: Record = {}; // XXX(ts): We type the return type for requestPromise and request as `any`. Typically these woul request(url: string, options: Readonly = {}): any { const [response, mock] = Client.findMockResponse(url, options) || [ undefined, undefined, ]; if (!response || !mock) { const methodAndUrl = `${options.method || 'GET'} ${url}`; // Endpoints need to be mocked const err = new Error(`No mocked response found for request: ${methodAndUrl}`); // Mutate stack to drop frames since test file so that we know where in the test // this needs to be mocked const lines = err.stack?.split('\n'); const startIndex = lines?.findIndex(line => line.includes('.spec.')); err.stack = ['\n', lines?.[0], ...(lines?.slice(startIndex) ?? [])].join('\n'); // Throwing an error here does not do what we want it to do.... // Because we are mocking an API client, we generally catch errors to show // user-friendly error messages, this means in tests this error gets gobbled // up and developer frustration ensues. // We track the errors on a static member and warn afterEach test. Client.errors[methodAndUrl] = err; } else { // has mocked response // mock gets returned when we add a mock response, will represent calls to api.request mock(url, options); const body = typeof response.body === 'function' ? response.body(url, options) : response.body; if (![200, 202].includes(response.statusCode)) { response.callCount++; const errorResponse = Object.assign( { status: response.statusCode, responseText: JSON.stringify(body), responseJSON: body, }, { overrideMimeType: () => {}, abort: () => {}, then: () => {}, error: () => {}, }, new XMLHttpRequest() ); this.handleRequestError( { id: '1234', path: url, requestOptions: options, }, errorResponse as any, 'error', 'error' ); } else { response.callCount++; respond( response.asyncDelay, options.success, body, {}, { getResponseHeader: (key: string) => response.headers[key], statusCode: response.statusCode, status: response.statusCode, } ); } } respond(response?.asyncDelay, options.complete); } handleRequestError = RealApi.Client.prototype.handleRequestError; } export {Client};