@uploadista/server 0.0.13-beta.4 → 0.0.13

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,657 @@
1
+ /**
2
+ * Tests for HTTP Upload Handlers
3
+ *
4
+ * Covers:
5
+ * - Chunked upload processing
6
+ * - Multipart upload handling
7
+ * - Upload progress tracking
8
+ * - HTTP request/response handling
9
+ * - Error handling and status codes
10
+ * - Upload metadata validation
11
+ */
12
+
13
+ import { it } from "@effect/vitest";
14
+ import { Effect } from "effect";
15
+ import { describe, expect } from "vitest";
16
+
17
+ describe("HTTP Upload Handlers", () => {
18
+ describe("Chunked Upload Processing", () => {
19
+ it.effect("should process single chunk upload", () =>
20
+ Effect.gen(function* () {
21
+ // Mock upload service
22
+ const mockUploadService = {
23
+ processChunk: (
24
+ uploadId: string,
25
+ chunkData: Uint8Array,
26
+ chunkIndex: number,
27
+ ) =>
28
+ Effect.succeed({
29
+ uploadId,
30
+ chunkIndex,
31
+ bytesReceived: chunkData.length,
32
+ status: "in-progress" as const,
33
+ }),
34
+ };
35
+
36
+ const chunkData = new Uint8Array([1, 2, 3, 4, 5]);
37
+ const result = yield* mockUploadService.processChunk(
38
+ "upload-123",
39
+ chunkData,
40
+ 0,
41
+ );
42
+
43
+ expect(result.uploadId).toBe("upload-123");
44
+ expect(result.chunkIndex).toBe(0);
45
+ expect(result.bytesReceived).toBe(5);
46
+ expect(result.status).toBe("in-progress");
47
+ }),
48
+ );
49
+
50
+ it.effect("should process multiple chunks in sequence", () =>
51
+ Effect.gen(function* () {
52
+ const chunks: Array<{ index: number; size: number }> = [];
53
+
54
+ const mockUploadService = {
55
+ processChunk: (
56
+ uploadId: string,
57
+ chunkData: Uint8Array,
58
+ chunkIndex: number,
59
+ ) =>
60
+ Effect.sync(() => {
61
+ chunks.push({ index: chunkIndex, size: chunkData.length });
62
+ return {
63
+ uploadId,
64
+ chunkIndex,
65
+ bytesReceived: chunkData.length,
66
+ status:
67
+ chunkIndex === 2
68
+ ? ("completed" as const)
69
+ : ("in-progress" as const),
70
+ };
71
+ }),
72
+ };
73
+
74
+ // Process 3 chunks
75
+ yield* mockUploadService.processChunk(
76
+ "upload-123",
77
+ new Uint8Array(1024),
78
+ 0,
79
+ );
80
+ yield* mockUploadService.processChunk(
81
+ "upload-123",
82
+ new Uint8Array(1024),
83
+ 1,
84
+ );
85
+ const finalResult = yield* mockUploadService.processChunk(
86
+ "upload-123",
87
+ new Uint8Array(512),
88
+ 2,
89
+ );
90
+
91
+ expect(chunks).toHaveLength(3);
92
+ expect(chunks[0]?.index).toBe(0);
93
+ expect(chunks[1]?.index).toBe(1);
94
+ expect(chunks[2]?.index).toBe(2);
95
+ expect(finalResult.status).toBe("completed");
96
+ }),
97
+ );
98
+
99
+ it.effect("should handle chunk upload errors", () =>
100
+ Effect.gen(function* () {
101
+ const mockUploadService = {
102
+ processChunk: (
103
+ uploadId: string,
104
+ chunkData: Uint8Array,
105
+ chunkIndex: number,
106
+ ) =>
107
+ Effect.gen(function* () {
108
+ if (chunkIndex === 1) {
109
+ return yield* Effect.fail(new Error("Chunk processing failed"));
110
+ }
111
+ return {
112
+ uploadId,
113
+ chunkIndex,
114
+ bytesReceived: chunkData.length,
115
+ status: "in-progress" as const,
116
+ };
117
+ }),
118
+ };
119
+
120
+ // First chunk succeeds
121
+ const result1 = yield* mockUploadService.processChunk(
122
+ "upload-123",
123
+ new Uint8Array(1024),
124
+ 0,
125
+ );
126
+ expect(result1.status).toBe("in-progress");
127
+
128
+ // Second chunk fails
129
+ const result2 = yield* Effect.either(
130
+ mockUploadService.processChunk("upload-123", new Uint8Array(1024), 1),
131
+ );
132
+ expect(result2._tag).toBe("Left");
133
+ }),
134
+ );
135
+
136
+ it.effect("should validate chunk size limits", () =>
137
+ Effect.gen(function* () {
138
+ const MAX_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
139
+
140
+ const mockUploadService = {
141
+ processChunk: (
142
+ uploadId: string,
143
+ chunkData: Uint8Array,
144
+ chunkIndex: number,
145
+ ) =>
146
+ Effect.gen(function* () {
147
+ if (chunkData.length > MAX_CHUNK_SIZE) {
148
+ return yield* Effect.fail(
149
+ new Error("Chunk size exceeds maximum"),
150
+ );
151
+ }
152
+ return {
153
+ uploadId,
154
+ chunkIndex,
155
+ bytesReceived: chunkData.length,
156
+ status: "in-progress" as const,
157
+ };
158
+ }),
159
+ };
160
+
161
+ // Valid chunk size
162
+ const result1 = yield* mockUploadService.processChunk(
163
+ "upload-123",
164
+ new Uint8Array(1024 * 1024),
165
+ 0,
166
+ );
167
+ expect(result1.status).toBe("in-progress");
168
+
169
+ // Oversized chunk
170
+ const result2 = yield* Effect.either(
171
+ mockUploadService.processChunk(
172
+ "upload-123",
173
+ new Uint8Array(6 * 1024 * 1024),
174
+ 1,
175
+ ),
176
+ );
177
+ expect(result2._tag).toBe("Left");
178
+ }),
179
+ );
180
+
181
+ it.effect("should track total bytes received across chunks", () =>
182
+ Effect.gen(function* () {
183
+ let totalBytesReceived = 0;
184
+
185
+ const mockUploadService = {
186
+ processChunk: (
187
+ uploadId: string,
188
+ chunkData: Uint8Array,
189
+ chunkIndex: number,
190
+ ) =>
191
+ Effect.sync(() => {
192
+ totalBytesReceived += chunkData.length;
193
+ return {
194
+ uploadId,
195
+ chunkIndex,
196
+ bytesReceived: chunkData.length,
197
+ totalBytesReceived,
198
+ status: "in-progress" as const,
199
+ };
200
+ }),
201
+ };
202
+
203
+ yield* mockUploadService.processChunk(
204
+ "upload-123",
205
+ new Uint8Array(1024),
206
+ 0,
207
+ );
208
+ yield* mockUploadService.processChunk(
209
+ "upload-123",
210
+ new Uint8Array(2048),
211
+ 1,
212
+ );
213
+ yield* mockUploadService.processChunk(
214
+ "upload-123",
215
+ new Uint8Array(512),
216
+ 2,
217
+ );
218
+
219
+ expect(totalBytesReceived).toBe(3584); // 1024 + 2048 + 512
220
+ }),
221
+ );
222
+ });
223
+
224
+ describe("Multipart Upload Handling", () => {
225
+ it.effect("should initiate multipart upload", () =>
226
+ Effect.gen(function* () {
227
+ const mockUploadService = {
228
+ initiateMultipartUpload: (
229
+ fileName: string,
230
+ fileSize: number,
231
+ contentType: string,
232
+ ) =>
233
+ Effect.succeed({
234
+ uploadId: "multipart-123",
235
+ fileName,
236
+ fileSize,
237
+ contentType,
238
+ status: "initiated" as const,
239
+ parts: [] as Array<{ partNumber: number; etag: string }>,
240
+ }),
241
+ };
242
+
243
+ const result = yield* mockUploadService.initiateMultipartUpload(
244
+ "large-file.bin",
245
+ 100 * 1024 * 1024, // 100MB
246
+ "application/octet-stream",
247
+ );
248
+
249
+ expect(result.uploadId).toBe("multipart-123");
250
+ expect(result.fileName).toBe("large-file.bin");
251
+ expect(result.fileSize).toBe(100 * 1024 * 1024);
252
+ expect(result.status).toBe("initiated");
253
+ }),
254
+ );
255
+
256
+ it.effect("should upload multipart parts", () =>
257
+ Effect.gen(function* () {
258
+ const uploadedParts: Array<{ partNumber: number; size: number }> = [];
259
+
260
+ const mockUploadService = {
261
+ uploadPart: (
262
+ uploadId: string,
263
+ partNumber: number,
264
+ data: Uint8Array,
265
+ ) =>
266
+ Effect.sync(() => {
267
+ uploadedParts.push({ partNumber, size: data.length });
268
+ return {
269
+ uploadId,
270
+ partNumber,
271
+ etag: `etag-${partNumber}`,
272
+ size: data.length,
273
+ };
274
+ }),
275
+ };
276
+
277
+ // Upload 3 parts
278
+ yield* mockUploadService.uploadPart(
279
+ "multipart-123",
280
+ 1,
281
+ new Uint8Array(5 * 1024 * 1024),
282
+ );
283
+ yield* mockUploadService.uploadPart(
284
+ "multipart-123",
285
+ 2,
286
+ new Uint8Array(5 * 1024 * 1024),
287
+ );
288
+ yield* mockUploadService.uploadPart(
289
+ "multipart-123",
290
+ 3,
291
+ new Uint8Array(2 * 1024 * 1024),
292
+ );
293
+
294
+ expect(uploadedParts).toHaveLength(3);
295
+ expect(uploadedParts[0]?.partNumber).toBe(1);
296
+ expect(uploadedParts[1]?.partNumber).toBe(2);
297
+ expect(uploadedParts[2]?.partNumber).toBe(3);
298
+ }),
299
+ );
300
+
301
+ it.effect("should complete multipart upload", () =>
302
+ Effect.gen(function* () {
303
+ const mockUploadService = {
304
+ completeMultipartUpload: (
305
+ uploadId: string,
306
+ parts: Array<{ partNumber: number; etag: string }>,
307
+ ) =>
308
+ Effect.succeed({
309
+ uploadId,
310
+ status: "completed" as const,
311
+ totalParts: parts.length,
312
+ location: `https://storage.example.com/files/${uploadId}`,
313
+ }),
314
+ };
315
+
316
+ const parts = [
317
+ { partNumber: 1, etag: "etag-1" },
318
+ { partNumber: 2, etag: "etag-2" },
319
+ { partNumber: 3, etag: "etag-3" },
320
+ ];
321
+
322
+ const result = yield* mockUploadService.completeMultipartUpload(
323
+ "multipart-123",
324
+ parts,
325
+ );
326
+
327
+ expect(result.uploadId).toBe("multipart-123");
328
+ expect(result.status).toBe("completed");
329
+ expect(result.totalParts).toBe(3);
330
+ expect(result.location).toContain("multipart-123");
331
+ }),
332
+ );
333
+
334
+ it.effect("should abort multipart upload", () =>
335
+ Effect.gen(function* () {
336
+ const mockUploadService = {
337
+ abortMultipartUpload: (uploadId: string, reason: string) =>
338
+ Effect.succeed({
339
+ uploadId,
340
+ status: "aborted" as const,
341
+ reason,
342
+ timestamp: Date.now(),
343
+ }),
344
+ };
345
+
346
+ const result = yield* mockUploadService.abortMultipartUpload(
347
+ "multipart-123",
348
+ "User cancelled upload",
349
+ );
350
+
351
+ expect(result.uploadId).toBe("multipart-123");
352
+ expect(result.status).toBe("aborted");
353
+ expect(result.reason).toBe("User cancelled upload");
354
+ }),
355
+ );
356
+
357
+ it.effect("should handle multipart upload errors", () =>
358
+ Effect.gen(function* () {
359
+ const mockUploadService = {
360
+ uploadPart: (
361
+ uploadId: string,
362
+ partNumber: number,
363
+ data: Uint8Array,
364
+ ) =>
365
+ Effect.gen(function* () {
366
+ if (partNumber === 2) {
367
+ return yield* Effect.fail(new Error("Part 2 upload failed"));
368
+ }
369
+ return {
370
+ uploadId,
371
+ partNumber,
372
+ etag: `etag-${partNumber}`,
373
+ size: data.length,
374
+ };
375
+ }),
376
+ };
377
+
378
+ // Part 1 succeeds
379
+ const result1 = yield* mockUploadService.uploadPart(
380
+ "multipart-123",
381
+ 1,
382
+ new Uint8Array(5 * 1024 * 1024),
383
+ );
384
+ expect(result1.partNumber).toBe(1);
385
+
386
+ // Part 2 fails
387
+ const result2 = yield* Effect.either(
388
+ mockUploadService.uploadPart(
389
+ "multipart-123",
390
+ 2,
391
+ new Uint8Array(5 * 1024 * 1024),
392
+ ),
393
+ );
394
+ expect(result2._tag).toBe("Left");
395
+ }),
396
+ );
397
+ });
398
+
399
+ describe("Upload Progress Tracking", () => {
400
+ it.effect("should report upload progress", () =>
401
+ Effect.gen(function* () {
402
+ const progressEvents: Array<{
403
+ bytesUploaded: number;
404
+ percentage: number;
405
+ }> = [];
406
+
407
+ const mockUploadService = {
408
+ trackProgress: (
409
+ uploadId: string,
410
+ bytesUploaded: number,
411
+ totalBytes: number,
412
+ ) =>
413
+ Effect.sync(() => {
414
+ const percentage = Math.round((bytesUploaded / totalBytes) * 100);
415
+ progressEvents.push({ bytesUploaded, percentage });
416
+ return {
417
+ uploadId,
418
+ bytesUploaded,
419
+ totalBytes,
420
+ percentage,
421
+ };
422
+ }),
423
+ };
424
+
425
+ const totalBytes = 10 * 1024 * 1024; // 10MB
426
+
427
+ yield* mockUploadService.trackProgress(
428
+ "upload-123",
429
+ 2 * 1024 * 1024,
430
+ totalBytes,
431
+ ); // 20%
432
+ yield* mockUploadService.trackProgress(
433
+ "upload-123",
434
+ 5 * 1024 * 1024,
435
+ totalBytes,
436
+ ); // 50%
437
+ yield* mockUploadService.trackProgress(
438
+ "upload-123",
439
+ 8 * 1024 * 1024,
440
+ totalBytes,
441
+ ); // 80%
442
+ yield* mockUploadService.trackProgress(
443
+ "upload-123",
444
+ 10 * 1024 * 1024,
445
+ totalBytes,
446
+ ); // 100%
447
+
448
+ expect(progressEvents).toHaveLength(4);
449
+ expect(progressEvents[0]?.percentage).toBe(20);
450
+ expect(progressEvents[1]?.percentage).toBe(50);
451
+ expect(progressEvents[2]?.percentage).toBe(80);
452
+ expect(progressEvents[3]?.percentage).toBe(100);
453
+ }),
454
+ );
455
+
456
+ it.effect("should calculate upload speed", () =>
457
+ Effect.gen(function* () {
458
+ const mockUploadService = {
459
+ calculateSpeed: (bytesUploaded: number, elapsedSeconds: number) =>
460
+ Effect.succeed({
461
+ bytesPerSecond: Math.floor(bytesUploaded / elapsedSeconds),
462
+ mbps: parseFloat(
463
+ ((bytesUploaded / elapsedSeconds / 1024 / 1024) * 8).toFixed(2),
464
+ ),
465
+ }),
466
+ };
467
+
468
+ // 5MB in 2 seconds
469
+ const result = yield* mockUploadService.calculateSpeed(
470
+ 5 * 1024 * 1024,
471
+ 2,
472
+ );
473
+
474
+ expect(result.bytesPerSecond).toBeGreaterThan(0);
475
+ expect(result.mbps).toBeGreaterThan(0);
476
+ }),
477
+ );
478
+
479
+ it.effect("should estimate time remaining", () =>
480
+ Effect.gen(function* () {
481
+ const mockUploadService = {
482
+ estimateTimeRemaining: (
483
+ bytesUploaded: number,
484
+ totalBytes: number,
485
+ bytesPerSecond: number,
486
+ ) =>
487
+ Effect.succeed({
488
+ bytesRemaining: totalBytes - bytesUploaded,
489
+ secondsRemaining: Math.ceil(
490
+ (totalBytes - bytesUploaded) / bytesPerSecond,
491
+ ),
492
+ }),
493
+ };
494
+
495
+ // 3MB uploaded out of 10MB, at 1MB/s
496
+ const result = yield* mockUploadService.estimateTimeRemaining(
497
+ 3 * 1024 * 1024,
498
+ 10 * 1024 * 1024,
499
+ 1024 * 1024,
500
+ );
501
+
502
+ expect(result.bytesRemaining).toBe(7 * 1024 * 1024);
503
+ expect(result.secondsRemaining).toBe(7);
504
+ }),
505
+ );
506
+ });
507
+
508
+ describe("HTTP Status Codes", () => {
509
+ it.effect("should return 200 OK for successful upload", () =>
510
+ Effect.gen(function* () {
511
+ const mockHandler = {
512
+ handleUpload: () =>
513
+ Effect.succeed({
514
+ statusCode: 200,
515
+ body: { success: true, uploadId: "upload-123" },
516
+ }),
517
+ };
518
+
519
+ const result = yield* mockHandler.handleUpload();
520
+ expect(result.statusCode).toBe(200);
521
+ expect(result.body.success).toBe(true);
522
+ }),
523
+ );
524
+
525
+ it.effect("should return 400 Bad Request for invalid input", () =>
526
+ Effect.gen(function* () {
527
+ const mockHandler = {
528
+ handleUpload: (fileSize: number) =>
529
+ Effect.succeed(
530
+ fileSize <= 0
531
+ ? {
532
+ statusCode: 400,
533
+ body: { error: "Invalid file size" },
534
+ }
535
+ : {
536
+ statusCode: 200,
537
+ body: { success: true },
538
+ },
539
+ ),
540
+ };
541
+
542
+ const result = yield* mockHandler.handleUpload(0);
543
+ expect(result.statusCode).toBe(400);
544
+ expect(result.body.error).toBe("Invalid file size");
545
+ }),
546
+ );
547
+
548
+ it.effect("should return 413 Payload Too Large for oversized uploads", () =>
549
+ Effect.gen(function* () {
550
+ const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
551
+
552
+ const mockHandler = {
553
+ handleUpload: (fileSize: number) =>
554
+ Effect.succeed(
555
+ fileSize > MAX_FILE_SIZE
556
+ ? {
557
+ statusCode: 413,
558
+ body: { error: "File too large" },
559
+ }
560
+ : {
561
+ statusCode: 200,
562
+ body: { success: true },
563
+ },
564
+ ),
565
+ };
566
+
567
+ const result = yield* mockHandler.handleUpload(200 * 1024 * 1024);
568
+ expect(result.statusCode).toBe(413);
569
+ }),
570
+ );
571
+
572
+ it.effect(
573
+ "should return 500 Internal Server Error for server failures",
574
+ () =>
575
+ Effect.gen(function* () {
576
+ const mockHandler = {
577
+ handleUpload: () =>
578
+ Effect.succeed({
579
+ statusCode: 500,
580
+ body: { error: "Internal server error" },
581
+ }),
582
+ };
583
+
584
+ const result = yield* mockHandler.handleUpload();
585
+ expect(result.statusCode).toBe(500);
586
+ }),
587
+ );
588
+ });
589
+
590
+ describe("Upload Metadata Validation", () => {
591
+ it.effect("should validate required metadata fields", () =>
592
+ Effect.gen(function* () {
593
+ const mockValidator = {
594
+ validateMetadata: (metadata: Record<string, unknown>) =>
595
+ Effect.gen(function* () {
596
+ const requiredFields = ["fileName", "fileSize", "contentType"];
597
+ const missingFields = requiredFields.filter(
598
+ (field) => !(field in metadata),
599
+ );
600
+
601
+ if (missingFields.length > 0) {
602
+ return yield* Effect.fail(
603
+ new Error(
604
+ `Missing required fields: ${missingFields.join(", ")}`,
605
+ ),
606
+ );
607
+ }
608
+
609
+ return { valid: true };
610
+ }),
611
+ };
612
+
613
+ // Valid metadata
614
+ const result1 = yield* mockValidator.validateMetadata({
615
+ fileName: "test.txt",
616
+ fileSize: 1024,
617
+ contentType: "text/plain",
618
+ });
619
+ expect(result1.valid).toBe(true);
620
+
621
+ // Missing fields
622
+ const result2 = yield* Effect.either(
623
+ mockValidator.validateMetadata({
624
+ fileName: "test.txt",
625
+ }),
626
+ );
627
+ expect(result2._tag).toBe("Left");
628
+ }),
629
+ );
630
+
631
+ it.effect("should validate file type restrictions", () =>
632
+ Effect.gen(function* () {
633
+ const allowedTypes = ["image/jpeg", "image/png", "image/gif"];
634
+
635
+ const mockValidator = {
636
+ validateFileType: (contentType: string) =>
637
+ Effect.gen(function* () {
638
+ if (!allowedTypes.includes(contentType)) {
639
+ return yield* Effect.fail(new Error("File type not allowed"));
640
+ }
641
+ return { valid: true };
642
+ }),
643
+ };
644
+
645
+ // Allowed type
646
+ const result1 = yield* mockValidator.validateFileType("image/jpeg");
647
+ expect(result1.valid).toBe(true);
648
+
649
+ // Disallowed type
650
+ const result2 = yield* Effect.either(
651
+ mockValidator.validateFileType("application/pdf"),
652
+ );
653
+ expect(result2._tag).toBe("Left");
654
+ }),
655
+ );
656
+ });
657
+ });