📄 src/downloader/test/fetcher.test.ts
D-OPEN SOVEREIGN
1import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
2import { trackProgress, fetchFromGateways, DEFAULT_GATEWAYS } from '../fetcher';
3
4const gzip = async (data: Uint8Array): Promise<Uint8Array> => {
5    const cs = new CompressionStream('gzip');
6    const writer = cs.writable.getWriter();
7    writer.write(data);
8    writer.close();
9    const chunks: Uint8Array[] = [];
10    const reader = cs.readable.getReader();
11    for (;;) {
12        const { done, value } = await reader.read();
13        if (done) break;
14        chunks.push(value);
15    }
16    return new Uint8Array(await new Blob(chunks).arrayBuffer());
17};
18
19describe('fetcher — trackProgress', () => {
20    it('reports cumulative loaded bytes and the declared total', async () => {
21        const body = new Uint8Array([1, 2, 3, 4, 5]);
22        const resp = new Response(body, { headers: { 'content-length': '5' } });
23        const progress: Array<[number, number | undefined]> = [];
24        const tracked = trackProgress(resp, (loaded, total) => progress.push([loaded, total]));
25
26        const buf = await tracked.arrayBuffer();
27        expect(new Uint8Array(buf)).toEqual(body);
28        expect(progress.length).toBeGreaterThan(0);
29        expect(progress[progress.length - 1]).toEqual([5, 5]);
30    });
31
32    it('reports undefined total when content-length is absent', async () => {
33        const resp = new Response(new Uint8Array([1, 2, 3]));
34        const progress: Array<[number, number | undefined]> = [];
35        const tracked = trackProgress(resp, (loaded, total) => progress.push([loaded, total]));
36        await tracked.arrayBuffer();
37        expect(progress[0][1]).toBeUndefined();
38    });
39});
40
41describe('fetcher — fetchFromGateways', () => {
42    const originalFetch = globalThis.fetch;
43
44    afterEach(() => {
45        globalThis.fetch = originalFetch;
46        vi.restoreAllMocks();
47    });
48
49    it('returns the response body from the first gateway that succeeds', async () => {
50        globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([9, 9, 9])));
51        const resp = await fetchFromGateways('tx1', DEFAULT_GATEWAYS, () => {});
52        expect(new Uint8Array(await resp.arrayBuffer())).toEqual(new Uint8Array([9, 9, 9]));
53        expect(fetch).toHaveBeenCalledTimes(1);
54    });
55
56    it('falls back to the next gateway on a non-ok response', async () => {
57        const fetchMock = vi.fn()
58            .mockResolvedValueOnce(new Response('err', { status: 502 }))
59            .mockResolvedValueOnce(new Response(new Uint8Array([1])));
60        globalThis.fetch = fetchMock as any;
61
62        const resp = await fetchFromGateways('tx1', ['https://a', 'https://b'], () => {});
63        expect(new Uint8Array(await resp.arrayBuffer())).toEqual(new Uint8Array([1]));
64        expect(fetchMock).toHaveBeenCalledTimes(2);
65    });
66
67    it('falls back to the next gateway on a thrown network error', async () => {
68        const fetchMock = vi.fn()
69            .mockRejectedValueOnce(new Error('network down'))
70            .mockResolvedValueOnce(new Response(new Uint8Array([1])));
71        globalThis.fetch = fetchMock as any;
72
73        const resp = await fetchFromGateways('tx1', ['https://a', 'https://b'], () => {});
74        expect(resp.status).toBe(200);
75    });
76
77    it('throws after every gateway fails', async () => {
78        globalThis.fetch = vi.fn(async () => new Response('err', { status: 500 })) as any;
79        await expect(fetchFromGateways('tx1', ['https://a', 'https://b'], () => {}))
80            .rejects.toThrow(/HTTP 500/);
81    });
82
83    it('strips content-length and content-encoding from the returned response', async () => {
84        globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
85            headers: { 'content-length': '3', 'content-encoding': 'gzip' },
86        })) as any;
87        const resp = await fetchFromGateways('tx1', DEFAULT_GATEWAYS, () => {});
88        expect(resp.headers.get('content-length')).toBeNull();
89        expect(resp.headers.get('content-encoding')).toBeNull();
90    });
91
92    it('marks the response with an X-DL2-Stored timestamp header', async () => {
93        globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([1]))) as any;
94        const resp = await fetchFromGateways('tx1', DEFAULT_GATEWAYS, () => {});
95        expect(resp.headers.get('X-DL2-Stored')).toBeTruthy();
96    });
97
98    it('transparently gunzips a gzip-magic-byte payload', async () => {
99        const plain = new TextEncoder().encode('hello world');
100        const compressed = await gzip(plain);
101        globalThis.fetch = vi.fn(async () => new Response(compressed)) as any;
102
103        const resp = await fetchFromGateways('tx1', DEFAULT_GATEWAYS, () => {});
104        const out = new Uint8Array(await resp.arrayBuffer());
105        expect(new TextDecoder().decode(out)).toBe('hello world');
106    });
107
108    it('returns an empty body untouched when the upstream response has no content', async () => {
109        globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([]))) as any;
110        const resp = await fetchFromGateways('tx1', DEFAULT_GATEWAYS, () => {});
111        expect((await resp.arrayBuffer()).byteLength).toBe(0);
112    });
113
114    it('rejects an opaque (CORS-blocked) response without trying to read its body', async () => {
115        const opaqueResp = new Response(new Uint8Array([1]));
116        Object.defineProperty(opaqueResp, 'type', { value: 'opaque' });
117        globalThis.fetch = vi.fn(async () => opaqueResp) as any;
118        await expect(fetchFromGateways('tx1', ['https://a'], () => {}))
119            .rejects.toThrow(/opaque/);
120    });
121});
122