@uploadista/client-core 0.0.13 → 0.0.14

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,588 @@
1
+ import type { UploadFile } from "@uploadista/core/types";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ UploadManager,
5
+ type UploadAbortController,
6
+ type UploadFunction,
7
+ type UploadManagerCallbacks,
8
+ type UploadOptions,
9
+ type UploadState,
10
+ } from "../upload-manager";
11
+
12
+ describe("UploadManager", () => {
13
+ let mockUploadFn: ReturnType<typeof vi.fn<UploadFunction>>;
14
+ let mockCallbacks: UploadManagerCallbacks;
15
+ let mockAbortController: UploadAbortController;
16
+ let stateChanges: UploadState[];
17
+
18
+ beforeEach(() => {
19
+ stateChanges = [];
20
+
21
+ mockAbortController = {
22
+ abort: vi.fn(),
23
+ };
24
+
25
+ mockUploadFn = vi.fn<UploadFunction>().mockResolvedValue(mockAbortController);
26
+
27
+ mockCallbacks = {
28
+ onStateChange: vi.fn((state) => stateChanges.push({ ...state })),
29
+ onProgress: vi.fn(),
30
+ onChunkComplete: vi.fn(),
31
+ onSuccess: vi.fn(),
32
+ onError: vi.fn(),
33
+ onAbort: vi.fn(),
34
+ };
35
+ });
36
+
37
+ describe("constructor", () => {
38
+ it("should initialize with idle state", () => {
39
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
40
+ const state = manager.getState();
41
+
42
+ expect(state).toEqual({
43
+ status: "idle",
44
+ progress: 0,
45
+ bytesUploaded: 0,
46
+ totalBytes: null,
47
+ error: null,
48
+ result: null,
49
+ });
50
+ });
51
+
52
+ it("should not call onStateChange during initialization", () => {
53
+ new UploadManager(mockUploadFn, mockCallbacks);
54
+ expect(mockCallbacks.onStateChange).not.toHaveBeenCalled();
55
+ });
56
+ });
57
+
58
+ describe("getState", () => {
59
+ it("should return a copy of the current state", () => {
60
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
61
+ const state1 = manager.getState();
62
+ const state2 = manager.getState();
63
+
64
+ expect(state1).toEqual(state2);
65
+ expect(state1).not.toBe(state2); // Different objects
66
+ });
67
+ });
68
+
69
+ describe("isUploading", () => {
70
+ it("should return false when idle", () => {
71
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
72
+ expect(manager.isUploading()).toBe(false);
73
+ });
74
+
75
+ it("should return true when uploading", async () => {
76
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
77
+ const uploadPromise = manager.upload({ size: 1000 });
78
+
79
+ expect(manager.isUploading()).toBe(true);
80
+ await uploadPromise;
81
+ });
82
+
83
+ it("should return false after upload completes", async () => {
84
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
85
+ const uploadPromise = manager.upload({ size: 1000 });
86
+
87
+ // Simulate success
88
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
89
+ uploadOptions.onSuccess?.({
90
+ id: "file-1",
91
+ offset: 1000,
92
+ storage: "s3",
93
+ } as UploadFile);
94
+
95
+ await uploadPromise;
96
+ expect(manager.isUploading()).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe("canRetry", () => {
101
+ it("should return false when idle", () => {
102
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
103
+ expect(manager.canRetry()).toBe(false);
104
+ });
105
+
106
+ it("should return false while uploading", async () => {
107
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
108
+ const uploadPromise = manager.upload({ size: 1000 });
109
+
110
+ expect(manager.canRetry()).toBe(false);
111
+ await uploadPromise;
112
+ });
113
+
114
+ it("should return true after error", async () => {
115
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
116
+ const uploadPromise = manager.upload({ size: 1000 });
117
+
118
+ // Simulate error
119
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
120
+ uploadOptions.onError?.(new Error("Upload failed"));
121
+
122
+ await uploadPromise;
123
+ expect(manager.canRetry()).toBe(true);
124
+ });
125
+
126
+ it("should return true after abort", async () => {
127
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
128
+ const uploadPromise = manager.upload({ size: 1000 });
129
+
130
+ // Simulate abort
131
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
132
+ uploadOptions.onAbort?.();
133
+
134
+ await uploadPromise;
135
+ expect(manager.canRetry()).toBe(true);
136
+ });
137
+
138
+ it("should return false after successful upload", async () => {
139
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
140
+ const uploadPromise = manager.upload({ size: 1000 });
141
+
142
+ // Simulate success
143
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
144
+ uploadOptions.onSuccess?.({
145
+ id: "file-1",
146
+ offset: 1000,
147
+ storage: "s3",
148
+ } as UploadFile);
149
+
150
+ await uploadPromise;
151
+ expect(manager.canRetry()).toBe(false);
152
+ });
153
+
154
+ it("should return false after reset", async () => {
155
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
156
+ const uploadPromise = manager.upload({ size: 1000 });
157
+
158
+ // Simulate error
159
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
160
+ uploadOptions.onError?.(new Error("Upload failed"));
161
+
162
+ await uploadPromise;
163
+ expect(manager.canRetry()).toBe(true);
164
+
165
+ manager.reset();
166
+ expect(manager.canRetry()).toBe(false);
167
+ });
168
+ });
169
+
170
+ describe("upload", () => {
171
+ it("should update state to uploading", async () => {
172
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
173
+ await manager.upload({ size: 1000 });
174
+
175
+ expect(stateChanges[0]).toMatchObject({
176
+ status: "uploading",
177
+ progress: 0,
178
+ bytesUploaded: 0,
179
+ totalBytes: 1000,
180
+ });
181
+ });
182
+
183
+ it("should extract totalBytes from File/Blob-like input", async () => {
184
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
185
+ await manager.upload({ size: 5000 });
186
+
187
+ expect(stateChanges[0].totalBytes).toBe(5000);
188
+ });
189
+
190
+ it("should call uploadFn with input and options", async () => {
191
+ const options: UploadOptions = {
192
+ metadata: { key: "value" },
193
+ };
194
+ const manager = new UploadManager(mockUploadFn, mockCallbacks, options);
195
+ const input = { size: 1000 };
196
+
197
+ await manager.upload(input);
198
+
199
+ expect(mockUploadFn).toHaveBeenCalledWith(input, expect.any(Object));
200
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
201
+ expect(uploadOptions.metadata).toEqual({ key: "value" });
202
+ });
203
+
204
+ it("should handle successful upload", async () => {
205
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
206
+ const uploadPromise = manager.upload({ size: 1000 });
207
+
208
+ // Simulate success
209
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
210
+ const result: UploadFile = {
211
+ id: "file-1",
212
+ offset: 1000,
213
+ storage: "s3",
214
+ size: 1000,
215
+ };
216
+ uploadOptions.onSuccess?.(result);
217
+
218
+ await uploadPromise;
219
+
220
+ expect(mockCallbacks.onSuccess).toHaveBeenCalledWith(result);
221
+ expect(stateChanges).toContainEqual(
222
+ expect.objectContaining({
223
+ status: "success",
224
+ result,
225
+ progress: 100,
226
+ bytesUploaded: 1000,
227
+ }),
228
+ );
229
+ });
230
+
231
+ it("should handle upload error", async () => {
232
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
233
+ const uploadPromise = manager.upload({ size: 1000 });
234
+
235
+ // Simulate error
236
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
237
+ const error = new Error("Upload failed");
238
+ uploadOptions.onError?.(error);
239
+
240
+ await uploadPromise;
241
+
242
+ expect(mockCallbacks.onError).toHaveBeenCalledWith(error);
243
+ expect(stateChanges).toContainEqual(
244
+ expect.objectContaining({
245
+ status: "error",
246
+ error,
247
+ }),
248
+ );
249
+ });
250
+
251
+ it("should handle upload abort", async () => {
252
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
253
+ const uploadPromise = manager.upload({ size: 1000 });
254
+
255
+ // Simulate abort
256
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
257
+ uploadOptions.onAbort?.();
258
+
259
+ await uploadPromise;
260
+
261
+ expect(mockCallbacks.onAbort).toHaveBeenCalled();
262
+ expect(stateChanges).toContainEqual(
263
+ expect.objectContaining({
264
+ status: "aborted",
265
+ }),
266
+ );
267
+ });
268
+
269
+ it("should track progress updates", async () => {
270
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
271
+ const uploadPromise = manager.upload({ size: 1000 });
272
+
273
+ // Simulate progress
274
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
275
+ uploadOptions.onProgress?.(0, 0, 1000);
276
+ uploadOptions.onProgress?.(500, 500, 1000);
277
+ uploadOptions.onProgress?.(1000, 1000, 1000);
278
+
279
+ await uploadPromise;
280
+
281
+ expect(mockCallbacks.onProgress).toHaveBeenCalledTimes(3);
282
+ expect(mockCallbacks.onProgress).toHaveBeenNthCalledWith(1, 0, 0, 1000);
283
+ expect(mockCallbacks.onProgress).toHaveBeenNthCalledWith(2, 50, 500, 1000);
284
+ expect(mockCallbacks.onProgress).toHaveBeenNthCalledWith(3, 100, 1000, 1000);
285
+ });
286
+
287
+ it("should handle chunk completion", async () => {
288
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
289
+ const uploadPromise = manager.upload({ size: 1000 });
290
+
291
+ // Simulate chunk completion
292
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
293
+ uploadOptions.onChunkComplete?.(256, 256, 1000);
294
+ uploadOptions.onChunkComplete?.(256, 512, 1000);
295
+
296
+ await uploadPromise;
297
+
298
+ expect(mockCallbacks.onChunkComplete).toHaveBeenCalledTimes(2);
299
+ expect(mockCallbacks.onChunkComplete).toHaveBeenNthCalledWith(
300
+ 1,
301
+ 256,
302
+ 256,
303
+ 1000,
304
+ );
305
+ expect(mockCallbacks.onChunkComplete).toHaveBeenNthCalledWith(
306
+ 2,
307
+ 256,
308
+ 512,
309
+ 1000,
310
+ );
311
+ });
312
+
313
+ it("should handle uploadFn throwing error", async () => {
314
+ mockUploadFn.mockRejectedValue(new Error("Connection failed"));
315
+
316
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
317
+ await manager.upload({ size: 1000 });
318
+
319
+ expect(mockCallbacks.onError).toHaveBeenCalledWith(
320
+ expect.objectContaining({
321
+ message: "Connection failed",
322
+ }),
323
+ );
324
+ expect(stateChanges).toContainEqual(
325
+ expect.objectContaining({
326
+ status: "error",
327
+ error: expect.any(Error),
328
+ }),
329
+ );
330
+ });
331
+
332
+ it("should handle non-Error exceptions", async () => {
333
+ mockUploadFn.mockRejectedValue("Network timeout");
334
+
335
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
336
+ await manager.upload({ size: 1000 });
337
+
338
+ expect(mockCallbacks.onError).toHaveBeenCalledWith(
339
+ expect.objectContaining({
340
+ message: "Network timeout",
341
+ }),
342
+ );
343
+ });
344
+
345
+ it("should invoke both manager and options callbacks", async () => {
346
+ const optionCallbacks: Partial<UploadOptions> = {
347
+ onProgress: vi.fn(),
348
+ onSuccess: vi.fn(),
349
+ onChunkComplete: vi.fn(),
350
+ };
351
+
352
+ const manager = new UploadManager(
353
+ mockUploadFn,
354
+ mockCallbacks,
355
+ optionCallbacks,
356
+ );
357
+ const uploadPromise = manager.upload({ size: 1000 });
358
+
359
+ // Simulate callbacks
360
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
361
+ uploadOptions.onProgress?.(500, 500, 1000);
362
+ uploadOptions.onChunkComplete?.(500, 500, 1000);
363
+ uploadOptions.onSuccess?.({
364
+ id: "file-1",
365
+ offset: 1000,
366
+ storage: "s3",
367
+ } as UploadFile);
368
+
369
+ await uploadPromise;
370
+
371
+ // Both sets of callbacks should be called
372
+ expect(mockCallbacks.onProgress).toHaveBeenCalled();
373
+ expect(optionCallbacks.onProgress).toHaveBeenCalled();
374
+ expect(mockCallbacks.onChunkComplete).toHaveBeenCalled();
375
+ expect(optionCallbacks.onChunkComplete).toHaveBeenCalled();
376
+ expect(mockCallbacks.onSuccess).toHaveBeenCalled();
377
+ expect(optionCallbacks.onSuccess).toHaveBeenCalled();
378
+ });
379
+ });
380
+
381
+ describe("abort", () => {
382
+ it("should call abort on abort controller", async () => {
383
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
384
+ await manager.upload({ size: 1000 });
385
+
386
+ manager.abort();
387
+
388
+ expect(mockAbortController.abort).toHaveBeenCalled();
389
+ });
390
+
391
+ it("should do nothing if no upload is active", () => {
392
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
393
+
394
+ expect(() => manager.abort()).not.toThrow();
395
+ expect(mockAbortController.abort).not.toHaveBeenCalled();
396
+ });
397
+ });
398
+
399
+ describe("reset", () => {
400
+ it("should reset state to idle", async () => {
401
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
402
+ const uploadPromise = manager.upload({ size: 1000 });
403
+
404
+ // Simulate error
405
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
406
+ uploadOptions.onError?.(new Error("Upload failed"));
407
+
408
+ await uploadPromise;
409
+
410
+ // Reset
411
+ stateChanges = [];
412
+ manager.reset();
413
+
414
+ expect(stateChanges[0]).toEqual({
415
+ status: "idle",
416
+ progress: 0,
417
+ bytesUploaded: 0,
418
+ totalBytes: null,
419
+ error: null,
420
+ result: null,
421
+ });
422
+ });
423
+
424
+ it("should abort active upload before resetting", async () => {
425
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
426
+ await manager.upload({ size: 1000 });
427
+
428
+ manager.reset();
429
+
430
+ expect(mockAbortController.abort).toHaveBeenCalled();
431
+ });
432
+
433
+ it("should clear lastInput so retry is not possible", async () => {
434
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
435
+ const uploadPromise = manager.upload({ size: 1000 });
436
+
437
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
438
+ uploadOptions.onError?.(new Error("Upload failed"));
439
+
440
+ await uploadPromise;
441
+ expect(manager.canRetry()).toBe(true);
442
+
443
+ manager.reset();
444
+ expect(manager.canRetry()).toBe(false);
445
+ });
446
+ });
447
+
448
+ describe("retry", () => {
449
+ it("should retry with same input after error", async () => {
450
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
451
+ const input = { size: 1000 };
452
+
453
+ // First upload fails
454
+ const uploadPromise = manager.upload(input);
455
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
456
+ uploadOptions.onError?.(new Error("Upload failed"));
457
+ await uploadPromise;
458
+
459
+ // Retry
460
+ mockUploadFn.mockClear();
461
+ manager.retry();
462
+
463
+ expect(mockUploadFn).toHaveBeenCalledWith(input, expect.any(Object));
464
+ });
465
+
466
+ it("should retry with same input after abort", async () => {
467
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
468
+ const input = { size: 1000 };
469
+
470
+ // First upload aborted
471
+ const uploadPromise = manager.upload(input);
472
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
473
+ uploadOptions.onAbort?.();
474
+ await uploadPromise;
475
+
476
+ // Retry
477
+ mockUploadFn.mockClear();
478
+ manager.retry();
479
+
480
+ expect(mockUploadFn).toHaveBeenCalledWith(input, expect.any(Object));
481
+ });
482
+
483
+ it("should do nothing if canRetry is false", () => {
484
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
485
+
486
+ manager.retry();
487
+
488
+ expect(mockUploadFn).not.toHaveBeenCalled();
489
+ });
490
+
491
+ it("should do nothing after successful upload", async () => {
492
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
493
+ const uploadPromise = manager.upload({ size: 1000 });
494
+
495
+ const uploadOptions = mockUploadFn.mock.calls[0][1];
496
+ uploadOptions.onSuccess?.({
497
+ id: "file-1",
498
+ offset: 1000,
499
+ storage: "s3",
500
+ } as UploadFile);
501
+
502
+ await uploadPromise;
503
+
504
+ mockUploadFn.mockClear();
505
+ manager.retry();
506
+
507
+ expect(mockUploadFn).not.toHaveBeenCalled();
508
+ });
509
+ });
510
+
511
+ describe("cleanup", () => {
512
+ it("should abort active upload", async () => {
513
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
514
+ await manager.upload({ size: 1000 });
515
+
516
+ manager.cleanup();
517
+
518
+ expect(mockAbortController.abort).toHaveBeenCalled();
519
+ });
520
+
521
+ it("should do nothing if no upload is active", () => {
522
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
523
+
524
+ expect(() => manager.cleanup()).not.toThrow();
525
+ });
526
+
527
+ it("should allow creating new upload after cleanup", async () => {
528
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
529
+ await manager.upload({ size: 1000 });
530
+
531
+ manager.cleanup();
532
+
533
+ // Should be able to upload again
534
+ await manager.upload({ size: 2000 });
535
+
536
+ expect(mockUploadFn).toHaveBeenCalledTimes(2);
537
+ });
538
+ });
539
+
540
+ describe("edge cases", () => {
541
+ it("should handle calling upload multiple times", async () => {
542
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
543
+
544
+ await manager.upload({ size: 1000 });
545
+ await manager.upload({ size: 2000 });
546
+
547
+ expect(mockUploadFn).toHaveBeenCalledTimes(2);
548
+ });
549
+
550
+ it("should handle abort before upload function resolves", async () => {
551
+ let resolveUpload: (value: UploadAbortController) => void;
552
+ const uploadPromise = new Promise<UploadAbortController>((resolve) => {
553
+ resolveUpload = resolve;
554
+ });
555
+ mockUploadFn.mockReturnValue(uploadPromise);
556
+
557
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
558
+ const upload = manager.upload({ size: 1000 });
559
+
560
+ // Abort before upload function resolves
561
+ manager.abort();
562
+
563
+ // Now resolve
564
+ resolveUpload!(mockAbortController);
565
+ await upload;
566
+
567
+ // Abort should have been attempted (though controller wasn't available yet)
568
+ expect(manager.getState().status).toBe("uploading");
569
+ });
570
+
571
+ it("should handle input without size property", async () => {
572
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
573
+ await manager.upload("string-input");
574
+
575
+ expect(stateChanges[0].totalBytes).toBe(null);
576
+ });
577
+
578
+ it("should handle null/undefined input", async () => {
579
+ const manager = new UploadManager(mockUploadFn, mockCallbacks);
580
+
581
+ await manager.upload(null);
582
+ expect(stateChanges[0].totalBytes).toBe(null);
583
+
584
+ await manager.upload(undefined);
585
+ expect(stateChanges[1].totalBytes).toBe(null);
586
+ });
587
+ });
588
+ });