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 = (isAsync: boolean, fn?: Function, ...args: any[]): void => { if (!fn) { return; } if (isAsync) { setTimeout(() => fn(...args), 1); 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 ResponseType = ApiNamespace.ResponseMeta & { body: any; callCount: 0; headers: Record; match: MatchCallable[]; method: string; statusCode: number; url: string; }; 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; } class Client implements ApiNamespace.Client { static mockResponses: MockResponse[] = []; static mockAsync = false; 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, headers: response.headers ?? {}, getResponseHeader: (key: string) => response.headers?.[key] ?? null, rawResponse: { headers: new Headers(), ok: true, redirected: false, status: 200, statusText: 'OK', url: 'http://localhost', bodyUsed: false, body: { locked: false, cancel: jest.fn(), getReader: jest.fn(), pipeThrough: jest.fn(), pipeTo: jest.fn(), tee: jest.fn(), }, blob: jest.fn(), arrayBuffer: jest.fn(), json: jest.fn(), text: jest.fn(), formData: jest.fn(), clone: jest.fn(), type: 'basic', }, }, 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)); }); } activeRequests: Record = {}; baseUrl = ''; 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() {} wrapCallback( _id: string, func: FunctionCallback | undefined, _cleanup: boolean = false ) { return (...args: T) => { // @ts-expect-error if (RealApi.hasProjectBeenRenamed(...args)) { return; } respond(Client.mockAsync, 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); }, }); }); } // 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) { // Endpoints need to be mocked const err = new Error( `No mocked response found for request: ${options.method || 'GET'} ${url}` ); // 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('tests/js/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. Use `setTimeout` to get around this setTimeout(() => { throw 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( Client.mockAsync, options.success, body, {}, { getResponseHeader: (key: string) => response.headers[key], statusCode: response.statusCode, status: response.statusCode, } ); } } respond(Client.mockAsync, options.complete); } handleRequestError = RealApi.Client.prototype.handleRequestError; } export {Client};