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) {
11 return headers[headerName];
14 describe('MetricsApi', () => {
16 fetchMock.resetMocks();
19 describe('constructor', () => {
20 describe('auth headers', () => {
21 it('sets auth headers when uid is defined', async () => {
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);
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);
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`);
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);
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);
80 describe('setAuthHeaders', () => {
81 it('sets auth headers when uid is defined', async () => {
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);
94 it('does not set auth headers when uid is an empty string', async () => {
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);
107 it('sets auth headers with Authorization when uid and access token are defined', async () => {
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}`);
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`);
138 it('does not set app version headers when clientID is an empty string', async () => {
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);
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);
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();
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');
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');
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);
208 it('forwards request init params', async () => {
209 const method = 'post';
211 const metricsApi = new MetricsApi();
213 await metricsApi.fetch('/route', {
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');
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 }
234 status: HTTP_STATUS_CODE.TOO_MANY_REQUESTS,
236 'retry-after': retrySeconds,
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);
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);
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);
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);
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);
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);
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);
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);
321 describe('timeout', () => {
323 jest.useFakeTimers();
327 jest.clearAllTimers();
331 jest.useRealTimers();
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);
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);
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);
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);
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);
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);