@opensaas/stack-storage 0.1.1

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.
Files changed (61) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +11 -0
  3. package/CLAUDE.md +426 -0
  4. package/LICENSE +21 -0
  5. package/README.md +425 -0
  6. package/dist/config/index.d.ts +19 -0
  7. package/dist/config/index.d.ts.map +1 -0
  8. package/dist/config/index.js +25 -0
  9. package/dist/config/index.js.map +1 -0
  10. package/dist/config/types.d.ts +113 -0
  11. package/dist/config/types.d.ts.map +1 -0
  12. package/dist/config/types.js +2 -0
  13. package/dist/config/types.js.map +1 -0
  14. package/dist/fields/index.d.ts +111 -0
  15. package/dist/fields/index.d.ts.map +1 -0
  16. package/dist/fields/index.js +237 -0
  17. package/dist/fields/index.js.map +1 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +11 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/providers/index.d.ts +2 -0
  23. package/dist/providers/index.d.ts.map +1 -0
  24. package/dist/providers/index.js +2 -0
  25. package/dist/providers/index.js.map +1 -0
  26. package/dist/providers/local.d.ts +22 -0
  27. package/dist/providers/local.d.ts.map +1 -0
  28. package/dist/providers/local.js +64 -0
  29. package/dist/providers/local.js.map +1 -0
  30. package/dist/runtime/index.d.ts +75 -0
  31. package/dist/runtime/index.d.ts.map +1 -0
  32. package/dist/runtime/index.js +157 -0
  33. package/dist/runtime/index.js.map +1 -0
  34. package/dist/utils/image.d.ts +18 -0
  35. package/dist/utils/image.d.ts.map +1 -0
  36. package/dist/utils/image.js +82 -0
  37. package/dist/utils/image.js.map +1 -0
  38. package/dist/utils/index.d.ts +3 -0
  39. package/dist/utils/index.d.ts.map +1 -0
  40. package/dist/utils/index.js +3 -0
  41. package/dist/utils/index.js.map +1 -0
  42. package/dist/utils/upload.d.ts +56 -0
  43. package/dist/utils/upload.d.ts.map +1 -0
  44. package/dist/utils/upload.js +74 -0
  45. package/dist/utils/upload.js.map +1 -0
  46. package/package.json +50 -0
  47. package/src/config/index.ts +30 -0
  48. package/src/config/types.ts +127 -0
  49. package/src/fields/index.ts +347 -0
  50. package/src/index.ts +14 -0
  51. package/src/providers/index.ts +1 -0
  52. package/src/providers/local.ts +85 -0
  53. package/src/runtime/index.ts +243 -0
  54. package/src/utils/image.ts +111 -0
  55. package/src/utils/index.ts +2 -0
  56. package/src/utils/upload.ts +122 -0
  57. package/tests/image-utils.test.ts +498 -0
  58. package/tests/local-provider.test.ts +349 -0
  59. package/tests/upload-utils.test.ts +313 -0
  60. package/tsconfig.json +9 -0
  61. package/vitest.config.ts +14 -0
