Merge branch 'renovate/all-minor-patch' into 'main'
[ProtonMail-WebClient.git] / packages / metrics / tests / MetricsApi.test.ts
blobad5b29bd04866ba330592260473c5e8ce21449c6
1 import { HTTP_STATUS_CODE, SECOND } from '@proton/shared/lib/constants';
2 import { wait } from '@proton/shared/lib/helpers/promise';
4 import { METRICS_DEFAULT_RETRY_SECONDS, METRICS_MAX_ATTEMPTS, METRICS_REQUEST_TIMEOUT_SECONDS } from '../constants';
5 import MetricsApi from '../lib/MetricsApi';
7 jest.mock('@proton/shared/lib/helpers/promise');
9 function getHeader(headers: HeadersInit | undefined, headerName: string) {
10     // @ts-ignore
11     return headers[headerName];
14 describe('MetricsApi', () => {
15     beforeEach(() => {
16         fetchMock.resetMocks();
17     });
19     describe('constructor', () => {
20         describe('auth headers', () => {
21             it('sets auth headers when uid is defined', async () => {
22                 const uid = 'uid';
23                 const metricsApi = new MetricsApi({ uid });
25                 await metricsApi.fetch('/route');
26                 const content = fetchMock.mock.lastCall?.[1];
28                 expect(fetchMock).toHaveBeenCalledTimes(1);
29                 expect(getHeader(content?.headers, 'x-pm-uid')).toBe(uid);
30             });
32             it('does not set auth headers when uid is not defined', async () => {
33                 const metricsApi = new MetricsApi();
35                 await metricsApi.fetch('/route');
36                 const content = fetchMock.mock.lastCall?.[1];
38                 expect(fetchMock).toHaveBeenCalledTimes(1);
39                 expect(getHeader(content?.headers, 'x-pm-uid')).toBe(undefined);
40             });
41         });
43         describe('app version headers', () => {
44             it('sets app version headers when clientID and appVersion are defined', async () => {
45                 const clientID = 'clientID';
46                 const appVersion = 'appVersion';
47                 const metricsApi = new MetricsApi({ clientID, appVersion });
49                 await metricsApi.fetch('/route');
50                 const content = fetchMock.mock.lastCall?.[1];
52                 expect(fetchMock).toHaveBeenCalledTimes(1);
53                 expect(getHeader(content?.headers, 'x-pm-appversion')).toBe(`${clientID}@${appVersion}-dev`);
54             });
56             it('does not set app version headers when clientID is not defined', async () => {
57                 const appVersion = 'appVersion';
58                 const metricsApi = new MetricsApi({ appVersion });
60                 await metricsApi.fetch('/route');
61                 const content = fetchMock.mock.lastCall?.[1];
63                 expect(fetchMock).toHaveBeenCalledTimes(1);
64                 expect(getHeader(content?.headers, 'x-pm-appversion')).toBe(undefined);
65             });
67             it('does not set app version headers when appVersion is not defined', async () => {
68                 const clientID = 'clientID';
69                 const metricsApi = new MetricsApi({ clientID });
71                 await metricsApi.fetch('/route');
72                 const content = fetchMock.mock.lastCall?.[1];
74                 expect(fetchMock).toHaveBeenCalledTimes(1);
75                 expect(getHeader(content?.headers, 'x-pm-appversion')).toBe(undefined);
76             });
77         });
78     });
80     describe('setAuthHeaders', () => {
81         it('sets auth headers when uid is defined', async () => {
82             const uid = 'uid';
83             const metricsApi = new MetricsApi();
85             metricsApi.setAuthHeaders(uid);
87             await metricsApi.fetch('/route');
88             const content = fetchMock.mock.lastCall?.[1];
90             expect(fetchMock).toHaveBeenCalledTimes(1);
91             expect(getHeader(content?.headers, 'x-pm-uid')).toBe(uid);
92         });
94         it('does not set auth headers when uid is an empty string', async () => {
95             const uid = '';
96             const metricsApi = new MetricsApi();
98             metricsApi.setAuthHeaders(uid);
100             await metricsApi.fetch('/route');
101             const content = fetchMock.mock.lastCall?.[1];
103             expect(fetchMock).toHaveBeenCalledTimes(1);
104             expect(getHeader(content?.headers, 'x-pm-uid')).toBe(undefined);
105         });
107         it('sets auth headers with Authorization when uid and access token are defined', async () => {
108             const uid = 'uid';
109             const accessToken = 'accessToken';
110             const metricsApi = new MetricsApi();
112             metricsApi.setAuthHeaders(uid, accessToken);
114             await metricsApi.fetch('/route');
115             const content = fetchMock.mock.lastCall?.[1];
117             expect(fetchMock).toHaveBeenCalledTimes(1);
118             expect(getHeader(content?.headers, 'x-pm-uid')).toBe(uid);
119             expect(getHeader(content?.headers, 'Authorization')).toBe(`Bearer ${accessToken}`);
120         });
121     });
123     describe('setVersionHeaders', () => {
124         it('sets app version headers when clientID and appVersion are defined', async () => {
125             const clientID = 'clientID';
126             const appVersion = 'appVersion';
127             const metricsApi = new MetricsApi();
129             metricsApi.setVersionHeaders(clientID, appVersion);
131             await metricsApi.fetch('/route');
132             const content = fetchMock.mock.lastCall?.[1];
134             expect(fetchMock).toHaveBeenCalledTimes(1);
135             expect(getHeader(content?.headers, 'x-pm-appversion')).toBe(`${clientID}@${appVersion}-dev`);
136         });
138         it('does not set app version headers when clientID is an empty string', async () => {
139             const clientID = '';
140             const appVersion = 'appVersion';
141             const metricsApi = new MetricsApi();
143             metricsApi.setVersionHeaders(clientID, appVersion);
145             await metricsApi.fetch('/route');
146             const content = fetchMock.mock.lastCall?.[1];
148             expect(fetchMock).toHaveBeenCalledTimes(1);
149             expect(getHeader(content?.headers, 'x-pm-appversion')).toBe(undefined);
150         });
152         it('does not set app version headers when appVersion is an empty string', async () => {
153             const clientID = 'clientID';
154             const appVersion = '';
155             const metricsApi = new MetricsApi();
157             metricsApi.setVersionHeaders(clientID, appVersion);
159             await metricsApi.fetch('/route');
160             const content = fetchMock.mock.lastCall?.[1];
162             expect(fetchMock).toHaveBeenCalledTimes(1);
163             expect(getHeader(content?.headers, 'x-pm-appversion')).toBe(undefined);
164         });
165     });
167     describe('fetch', () => {
168         it('throws if fetch rejects', async () => {
169             fetchMock.mockResponseOnce(() => Promise.reject(new Error('asd')));
170             const metricsApi = new MetricsApi();
172             await expect(async () => {
173                 await metricsApi.fetch('/route');
174             }).rejects.toThrow();
175         });
177         it('sets content-type header to application/json', async () => {
178             const metricsApi = new MetricsApi();
180             await metricsApi.fetch('/route');
181             const content = fetchMock.mock.lastCall?.[1];
183             expect(fetchMock).toHaveBeenCalledTimes(1);
184             expect(getHeader(content?.headers, 'content-type')).toBe('application/json');
185         });
187         it('sets priority header to u=6', async () => {
188             const metricsApi = new MetricsApi();
190             await metricsApi.fetch('/route');
191             const content = fetchMock.mock.lastCall?.[1];
193             expect(fetchMock).toHaveBeenCalledTimes(1);
194             expect(getHeader(content?.headers, 'priority')).toBe('u=6');
195         });
197         it('forwards request info', async () => {
198             const route = '/route';
199             const metricsApi = new MetricsApi();
201             await metricsApi.fetch(route);
202             const url = fetchMock.mock.lastCall?.[0];
204             expect(fetchMock).toHaveBeenCalledTimes(1);
205             expect(url).toBe(route);
206         });
208         it('forwards request init params', async () => {
209             const method = 'post';
210             const body = 'body';
211             const metricsApi = new MetricsApi();
213             await metricsApi.fetch('/route', {
214                 method,
215                 body,
216                 headers: {
217                     foo: 'bar',
218                 },
219             });
220             const content = fetchMock.mock.lastCall?.[1];
222             expect(fetchMock).toHaveBeenCalledTimes(1);
223             expect(content?.method).toBe(method);
224             expect(content?.body).toBe(body);
225             expect(getHeader(content?.headers, 'foo')).toBe('bar');
226         });
228         describe('retry', () => {
229             const getRetryImplementation = (url: string, retrySeconds?: string) => async (req: Request) => {
230                 if (req.url === url) {
231                     return retrySeconds === undefined
232                         ? { status: HTTP_STATUS_CODE.TOO_MANY_REQUESTS }
233                         : {
234                               status: HTTP_STATUS_CODE.TOO_MANY_REQUESTS,
235                               headers: {
236                                   'retry-after': retrySeconds,
237                               },
238                           };
239                 }
241                 return '';
242             };
244             it('retries request if response contains retry-after header', async () => {
245                 fetchMock.mockResponseOnce(getRetryImplementation('/retry', '1'));
246                 const metricsApi = new MetricsApi();
248                 await metricsApi.fetch('/retry');
250                 expect(fetchMock).toHaveBeenCalledTimes(2);
251             });
253             it('respects the time the retry header contains', async () => {
254                 fetchMock.mockResponseOnce(getRetryImplementation('/retry', '1'));
255                 fetchMock.mockResponseOnce(getRetryImplementation('/retry', '2'));
256                 fetchMock.mockResponseOnce(getRetryImplementation('/retry', '3'));
257                 const metricsApi = new MetricsApi();
259                 await metricsApi.fetch('/retry');
261                 expect(wait).toHaveBeenNthCalledWith(1, 1 * SECOND);
262                 expect(wait).toHaveBeenNthCalledWith(2, 2 * SECOND);
263                 expect(wait).toHaveBeenNthCalledWith(3, 3 * SECOND);
264             });
266             it(`throws error if maximum retry limit is hit`, async () => {
267                 fetchMock.mockResponse(getRetryImplementation('/retry', '1'));
268                 const metricsApi = new MetricsApi();
270                 await expect(metricsApi.fetch('/retry')).rejects.toThrow('Too many requests');
272                 expect(fetchMock).toHaveBeenCalledTimes(METRICS_MAX_ATTEMPTS);
273             });
275             it(`uses default retry of ${METRICS_DEFAULT_RETRY_SECONDS} if retry is 0`, async () => {
276                 fetchMock.mockResponseOnce(getRetryImplementation('/retry', '0'));
277                 const metricsApi = new MetricsApi();
279                 await metricsApi.fetch('/retry');
281                 expect(wait).toHaveBeenCalledWith(METRICS_DEFAULT_RETRY_SECONDS * SECOND);
282             });
284             it(`uses default retry of ${METRICS_DEFAULT_RETRY_SECONDS} if retry is NaN`, async () => {
285                 fetchMock.mockResponseOnce(getRetryImplementation('/retry', 'hello'));
286                 const metricsApi = new MetricsApi();
288                 await metricsApi.fetch('/retry');
290                 expect(wait).toHaveBeenCalledWith(METRICS_DEFAULT_RETRY_SECONDS * SECOND);
291             });
293             it(`uses default retry of ${METRICS_DEFAULT_RETRY_SECONDS} if retry is negative`, async () => {
294                 fetchMock.mockResponseOnce(getRetryImplementation('/retry', '-1'));
295                 const metricsApi = new MetricsApi();
297                 await metricsApi.fetch('/retry');
299                 expect(wait).toHaveBeenCalledWith(METRICS_DEFAULT_RETRY_SECONDS * SECOND);
300             });
302             it(`uses default retry of ${METRICS_DEFAULT_RETRY_SECONDS} if retry is not defined`, async () => {
303                 fetchMock.mockResponseOnce(getRetryImplementation('/retry'));
304                 const metricsApi = new MetricsApi();
306                 await metricsApi.fetch('/retry');
308                 expect(wait).toHaveBeenCalledWith(METRICS_DEFAULT_RETRY_SECONDS * SECOND);
309             });
311             it('floors non integer values', async () => {
312                 fetchMock.mockResponseOnce(getRetryImplementation('/retry', '2.5'));
313                 const metricsApi = new MetricsApi();
315                 await metricsApi.fetch('/retry');
317                 expect(wait).toHaveBeenCalledWith(2 * SECOND);
318             });
319         });
321         describe('timeout', () => {
322             beforeAll(() => {
323                 jest.useFakeTimers();
324             });
326             beforeEach(() => {
327                 jest.clearAllTimers();
328             });
330             afterAll(() => {
331                 jest.useRealTimers();
332             });
334             const timeoutRequestMock = () =>
335                 new Promise<{ body: string }>((resolve) => {
336                     setTimeout(() => resolve({ body: 'ok' }), METRICS_REQUEST_TIMEOUT_SECONDS * SECOND + 1);
337                     jest.advanceTimersByTime(METRICS_REQUEST_TIMEOUT_SECONDS * SECOND + 1);
338                 });
340             it('retries request if request aborts', async () => {
341                 fetchMock.mockAbortOnce();
342                 const metricsApi = new MetricsApi();
344                 await metricsApi.fetch('/route');
346                 expect(fetchMock).toHaveBeenCalledTimes(2);
347             });
349             it('throws error if request continually aborts', async () => {
350                 fetchMock.mockAbort();
351                 const metricsApi = new MetricsApi();
353                 await expect(metricsApi.fetch('/route')).rejects.toThrow('Too many requests');
354                 expect(fetchMock).toHaveBeenCalledTimes(METRICS_MAX_ATTEMPTS);
355             });
357             it(`retries request if response takes longer than ${METRICS_REQUEST_TIMEOUT_SECONDS} seconds`, async () => {
358                 fetchMock.mockResponseOnce(timeoutRequestMock);
359                 const metricsApi = new MetricsApi();
361                 await metricsApi.fetch('/route');
363                 expect(fetchMock).toHaveBeenCalledTimes(2);
364             });
366             it(`waits ${METRICS_DEFAULT_RETRY_SECONDS} seconds before retrying`, async () => {
367                 fetchMock.mockResponseOnce(timeoutRequestMock);
368                 const metricsApi = new MetricsApi();
370                 await metricsApi.fetch('/route');
372                 expect(wait).toHaveBeenNthCalledWith(1, METRICS_DEFAULT_RETRY_SECONDS * SECOND);
373             });
375             it(`throws error if request continually timesout`, async () => {
376                 fetchMock.mockResponse(timeoutRequestMock);
377                 const metricsApi = new MetricsApi();
379                 await expect(metricsApi.fetch('/route')).rejects.toThrow('Too many requests');
381                 expect(fetchMock).toHaveBeenCalledTimes(METRICS_MAX_ATTEMPTS);
382             });
383         });
384     });