1 import type { Api } from '@proton/pass/types';
2 import { ApiError } from '@proton/shared/lib/fetch/ApiError';
4 import * as cache from './cache';
5 import { createAbortResponse, createNetworkError } from './fetch-controller';
6 import { createImageProxyHandler } from './images';
8 const { withMaxAgeHeaders } = cache;
10 describe('createImageProxyHandler', () => {
11 const mockUrl = '/test/image';
12 const mockApi = jest.fn();
13 const mockCache = { match: jest.fn(), put: jest.fn().mockImplementation(() => Promise.resolve()) };
14 const imageProxy = createImageProxyHandler(mockApi as unknown as Api);
15 const apiParams = { url: mockUrl, output: 'raw', sideEffects: false };
18 jest.spyOn(cache, 'getCache').mockImplementation(() => Promise.resolve(mockCache as unknown as Cache));
20 mockCache.match.mockClear();
21 mockCache.put.mockClear();
24 it('should return cached response if available and not expired', async () => {
25 const response = new Response('cached', { headers: withMaxAgeHeaders(new Response(), 100) });
26 mockCache.match.mockResolvedValue(response);
28 const result = await imageProxy(mockUrl);
29 await expect(result).toMatchResponse(response);
30 expect(mockApi).not.toHaveBeenCalled();
33 it('should fetch from network if cache is empty', async () => {
34 const response = new Response('from_network', { status: 200 });
35 mockApi.mockResolvedValue(response);
36 mockCache.match.mockResolvedValue(undefined);
38 const result = await imageProxy(mockUrl);
39 const [url, cached] = mockCache.put.mock.lastCall!;
41 expect(mockApi).toHaveBeenCalledWith(expect.objectContaining(apiParams));
42 expect(mockCache.put).toHaveBeenCalled();
43 expect(result.status).toBe(200);
44 expect(url).toEqual(mockUrl);
45 await expect(cached).toMatchResponse(result);
48 it('should handle AbortError', async () => {
49 mockCache.match.mockResolvedValue(undefined);
50 mockApi.mockRejectedValue({ name: 'AbortError' });
52 const result = await imageProxy(mockUrl);
54 expect(mockApi).toHaveBeenCalledWith(expect.objectContaining(apiParams));
55 expect(mockCache.put).not.toHaveBeenCalled();
56 await expect(result).toMatchResponse(createAbortResponse());
59 it('should handle ApiError with 422 status', async () => {
60 mockCache.match.mockResolvedValue(undefined);
61 mockApi.mockRejectedValue(new ApiError('Unprocessable Content', 422, 'StatusCodeError'));
63 const result = await imageProxy(mockUrl);
64 const [url, cached] = mockCache.put.mock.lastCall!;
66 expect(mockApi).toHaveBeenCalledWith(expect.objectContaining(apiParams));
67 expect(result.status).toEqual(422);
68 expect(result.headers.get('Cache-Control')).toBeDefined();
69 expect(url).toEqual(mockUrl);
70 await expect(cached).toMatchResponse(result);
73 it('should handle network error', async () => {
74 mockCache.match.mockResolvedValue(undefined);
75 mockApi.mockRejectedValue(new Error('Network error'));
77 const result = await imageProxy(mockUrl);
79 expect(mockApi).toHaveBeenCalledWith(expect.objectContaining(apiParams));
80 expect(mockCache.put).not.toHaveBeenCalled();
81 await expect(result).toMatchResponse(createNetworkError(408));
84 it('should return cached response while revalidating', async () => {
85 const staleHeaders = new Headers();
86 staleHeaders.set('Date', 'Fri, 14 Dec 1990 09:39:46 GMT');
87 staleHeaders.set('Cache-Control', `max-age=0`);
89 const cachedResponse = new Response('cached', { headers: staleHeaders });
90 const response = new Response('from_network', { status: 200 });
92 mockCache.match.mockResolvedValue(cachedResponse);
93 mockApi.mockResolvedValue(response);
95 const result = await imageProxy(mockUrl);
96 await new Promise(process.nextTick); // let cache side-effect happen
97 const [url, cached] = mockCache.put.mock.lastCall!;
99 expect(mockApi).toHaveBeenCalledWith(expect.objectContaining(apiParams));
100 expect(url).toEqual(mockUrl);
101 expect(mockCache.put).toHaveBeenCalled();
102 expect(cached.status).toEqual(200);
103 expect(cached.headers.get('Cache-Control')).toBeDefined();
104 await expect(result).toMatchResponse(cachedResponse);