@@ -0,0 +1,498 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import {
3
+ getImageDimensions,
4
+ transformImage,
5
+ processImageTransformations,
6
+ } from '../src/utils/image.js'
7
+ import type { ImageTransformationConfig, StorageProvider } from '../src/config/types.js'
8
+
9
+ // Type for mocked sharp instances
10
+ interface MockSharpInstance {
11
+ metadata?: ReturnType<typeof vi.fn>
12
+ resize?: ReturnType<typeof vi.fn>
13
+ jpeg?: ReturnType<typeof vi.fn>
14
+ png?: ReturnType<typeof vi.fn>
15
+ webp?: ReturnType<typeof vi.fn>
16
+ avif?: ReturnType<typeof vi.fn>
17
+ toBuffer?: ReturnType<typeof vi.fn>
18
+ }
19
+
20
+ // Mock sharp
21
+ vi.mock('sharp', () => {
22
+ const mockSharp = vi.fn(() => ({
23
+ metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
24
+ resize: vi.fn().mockReturnThis(),
25
+ jpeg: vi.fn().mockReturnThis(),
26
+ png: vi.fn().mockReturnThis(),
27
+ webp: vi.fn().mockReturnThis(),
28
+ avif: vi.fn().mockReturnThis(),
29
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('transformed-image-data')),
30
+ }))
31
+
32
+ return { default: mockSharp }
33
+ })
34
+
35
+ describe('Image Utilities', () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks()
38
+ })
39
+
40
+ describe('getImageDimensions', () => {
41
+ it('should return image dimensions from Buffer', async () => {
42
+ const buffer = Buffer.from('fake-image-data')
43
+ const { default: sharp } = await import('sharp')
44
+
45
+ const dimensions = await getImageDimensions(buffer)
46
+
47
+ expect(sharp).toHaveBeenCalledWith(buffer)
48
+ expect(dimensions).toEqual({ width: 800, height: 600 })
49
+ })
50
+
51
+ it('should return image dimensions from Uint8Array', async () => {
52
+ const uint8Array = new Uint8Array([1, 2, 3, 4])
53
+ const { default: sharp } = await import('sharp')
54
+
55
+ const dimensions = await getImageDimensions(uint8Array)
56
+
57
+ expect(sharp).toHaveBeenCalledWith(uint8Array)
58
+ expect(dimensions).toEqual({ width: 800, height: 600 })
59
+ })
60
+
61
+ it('should return 0 dimensions when metadata is missing', async () => {
62
+ const { default: sharp } = await import('sharp')
63
+ const mockInstance: MockSharpInstance = {
64
+ metadata: vi.fn().mockResolvedValue({}),
65
+ }
66
+ vi.mocked(sharp).mockReturnValueOnce(mockInstance as never)
67
+
68
+ const buffer = Buffer.from('fake-image-data')
69
+ const dimensions = await getImageDimensions(buffer)
70
+
71
+ expect(dimensions).toEqual({ width: 0, height: 0 })
72
+ })
73
+ })
74
+
75
+ describe('transformImage', () => {
76
+ it('should resize image with width and height', async () => {
77
+ const { default: sharp } = await import('sharp')
78
+ const buffer = Buffer.from('original-image')
79
+ const transformation: ImageTransformationConfig = {
80
+ width: 400,
81
+ height: 300,
82
+ }
83
+
84
+ const mockInstance: MockSharpInstance = {
85
+ resize: vi.fn().mockReturnThis(),
86
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('resized-image')),
87
+ }
88
+ vi.mocked(sharp).mockReturnValueOnce(mockInstance as never)
89
+
90
+ const result = await transformImage(buffer, transformation)
91
+
92
+ expect(sharp).toHaveBeenCalledWith(buffer)
93
+ expect(mockInstance.resize).toHaveBeenCalledWith({
94
+ width: 400,
95
+ height: 300,
96
+ fit: 'cover',
97
+ })
98
+ expect(result).toBeInstanceOf(Buffer)
99
+ expect(result.toString()).toBe('resized-image')
100
+ })
101
+
102
+ it('should resize with only width', async () => {
103
+ const { default: sharp } = await import('sharp')
104
+ const buffer = Buffer.from('original-image')
105
+ const transformation: ImageTransformationConfig = {
106
+ width: 500,
107
+ }
108
+
109
+ const mockInstance: MockSharpInstance = {
110
+ resize: vi.fn().mockReturnThis(),
111
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('resized-image')),
112
+ }
113
+ vi.mocked(sharp).mockReturnValueOnce(mockInstance as never)
114
+
115
+ await transformImage(buffer, transformation)
116
+
117
+ expect(mockInstance.resize).toHaveBeenCalledWith({
118
+ width: 500,
119
+ height: undefined,
120
+ fit: 'cover',
121
+ })
122
+ })
123
+
124
+ it('should resize with custom fit option', async () => {
125
+ const { default: sharp } = await import('sharp')
126
+ const buffer = Buffer.from('original-image')
127
+ const transformation: ImageTransformationConfig = {
128
+ width: 400,
129
+ height: 300,
130
+ fit: 'contain',
131
+ }
132
+
133
+ const mockInstance: MockSharpInstance = {
134
+ resize: vi.fn().mockReturnThis(),
135
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('resized-image')),
136
+ }
137
+ vi.mocked(sharp).mockReturnValueOnce(mockInstance as never)
138
+
139
+ await transformImage(buffer, transformation)
140
+
141
+ expect(mockInstance.resize).toHaveBeenCalledWith({
142
+ width: 400,
143
+ height: 300,
144
+ fit: 'contain',
145
+ })
146
+ })
147
+
148
+ it('should convert to JPEG format', async () => {
149
+ const { default: sharp } = await import('sharp')
150
+ const buffer = Buffer.from('original-image')
151
+ const transformation: ImageTransformationConfig = {
152
+ format: 'jpeg',
153
+ }
154
+
155
+ const mockInstance: MockSharpInstance = {
156
+ jpeg: vi.fn().mockReturnThis(),
157
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('jpeg-image')),
158
+ }
159
+ vi.mocked(sharp).mockReturnValueOnce(mockInstance as never)
160
+
161
+ await transformImage(buffer, transformation)
162
+
163
+ expect(mockInstance.jpeg).toHaveBeenCalledWith({ quality: 80 })
164
+ })
165
+
166
+ it('should convert to PNG format', async () => {
167
+ const { default: sharp } = await import('sharp')
168
+ const buffer = Buffer.from('original-image')
169
+ const transformation: ImageTransformationConfig = {
170
+ format: 'png',
171
+ }
172
+
173
+ const mockInstance: MockSharpInstance = {
174
+ png: vi.fn().mockReturnThis(),
175
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('png-image')),
176
+ }
177
+ vi.mocked(sharp).mockReturnValueOnce(mockInstance as never)
178
+
179
+ await transformImage(buffer, transformation)
180
+
181
+ expect(mockInstance.png).toHaveBeenCalledWith({ quality: 80 })
182
+ })
183
+
184
+ it('should convert to WebP format', async () => {
185
+ const { default: sharp } = await import('sharp')
186
+ const buffer = Buffer.from('original-image')
187
+ const transformation: ImageTransformationConfig = {
188
+ format: 'webp',
189
+ }
190
+
191
+ const mockInstance: MockSharpInstance = {
192
+ webp: vi.fn().mockReturnThis(),
193
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('webp-image')),
194
+ }
195
+ vi.mocked(sharp).mockReturnValueOnce(mockInstance as never)
196
+
197
+ await transformImage(buffer, transformation)
198
+
199
+ expect(mockInstance.webp).toHaveBeenCalledWith({ quality: 80 })
200
+ })
201
+
202
+ it('should convert to AVIF format', async () => {
203
+ const { default: sharp } = await import('sharp')
204
+ const buffer = Buffer.from('original-image')
205
+ const transformation: ImageTransformationConfig = {
206
+ format: 'avif',
207
+ }
208
+
209
+ const mockInstance: MockSharpInstance = {
210
+ avif: vi.fn().mockReturnThis(),
211
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('avif-image')),
212
+ }
213
+ vi.mocked(sharp).mockReturnValueOnce(mockInstance as never)
214
+
215
+ await transformImage(buffer, transformation)
216
+
217
+ expect(mockInstance.avif).toHaveBeenCalledWith({ quality: 80 })
218
+ })
219
+
220
+ it('should use custom quality setting', async () => {
221
+ const { default: sharp } = await import('sharp')
222
+ const buffer = Buffer.from('original-image')
223
+ const transformation: ImageTransformationConfig = {
224
+ format: 'jpeg',
225
+ quality: 95,
226
+ }
227
+
228
+ const mockInstance: MockSharpInstance = {
229
+ jpeg: vi.fn().mockReturnThis(),
230
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('high-quality-jpeg')),
231
+ }
232
+ vi.mocked(sharp).mockReturnValueOnce(mockInstance as never)
233
+
234
+ await transformImage(buffer, transformation)
235
+
236
+ expect(mockInstance.jpeg).toHaveBeenCalledWith({ quality: 95 })
237
+ })
238
+
239
+ it('should combine resize and format conversion', async () => {
240
+ const { default: sharp } = await import('sharp')
241
+ const buffer = Buffer.from('original-image')
242
+ const transformation: ImageTransformationConfig = {
243
+ width: 300,
244
+ height: 200,
245
+ format: 'webp',
246
+ quality: 90,
247
+ }
248
+
249
+ const mockInstance: MockSharpInstance = {
250
+ resize: vi.fn().mockReturnThis(),
251
+ webp: vi.fn().mockReturnThis(),
252
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('resized-webp')),
253
+ }
254
+ vi.mocked(sharp).mockReturnValueOnce(mockInstance as never)
255
+
256
+ const result = await transformImage(buffer, transformation)
257
+
258
+ expect(mockInstance.resize).toHaveBeenCalledWith({
259
+ width: 300,
260
+ height: 200,
261
+ fit: 'cover',
262
+ })
263
+ expect(mockInstance.webp).toHaveBeenCalledWith({ quality: 90 })
264
+ expect(result.toString()).toBe('resized-webp')
265
+ })
266
+
267
+ it('should handle empty transformation config', async () => {
268
+ const { default: sharp } = await import('sharp')
269
+ const buffer = Buffer.from('original-image')
270
+ const transformation: ImageTransformationConfig = {}
271
+
272
+ const mockInstance: MockSharpInstance = {
273
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('unchanged-image')),
274
+ }
275
+ vi.mocked(sharp).mockReturnValueOnce(mockInstance as never)
276
+
277
+ const result = await transformImage(buffer, transformation)
278
+
279
+ expect(result.toString()).toBe('unchanged-image')
280
+ })
281
+ })
282
+
283
+ describe('processImageTransformations', () => {
284
+ it('should process multiple transformations', async () => {
285
+ const { default: sharp } = await import('sharp')
286
+ const buffer = Buffer.from('original-image')
287
+ const originalFilename = 'photo.jpg'
288
+ const transformations: Record<string, ImageTransformationConfig> = {
289
+ thumbnail: { width: 100, height: 100, format: 'webp' },
290
+ medium: { width: 500, height: 500, format: 'jpeg' },
291
+ }
292
+
293
+ const mockProvider: StorageProvider = {
294
+ upload: vi
295
+ .fn()
296
+ .mockResolvedValueOnce({
297
+ filename: 'photo.jpg-thumbnail.webp',
298
+ url: 'https://example.com/photo-thumbnail.webp',
299
+ size: 5000,
300
+ })
301
+ .mockResolvedValueOnce({
302
+ filename: 'photo.jpg-medium.jpeg',
303
+ url: 'https://example.com/photo-medium.jpeg',
304
+ size: 15000,
305
+ }),
306
+ download: vi.fn(),
307
+ delete: vi.fn(),
308
+ getUrl: vi.fn(),
309
+ }
310
+
311
+ // Mock sharp instances for each transformation
312
+ const mockInstance1: MockSharpInstance = {
313
+ resize: vi.fn().mockReturnThis(),
314
+ webp: vi.fn().mockReturnThis(),
315
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('thumbnail-data')),
316
+ metadata: vi.fn().mockResolvedValue({ width: 100, height: 100 }),
317
+ }
318
+ const mockInstance2: MockSharpInstance = {
319
+ resize: vi.fn().mockReturnThis(),
320
+ jpeg: vi.fn().mockReturnThis(),
321
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('medium-data')),
322
+ metadata: vi.fn().mockResolvedValue({ width: 500, height: 500 }),
323
+ }
324
+ const mockInstance3: MockSharpInstance = {
325
+ metadata: vi.fn().mockResolvedValue({ width: 100, height: 100 }),
326
+ }
327
+ const mockInstance4: MockSharpInstance = {
328
+ metadata: vi.fn().mockResolvedValue({ width: 500, height: 500 }),
329
+ }
330
+
331
+ vi.mocked(sharp)
332
+ .mockReturnValueOnce(mockInstance1 as never)
333
+ .mockReturnValueOnce(mockInstance3 as never)
334
+ .mockReturnValueOnce(mockInstance2 as never)
335
+ .mockReturnValueOnce(mockInstance4 as never)
336
+
337
+ const results = await processImageTransformations(
338
+ buffer,
339
+ originalFilename,
340
+ transformations,
341
+ mockProvider,
342
+ 'image/jpeg',
343
+ )
344
+
345
+ expect(results).toEqual({
346
+ thumbnail: {
347
+ url: 'https://example.com/photo-thumbnail.webp',
348
+ width: 100,
349
+ height: 100,
350
+ size: 5000,
351
+ },
352
+ medium: {
353
+ url: 'https://example.com/photo-medium.jpeg',
354
+ width: 500,
355
+ height: 500,
356
+ size: 15000,
357
+ },
358
+ })
359
+
360
+ expect(mockProvider.upload).toHaveBeenCalledTimes(2)
361
+ expect(mockProvider.upload).toHaveBeenNthCalledWith(
362
+ 1,
363
+ expect.any(Buffer),
364
+ 'photo.jpg-thumbnail.webp',
365
+ { contentType: 'image/webp' },
366
+ )
367
+ expect(mockProvider.upload).toHaveBeenNthCalledWith(
368
+ 2,
369
+ expect.any(Buffer),
370
+ 'photo.jpg-medium.jpeg',
371
+ { contentType: 'image/jpeg' },
372
+ )
373
+ })
374
+
375
+ it('should handle transformation without format change', async () => {
376
+ const { default: sharp } = await import('sharp')
377
+ const buffer = Buffer.from('original-image')
378
+ const transformations: Record<string, ImageTransformationConfig> = {
379
+ resized: { width: 300, height: 200 },
380
+ }
381
+
382
+ const mockProvider: StorageProvider = {
383
+ upload: vi.fn().mockResolvedValue({
384
+ filename: 'photo.jpg-resized',
385
+ url: 'https://example.com/photo-resized',
386
+ size: 10000,
387
+ }),
388
+ download: vi.fn(),
389
+ delete: vi.fn(),
390
+ getUrl: vi.fn(),
391
+ }
392
+
393
+ const mockInstance1: MockSharpInstance = {
394
+ resize: vi.fn().mockReturnThis(),
395
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('resized-data')),
396
+ }
397
+ const mockInstance2: MockSharpInstance = {
398
+ metadata: vi.fn().mockResolvedValue({ width: 300, height: 200 }),
399
+ }
400
+
401
+ vi.mocked(sharp)
402
+ .mockReturnValueOnce(mockInstance1 as never)
403
+ .mockReturnValueOnce(mockInstance2 as never)
404
+
405
+ const results = await processImageTransformations(
406
+ buffer,
407
+ 'photo.jpg',
408
+ transformations,
409
+ mockProvider,
410
+ 'image/jpeg',
411
+ )
412
+
413
+ expect(mockProvider.upload).toHaveBeenCalledWith(expect.any(Buffer), 'photo.jpg-resized', {
414
+ contentType: 'image/jpeg',
415
+ })
416
+ expect(results.resized.width).toBe(300)
417
+ expect(results.resized.height).toBe(200)
418
+ })
419
+
420
+ it('should process empty transformations object', async () => {
421
+ const buffer = Buffer.from('original-image')
422
+ const mockProvider: StorageProvider = {
423
+ upload: vi.fn(),
424
+ download: vi.fn(),
425
+ delete: vi.fn(),
426
+ getUrl: vi.fn(),
427
+ }
428
+
429
+ const results = await processImageTransformations(
430
+ buffer,
431
+ 'photo.jpg',
432
+ {},
433
+ mockProvider,
434
+ 'image/jpeg',
435
+ )
436
+
437
+ expect(results).toEqual({})
438
+ expect(mockProvider.upload).not.toHaveBeenCalled()
439
+ })
440
+
441
+ it('should append format extension when format is specified', async () => {
442
+ const { default: sharp } = await import('sharp')
443
+ const buffer = Buffer.from('original-image')
444
+ const transformations: Record<string, ImageTransformationConfig> = {
445
+ webp: { format: 'webp' },
446
+ png: { format: 'png' },
447
+ avif: { format: 'avif' },
448
+ }
449
+
450
+ const mockProvider: StorageProvider = {
451
+ upload: vi
452
+ .fn()
453
+ .mockResolvedValueOnce({ filename: 'photo-webp.webp', url: 'url1', size: 1000 })
454
+ .mockResolvedValueOnce({ filename: 'photo-png.png', url: 'url2', size: 2000 })
455
+ .mockResolvedValueOnce({ filename: 'photo-avif.avif', url: 'url3', size: 3000 }),
456
+ download: vi.fn(),
457
+ delete: vi.fn(),
458
+ getUrl: vi.fn(),
459
+ }
460
+
461
+ // Mock multiple sharp instances
462
+ for (let i = 0; i < 6; i++) {
463
+ const mockInstance: MockSharpInstance = {
464
+ webp: vi.fn().mockReturnThis(),
465
+ png: vi.fn().mockReturnThis(),
466
+ avif: vi.fn().mockReturnThis(),
467
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('data')),
468
+ metadata: vi.fn().mockResolvedValue({ width: 100, height: 100 }),
469
+ }
470
+ vi.mocked(sharp).mockReturnValueOnce(mockInstance as never)
471
+ }
472
+
473
+ await processImageTransformations(
474
+ buffer,
475
+ 'photo',
476
+ transformations,
477
+ mockProvider,
478
+ 'image/jpeg',
479
+ )
480
+
481
+ expect(mockProvider.upload).toHaveBeenNthCalledWith(
482
+ 1,
483
+ expect.any(Buffer),
484
+ 'photo-webp.webp',
485
+ { contentType: 'image/webp' },
486
+ )
487
+ expect(mockProvider.upload).toHaveBeenNthCalledWith(2, expect.any(Buffer), 'photo-png.png', {
488
+ contentType: 'image/png',
489
+ })
490
+ expect(mockProvider.upload).toHaveBeenNthCalledWith(
491
+ 3,
492
+ expect.any(Buffer),
493
+ 'photo-avif.avif',
494
+ { contentType: 'image/avif' },
495
+ )
496
+ })
497
+ })
498
+ })