@uploadista/client-browser 0.0.20 → 0.1.0-beta.5

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,135 @@
1
+ /**
2
+ * Vitest setup file for browser client tests.
3
+ *
4
+ * Sets up browser-like environment with mocks for Web APIs.
5
+ */
6
+
7
+ import { afterEach, vi } from "vitest";
8
+
9
+ // Mock crypto.subtle for tests that need it
10
+ const mockSubtle = {
11
+ digest: vi.fn().mockImplementation(async (_algorithm: string, data: ArrayBuffer) => {
12
+ // Return a mock hash based on data length for testing
13
+ const mockHash = new Uint8Array(32);
14
+ const view = new Uint8Array(data);
15
+ for (let i = 0; i < 32; i++) {
16
+ mockHash[i] = (view[i % view.length] || 0) ^ (i * 7);
17
+ }
18
+ return mockHash.buffer;
19
+ }),
20
+ };
21
+
22
+ // Mock crypto.randomUUID
23
+ const mockRandomUUID = vi.fn(() => "550e8400-e29b-41d4-a716-446655440000");
24
+
25
+ // Setup global crypto mock if not available
26
+ if (typeof globalThis.crypto === "undefined") {
27
+ Object.defineProperty(globalThis, "crypto", {
28
+ value: {
29
+ subtle: mockSubtle,
30
+ randomUUID: mockRandomUUID,
31
+ getRandomValues: (arr: Uint8Array) => {
32
+ for (let i = 0; i < arr.length; i++) {
33
+ arr[i] = Math.floor(Math.random() * 256);
34
+ }
35
+ return arr;
36
+ },
37
+ },
38
+ writable: true,
39
+ });
40
+ } else {
41
+ // Patch existing crypto object
42
+ if (!globalThis.crypto.subtle) {
43
+ Object.defineProperty(globalThis.crypto, "subtle", {
44
+ value: mockSubtle,
45
+ writable: true,
46
+ });
47
+ }
48
+ if (!globalThis.crypto.randomUUID) {
49
+ Object.defineProperty(globalThis.crypto, "randomUUID", {
50
+ value: mockRandomUUID,
51
+ writable: true,
52
+ });
53
+ }
54
+ }
55
+
56
+ // Clean up mocks after each test
57
+ afterEach(() => {
58
+ vi.clearAllMocks();
59
+ });
60
+
61
+ // Mock localStorage and sessionStorage for storage tests
62
+ const createStorageMock = () => {
63
+ let store: Record<string, string> = {};
64
+ return {
65
+ getItem: vi.fn((key: string) => store[key] ?? null),
66
+ setItem: vi.fn((key: string, value: string) => {
67
+ store[key] = value;
68
+ }),
69
+ removeItem: vi.fn((key: string) => {
70
+ delete store[key];
71
+ }),
72
+ clear: vi.fn(() => {
73
+ store = {};
74
+ }),
75
+ get length() {
76
+ return Object.keys(store).length;
77
+ },
78
+ key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
79
+ // Iterator support for for...in loops
80
+ [Symbol.iterator]: function* () {
81
+ for (const key of Object.keys(store)) {
82
+ yield key;
83
+ }
84
+ },
85
+ };
86
+ };
87
+
88
+ // Setup storage mocks if not available
89
+ if (typeof globalThis.localStorage === "undefined") {
90
+ Object.defineProperty(globalThis, "localStorage", {
91
+ value: createStorageMock(),
92
+ writable: true,
93
+ });
94
+ }
95
+
96
+ if (typeof globalThis.sessionStorage === "undefined") {
97
+ Object.defineProperty(globalThis, "sessionStorage", {
98
+ value: createStorageMock(),
99
+ writable: true,
100
+ });
101
+ }
102
+
103
+ // Make storage iterable for for...in loops used in the actual code
104
+ const makeStorageIterable = (storage: Storage) => {
105
+ const originalSetItem = storage.setItem.bind(storage);
106
+ const originalRemoveItem = storage.removeItem.bind(storage);
107
+ const originalClear = storage.clear?.bind(storage);
108
+
109
+ const keys = new Set<string>();
110
+
111
+ // Override setItem to track keys
112
+ storage.setItem = (key: string, value: string) => {
113
+ keys.add(key);
114
+ originalSetItem(key, value);
115
+ };
116
+
117
+ // Override removeItem to track keys
118
+ storage.removeItem = (key: string) => {
119
+ keys.delete(key);
120
+ originalRemoveItem(key);
121
+ };
122
+
123
+ // Override clear to track keys
124
+ if (originalClear) {
125
+ storage.clear = () => {
126
+ keys.clear();
127
+ originalClear();
128
+ };
129
+ }
130
+
131
+ return storage;
132
+ };
133
+
134
+ // Export utilities for tests
135
+ export { createStorageMock, makeStorageIterable };
@@ -0,0 +1,519 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ calculateBackoff,
4
+ calculateMultiUploadStats,
5
+ calculateProgress,
6
+ composeValidators,
7
+ createFileSizeValidator,
8
+ createFileTypeValidator,
9
+ createRetryWrapper,
10
+ delay,
11
+ formatDuration,
12
+ formatFileSize,
13
+ formatProgress,
14
+ formatSpeed,
15
+ generateUploadId,
16
+ getFileExtension,
17
+ isAbortError,
18
+ isAudioFile,
19
+ isDocumentFile,
20
+ isImageFile,
21
+ isNetworkError,
22
+ isVideoFile,
23
+ validateFileType,
24
+ type UploadItem,
25
+ } from "./framework-utils";
26
+
27
+ describe("formatFileSize", () => {
28
+ it("should format 0 bytes correctly", () => {
29
+ expect(formatFileSize(0)).toBe("0 Bytes");
30
+ });
31
+
32
+ it("should format bytes correctly", () => {
33
+ expect(formatFileSize(500)).toBe("500 Bytes");
34
+ });
35
+
36
+ it("should format KB correctly", () => {
37
+ expect(formatFileSize(1024)).toBe("1 KB");
38
+ expect(formatFileSize(1536)).toBe("1.5 KB");
39
+ });
40
+
41
+ it("should format MB correctly", () => {
42
+ expect(formatFileSize(1024 * 1024)).toBe("1 MB");
43
+ expect(formatFileSize(5.5 * 1024 * 1024)).toBe("5.5 MB");
44
+ });
45
+
46
+ it("should format GB correctly", () => {
47
+ expect(formatFileSize(1024 * 1024 * 1024)).toBe("1 GB");
48
+ });
49
+
50
+ it("should format TB correctly", () => {
51
+ expect(formatFileSize(1024 * 1024 * 1024 * 1024)).toBe("1 TB");
52
+ });
53
+ });
54
+
55
+ describe("formatProgress", () => {
56
+ it("should format progress percentage", () => {
57
+ expect(formatProgress(0)).toBe("0%");
58
+ expect(formatProgress(50)).toBe("50%");
59
+ expect(formatProgress(100)).toBe("100%");
60
+ });
61
+
62
+ it("should round decimal progress", () => {
63
+ expect(formatProgress(33.33)).toBe("33%");
64
+ expect(formatProgress(66.67)).toBe("67%");
65
+ });
66
+ });
67
+
68
+ describe("formatSpeed", () => {
69
+ it("should format 0 B/s correctly", () => {
70
+ expect(formatSpeed(0)).toBe("0 B/s");
71
+ });
72
+
73
+ it("should format B/s correctly", () => {
74
+ expect(formatSpeed(500)).toBe("500 B/s");
75
+ });
76
+
77
+ it("should format KB/s correctly", () => {
78
+ expect(formatSpeed(1024)).toBe("1 KB/s");
79
+ });
80
+
81
+ it("should format MB/s correctly", () => {
82
+ expect(formatSpeed(1024 * 1024)).toBe("1 MB/s");
83
+ });
84
+
85
+ it("should format GB/s correctly", () => {
86
+ expect(formatSpeed(1024 * 1024 * 1024)).toBe("1 GB/s");
87
+ });
88
+ });
89
+
90
+ describe("formatDuration", () => {
91
+ it("should format milliseconds", () => {
92
+ expect(formatDuration(500)).toBe("500ms");
93
+ });
94
+
95
+ it("should format seconds", () => {
96
+ expect(formatDuration(5000)).toBe("5s");
97
+ expect(formatDuration(30000)).toBe("30s");
98
+ });
99
+
100
+ it("should format minutes", () => {
101
+ expect(formatDuration(60000)).toBe("1m");
102
+ expect(formatDuration(90000)).toBe("1m 30s");
103
+ });
104
+
105
+ it("should format hours", () => {
106
+ expect(formatDuration(3600000)).toBe("1h");
107
+ expect(formatDuration(3660000)).toBe("1h 1m");
108
+ });
109
+ });
110
+
111
+ describe("getFileExtension", () => {
112
+ it("should extract file extension", () => {
113
+ expect(getFileExtension("document.pdf")).toBe("pdf");
114
+ expect(getFileExtension("image.PNG")).toBe("png");
115
+ expect(getFileExtension("archive.tar.gz")).toBe("gz");
116
+ });
117
+
118
+ it("should return empty string for files without extension", () => {
119
+ expect(getFileExtension("README")).toBe("");
120
+ expect(getFileExtension("Makefile")).toBe("");
121
+ });
122
+ });
123
+
124
+ describe("isImageFile", () => {
125
+ it("should return true for image files", () => {
126
+ const imageFile = new File([""], "test.png", { type: "image/png" });
127
+ expect(isImageFile(imageFile)).toBe(true);
128
+ });
129
+
130
+ it("should return false for non-image files", () => {
131
+ const textFile = new File([""], "test.txt", { type: "text/plain" });
132
+ expect(isImageFile(textFile)).toBe(false);
133
+ });
134
+ });
135
+
136
+ describe("isVideoFile", () => {
137
+ it("should return true for video files", () => {
138
+ const videoFile = new File([""], "test.mp4", { type: "video/mp4" });
139
+ expect(isVideoFile(videoFile)).toBe(true);
140
+ });
141
+
142
+ it("should return false for non-video files", () => {
143
+ const textFile = new File([""], "test.txt", { type: "text/plain" });
144
+ expect(isVideoFile(textFile)).toBe(false);
145
+ });
146
+ });
147
+
148
+ describe("isAudioFile", () => {
149
+ it("should return true for audio files", () => {
150
+ const audioFile = new File([""], "test.mp3", { type: "audio/mpeg" });
151
+ expect(isAudioFile(audioFile)).toBe(true);
152
+ });
153
+
154
+ it("should return false for non-audio files", () => {
155
+ const textFile = new File([""], "test.txt", { type: "text/plain" });
156
+ expect(isAudioFile(textFile)).toBe(false);
157
+ });
158
+ });
159
+
160
+ describe("isDocumentFile", () => {
161
+ it("should return true for PDF files", () => {
162
+ const pdfFile = new File([""], "test.pdf", { type: "application/pdf" });
163
+ expect(isDocumentFile(pdfFile)).toBe(true);
164
+ });
165
+
166
+ it("should return true for Word documents", () => {
167
+ const docFile = new File([""], "test.docx", {
168
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
169
+ });
170
+ expect(isDocumentFile(docFile)).toBe(true);
171
+ });
172
+
173
+ it("should return true for text files", () => {
174
+ const textFile = new File([""], "test.txt", { type: "text/plain" });
175
+ expect(isDocumentFile(textFile)).toBe(true);
176
+ });
177
+
178
+ it("should return false for image files", () => {
179
+ const imageFile = new File([""], "test.png", { type: "image/png" });
180
+ expect(isDocumentFile(imageFile)).toBe(false);
181
+ });
182
+ });
183
+
184
+ describe("validateFileType", () => {
185
+ it("should return true when no accept types specified", () => {
186
+ const file = new File([""], "test.txt", { type: "text/plain" });
187
+ expect(validateFileType(file, [])).toBe(true);
188
+ });
189
+
190
+ it("should validate by file extension", () => {
191
+ const file = new File([""], "test.pdf", { type: "application/pdf" });
192
+ expect(validateFileType(file, [".pdf"])).toBe(true);
193
+ expect(validateFileType(file, [".doc"])).toBe(false);
194
+ });
195
+
196
+ it("should validate by MIME type", () => {
197
+ const file = new File([""], "test.png", { type: "image/png" });
198
+ expect(validateFileType(file, ["image/png"])).toBe(true);
199
+ expect(validateFileType(file, ["image/jpeg"])).toBe(false);
200
+ });
201
+
202
+ it("should validate by MIME type wildcard", () => {
203
+ const file = new File([""], "test.png", { type: "image/png" });
204
+ expect(validateFileType(file, ["image/*"])).toBe(true);
205
+ expect(validateFileType(file, ["video/*"])).toBe(false);
206
+ });
207
+ });
208
+
209
+ describe("createFileSizeValidator", () => {
210
+ it("should pass files under the size limit", () => {
211
+ const validator = createFileSizeValidator(1024 * 1024); // 1MB
212
+ const file = new File(["x".repeat(100)], "small.txt", { type: "text/plain" });
213
+ const result = validator(file);
214
+ expect(result.valid).toBe(true);
215
+ });
216
+
217
+ it("should fail files over the size limit", () => {
218
+ const validator = createFileSizeValidator(100);
219
+ const file = new File(["x".repeat(200)], "large.txt", { type: "text/plain" });
220
+ const result = validator(file);
221
+ expect(result.valid).toBe(false);
222
+ expect(result.error).toContain("exceeds maximum");
223
+ });
224
+ });
225
+
226
+ describe("createFileTypeValidator", () => {
227
+ it("should pass files with allowed extensions", () => {
228
+ const validator = createFileTypeValidator([".pdf", ".doc"]);
229
+ const file = new File([""], "test.pdf", { type: "application/pdf" });
230
+ const result = validator(file);
231
+ expect(result.valid).toBe(true);
232
+ });
233
+
234
+ it("should pass files with allowed MIME types", () => {
235
+ const validator = createFileTypeValidator(["image/png"]);
236
+ const file = new File([""], "test.png", { type: "image/png" });
237
+ const result = validator(file);
238
+ expect(result.valid).toBe(true);
239
+ });
240
+
241
+ it("should pass files with wildcard MIME types", () => {
242
+ const validator = createFileTypeValidator(["image/*"]);
243
+ const file = new File([""], "test.png", { type: "image/png" });
244
+ const result = validator(file);
245
+ expect(result.valid).toBe(true);
246
+ });
247
+
248
+ it("should fail files with disallowed types", () => {
249
+ const validator = createFileTypeValidator([".pdf"]);
250
+ const file = new File([""], "test.txt", { type: "text/plain" });
251
+ const result = validator(file);
252
+ expect(result.valid).toBe(false);
253
+ expect(result.error).toContain("not allowed");
254
+ });
255
+ });
256
+
257
+ describe("composeValidators", () => {
258
+ it("should pass when all validators pass", () => {
259
+ const sizeValidator = createFileSizeValidator(1024 * 1024);
260
+ const typeValidator = createFileTypeValidator(["image/*"]);
261
+ const composed = composeValidators(sizeValidator, typeValidator);
262
+
263
+ const file = new File(["x".repeat(100)], "test.png", { type: "image/png" });
264
+ const result = composed(file);
265
+ expect(result.valid).toBe(true);
266
+ });
267
+
268
+ it("should fail when first validator fails", () => {
269
+ const sizeValidator = createFileSizeValidator(10);
270
+ const typeValidator = createFileTypeValidator(["image/*"]);
271
+ const composed = composeValidators(sizeValidator, typeValidator);
272
+
273
+ const file = new File(["x".repeat(100)], "test.png", { type: "image/png" });
274
+ const result = composed(file);
275
+ expect(result.valid).toBe(false);
276
+ expect(result.error).toContain("exceeds");
277
+ });
278
+
279
+ it("should fail when any validator fails", () => {
280
+ const sizeValidator = createFileSizeValidator(1024 * 1024);
281
+ const typeValidator = createFileTypeValidator([".pdf"]);
282
+ const composed = composeValidators(sizeValidator, typeValidator);
283
+
284
+ const file = new File(["x".repeat(100)], "test.png", { type: "image/png" });
285
+ const result = composed(file);
286
+ expect(result.valid).toBe(false);
287
+ expect(result.error).toContain("not allowed");
288
+ });
289
+ });
290
+
291
+ describe("generateUploadId", () => {
292
+ it("should generate unique IDs", () => {
293
+ const id1 = generateUploadId();
294
+ const id2 = generateUploadId();
295
+ expect(id1).not.toBe(id2);
296
+ });
297
+
298
+ it("should start with 'upload-' prefix", () => {
299
+ const id = generateUploadId();
300
+ expect(id.startsWith("upload-")).toBe(true);
301
+ });
302
+ });
303
+
304
+ describe("delay", () => {
305
+ it("should resolve after specified time", async () => {
306
+ const start = Date.now();
307
+ await delay(50);
308
+ const elapsed = Date.now() - start;
309
+ expect(elapsed).toBeGreaterThanOrEqual(45);
310
+ });
311
+ });
312
+
313
+ describe("calculateBackoff", () => {
314
+ it("should calculate exponential backoff", () => {
315
+ const backoff0 = calculateBackoff(0, 1000, 30000);
316
+ const backoff1 = calculateBackoff(1, 1000, 30000);
317
+ const backoff2 = calculateBackoff(2, 1000, 30000);
318
+
319
+ // Account for jitter (random addition up to 1000ms)
320
+ expect(backoff0).toBeGreaterThanOrEqual(1000);
321
+ expect(backoff0).toBeLessThan(2000);
322
+
323
+ expect(backoff1).toBeGreaterThanOrEqual(2000);
324
+ expect(backoff1).toBeLessThan(3000);
325
+
326
+ expect(backoff2).toBeGreaterThanOrEqual(4000);
327
+ expect(backoff2).toBeLessThan(5000);
328
+ });
329
+
330
+ it("should respect max delay", () => {
331
+ const backoff = calculateBackoff(10, 1000, 5000);
332
+ // With jitter, should be between 5000 and 6000
333
+ expect(backoff).toBeLessThanOrEqual(6000);
334
+ });
335
+ });
336
+
337
+ describe("createRetryWrapper", () => {
338
+ it("should succeed on first try", async () => {
339
+ const fn = vi.fn().mockResolvedValue("success");
340
+ const wrapped = createRetryWrapper(fn, 3);
341
+
342
+ const result = await wrapped();
343
+ expect(result).toBe("success");
344
+ expect(fn).toHaveBeenCalledTimes(1);
345
+ });
346
+
347
+ it("should retry on failure and eventually succeed", async () => {
348
+ const fn = vi
349
+ .fn()
350
+ .mockRejectedValueOnce(new Error("fail"))
351
+ .mockResolvedValue("success");
352
+
353
+ const wrapped = createRetryWrapper(fn, 3);
354
+
355
+ const result = await wrapped();
356
+ expect(result).toBe("success");
357
+ expect(fn).toHaveBeenCalledTimes(2);
358
+ });
359
+
360
+ it("should throw after max attempts", async () => {
361
+ const fn = vi.fn().mockRejectedValue(new Error("always fails"));
362
+ const wrapped = createRetryWrapper(fn, 2);
363
+
364
+ await expect(wrapped()).rejects.toThrow("always fails");
365
+ expect(fn).toHaveBeenCalledTimes(2);
366
+ });
367
+
368
+ it("should respect shouldRetry callback", async () => {
369
+ const fn = vi.fn().mockRejectedValue(new Error("no retry"));
370
+ const shouldRetry = vi.fn().mockReturnValue(false);
371
+ const wrapped = createRetryWrapper(fn, 3, shouldRetry);
372
+
373
+ await expect(wrapped()).rejects.toThrow("no retry");
374
+ expect(fn).toHaveBeenCalledTimes(1);
375
+ });
376
+ });
377
+
378
+ describe("isNetworkError", () => {
379
+ it("should return true for network-related errors", () => {
380
+ expect(isNetworkError(new Error("network error"))).toBe(true);
381
+ expect(isNetworkError(new Error("connection refused"))).toBe(true);
382
+ expect(isNetworkError(new Error("timeout occurred"))).toBe(true);
383
+ expect(isNetworkError(new Error("ECONNREFUSED"))).toBe(true);
384
+ expect(isNetworkError(new Error("ETIMEDOUT"))).toBe(true);
385
+ });
386
+
387
+ it("should return false for non-network errors", () => {
388
+ expect(isNetworkError(new Error("generic error"))).toBe(false);
389
+ expect(isNetworkError(new Error("validation failed"))).toBe(false);
390
+ });
391
+
392
+ it("should return false for non-Error values", () => {
393
+ expect(isNetworkError("string")).toBe(false);
394
+ expect(isNetworkError(null)).toBe(false);
395
+ expect(isNetworkError(undefined)).toBe(false);
396
+ });
397
+ });
398
+
399
+ describe("isAbortError", () => {
400
+ it("should return true for abort errors", () => {
401
+ const abortError = new Error("abort");
402
+ abortError.name = "AbortError";
403
+ expect(isAbortError(abortError)).toBe(true);
404
+ });
405
+
406
+ it("should return true for errors containing abort message", () => {
407
+ expect(isAbortError(new Error("operation aborted"))).toBe(true);
408
+ });
409
+
410
+ it("should return false for non-abort errors", () => {
411
+ expect(isAbortError(new Error("generic error"))).toBe(false);
412
+ });
413
+
414
+ it("should return false for non-Error values", () => {
415
+ expect(isAbortError("string")).toBe(false);
416
+ expect(isAbortError(null)).toBe(false);
417
+ });
418
+ });
419
+
420
+ describe("calculateProgress", () => {
421
+ it("should calculate progress percentage", () => {
422
+ expect(calculateProgress(0, 100)).toBe(0);
423
+ expect(calculateProgress(50, 100)).toBe(50);
424
+ expect(calculateProgress(100, 100)).toBe(100);
425
+ });
426
+
427
+ it("should handle zero total", () => {
428
+ expect(calculateProgress(50, 0)).toBe(0);
429
+ });
430
+
431
+ it("should clamp values between 0 and 100", () => {
432
+ expect(calculateProgress(-10, 100)).toBe(0);
433
+ expect(calculateProgress(150, 100)).toBe(100);
434
+ });
435
+
436
+ it("should round to nearest integer", () => {
437
+ expect(calculateProgress(33, 100)).toBe(33);
438
+ expect(calculateProgress(1, 3)).toBe(33);
439
+ });
440
+ });
441
+
442
+ describe("calculateMultiUploadStats", () => {
443
+ it("should calculate stats for empty uploads", () => {
444
+ const stats = calculateMultiUploadStats([]);
445
+ expect(stats.totalFiles).toBe(0);
446
+ expect(stats.completedFiles).toBe(0);
447
+ expect(stats.failedFiles).toBe(0);
448
+ expect(stats.totalBytes).toBe(0);
449
+ expect(stats.uploadedBytes).toBe(0);
450
+ expect(stats.totalProgress).toBe(0);
451
+ expect(stats.allComplete).toBe(true);
452
+ expect(stats.hasErrors).toBe(false);
453
+ });
454
+
455
+ it("should calculate stats for multiple uploads", () => {
456
+ const uploads: UploadItem[] = [
457
+ {
458
+ id: "1",
459
+ file: new File(["x".repeat(100)], "file1.txt"),
460
+ status: "success",
461
+ progress: 100,
462
+ bytesUploaded: 100,
463
+ totalBytes: 100,
464
+ },
465
+ {
466
+ id: "2",
467
+ file: new File(["x".repeat(200)], "file2.txt"),
468
+ status: "uploading",
469
+ progress: 50,
470
+ bytesUploaded: 100,
471
+ totalBytes: 200,
472
+ },
473
+ {
474
+ id: "3",
475
+ file: new File(["x".repeat(100)], "file3.txt"),
476
+ status: "error",
477
+ progress: 0,
478
+ bytesUploaded: 0,
479
+ totalBytes: 100,
480
+ error: new Error("Failed"),
481
+ },
482
+ ];
483
+
484
+ const stats = calculateMultiUploadStats(uploads);
485
+ expect(stats.totalFiles).toBe(3);
486
+ expect(stats.completedFiles).toBe(1);
487
+ expect(stats.failedFiles).toBe(1);
488
+ expect(stats.totalBytes).toBe(400);
489
+ expect(stats.uploadedBytes).toBe(200);
490
+ expect(stats.totalProgress).toBe(50);
491
+ expect(stats.allComplete).toBe(false);
492
+ expect(stats.hasErrors).toBe(true);
493
+ });
494
+
495
+ it("should report allComplete when all uploads succeed", () => {
496
+ const uploads: UploadItem[] = [
497
+ {
498
+ id: "1",
499
+ file: new File(["x".repeat(100)], "file1.txt"),
500
+ status: "success",
501
+ progress: 100,
502
+ bytesUploaded: 100,
503
+ totalBytes: 100,
504
+ },
505
+ {
506
+ id: "2",
507
+ file: new File(["x".repeat(100)], "file2.txt"),
508
+ status: "success",
509
+ progress: 100,
510
+ bytesUploaded: 100,
511
+ totalBytes: 100,
512
+ },
513
+ ];
514
+
515
+ const stats = calculateMultiUploadStats(uploads);
516
+ expect(stats.allComplete).toBe(true);
517
+ expect(stats.hasErrors).toBe(false);
518
+ });
519
+ });