@geekmidas/storage 0.1.0 → 1.0.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.
@@ -2,509 +2,509 @@ import { S3Client } from '@aws-sdk/client-s3';
2
2
  import { InMemoryCache } from '@geekmidas/cache/memory';
3
3
  import { beforeAll, describe, expect, it } from 'vitest';
4
4
  import {
5
- AmazonCannedAccessControlList,
6
- AmazonStorageClient,
5
+ AmazonCannedAccessControlList,
6
+ AmazonStorageClient,
7
7
  } from '../AmazonStorageClient';
8
8
  import { StorageProvider } from '../StorageClient';
9
9
 
10
10
  describe('AmazonStorageClient Integration Tests', () => {
11
- let client: AmazonStorageClient;
12
- const testBucket = 'geekmidas';
13
- const testKey = 'test-files/test.txt';
14
- const testContent = 'Hello, World!';
15
- const testContentType = 'text/plain';
16
-
17
- beforeAll(async () => {
18
- // Create storage client pointing to MinIO
19
- client = AmazonStorageClient.create({
20
- bucket: testBucket,
21
- region: 'us-east-1',
22
- accessKeyId: 'geekmidas',
23
- secretAccessKey: 'geekmidas',
24
- endpoint: 'http://localhost:9000',
25
- acl: AmazonCannedAccessControlList.PublicRead,
26
- forcePathStyle: true,
27
- });
28
-
29
- // Wait for MinIO to be ready
30
- await new Promise((resolve) => setTimeout(resolve, 2000));
31
- });
32
-
33
- describe('provider property', () => {
34
- it('should return AWS S3 provider', () => {
35
- expect(client.provider).toBe(StorageProvider.AWSS3);
36
- });
37
- });
38
-
39
- describe('upload and download workflow', () => {
40
- it.skip('should upload a file directly and generate download URL', async () => {
41
- // Upload file directly
42
- await client.upload(testKey, testContent, testContentType);
43
-
44
- // Generate download URL
45
- const downloadUrl = await client.getDownloadURL({ path: testKey });
46
-
47
- expect(downloadUrl).toMatch(
48
- /^http:\/\/localhost:9000\/geekmidas\/test-files\/test\.txt/,
49
- );
50
- expect(downloadUrl).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256');
51
- expect(downloadUrl).toContain('X-Amz-Credential=');
52
- expect(downloadUrl).toContain('X-Amz-Date=');
53
- expect(downloadUrl).toContain('X-Amz-SignedHeaders=');
54
- expect(downloadUrl).toContain('X-Amz-Signature=');
55
-
56
- // Verify file can be downloaded
57
- const response = await fetch(downloadUrl);
58
- expect(response.ok).toBe(true);
59
-
60
- const downloadedContent = await response.text();
61
- expect(downloadedContent).toBe(testContent);
62
- });
63
-
64
- it('should upload a file with filename and generate download URL with content disposition', async () => {
65
- const keyWithName = 'test-files/named-file.txt';
66
- const fileName = 'my-document.txt';
67
-
68
- await client.upload(keyWithName, testContent, testContentType);
69
-
70
- const downloadUrl = await client.getDownloadURL({
71
- path: keyWithName,
72
- name: fileName,
73
- });
74
-
75
- expect(downloadUrl).toContain(
76
- 'response-content-disposition=attachment%3B%20filename%3Dmy-document.txt',
77
- );
78
- });
79
-
80
- it('should handle binary data upload', async () => {
81
- const binaryKey = 'test-files/binary.bin';
82
- const binaryData = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" in bytes
83
-
84
- await client.upload(binaryKey, binaryData, 'application/octet-stream');
85
-
86
- const downloadUrl = await client.getDownloadURL({ path: binaryKey });
87
- const response = await fetch(downloadUrl);
88
- const downloadedBuffer = await response.arrayBuffer();
89
-
90
- expect(new Uint8Array(downloadedBuffer)).toEqual(
91
- new Uint8Array(binaryData),
92
- );
93
- });
94
- });
95
-
96
- describe('presigned upload URLs', () => {
97
- it('should generate presigned PUT upload URL', async () => {
98
- const uploadKey = 'test-files/presigned-upload.txt';
99
- const uploadUrl = await client.getUploadURL({
100
- path: uploadKey,
101
- contentType: testContentType,
102
- contentLength: testContent.length,
103
- });
104
-
105
- expect(uploadUrl).toMatch(
106
- /^http:\/\/localhost:9000\/geekmidas\/test-files\/presigned-upload\.txt/,
107
- );
108
- expect(uploadUrl).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256');
109
-
110
- // Test actual upload using the presigned URL
111
- const uploadResponse = await fetch(uploadUrl, {
112
- method: 'PUT',
113
- headers: {
114
- 'Content-Type': testContentType,
115
- 'Content-Length': testContent.length.toString(),
116
- },
117
- body: testContent,
118
- });
119
-
120
- expect(uploadResponse.ok).toBe(true);
121
-
122
- // Verify file was uploaded
123
- const downloadUrl = await client.getDownloadURL({ path: uploadKey });
124
- const downloadResponse = await fetch(downloadUrl);
125
- const downloadedContent = await downloadResponse.text();
126
- expect(downloadedContent).toBe(testContent);
127
- });
128
-
129
- it('should generate presigned POST upload with form fields', async () => {
130
- const uploadKey = 'test-files/presigned-post.txt';
131
- const uploadData = await client.getUpload({
132
- path: uploadKey,
133
- contentType: testContentType,
134
- contentLength: testContent.length,
135
- });
136
-
137
- expect(uploadData.url).toBe('http://localhost:9000/geekmidas');
138
- expect(uploadData.fields).toContainEqual({
139
- key: 'key',
140
- value: uploadKey,
141
- });
142
- expect(uploadData.fields).toContainEqual({
143
- key: 'acl',
144
- value: 'public-read',
145
- });
146
-
147
- // Test actual upload using the presigned POST
148
- const formData = new FormData();
149
- uploadData.fields.forEach(({ key, value }) => {
150
- formData.append(key, value);
151
- });
152
- formData.append(
153
- 'file',
154
- new Blob([testContent], { type: testContentType }),
155
- );
156
-
157
- const uploadResponse = await fetch(uploadData.url, {
158
- method: 'POST',
159
- body: formData,
160
- });
161
-
162
- expect(uploadResponse.ok).toBe(true);
163
-
164
- // Verify file was uploaded
165
- const downloadUrl = await client.getDownloadURL({ path: uploadKey });
166
- const downloadResponse = await fetch(downloadUrl);
167
- const downloadedContent = await downloadResponse.blob();
168
- expect(downloadedContent).toEqual(
169
- new Blob([testContent], { type: testContentType }),
170
- );
171
- });
172
- });
173
-
174
- describe('versioning', () => {
175
- it('should handle file versions', async () => {
176
- const versionKey = 'test-files/versioned.txt';
177
- const content1 = 'Version 1';
178
- const content2 = 'Version 2';
179
-
180
- // Upload first version
181
- await client.upload(versionKey, content1, testContentType);
182
-
183
- // Upload second version
184
- await client.upload(versionKey, content2, testContentType);
185
-
186
- // Get versions - MinIO might not enable versioning by default
187
- // This test verifies the method works even if versioning is not enabled
188
- const versions = await client.getVersions(versionKey);
189
- expect(Array.isArray(versions)).toBe(true);
190
-
191
- // If versioning is enabled, we should have multiple versions
192
- // If not, we should have at least the current version
193
- if (versions.length > 0) {
194
- expect(versions[0]).toHaveProperty('id');
195
- expect(versions[0]).toHaveProperty('createdAt');
196
- expect(versions[0].createdAt).toBeInstanceOf(Date);
197
- }
198
- });
199
- });
200
-
201
- describe('error handling', () => {
202
- it('should handle download URL generation for non-existent files', async () => {
203
- // This should not throw - presigned URLs can be generated for non-existent files
204
- const downloadUrl = await client.getDownloadURL({
205
- path: 'non-existent/file.txt',
206
- });
207
- expect(downloadUrl).toMatch(
208
- /^http:\/\/localhost:9000\/geekmidas\/non-existent\/file\.txt/,
209
- );
210
-
211
- // But accessing the URL should return 404
212
- const response = await fetch(downloadUrl);
213
- expect(response.status).toBe(404);
214
- });
215
- });
216
-
217
- describe('custom expiration times', () => {
218
- it('should respect custom expiration for download URLs', async () => {
219
- const key = 'test-files/expiration-test.txt';
220
- await client.upload(key, testContent, testContentType);
221
-
222
- const shortExpirationUrl = await client.getDownloadURL({ path: key }, 1); // 1 second
223
- const longExpirationUrl = await client.getDownloadURL(
224
- { path: key },
225
- 3600,
226
- ); // 1 hour
227
-
228
- expect(shortExpirationUrl).toContain('X-Amz-Expires=1');
229
- expect(longExpirationUrl).toContain('X-Amz-Expires=3600');
230
- });
231
-
232
- it('should respect custom expiration for upload URLs', async () => {
233
- const key = 'test-files/upload-expiration-test.txt';
234
-
235
- const shortExpirationUrl = await client.getUploadURL(
236
- {
237
- path: key,
238
- contentType: testContentType,
239
- contentLength: testContent.length,
240
- },
241
- 1,
242
- ); // 1 second
243
-
244
- const longExpirationUrl = await client.getUploadURL(
245
- {
246
- path: key,
247
- contentType: testContentType,
248
- contentLength: testContent.length,
249
- },
250
- 3600,
251
- ); // 1 hour
252
-
253
- expect(shortExpirationUrl).toContain('X-Amz-Expires=1');
254
- expect(longExpirationUrl).toContain('X-Amz-Expires=3600');
255
- });
256
- });
257
-
258
- describe('factory method', () => {
259
- it('should create client with minimal configuration', () => {
260
- const minimalClient = AmazonStorageClient.create({
261
- bucket: 'geekmidas',
262
- });
263
-
264
- expect(minimalClient).toBeInstanceOf(AmazonStorageClient);
265
- expect(minimalClient.provider).toBe(StorageProvider.AWSS3);
266
- });
267
-
268
- it('should create client with full configuration', () => {
269
- const fullClient = AmazonStorageClient.create({
270
- bucket: 'geekmidas',
271
- region: 'us-west-2',
272
- accessKeyId: 'test-key',
273
- secretAccessKey: 'test-secret',
274
- endpoint: 'https://custom-s3.example.com',
275
- acl: AmazonCannedAccessControlList.Private,
276
- });
277
-
278
- expect(fullClient).toBeInstanceOf(AmazonStorageClient);
279
- expect(fullClient.provider).toBe(StorageProvider.AWSS3);
280
- });
281
- });
282
-
283
- describe('different ACL configurations', () => {
284
- it('should work with different ACL settings', async () => {
285
- const privateClient = AmazonStorageClient.create({
286
- bucket: testBucket,
287
- region: 'us-east-1',
288
- accessKeyId: 'minioadmin',
289
- secretAccessKey: 'minioadmin',
290
- endpoint: 'http://localhost:9000',
291
- acl: AmazonCannedAccessControlList.Private,
292
- });
293
-
294
- const uploadData = await privateClient.getUpload({
295
- path: 'test-files/private.txt',
296
- contentType: testContentType,
297
- contentLength: testContent.length,
298
- });
299
-
300
- expect(uploadData.fields).toContainEqual({
301
- key: 'acl',
302
- value: 'private',
303
- });
304
- });
305
- });
306
-
307
- describe('S3Client integration', () => {
308
- it('should work with externally created S3Client', () => {
309
- const s3Client = new S3Client({
310
- region: 'us-east-1',
311
- credentials: {
312
- accessKeyId: 'minioadmin',
313
- secretAccessKey: 'minioadmin',
314
- },
315
- endpoint: 'http://localhost:9000',
316
- });
317
-
318
- const storageClient = new AmazonStorageClient(
319
- s3Client,
320
- testBucket,
321
- AmazonCannedAccessControlList.PublicRead,
322
- );
323
-
324
- expect(storageClient).toBeInstanceOf(AmazonStorageClient);
325
- expect(storageClient.provider).toBe(StorageProvider.AWSS3);
326
- });
327
- });
328
-
329
- describe('cache functionality', () => {
330
- it('should cache download URLs', async () => {
331
- const cache = new InMemoryCache<string>();
332
- const clientWithCache = AmazonStorageClient.create({
333
- bucket: testBucket,
334
- region: 'us-east-1',
335
- accessKeyId: 'geekmidas',
336
- secretAccessKey: 'geekmidas',
337
- endpoint: 'http://localhost:9000',
338
- forcePathStyle: true,
339
- cache,
340
- });
341
-
342
- const key = 'test-files/cache-test.txt';
343
- await clientWithCache.upload(key, testContent, testContentType);
344
-
345
- // First call should generate and cache the URL
346
- const url1 = await clientWithCache.getDownloadURL({ path: key });
347
-
348
- // Second call should return the cached URL
349
- const url2 = await clientWithCache.getDownloadURL({ path: key });
350
-
351
- // URLs should be identical (same cached value)
352
- expect(url1).toBe(url2);
353
-
354
- // Verify the URL was cached
355
- const cacheKey = `download-url:${key}`;
356
- const cachedUrl = await cache.get(cacheKey);
357
- expect(cachedUrl).toBe(url1);
358
- });
359
-
360
- it('should respect cache expiration based on URL expiry', async () => {
361
- const cache = new InMemoryCache<string>();
362
- const clientWithCache = AmazonStorageClient.create({
363
- bucket: testBucket,
364
- region: 'us-east-1',
365
- accessKeyId: 'geekmidas',
366
- secretAccessKey: 'geekmidas',
367
- endpoint: 'http://localhost:9000',
368
- forcePathStyle: true,
369
- cache,
370
- });
371
-
372
- const key = 'test-files/cache-expiry.txt';
373
- await clientWithCache.upload(key, testContent, testContentType);
374
-
375
- // Generate URL with short expiry that won't be cached
376
- await clientWithCache.getDownloadURL({ path: key }, 30); // 30 seconds
377
-
378
- // Check cache doesn't contain the URL (too short to cache)
379
- const cacheKey = `download-url:${key}`;
380
- const cachedShortUrl = await cache.get(cacheKey);
381
- expect(cachedShortUrl).toBeUndefined();
382
-
383
- // Generate URL with longer expiry that will be cached
384
- const longExpiryUrl = await clientWithCache.getDownloadURL(
385
- { path: key },
386
- 3600,
387
- ); // 1 hour
388
-
389
- // Check cache contains the URL
390
- const cachedLongUrl = await cache.get(cacheKey);
391
- expect(cachedLongUrl).toBe(longExpiryUrl);
392
- });
393
-
394
- it('should use cached URL when available', async () => {
395
- const cache = new InMemoryCache<string>();
396
- const clientWithCache = AmazonStorageClient.create({
397
- bucket: testBucket,
398
- region: 'us-east-1',
399
- accessKeyId: 'geekmidas',
400
- secretAccessKey: 'geekmidas',
401
- endpoint: 'http://localhost:9000',
402
- forcePathStyle: true,
403
- cache,
404
- });
405
-
406
- const key = 'test-files/pre-cached.txt';
407
- const cachedUrl = 'https://pre-cached-url.example.com';
408
- const cacheKey = `download-url:${key}`;
409
-
410
- // Pre-populate cache
411
- await cache.set(cacheKey, cachedUrl);
412
-
413
- // Get download URL should return cached value
414
- const url = await clientWithCache.getDownloadURL({ path: key });
415
- expect(url).toBe(cachedUrl);
416
- });
417
-
418
- it('should work without cache', async () => {
419
- // Client without cache should work normally
420
- const clientNoCache = AmazonStorageClient.create({
421
- bucket: testBucket,
422
- region: 'us-east-1',
423
- accessKeyId: 'geekmidas',
424
- secretAccessKey: 'geekmidas',
425
- endpoint: 'http://localhost:9000',
426
- forcePathStyle: true,
427
- });
428
-
429
- const key = 'test-files/no-cache.txt';
430
- await clientNoCache.upload(key, testContent, testContentType);
431
-
432
- const url1 = await clientNoCache.getDownloadURL({ path: key });
433
- const url2 = await clientNoCache.getDownloadURL({ path: key });
434
-
435
- // Without cache, new URLs might be generated each time
436
- // Both should be valid URLs
437
- expect(url1).toMatch(
438
- /^http:\/\/localhost:9000\/geekmidas\/test-files\/no-cache\.txt/,
439
- );
440
- expect(url2).toMatch(
441
- /^http:\/\/localhost:9000\/geekmidas\/test-files\/no-cache\.txt/,
442
- );
443
- });
444
-
445
- it('should pass cache to constructor', () => {
446
- const cache = new InMemoryCache<string>();
447
- const s3Client = new S3Client({
448
- region: 'us-east-1',
449
- credentials: {
450
- accessKeyId: 'geekmidas',
451
- secretAccessKey: 'geekmidas',
452
- },
453
- endpoint: 'http://localhost:9000',
454
- forcePathStyle: true,
455
- });
456
-
457
- const clientWithCache = new AmazonStorageClient(
458
- s3Client,
459
- testBucket,
460
- AmazonCannedAccessControlList.PublicRead,
461
- cache,
462
- );
463
-
464
- expect(clientWithCache.cache).toBe(cache);
465
- });
466
- });
467
-
468
- describe('getVersionDownloadURL', () => {
469
- it('should generate version-specific download URL', async () => {
470
- const versionKey = 'test-files/version-download.txt';
471
- const versionId = 'test-version-id';
472
-
473
- // Upload a file first
474
- await client.upload(versionKey, testContent, testContentType);
475
-
476
- // Generate version-specific download URL
477
- const downloadUrl = await client.getVersionDownloadURL(
478
- { path: versionKey, name: 'versioned-file.txt' },
479
- versionId,
480
- );
481
-
482
- expect(downloadUrl).toMatch(
483
- /^http:\/\/localhost:9000\/geekmidas\/test-files\/version-download\.txt/,
484
- );
485
- expect(downloadUrl).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256');
486
- expect(downloadUrl).toContain(
487
- 'response-content-disposition=attachment%3B%20filename%3Dversioned-file.txt',
488
- );
489
- });
490
-
491
- it('should generate version URL without filename', async () => {
492
- const versionKey = 'test-files/version-no-name.txt';
493
- const versionId = 'test-version-id';
494
-
495
- // Upload a file first
496
- await client.upload(versionKey, testContent, testContentType);
497
-
498
- // Generate version-specific download URL without filename
499
- const downloadUrl = await client.getVersionDownloadURL(
500
- { path: versionKey },
501
- versionId,
502
- );
503
-
504
- expect(downloadUrl).toMatch(
505
- /^http:\/\/localhost:9000\/geekmidas\/test-files\/version-no-name\.txt/,
506
- );
507
- expect(downloadUrl).not.toContain('response-content-disposition');
508
- });
509
- });
11
+ let client: AmazonStorageClient;
12
+ const testBucket = 'geekmidas';
13
+ const testKey = 'test-files/test.txt';
14
+ const testContent = 'Hello, World!';
15
+ const testContentType = 'text/plain';
16
+
17
+ beforeAll(async () => {
18
+ // Create storage client pointing to MinIO
19
+ client = AmazonStorageClient.create({
20
+ bucket: testBucket,
21
+ region: 'us-east-1',
22
+ accessKeyId: 'geekmidas',
23
+ secretAccessKey: 'geekmidas',
24
+ endpoint: 'http://localhost:9000',
25
+ acl: AmazonCannedAccessControlList.PublicRead,
26
+ forcePathStyle: true,
27
+ });
28
+
29
+ // Wait for MinIO to be ready
30
+ await new Promise((resolve) => setTimeout(resolve, 2000));
31
+ });
32
+
33
+ describe('provider property', () => {
34
+ it('should return AWS S3 provider', () => {
35
+ expect(client.provider).toBe(StorageProvider.AWSS3);
36
+ });
37
+ });
38
+
39
+ describe('upload and download workflow', () => {
40
+ it.skip('should upload a file directly and generate download URL', async () => {
41
+ // Upload file directly
42
+ await client.upload(testKey, testContent, testContentType);
43
+
44
+ // Generate download URL
45
+ const downloadUrl = await client.getDownloadURL({ path: testKey });
46
+
47
+ expect(downloadUrl).toMatch(
48
+ /^http:\/\/localhost:9000\/geekmidas\/test-files\/test\.txt/,
49
+ );
50
+ expect(downloadUrl).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256');
51
+ expect(downloadUrl).toContain('X-Amz-Credential=');
52
+ expect(downloadUrl).toContain('X-Amz-Date=');
53
+ expect(downloadUrl).toContain('X-Amz-SignedHeaders=');
54
+ expect(downloadUrl).toContain('X-Amz-Signature=');
55
+
56
+ // Verify file can be downloaded
57
+ const response = await fetch(downloadUrl);
58
+ expect(response.ok).toBe(true);
59
+
60
+ const downloadedContent = await response.text();
61
+ expect(downloadedContent).toBe(testContent);
62
+ });
63
+
64
+ it('should upload a file with filename and generate download URL with content disposition', async () => {
65
+ const keyWithName = 'test-files/named-file.txt';
66
+ const fileName = 'my-document.txt';
67
+
68
+ await client.upload(keyWithName, testContent, testContentType);
69
+
70
+ const downloadUrl = await client.getDownloadURL({
71
+ path: keyWithName,
72
+ name: fileName,
73
+ });
74
+
75
+ expect(downloadUrl).toContain(
76
+ 'response-content-disposition=attachment%3B%20filename%3Dmy-document.txt',
77
+ );
78
+ });
79
+
80
+ it('should handle binary data upload', async () => {
81
+ const binaryKey = 'test-files/binary.bin';
82
+ const binaryData = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" in bytes
83
+
84
+ await client.upload(binaryKey, binaryData, 'application/octet-stream');
85
+
86
+ const downloadUrl = await client.getDownloadURL({ path: binaryKey });
87
+ const response = await fetch(downloadUrl);
88
+ const downloadedBuffer = await response.arrayBuffer();
89
+
90
+ expect(new Uint8Array(downloadedBuffer)).toEqual(
91
+ new Uint8Array(binaryData),
92
+ );
93
+ });
94
+ });
95
+
96
+ describe('presigned upload URLs', () => {
97
+ it('should generate presigned PUT upload URL', async () => {
98
+ const uploadKey = 'test-files/presigned-upload.txt';
99
+ const uploadUrl = await client.getUploadURL({
100
+ path: uploadKey,
101
+ contentType: testContentType,
102
+ contentLength: testContent.length,
103
+ });
104
+
105
+ expect(uploadUrl).toMatch(
106
+ /^http:\/\/localhost:9000\/geekmidas\/test-files\/presigned-upload\.txt/,
107
+ );
108
+ expect(uploadUrl).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256');
109
+
110
+ // Test actual upload using the presigned URL
111
+ const uploadResponse = await fetch(uploadUrl, {
112
+ method: 'PUT',
113
+ headers: {
114
+ 'Content-Type': testContentType,
115
+ 'Content-Length': testContent.length.toString(),
116
+ },
117
+ body: testContent,
118
+ });
119
+
120
+ expect(uploadResponse.ok).toBe(true);
121
+
122
+ // Verify file was uploaded
123
+ const downloadUrl = await client.getDownloadURL({ path: uploadKey });
124
+ const downloadResponse = await fetch(downloadUrl);
125
+ const downloadedContent = await downloadResponse.text();
126
+ expect(downloadedContent).toBe(testContent);
127
+ });
128
+
129
+ it('should generate presigned POST upload with form fields', async () => {
130
+ const uploadKey = 'test-files/presigned-post.txt';
131
+ const uploadData = await client.getUpload({
132
+ path: uploadKey,
133
+ contentType: testContentType,
134
+ contentLength: testContent.length,
135
+ });
136
+
137
+ expect(uploadData.url).toBe('http://localhost:9000/geekmidas');
138
+ expect(uploadData.fields).toContainEqual({
139
+ key: 'key',
140
+ value: uploadKey,
141
+ });
142
+ expect(uploadData.fields).toContainEqual({
143
+ key: 'acl',
144
+ value: 'public-read',
145
+ });
146
+
147
+ // Test actual upload using the presigned POST
148
+ const formData = new FormData();
149
+ uploadData.fields.forEach(({ key, value }) => {
150
+ formData.append(key, value);
151
+ });
152
+ formData.append(
153
+ 'file',
154
+ new Blob([testContent], { type: testContentType }),
155
+ );
156
+
157
+ const uploadResponse = await fetch(uploadData.url, {
158
+ method: 'POST',
159
+ body: formData,
160
+ });
161
+
162
+ expect(uploadResponse.ok).toBe(true);
163
+
164
+ // Verify file was uploaded
165
+ const downloadUrl = await client.getDownloadURL({ path: uploadKey });
166
+ const downloadResponse = await fetch(downloadUrl);
167
+ const downloadedContent = await downloadResponse.blob();
168
+ expect(downloadedContent).toEqual(
169
+ new Blob([testContent], { type: testContentType }),
170
+ );
171
+ });
172
+ });
173
+
174
+ describe('versioning', () => {
175
+ it('should handle file versions', async () => {
176
+ const versionKey = 'test-files/versioned.txt';
177
+ const content1 = 'Version 1';
178
+ const content2 = 'Version 2';
179
+
180
+ // Upload first version
181
+ await client.upload(versionKey, content1, testContentType);
182
+
183
+ // Upload second version
184
+ await client.upload(versionKey, content2, testContentType);
185
+
186
+ // Get versions - MinIO might not enable versioning by default
187
+ // This test verifies the method works even if versioning is not enabled
188
+ const versions = await client.getVersions(versionKey);
189
+ expect(Array.isArray(versions)).toBe(true);
190
+
191
+ // If versioning is enabled, we should have multiple versions
192
+ // If not, we should have at least the current version
193
+ if (versions.length > 0) {
194
+ expect(versions[0]).toHaveProperty('id');
195
+ expect(versions[0]).toHaveProperty('createdAt');
196
+ expect(versions[0].createdAt).toBeInstanceOf(Date);
197
+ }
198
+ });
199
+ });
200
+
201
+ describe('error handling', () => {
202
+ it('should handle download URL generation for non-existent files', async () => {
203
+ // This should not throw - presigned URLs can be generated for non-existent files
204
+ const downloadUrl = await client.getDownloadURL({
205
+ path: 'non-existent/file.txt',
206
+ });
207
+ expect(downloadUrl).toMatch(
208
+ /^http:\/\/localhost:9000\/geekmidas\/non-existent\/file\.txt/,
209
+ );
210
+
211
+ // But accessing the URL should return 404
212
+ const response = await fetch(downloadUrl);
213
+ expect(response.status).toBe(404);
214
+ });
215
+ });
216
+
217
+ describe('custom expiration times', () => {
218
+ it('should respect custom expiration for download URLs', async () => {
219
+ const key = 'test-files/expiration-test.txt';
220
+ await client.upload(key, testContent, testContentType);
221
+
222
+ const shortExpirationUrl = await client.getDownloadURL({ path: key }, 1); // 1 second
223
+ const longExpirationUrl = await client.getDownloadURL(
224
+ { path: key },
225
+ 3600,
226
+ ); // 1 hour
227
+
228
+ expect(shortExpirationUrl).toContain('X-Amz-Expires=1');
229
+ expect(longExpirationUrl).toContain('X-Amz-Expires=3600');
230
+ });
231
+
232
+ it('should respect custom expiration for upload URLs', async () => {
233
+ const key = 'test-files/upload-expiration-test.txt';
234
+
235
+ const shortExpirationUrl = await client.getUploadURL(
236
+ {
237
+ path: key,
238
+ contentType: testContentType,
239
+ contentLength: testContent.length,
240
+ },
241
+ 1,
242
+ ); // 1 second
243
+
244
+ const longExpirationUrl = await client.getUploadURL(
245
+ {
246
+ path: key,
247
+ contentType: testContentType,
248
+ contentLength: testContent.length,
249
+ },
250
+ 3600,
251
+ ); // 1 hour
252
+
253
+ expect(shortExpirationUrl).toContain('X-Amz-Expires=1');
254
+ expect(longExpirationUrl).toContain('X-Amz-Expires=3600');
255
+ });
256
+ });
257
+
258
+ describe('factory method', () => {
259
+ it('should create client with minimal configuration', () => {
260
+ const minimalClient = AmazonStorageClient.create({
261
+ bucket: 'geekmidas',
262
+ });
263
+
264
+ expect(minimalClient).toBeInstanceOf(AmazonStorageClient);
265
+ expect(minimalClient.provider).toBe(StorageProvider.AWSS3);
266
+ });
267
+
268
+ it('should create client with full configuration', () => {
269
+ const fullClient = AmazonStorageClient.create({
270
+ bucket: 'geekmidas',
271
+ region: 'us-west-2',
272
+ accessKeyId: 'test-key',
273
+ secretAccessKey: 'test-secret',
274
+ endpoint: 'https://custom-s3.example.com',
275
+ acl: AmazonCannedAccessControlList.Private,
276
+ });
277
+
278
+ expect(fullClient).toBeInstanceOf(AmazonStorageClient);
279
+ expect(fullClient.provider).toBe(StorageProvider.AWSS3);
280
+ });
281
+ });
282
+
283
+ describe('different ACL configurations', () => {
284
+ it('should work with different ACL settings', async () => {
285
+ const privateClient = AmazonStorageClient.create({
286
+ bucket: testBucket,
287
+ region: 'us-east-1',
288
+ accessKeyId: 'minioadmin',
289
+ secretAccessKey: 'minioadmin',
290
+ endpoint: 'http://localhost:9000',
291
+ acl: AmazonCannedAccessControlList.Private,
292
+ });
293
+
294
+ const uploadData = await privateClient.getUpload({
295
+ path: 'test-files/private.txt',
296
+ contentType: testContentType,
297
+ contentLength: testContent.length,
298
+ });
299
+
300
+ expect(uploadData.fields).toContainEqual({
301
+ key: 'acl',
302
+ value: 'private',
303
+ });
304
+ });
305
+ });
306
+
307
+ describe('S3Client integration', () => {
308
+ it('should work with externally created S3Client', () => {
309
+ const s3Client = new S3Client({
310
+ region: 'us-east-1',
311
+ credentials: {
312
+ accessKeyId: 'minioadmin',
313
+ secretAccessKey: 'minioadmin',
314
+ },
315
+ endpoint: 'http://localhost:9000',
316
+ });
317
+
318
+ const storageClient = new AmazonStorageClient(
319
+ s3Client,
320
+ testBucket,
321
+ AmazonCannedAccessControlList.PublicRead,
322
+ );
323
+
324
+ expect(storageClient).toBeInstanceOf(AmazonStorageClient);
325
+ expect(storageClient.provider).toBe(StorageProvider.AWSS3);
326
+ });
327
+ });
328
+
329
+ describe('cache functionality', () => {
330
+ it('should cache download URLs', async () => {
331
+ const cache = new InMemoryCache<string>();
332
+ const clientWithCache = AmazonStorageClient.create({
333
+ bucket: testBucket,
334
+ region: 'us-east-1',
335
+ accessKeyId: 'geekmidas',
336
+ secretAccessKey: 'geekmidas',
337
+ endpoint: 'http://localhost:9000',
338
+ forcePathStyle: true,
339
+ cache,
340
+ });
341
+
342
+ const key = 'test-files/cache-test.txt';
343
+ await clientWithCache.upload(key, testContent, testContentType);
344
+
345
+ // First call should generate and cache the URL
346
+ const url1 = await clientWithCache.getDownloadURL({ path: key });
347
+
348
+ // Second call should return the cached URL
349
+ const url2 = await clientWithCache.getDownloadURL({ path: key });
350
+
351
+ // URLs should be identical (same cached value)
352
+ expect(url1).toBe(url2);
353
+
354
+ // Verify the URL was cached
355
+ const cacheKey = `download-url:${key}`;
356
+ const cachedUrl = await cache.get(cacheKey);
357
+ expect(cachedUrl).toBe(url1);
358
+ });
359
+
360
+ it('should respect cache expiration based on URL expiry', async () => {
361
+ const cache = new InMemoryCache<string>();
362
+ const clientWithCache = AmazonStorageClient.create({
363
+ bucket: testBucket,
364
+ region: 'us-east-1',
365
+ accessKeyId: 'geekmidas',
366
+ secretAccessKey: 'geekmidas',
367
+ endpoint: 'http://localhost:9000',
368
+ forcePathStyle: true,
369
+ cache,
370
+ });
371
+
372
+ const key = 'test-files/cache-expiry.txt';
373
+ await clientWithCache.upload(key, testContent, testContentType);
374
+
375
+ // Generate URL with short expiry that won't be cached
376
+ await clientWithCache.getDownloadURL({ path: key }, 30); // 30 seconds
377
+
378
+ // Check cache doesn't contain the URL (too short to cache)
379
+ const cacheKey = `download-url:${key}`;
380
+ const cachedShortUrl = await cache.get(cacheKey);
381
+ expect(cachedShortUrl).toBeUndefined();
382
+
383
+ // Generate URL with longer expiry that will be cached
384
+ const longExpiryUrl = await clientWithCache.getDownloadURL(
385
+ { path: key },
386
+ 3600,
387
+ ); // 1 hour
388
+
389
+ // Check cache contains the URL
390
+ const cachedLongUrl = await cache.get(cacheKey);
391
+ expect(cachedLongUrl).toBe(longExpiryUrl);
392
+ });
393
+
394
+ it('should use cached URL when available', async () => {
395
+ const cache = new InMemoryCache<string>();
396
+ const clientWithCache = AmazonStorageClient.create({
397
+ bucket: testBucket,
398
+ region: 'us-east-1',
399
+ accessKeyId: 'geekmidas',
400
+ secretAccessKey: 'geekmidas',
401
+ endpoint: 'http://localhost:9000',
402
+ forcePathStyle: true,
403
+ cache,
404
+ });
405
+
406
+ const key = 'test-files/pre-cached.txt';
407
+ const cachedUrl = 'https://pre-cached-url.example.com';
408
+ const cacheKey = `download-url:${key}`;
409
+
410
+ // Pre-populate cache
411
+ await cache.set(cacheKey, cachedUrl);
412
+
413
+ // Get download URL should return cached value
414
+ const url = await clientWithCache.getDownloadURL({ path: key });
415
+ expect(url).toBe(cachedUrl);
416
+ });
417
+
418
+ it('should work without cache', async () => {
419
+ // Client without cache should work normally
420
+ const clientNoCache = AmazonStorageClient.create({
421
+ bucket: testBucket,
422
+ region: 'us-east-1',
423
+ accessKeyId: 'geekmidas',
424
+ secretAccessKey: 'geekmidas',
425
+ endpoint: 'http://localhost:9000',
426
+ forcePathStyle: true,
427
+ });
428
+
429
+ const key = 'test-files/no-cache.txt';
430
+ await clientNoCache.upload(key, testContent, testContentType);
431
+
432
+ const url1 = await clientNoCache.getDownloadURL({ path: key });
433
+ const url2 = await clientNoCache.getDownloadURL({ path: key });
434
+
435
+ // Without cache, new URLs might be generated each time
436
+ // Both should be valid URLs
437
+ expect(url1).toMatch(
438
+ /^http:\/\/localhost:9000\/geekmidas\/test-files\/no-cache\.txt/,
439
+ );
440
+ expect(url2).toMatch(
441
+ /^http:\/\/localhost:9000\/geekmidas\/test-files\/no-cache\.txt/,
442
+ );
443
+ });
444
+
445
+ it('should pass cache to constructor', () => {
446
+ const cache = new InMemoryCache<string>();
447
+ const s3Client = new S3Client({
448
+ region: 'us-east-1',
449
+ credentials: {
450
+ accessKeyId: 'geekmidas',
451
+ secretAccessKey: 'geekmidas',
452
+ },
453
+ endpoint: 'http://localhost:9000',
454
+ forcePathStyle: true,
455
+ });
456
+
457
+ const clientWithCache = new AmazonStorageClient(
458
+ s3Client,
459
+ testBucket,
460
+ AmazonCannedAccessControlList.PublicRead,
461
+ cache,
462
+ );
463
+
464
+ expect(clientWithCache.cache).toBe(cache);
465
+ });
466
+ });
467
+
468
+ describe('getVersionDownloadURL', () => {
469
+ it('should generate version-specific download URL', async () => {
470
+ const versionKey = 'test-files/version-download.txt';
471
+ const versionId = 'test-version-id';
472
+
473
+ // Upload a file first
474
+ await client.upload(versionKey, testContent, testContentType);
475
+
476
+ // Generate version-specific download URL
477
+ const downloadUrl = await client.getVersionDownloadURL(
478
+ { path: versionKey, name: 'versioned-file.txt' },
479
+ versionId,
480
+ );
481
+
482
+ expect(downloadUrl).toMatch(
483
+ /^http:\/\/localhost:9000\/geekmidas\/test-files\/version-download\.txt/,
484
+ );
485
+ expect(downloadUrl).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256');
486
+ expect(downloadUrl).toContain(
487
+ 'response-content-disposition=attachment%3B%20filename%3Dversioned-file.txt',
488
+ );
489
+ });
490
+
491
+ it('should generate version URL without filename', async () => {
492
+ const versionKey = 'test-files/version-no-name.txt';
493
+ const versionId = 'test-version-id';
494
+
495
+ // Upload a file first
496
+ await client.upload(versionKey, testContent, testContentType);
497
+
498
+ // Generate version-specific download URL without filename
499
+ const downloadUrl = await client.getVersionDownloadURL(
500
+ { path: versionKey },
501
+ versionId,
502
+ );
503
+
504
+ expect(downloadUrl).toMatch(
505
+ /^http:\/\/localhost:9000\/geekmidas\/test-files\/version-no-name\.txt/,
506
+ );
507
+ expect(downloadUrl).not.toContain('response-content-disposition');
508
+ });
509
+ });
510
510
  });