📄 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