@xiboplayer/cache 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/CACHE_PROXY_ARCHITECTURE.md +439 -0
- package/docs/README.md +118 -0
- package/package.json +41 -0
- package/src/cache-proxy.js +493 -0
- package/src/cache-proxy.test.js +391 -0
- package/src/cache.js +739 -0
- package/src/cache.test.js +760 -0
- package/src/download-manager.js +434 -0
- package/src/download-manager.test.js +726 -0
- package/src/index.js +4 -0
- package/src/test-utils.js +133 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DownloadManager Tests
|
|
3
|
+
*
|
|
4
|
+
* Contract-based testing for DownloadTask, DownloadQueue, and DownloadManager
|
|
5
|
+
* Tests state machines, concurrency control, and error handling
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
9
|
+
import { DownloadTask, DownloadQueue, DownloadManager } from './download-manager.js';
|
|
10
|
+
import { mockFetch, mockChunkedFetch, createTestBlob, waitFor, createSpy } from './test-utils.js';
|
|
11
|
+
|
|
12
|
+
describe('DownloadTask', () => {
|
|
13
|
+
describe('State Machine', () => {
|
|
14
|
+
it('should start in pending state', () => {
|
|
15
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
16
|
+
|
|
17
|
+
// Post-condition: Initial state
|
|
18
|
+
expect(task.state).toBe('pending');
|
|
19
|
+
expect(task.downloadedBytes).toBe(0);
|
|
20
|
+
expect(task.totalBytes).toBe(0);
|
|
21
|
+
expect(task.waiters.length).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should transition pending -> downloading -> complete', async () => {
|
|
25
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
26
|
+
const testBlob = createTestBlob(1024);
|
|
27
|
+
|
|
28
|
+
mockFetch({
|
|
29
|
+
'HEAD http://test.com/file.mp4': {
|
|
30
|
+
headers: { 'Content-Length': '1024' }
|
|
31
|
+
},
|
|
32
|
+
'GET http://test.com/file.mp4': {
|
|
33
|
+
blob: testBlob
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Pre-condition
|
|
38
|
+
expect(task.state).toBe('pending');
|
|
39
|
+
|
|
40
|
+
// Start download
|
|
41
|
+
const promise = task.start();
|
|
42
|
+
|
|
43
|
+
// Should be downloading (but completes quickly in tests)
|
|
44
|
+
await promise;
|
|
45
|
+
|
|
46
|
+
// Post-condition
|
|
47
|
+
expect(task.state).toBe('complete');
|
|
48
|
+
expect(task.downloadedBytes).toBe(1024);
|
|
49
|
+
expect(task.totalBytes).toBe(1024);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should transition pending -> downloading -> failed on error', async () => {
|
|
53
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
54
|
+
|
|
55
|
+
mockFetch({
|
|
56
|
+
'HEAD http://test.com/file.mp4': {
|
|
57
|
+
ok: false,
|
|
58
|
+
status: 500,
|
|
59
|
+
statusText: 'Server Error'
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Pre-condition
|
|
64
|
+
expect(task.state).toBe('pending');
|
|
65
|
+
|
|
66
|
+
// Start download
|
|
67
|
+
await expect(task.start()).rejects.toThrow();
|
|
68
|
+
|
|
69
|
+
// Post-condition
|
|
70
|
+
expect(task.state).toBe('failed');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('wait()', () => {
|
|
75
|
+
it('should satisfy contract: returns Promise<Blob> when complete', async () => {
|
|
76
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
77
|
+
const testBlob = createTestBlob(1024);
|
|
78
|
+
|
|
79
|
+
mockFetch({
|
|
80
|
+
'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '1024' } },
|
|
81
|
+
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Start download
|
|
85
|
+
const startPromise = task.start();
|
|
86
|
+
|
|
87
|
+
// Wait for completion
|
|
88
|
+
const blob = await task.wait();
|
|
89
|
+
|
|
90
|
+
// Post-condition: Returns blob
|
|
91
|
+
expect(blob).toBeInstanceOf(Blob);
|
|
92
|
+
expect(blob.size).toBe(1024);
|
|
93
|
+
|
|
94
|
+
await startPromise; // Ensure start completes
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should support multiple waiters', async () => {
|
|
98
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
99
|
+
const testBlob = createTestBlob(1024);
|
|
100
|
+
|
|
101
|
+
mockFetch({
|
|
102
|
+
'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '1024' } },
|
|
103
|
+
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Multiple waiters before start
|
|
107
|
+
const waiter1 = task.wait();
|
|
108
|
+
const waiter2 = task.wait();
|
|
109
|
+
const waiter3 = task.wait();
|
|
110
|
+
|
|
111
|
+
expect(task.waiters.length).toBe(3);
|
|
112
|
+
|
|
113
|
+
// Start download
|
|
114
|
+
await task.start();
|
|
115
|
+
|
|
116
|
+
// All waiters resolve with same blob
|
|
117
|
+
const [blob1, blob2, blob3] = await Promise.all([waiter1, waiter2, waiter3]);
|
|
118
|
+
|
|
119
|
+
expect(blob1).toBe(blob2);
|
|
120
|
+
expect(blob2).toBe(blob3);
|
|
121
|
+
expect(task.waiters.length).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should return immediately if already complete', async () => {
|
|
125
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
126
|
+
const testBlob = createTestBlob(1024);
|
|
127
|
+
|
|
128
|
+
mockFetch({
|
|
129
|
+
'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '1024' } },
|
|
130
|
+
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await task.start();
|
|
134
|
+
|
|
135
|
+
// Post-condition: wait() after completion returns immediately
|
|
136
|
+
const blob = await task.wait();
|
|
137
|
+
expect(blob).toBeInstanceOf(Blob);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should reject all waiters on failure', async () => {
|
|
141
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
142
|
+
|
|
143
|
+
mockFetch({
|
|
144
|
+
'HEAD http://test.com/file.mp4': { ok: false, status: 404 }
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const waiter1 = task.wait();
|
|
148
|
+
const waiter2 = task.wait();
|
|
149
|
+
|
|
150
|
+
// Start (will fail)
|
|
151
|
+
try {
|
|
152
|
+
await task.start();
|
|
153
|
+
} catch (e) {
|
|
154
|
+
// Expected
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// All waiters rejected
|
|
158
|
+
await expect(waiter1).rejects.toThrow();
|
|
159
|
+
await expect(waiter2).rejects.toThrow();
|
|
160
|
+
expect(task.waiters.length).toBe(0);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('Small File Downloads (<100MB)', () => {
|
|
165
|
+
it('should download in single request', async () => {
|
|
166
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/small.mp4' });
|
|
167
|
+
const testBlob = createTestBlob(10 * 1024 * 1024); // 10MB
|
|
168
|
+
|
|
169
|
+
const fetchMock = mockFetch({
|
|
170
|
+
'HEAD http://test.com/small.mp4': {
|
|
171
|
+
headers: { 'Content-Length': String(10 * 1024 * 1024) }
|
|
172
|
+
},
|
|
173
|
+
'GET http://test.com/small.mp4': { blob: testBlob }
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await task.start();
|
|
177
|
+
|
|
178
|
+
// Verify single GET request (plus HEAD)
|
|
179
|
+
const getCalls = fetchMock.mock.calls.filter(call => {
|
|
180
|
+
const options = call[1];
|
|
181
|
+
return !options || options.method !== 'HEAD';
|
|
182
|
+
});
|
|
183
|
+
expect(getCalls.length).toBe(1);
|
|
184
|
+
expect(task.blob.size).toBe(10 * 1024 * 1024);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should update downloadedBytes correctly', async () => {
|
|
188
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
189
|
+
const testBlob = createTestBlob(5000);
|
|
190
|
+
|
|
191
|
+
mockFetch({
|
|
192
|
+
'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '5000' } },
|
|
193
|
+
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await task.start();
|
|
197
|
+
|
|
198
|
+
// Invariant: downloadedBytes = totalBytes after completion
|
|
199
|
+
expect(task.downloadedBytes).toBe(task.totalBytes);
|
|
200
|
+
expect(task.downloadedBytes).toBe(5000);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('Error Handling', () => {
|
|
205
|
+
it('should handle network errors gracefully', async () => {
|
|
206
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
207
|
+
|
|
208
|
+
global.fetch = vi.fn(() => Promise.reject(new Error('Network error')));
|
|
209
|
+
|
|
210
|
+
await expect(task.start()).rejects.toThrow('Network error');
|
|
211
|
+
expect(task.state).toBe('failed');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should handle HTTP errors', async () => {
|
|
215
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
216
|
+
|
|
217
|
+
mockFetch({
|
|
218
|
+
'HEAD http://test.com/file.mp4': { ok: false, status: 404, statusText: 'Not Found' }
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await expect(task.start()).rejects.toThrow('HEAD request failed: 404');
|
|
222
|
+
expect(task.state).toBe('failed');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('DownloadQueue', () => {
|
|
228
|
+
afterEach(() => {
|
|
229
|
+
vi.restoreAllMocks();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('Concurrency Control', () => {
|
|
233
|
+
it('should respect concurrency limit', async () => {
|
|
234
|
+
const queue = new DownloadQueue({ concurrency: 2 });
|
|
235
|
+
|
|
236
|
+
// Mock slow downloads to test concurrency
|
|
237
|
+
const testBlob = createTestBlob(1024);
|
|
238
|
+
global.fetch = vi.fn(async (url, options) => {
|
|
239
|
+
// Delay to simulate network
|
|
240
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
241
|
+
|
|
242
|
+
if (options?.method === 'HEAD') {
|
|
243
|
+
return {
|
|
244
|
+
ok: true,
|
|
245
|
+
status: 200,
|
|
246
|
+
headers: {
|
|
247
|
+
get: (name) => name === 'Content-Length' ? '1024' : null
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
ok: true,
|
|
254
|
+
status: 200,
|
|
255
|
+
headers: { get: () => null },
|
|
256
|
+
blob: () => Promise.resolve(testBlob)
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Enqueue 5 files
|
|
261
|
+
queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file1.mp4' });
|
|
262
|
+
queue.enqueue({ id: '2', type: 'media', path: 'http://test.com/file2.mp4' });
|
|
263
|
+
queue.enqueue({ id: '3', type: 'media', path: 'http://test.com/file3.mp4' });
|
|
264
|
+
queue.enqueue({ id: '4', type: 'media', path: 'http://test.com/file4.mp4' });
|
|
265
|
+
queue.enqueue({ id: '5', type: 'media', path: 'http://test.com/file5.mp4' });
|
|
266
|
+
|
|
267
|
+
// Wait a bit for processQueue to run
|
|
268
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
269
|
+
|
|
270
|
+
// Invariant: running <= concurrency
|
|
271
|
+
expect(queue.running).toBeLessThanOrEqual(2);
|
|
272
|
+
expect(queue.running).toBeGreaterThan(0); // Some should be running
|
|
273
|
+
|
|
274
|
+
// Queue should have pending items
|
|
275
|
+
expect(queue.queue.length + queue.running).toBeGreaterThan(2);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should process queue as tasks complete', async () => {
|
|
279
|
+
const queue = new DownloadQueue({ concurrency: 2 });
|
|
280
|
+
|
|
281
|
+
const testBlob = createTestBlob(1024);
|
|
282
|
+
mockFetch({
|
|
283
|
+
'HEAD http://test.com/file1.mp4': { headers: { 'Content-Length': '1024' } },
|
|
284
|
+
'GET http://test.com/file1.mp4': { blob: testBlob },
|
|
285
|
+
'HEAD http://test.com/file2.mp4': { headers: { 'Content-Length': '1024' } },
|
|
286
|
+
'GET http://test.com/file2.mp4': { blob: testBlob },
|
|
287
|
+
'HEAD http://test.com/file3.mp4': { headers: { 'Content-Length': '1024' } },
|
|
288
|
+
'GET http://test.com/file3.mp4': { blob: testBlob }
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const task1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file1.mp4' });
|
|
292
|
+
const task2 = queue.enqueue({ id: '2', type: 'media', path: 'http://test.com/file2.mp4' });
|
|
293
|
+
const task3 = queue.enqueue({ id: '3', type: 'media', path: 'http://test.com/file3.mp4' });
|
|
294
|
+
|
|
295
|
+
// Wait for all to complete
|
|
296
|
+
await Promise.all([task1.wait(), task2.wait(), task3.wait()]);
|
|
297
|
+
|
|
298
|
+
// Post-condition: all complete
|
|
299
|
+
expect(queue.running).toBe(0);
|
|
300
|
+
expect(queue.queue.length).toBe(0);
|
|
301
|
+
expect(queue.active.size).toBe(0);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe('Idempotent Enqueue', () => {
|
|
306
|
+
it('should return same task for duplicate URLs', async () => {
|
|
307
|
+
const queue = new DownloadQueue();
|
|
308
|
+
|
|
309
|
+
// Mock fetch to prevent actual downloads
|
|
310
|
+
const testBlob = createTestBlob(1024);
|
|
311
|
+
mockFetch({
|
|
312
|
+
'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '1024' } },
|
|
313
|
+
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const task1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
317
|
+
const task2 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
318
|
+
|
|
319
|
+
// Should be same task instance
|
|
320
|
+
expect(task1).toBe(task2);
|
|
321
|
+
expect(queue.active.size).toBe(1);
|
|
322
|
+
|
|
323
|
+
// Queue length might be 0 if the task already started
|
|
324
|
+
expect(queue.queue.length + queue.running).toBeGreaterThanOrEqual(0);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should create different tasks for different URLs', () => {
|
|
328
|
+
const queue = new DownloadQueue();
|
|
329
|
+
const testBlob = createTestBlob(1024);
|
|
330
|
+
mockFetch({
|
|
331
|
+
'HEAD http://test.com/file1.mp4': { headers: { 'Content-Length': '1024' } },
|
|
332
|
+
'GET http://test.com/file1.mp4': { blob: testBlob },
|
|
333
|
+
'HEAD http://test.com/file2.mp4': { headers: { 'Content-Length': '1024' } },
|
|
334
|
+
'GET http://test.com/file2.mp4': { blob: testBlob }
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const task1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file1.mp4' });
|
|
338
|
+
const task2 = queue.enqueue({ id: '2', type: 'media', path: 'http://test.com/file2.mp4' });
|
|
339
|
+
|
|
340
|
+
expect(task1).not.toBe(task2);
|
|
341
|
+
expect(queue.active.size).toBe(2);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('getTask()', () => {
|
|
346
|
+
it('should return active task', () => {
|
|
347
|
+
const queue = new DownloadQueue();
|
|
348
|
+
const testBlob = createTestBlob(1024);
|
|
349
|
+
mockFetch({
|
|
350
|
+
'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '1024' } },
|
|
351
|
+
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const task = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
355
|
+
const retrieved = queue.getTask('http://test.com/file.mp4');
|
|
356
|
+
|
|
357
|
+
expect(retrieved).toBe(task);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should return null for non-existent task', () => {
|
|
361
|
+
const queue = new DownloadQueue();
|
|
362
|
+
|
|
363
|
+
const retrieved = queue.getTask('http://test.com/nonexistent.mp4');
|
|
364
|
+
|
|
365
|
+
expect(retrieved).toBeNull();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
describe('clear()', () => {
|
|
370
|
+
it('should clear queue and active tasks', () => {
|
|
371
|
+
const queue = new DownloadQueue();
|
|
372
|
+
const testBlob = createTestBlob(1024);
|
|
373
|
+
mockFetch({
|
|
374
|
+
'HEAD http://test.com/file1.mp4': { headers: { 'Content-Length': '1024' } },
|
|
375
|
+
'GET http://test.com/file1.mp4': { blob: testBlob },
|
|
376
|
+
'HEAD http://test.com/file2.mp4': { headers: { 'Content-Length': '1024' } },
|
|
377
|
+
'GET http://test.com/file2.mp4': { blob: testBlob },
|
|
378
|
+
'HEAD http://test.com/file3.mp4': { headers: { 'Content-Length': '1024' } },
|
|
379
|
+
'GET http://test.com/file3.mp4': { blob: testBlob }
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file1.mp4' });
|
|
383
|
+
queue.enqueue({ id: '2', type: 'media', path: 'http://test.com/file2.mp4' });
|
|
384
|
+
queue.enqueue({ id: '3', type: 'media', path: 'http://test.com/file3.mp4' });
|
|
385
|
+
|
|
386
|
+
// Clear
|
|
387
|
+
queue.clear();
|
|
388
|
+
|
|
389
|
+
// Post-condition: everything cleared
|
|
390
|
+
expect(queue.queue.length).toBe(0);
|
|
391
|
+
expect(queue.active.size).toBe(0);
|
|
392
|
+
expect(queue.running).toBe(0);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe('DownloadManager', () => {
|
|
398
|
+
afterEach(() => {
|
|
399
|
+
vi.restoreAllMocks();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe('API Delegation', () => {
|
|
403
|
+
it('should delegate enqueue to queue', () => {
|
|
404
|
+
const testBlob = createTestBlob(1024);
|
|
405
|
+
mockFetch({
|
|
406
|
+
'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '1024' } },
|
|
407
|
+
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
408
|
+
});
|
|
409
|
+
const manager = new DownloadManager({ concurrency: 4 });
|
|
410
|
+
|
|
411
|
+
const task = manager.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
412
|
+
|
|
413
|
+
expect(task).toBeInstanceOf(DownloadTask);
|
|
414
|
+
expect(manager.queue.active.has('http://test.com/file.mp4')).toBe(true);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should delegate getTask to queue', () => {
|
|
418
|
+
const testBlob = createTestBlob(1024);
|
|
419
|
+
mockFetch({
|
|
420
|
+
'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '1024' } },
|
|
421
|
+
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
422
|
+
});
|
|
423
|
+
const manager = new DownloadManager();
|
|
424
|
+
|
|
425
|
+
const task = manager.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
426
|
+
const retrieved = manager.getTask('http://test.com/file.mp4');
|
|
427
|
+
|
|
428
|
+
expect(retrieved).toBe(task);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('should delegate getProgress to queue', () => {
|
|
432
|
+
const testBlob = createTestBlob(1024);
|
|
433
|
+
mockFetch({
|
|
434
|
+
'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '1024' } },
|
|
435
|
+
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
436
|
+
});
|
|
437
|
+
const manager = new DownloadManager();
|
|
438
|
+
|
|
439
|
+
manager.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
440
|
+
|
|
441
|
+
const progress = manager.getProgress();
|
|
442
|
+
|
|
443
|
+
expect(progress).toBeDefined();
|
|
444
|
+
expect(typeof progress).toBe('object');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('should delegate clear to queue', () => {
|
|
448
|
+
const testBlob = createTestBlob(1024);
|
|
449
|
+
mockFetch({
|
|
450
|
+
'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '1024' } },
|
|
451
|
+
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
452
|
+
});
|
|
453
|
+
const manager = new DownloadManager();
|
|
454
|
+
|
|
455
|
+
manager.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
456
|
+
manager.clear();
|
|
457
|
+
|
|
458
|
+
expect(manager.queue.queue.length).toBe(0);
|
|
459
|
+
expect(manager.queue.active.size).toBe(0);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe('Configuration', () => {
|
|
464
|
+
it('should pass concurrency to queue', () => {
|
|
465
|
+
const manager = new DownloadManager({ concurrency: 8 });
|
|
466
|
+
|
|
467
|
+
expect(manager.queue.concurrency).toBe(8);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('should pass chunkSize to queue', () => {
|
|
471
|
+
const manager = new DownloadManager({ chunkSize: 25 * 1024 * 1024 });
|
|
472
|
+
|
|
473
|
+
expect(manager.queue.chunkSize).toBe(25 * 1024 * 1024);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('should pass chunksPerFile to queue', () => {
|
|
477
|
+
const manager = new DownloadManager({ chunksPerFile: 8 });
|
|
478
|
+
|
|
479
|
+
expect(manager.queue.chunksPerFile).toBe(8);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('should use defaults if not specified', () => {
|
|
483
|
+
const manager = new DownloadManager();
|
|
484
|
+
|
|
485
|
+
expect(manager.queue.concurrency).toBe(4); // DEFAULT_CONCURRENCY
|
|
486
|
+
expect(manager.queue.chunkSize).toBe(50 * 1024 * 1024); // DEFAULT_CHUNK_SIZE
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// ============================================================================
|
|
492
|
+
// Progressive Chunk Streaming Tests
|
|
493
|
+
// ============================================================================
|
|
494
|
+
|
|
495
|
+
describe('DownloadTask - Progressive Streaming', () => {
|
|
496
|
+
afterEach(() => {
|
|
497
|
+
vi.restoreAllMocks();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
describe('onChunkDownloaded callback', () => {
|
|
501
|
+
it('should initialize onChunkDownloaded as null', () => {
|
|
502
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
503
|
+
|
|
504
|
+
expect(task.onChunkDownloaded).toBeNull();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('should allow setting onChunkDownloaded before start', () => {
|
|
508
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
509
|
+
const callback = vi.fn();
|
|
510
|
+
|
|
511
|
+
task.onChunkDownloaded = callback;
|
|
512
|
+
|
|
513
|
+
expect(task.onChunkDownloaded).toBe(callback);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('should call onChunkDownloaded for each chunk during chunked download', async () => {
|
|
517
|
+
// 200MB file → will use downloadChunks (threshold is 100MB)
|
|
518
|
+
const fileSize = 200 * 1024 * 1024;
|
|
519
|
+
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
520
|
+
|
|
521
|
+
const task = new DownloadTask(
|
|
522
|
+
{ id: '1', type: 'media', path: 'http://test.com/big.mp4' },
|
|
523
|
+
{ chunkSize: 50 * 1024 * 1024, chunksPerFile: 4 }
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
mockChunkedFetch(sourceBlob);
|
|
527
|
+
|
|
528
|
+
const chunkCalls = [];
|
|
529
|
+
task.onChunkDownloaded = vi.fn(async (index, blob, total) => {
|
|
530
|
+
chunkCalls.push({ index, size: blob.size, total });
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
await task.start();
|
|
534
|
+
|
|
535
|
+
// 200MB / 50MB = 4 chunks
|
|
536
|
+
expect(task.onChunkDownloaded).toHaveBeenCalledTimes(4);
|
|
537
|
+
expect(chunkCalls.length).toBe(4);
|
|
538
|
+
|
|
539
|
+
// Each callback receives (chunkIndex, chunkBlob, totalChunks)
|
|
540
|
+
for (const call of chunkCalls) {
|
|
541
|
+
expect(call.total).toBe(4);
|
|
542
|
+
expect(call.size).toBeGreaterThan(0);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// All chunk indices should be present (order may vary due to parallelism)
|
|
546
|
+
const indices = chunkCalls.map(c => c.index).sort();
|
|
547
|
+
expect(indices).toEqual([0, 1, 2, 3]);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('should return empty blob when onChunkDownloaded is set', async () => {
|
|
551
|
+
const fileSize = 200 * 1024 * 1024;
|
|
552
|
+
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
553
|
+
|
|
554
|
+
const task = new DownloadTask(
|
|
555
|
+
{ id: '1', type: 'media', path: 'http://test.com/big.mp4' },
|
|
556
|
+
{ chunkSize: 50 * 1024 * 1024, chunksPerFile: 4 }
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
mockChunkedFetch(sourceBlob);
|
|
560
|
+
|
|
561
|
+
// Set callback → triggers empty blob return path
|
|
562
|
+
task.onChunkDownloaded = vi.fn(async () => {});
|
|
563
|
+
|
|
564
|
+
await task.start();
|
|
565
|
+
|
|
566
|
+
// Post-condition: blob should be empty (data was handled by callbacks)
|
|
567
|
+
expect(task.blob.size).toBe(0);
|
|
568
|
+
expect(task.state).toBe('complete');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('should return full blob when onChunkDownloaded is NOT set', async () => {
|
|
572
|
+
const fileSize = 200 * 1024 * 1024;
|
|
573
|
+
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
574
|
+
|
|
575
|
+
const task = new DownloadTask(
|
|
576
|
+
{ id: '1', type: 'media', path: 'http://test.com/big.mp4' },
|
|
577
|
+
{ chunkSize: 50 * 1024 * 1024, chunksPerFile: 4 }
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
mockChunkedFetch(sourceBlob);
|
|
581
|
+
|
|
582
|
+
// No callback set → traditional reassembly
|
|
583
|
+
await task.start();
|
|
584
|
+
|
|
585
|
+
// Post-condition: blob contains the full file
|
|
586
|
+
expect(task.blob.size).toBe(fileSize);
|
|
587
|
+
expect(task.state).toBe('complete');
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('should not call onChunkDownloaded for small files (single request)', async () => {
|
|
591
|
+
const fileSize = 10 * 1024 * 1024; // 10MB - below 100MB threshold
|
|
592
|
+
const sourceBlob = createTestBlob(fileSize);
|
|
593
|
+
|
|
594
|
+
const task = new DownloadTask(
|
|
595
|
+
{ id: '1', type: 'media', path: 'http://test.com/small.mp4' },
|
|
596
|
+
{ chunkSize: 50 * 1024 * 1024, chunksPerFile: 4 }
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
mockChunkedFetch(sourceBlob);
|
|
600
|
+
|
|
601
|
+
task.onChunkDownloaded = vi.fn(async () => {});
|
|
602
|
+
|
|
603
|
+
await task.start();
|
|
604
|
+
|
|
605
|
+
// Small file uses downloadFull, not downloadChunks → callback not called
|
|
606
|
+
expect(task.onChunkDownloaded).not.toHaveBeenCalled();
|
|
607
|
+
// But the blob should still contain data
|
|
608
|
+
expect(task.blob.size).toBe(fileSize);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('should handle async callback errors gracefully', async () => {
|
|
612
|
+
const fileSize = 200 * 1024 * 1024;
|
|
613
|
+
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
614
|
+
|
|
615
|
+
const task = new DownloadTask(
|
|
616
|
+
{ id: '1', type: 'media', path: 'http://test.com/big.mp4' },
|
|
617
|
+
{ chunkSize: 50 * 1024 * 1024, chunksPerFile: 4 }
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
mockChunkedFetch(sourceBlob);
|
|
621
|
+
|
|
622
|
+
// Callback throws — should not crash download
|
|
623
|
+
task.onChunkDownloaded = vi.fn(async () => {
|
|
624
|
+
throw new Error('Cache storage failed');
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Download should still complete despite callback errors
|
|
628
|
+
await task.start();
|
|
629
|
+
|
|
630
|
+
expect(task.state).toBe('complete');
|
|
631
|
+
expect(task.onChunkDownloaded).toHaveBeenCalledTimes(4);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('should resolve waiters with empty blob when callback is set', async () => {
|
|
635
|
+
const fileSize = 200 * 1024 * 1024;
|
|
636
|
+
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
637
|
+
|
|
638
|
+
const task = new DownloadTask(
|
|
639
|
+
{ id: '1', type: 'media', path: 'http://test.com/big.mp4' },
|
|
640
|
+
{ chunkSize: 50 * 1024 * 1024, chunksPerFile: 4 }
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
mockChunkedFetch(sourceBlob);
|
|
644
|
+
|
|
645
|
+
task.onChunkDownloaded = vi.fn(async () => {});
|
|
646
|
+
|
|
647
|
+
// Set up waiter before start
|
|
648
|
+
const waiterPromise = task.wait();
|
|
649
|
+
|
|
650
|
+
await task.start();
|
|
651
|
+
|
|
652
|
+
const result = await waiterPromise;
|
|
653
|
+
// Waiter gets the empty blob (data already handled by callbacks)
|
|
654
|
+
expect(result.size).toBe(0);
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// ============================================================================
|
|
660
|
+
// DownloadQueue Priority Tests
|
|
661
|
+
// ============================================================================
|
|
662
|
+
|
|
663
|
+
describe('DownloadQueue - Priority', () => {
|
|
664
|
+
afterEach(() => {
|
|
665
|
+
vi.restoreAllMocks();
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
describe('prioritize()', () => {
|
|
669
|
+
it('should move queued file to front', () => {
|
|
670
|
+
const queue = new DownloadQueue({ concurrency: 0 }); // Concurrency 0 prevents auto-start
|
|
671
|
+
|
|
672
|
+
// Enqueue 3 files (none will start due to concurrency 0)
|
|
673
|
+
// Actually concurrency 0 means the while loop never runs in processQueue
|
|
674
|
+
// But we need concurrency > 0 to create tasks. Let me just use a high enough count.
|
|
675
|
+
// Instead, create tasks manually
|
|
676
|
+
const task1 = new DownloadTask({ id: '1', type: 'media', path: 'http://a' });
|
|
677
|
+
const task2 = new DownloadTask({ id: '2', type: 'media', path: 'http://b' });
|
|
678
|
+
const task3 = new DownloadTask({ id: '3', type: 'media', path: 'http://c' });
|
|
679
|
+
|
|
680
|
+
queue.queue = [task1, task2, task3];
|
|
681
|
+
queue.active.set('http://a', task1);
|
|
682
|
+
queue.active.set('http://b', task2);
|
|
683
|
+
queue.active.set('http://c', task3);
|
|
684
|
+
|
|
685
|
+
// task3 is at position 2 → prioritize it
|
|
686
|
+
const found = queue.prioritize('media', '3');
|
|
687
|
+
|
|
688
|
+
expect(found).toBe(true);
|
|
689
|
+
expect(queue.queue[0]).toBe(task3); // Now at front
|
|
690
|
+
expect(queue.queue[1]).toBe(task1);
|
|
691
|
+
expect(queue.queue[2]).toBe(task2);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('should return true if file is already at front', () => {
|
|
695
|
+
const queue = new DownloadQueue();
|
|
696
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://a' });
|
|
697
|
+
queue.queue = [task];
|
|
698
|
+
queue.active.set('http://a', task);
|
|
699
|
+
|
|
700
|
+
const found = queue.prioritize('media', '1');
|
|
701
|
+
|
|
702
|
+
expect(found).toBe(true);
|
|
703
|
+
expect(queue.queue[0]).toBe(task);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('should return false if file not found', () => {
|
|
707
|
+
const queue = new DownloadQueue();
|
|
708
|
+
|
|
709
|
+
const found = queue.prioritize('media', '999');
|
|
710
|
+
|
|
711
|
+
expect(found).toBe(false);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('should return true if file is already downloading', () => {
|
|
715
|
+
const queue = new DownloadQueue();
|
|
716
|
+
const task = new DownloadTask({ id: '5', type: 'media', path: 'http://x' });
|
|
717
|
+
task.state = 'downloading';
|
|
718
|
+
queue.active.set('http://x', task);
|
|
719
|
+
// Not in queue (already started)
|
|
720
|
+
|
|
721
|
+
const found = queue.prioritize('media', '5');
|
|
722
|
+
|
|
723
|
+
expect(found).toBe(true);
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
});
|