@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.
@@ -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
+ });