@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,349 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { LocalStorageProvider } from '../src/providers/local.js'
3
+ import type { LocalStorageConfig } from '../src/config/types.js'
4
+
5
+ // Mock Node.js filesystem modules
6
+ vi.mock('node:fs/promises', () => ({
7
+ default: {
8
+ access: vi.fn(),
9
+ mkdir: vi.fn(),
10
+ writeFile: vi.fn(),
11
+ readFile: vi.fn(),
12
+ unlink: vi.fn(),
13
+ stat: vi.fn(),
14
+ },
15
+ }))
16
+
17
+ vi.mock('node:crypto', () => ({
18
+ randomBytes: vi.fn((size: number) => ({
19
+ toString: () => 'a'.repeat(size * 2), // Hex string is 2x the byte length
20
+ })),
21
+ }))
22
+
23
+ describe('LocalStorageProvider', () => {
24
+ let fs: typeof import('node:fs/promises').default
25
+
26
+ beforeEach(async () => {
27
+ vi.clearAllMocks()
28
+ fs = (await import('node:fs/promises')).default
29
+
30
+ // Default mock implementations
31
+ fs.access.mockResolvedValue(undefined)
32
+ fs.mkdir.mockResolvedValue(undefined)
33
+ fs.writeFile.mockResolvedValue(undefined)
34
+ fs.readFile.mockResolvedValue(Buffer.from('file-contents'))
35
+ fs.unlink.mockResolvedValue(undefined)
36
+ fs.stat.mockResolvedValue({ size: 12345 })
37
+ })
38
+
39
+ describe('constructor', () => {
40
+ it('should create instance with config', () => {
41
+ const config: LocalStorageConfig = {
42
+ type: 'local',
43
+ uploadDir: './uploads',
44
+ serveUrl: '/uploads',
45
+ }
46
+
47
+ const provider = new LocalStorageProvider(config)
48
+
49
+ expect(provider).toBeInstanceOf(LocalStorageProvider)
50
+ })
51
+ })
52
+
53
+ describe('upload', () => {
54
+ it('should upload file successfully', async () => {
55
+ const config: LocalStorageConfig = {
56
+ type: 'local',
57
+ uploadDir: './uploads',
58
+ serveUrl: '/uploads',
59
+ }
60
+ const provider = new LocalStorageProvider(config)
61
+ const fileBuffer = Buffer.from('test file content')
62
+ const filename = 'test.txt'
63
+
64
+ const result = await provider.upload(fileBuffer, filename)
65
+
66
+ expect(fs.writeFile).toHaveBeenCalledWith(
67
+ expect.stringMatching(/uploads\/\d+-.*\.txt$/),
68
+ fileBuffer,
69
+ )
70
+ expect(result.url).toMatch(/\/uploads\/\d+-.*\.txt/)
71
+ expect(result.size).toBe(12345)
72
+ expect(result.contentType).toBe('application/octet-stream')
73
+ })
74
+
75
+ it('should create upload directory if it does not exist', async () => {
76
+ fs.access.mockRejectedValueOnce(new Error('Directory does not exist'))
77
+
78
+ const config: LocalStorageConfig = {
79
+ type: 'local',
80
+ uploadDir: './new-uploads',
81
+ serveUrl: '/uploads',
82
+ }
83
+ const provider = new LocalStorageProvider(config)
84
+
85
+ await provider.upload(Buffer.from('test'), 'test.txt')
86
+
87
+ expect(fs.mkdir).toHaveBeenCalledWith('./new-uploads', { recursive: true })
88
+ })
89
+
90
+ it('should not create directory if it already exists', async () => {
91
+ fs.access.mockResolvedValueOnce(undefined)
92
+
93
+ const config: LocalStorageConfig = {
94
+ type: 'local',
95
+ uploadDir: './existing-uploads',
96
+ serveUrl: '/uploads',
97
+ }
98
+ const provider = new LocalStorageProvider(config)
99
+
100
+ await provider.upload(Buffer.from('test'), 'test.txt')
101
+
102
+ expect(fs.mkdir).not.toHaveBeenCalled()
103
+ })
104
+
105
+ it('should generate unique filenames by default', async () => {
106
+ const config: LocalStorageConfig = {
107
+ type: 'local',
108
+ uploadDir: './uploads',
109
+ serveUrl: '/uploads',
110
+ }
111
+ const provider = new LocalStorageProvider(config)
112
+
113
+ const result1 = await provider.upload(Buffer.from('test1'), 'test.txt')
114
+ const result2 = await provider.upload(Buffer.from('test2'), 'test.txt')
115
+
116
+ expect(result1.filename).not.toBe('test.txt')
117
+ expect(result2.filename).not.toBe('test.txt')
118
+ expect(result1.filename).toMatch(/\d+-[a-f0-9]+\.txt/)
119
+ expect(result2.filename).toMatch(/\d+-[a-f0-9]+\.txt/)
120
+ })
121
+
122
+ it('should preserve original filename when generateUniqueFilenames is false', async () => {
123
+ const config: LocalStorageConfig = {
124
+ type: 'local',
125
+ uploadDir: './uploads',
126
+ serveUrl: '/uploads',
127
+ generateUniqueFilenames: false,
128
+ }
129
+ const provider = new LocalStorageProvider(config)
130
+
131
+ const result = await provider.upload(Buffer.from('test'), 'original.txt')
132
+
133
+ expect(result.filename).toBe('original.txt')
134
+ expect(result.url).toBe('/uploads/original.txt')
135
+ })
136
+
137
+ it('should preserve file extension when generating unique names', async () => {
138
+ const config: LocalStorageConfig = {
139
+ type: 'local',
140
+ uploadDir: './uploads',
141
+ serveUrl: '/uploads',
142
+ }
143
+ const provider = new LocalStorageProvider(config)
144
+
145
+ const resultTxt = await provider.upload(Buffer.from('test'), 'file.txt')
146
+ const resultJpg = await provider.upload(Buffer.from('test'), 'photo.jpg')
147
+ const resultNoExt = await provider.upload(Buffer.from('test'), 'noext')
148
+
149
+ expect(resultTxt.filename).toMatch(/\.txt$/)
150
+ expect(resultJpg.filename).toMatch(/\.jpg$/)
151
+ expect(resultNoExt.filename).not.toMatch(/\./)
152
+ })
153
+
154
+ it('should use provided content type', async () => {
155
+ const config: LocalStorageConfig = {
156
+ type: 'local',
157
+ uploadDir: './uploads',
158
+ serveUrl: '/uploads',
159
+ }
160
+ const provider = new LocalStorageProvider(config)
161
+
162
+ const result = await provider.upload(Buffer.from('test'), 'test.txt', {
163
+ contentType: 'text/plain',
164
+ })
165
+
166
+ expect(result.contentType).toBe('text/plain')
167
+ })
168
+
169
+ it('should store custom metadata', async () => {
170
+ const config: LocalStorageConfig = {
171
+ type: 'local',
172
+ uploadDir: './uploads',
173
+ serveUrl: '/uploads',
174
+ }
175
+ const provider = new LocalStorageProvider(config)
176
+ const metadata = { uploadedBy: 'user123', category: 'documents' }
177
+
178
+ const result = await provider.upload(Buffer.from('test'), 'test.txt', { metadata })
179
+
180
+ expect(result.metadata).toEqual(metadata)
181
+ })
182
+
183
+ it('should handle Uint8Array input', async () => {
184
+ const config: LocalStorageConfig = {
185
+ type: 'local',
186
+ uploadDir: './uploads',
187
+ serveUrl: '/uploads',
188
+ }
189
+ const provider = new LocalStorageProvider(config)
190
+ const uint8Array = new Uint8Array([1, 2, 3, 4, 5])
191
+
192
+ await provider.upload(uint8Array, 'binary.dat')
193
+
194
+ expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), uint8Array)
195
+ })
196
+
197
+ it('should construct correct file path', async () => {
198
+ const config: LocalStorageConfig = {
199
+ type: 'local',
200
+ uploadDir: './my-uploads',
201
+ serveUrl: '/files',
202
+ generateUniqueFilenames: false,
203
+ }
204
+ const provider = new LocalStorageProvider(config)
205
+
206
+ await provider.upload(Buffer.from('test'), 'test.txt')
207
+
208
+ expect(fs.writeFile).toHaveBeenCalledWith('my-uploads/test.txt', expect.any(Buffer))
209
+ })
210
+
211
+ it('should return correct URL', async () => {
212
+ const config: LocalStorageConfig = {
213
+ type: 'local',
214
+ uploadDir: './uploads',
215
+ serveUrl: 'https://example.com/files',
216
+ generateUniqueFilenames: false,
217
+ }
218
+ const provider = new LocalStorageProvider(config)
219
+
220
+ const result = await provider.upload(Buffer.from('test'), 'photo.jpg')
221
+
222
+ expect(result.url).toBe('https://example.com/files/photo.jpg')
223
+ })
224
+ })
225
+
226
+ describe('download', () => {
227
+ it('should download file successfully', async () => {
228
+ const config: LocalStorageConfig = {
229
+ type: 'local',
230
+ uploadDir: './uploads',
231
+ serveUrl: '/uploads',
232
+ }
233
+ const provider = new LocalStorageProvider(config)
234
+ const expectedBuffer = Buffer.from('downloaded file content')
235
+ fs.readFile.mockResolvedValueOnce(expectedBuffer)
236
+
237
+ const result = await provider.download('test-file.txt')
238
+
239
+ expect(fs.readFile).toHaveBeenCalledWith('uploads/test-file.txt')
240
+ expect(result).toBe(expectedBuffer)
241
+ })
242
+
243
+ it('should construct correct file path for download', async () => {
244
+ const config: LocalStorageConfig = {
245
+ type: 'local',
246
+ uploadDir: './my-storage',
247
+ serveUrl: '/files',
248
+ }
249
+ const provider = new LocalStorageProvider(config)
250
+
251
+ await provider.download('document.pdf')
252
+
253
+ expect(fs.readFile).toHaveBeenCalledWith('my-storage/document.pdf')
254
+ })
255
+
256
+ it('should propagate filesystem errors', async () => {
257
+ const config: LocalStorageConfig = {
258
+ type: 'local',
259
+ uploadDir: './uploads',
260
+ serveUrl: '/uploads',
261
+ }
262
+ const provider = new LocalStorageProvider(config)
263
+ fs.readFile.mockRejectedValueOnce(new Error('File not found'))
264
+
265
+ await expect(provider.download('nonexistent.txt')).rejects.toThrow('File not found')
266
+ })
267
+ })
268
+
269
+ describe('delete', () => {
270
+ it('should delete file successfully', async () => {
271
+ const config: LocalStorageConfig = {
272
+ type: 'local',
273
+ uploadDir: './uploads',
274
+ serveUrl: '/uploads',
275
+ }
276
+ const provider = new LocalStorageProvider(config)
277
+
278
+ await provider.delete('file-to-delete.txt')
279
+
280
+ expect(fs.unlink).toHaveBeenCalledWith('uploads/file-to-delete.txt')
281
+ })
282
+
283
+ it('should construct correct file path for deletion', async () => {
284
+ const config: LocalStorageConfig = {
285
+ type: 'local',
286
+ uploadDir: './my-storage/files',
287
+ serveUrl: '/files',
288
+ }
289
+ const provider = new LocalStorageProvider(config)
290
+
291
+ await provider.delete('old-file.jpg')
292
+
293
+ expect(fs.unlink).toHaveBeenCalledWith('my-storage/files/old-file.jpg')
294
+ })
295
+
296
+ it('should propagate filesystem errors on delete', async () => {
297
+ const config: LocalStorageConfig = {
298
+ type: 'local',
299
+ uploadDir: './uploads',
300
+ serveUrl: '/uploads',
301
+ }
302
+ const provider = new LocalStorageProvider(config)
303
+ fs.unlink.mockRejectedValueOnce(new Error('Permission denied'))
304
+
305
+ await expect(provider.delete('protected.txt')).rejects.toThrow('Permission denied')
306
+ })
307
+ })
308
+
309
+ describe('getUrl', () => {
310
+ it('should return correct URL for filename', () => {
311
+ const config: LocalStorageConfig = {
312
+ type: 'local',
313
+ uploadDir: './uploads',
314
+ serveUrl: '/uploads',
315
+ }
316
+ const provider = new LocalStorageProvider(config)
317
+
318
+ const url = provider.getUrl('photo.jpg')
319
+
320
+ expect(url).toBe('/uploads/photo.jpg')
321
+ })
322
+
323
+ it('should work with custom serve URL', () => {
324
+ const config: LocalStorageConfig = {
325
+ type: 'local',
326
+ uploadDir: './storage',
327
+ serveUrl: 'https://cdn.example.com/files',
328
+ }
329
+ const provider = new LocalStorageProvider(config)
330
+
331
+ const url = provider.getUrl('document.pdf')
332
+
333
+ expect(url).toBe('https://cdn.example.com/files/document.pdf')
334
+ })
335
+
336
+ it('should handle filenames with special characters', () => {
337
+ const config: LocalStorageConfig = {
338
+ type: 'local',
339
+ uploadDir: './uploads',
340
+ serveUrl: '/uploads',
341
+ }
342
+ const provider = new LocalStorageProvider(config)
343
+
344
+ const url = provider.getUrl('my file (1).txt')
345
+
346
+ expect(url).toBe('/uploads/my file (1).txt')
347
+ })
348
+ })
349
+ })
@@ -0,0 +1,313 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ validateFile,
4
+ formatFileSize,
5
+ getMimeType,
6
+ fileToBuffer,
7
+ parseFileFromFormData,
8
+ type FileValidationOptions,
9
+ } from '../src/utils/upload.js'
10
+
11
+ describe('Upload Utilities', () => {
12
+ describe('validateFile', () => {
13
+ it('should return valid when no options provided', () => {
14
+ const file = { size: 1000, name: 'test.txt', type: 'text/plain' }
15
+ const result = validateFile(file)
16
+ expect(result.valid).toBe(true)
17
+ expect(result.error).toBeUndefined()
18
+ })
19
+
20
+ it('should return valid when options are empty', () => {
21
+ const file = { size: 1000, name: 'test.txt', type: 'text/plain' }
22
+ const result = validateFile(file, {})
23
+ expect(result.valid).toBe(true)
24
+ })
25
+
26
+ describe('file size validation', () => {
27
+ it('should accept files within size limit', () => {
28
+ const file = { size: 1000, name: 'test.txt', type: 'text/plain' }
29
+ const options: FileValidationOptions = { maxFileSize: 2000 }
30
+ const result = validateFile(file, options)
31
+ expect(result.valid).toBe(true)
32
+ })
33
+
34
+ it('should reject files exceeding size limit', () => {
35
+ const file = { size: 3000, name: 'test.txt', type: 'text/plain' }
36
+ const options: FileValidationOptions = { maxFileSize: 2000 }
37
+ const result = validateFile(file, options)
38
+ expect(result.valid).toBe(false)
39
+ expect(result.error).toContain('File size exceeds maximum')
40
+ expect(result.error).toContain('1.95 KB')
41
+ })
42
+
43
+ it('should accept files exactly at size limit', () => {
44
+ const file = { size: 2000, name: 'test.txt', type: 'text/plain' }
45
+ const options: FileValidationOptions = { maxFileSize: 2000 }
46
+ const result = validateFile(file, options)
47
+ expect(result.valid).toBe(true)
48
+ })
49
+ })
50
+
51
+ describe('MIME type validation', () => {
52
+ it('should accept files with allowed MIME type', () => {
53
+ const file = { size: 1000, name: 'test.jpg', type: 'image/jpeg' }
54
+ const options: FileValidationOptions = {
55
+ acceptedMimeTypes: ['image/jpeg', 'image/png'],
56
+ }
57
+ const result = validateFile(file, options)
58
+ expect(result.valid).toBe(true)
59
+ })
60
+
61
+ it('should reject files with disallowed MIME type', () => {
62
+ const file = { size: 1000, name: 'test.gif', type: 'image/gif' }
63
+ const options: FileValidationOptions = {
64
+ acceptedMimeTypes: ['image/jpeg', 'image/png'],
65
+ }
66
+ const result = validateFile(file, options)
67
+ expect(result.valid).toBe(false)
68
+ expect(result.error).toContain('File type')
69
+ expect(result.error).toContain('image/gif')
70
+ expect(result.error).toContain('image/jpeg')
71
+ })
72
+
73
+ it('should fall back to filename extension when type is empty', () => {
74
+ const file = { size: 1000, name: 'test.jpg', type: '' }
75
+ const options: FileValidationOptions = {
76
+ acceptedMimeTypes: ['image/jpeg'],
77
+ }
78
+ const result = validateFile(file, options)
79
+ expect(result.valid).toBe(true)
80
+ })
81
+
82
+ it('should reject unknown MIME types when restrictions exist', () => {
83
+ const file = { size: 1000, name: 'test.xyz', type: '' }
84
+ const options: FileValidationOptions = {
85
+ acceptedMimeTypes: ['image/jpeg'],
86
+ }
87
+ const result = validateFile(file, options)
88
+ expect(result.valid).toBe(false)
89
+ })
90
+ })
91
+
92
+ describe('file extension validation', () => {
93
+ it('should accept files with allowed extension', () => {
94
+ const file = { size: 1000, name: 'test.jpg', type: 'image/jpeg' }
95
+ const options: FileValidationOptions = {
96
+ acceptedExtensions: ['.jpg', '.png'],
97
+ }
98
+ const result = validateFile(file, options)
99
+ expect(result.valid).toBe(true)
100
+ })
101
+
102
+ it('should reject files with disallowed extension', () => {
103
+ const file = { size: 1000, name: 'test.gif', type: 'image/gif' }
104
+ const options: FileValidationOptions = {
105
+ acceptedExtensions: ['.jpg', '.png'],
106
+ }
107
+ const result = validateFile(file, options)
108
+ expect(result.valid).toBe(false)
109
+ expect(result.error).toContain('File extension')
110
+ expect(result.error).toContain('.gif')
111
+ })
112
+
113
+ it('should handle case-insensitive extensions', () => {
114
+ const file = { size: 1000, name: 'test.JPG', type: 'image/jpeg' }
115
+ const options: FileValidationOptions = {
116
+ acceptedExtensions: ['.jpg'],
117
+ }
118
+ const result = validateFile(file, options)
119
+ expect(result.valid).toBe(true)
120
+ })
121
+
122
+ it('should handle files with multiple dots in name', () => {
123
+ const file = { size: 1000, name: 'my.test.file.jpg', type: 'image/jpeg' }
124
+ const options: FileValidationOptions = {
125
+ acceptedExtensions: ['.jpg'],
126
+ }
127
+ const result = validateFile(file, options)
128
+ expect(result.valid).toBe(true)
129
+ })
130
+ })
131
+
132
+ describe('combined validation', () => {
133
+ it('should validate all criteria when multiple options provided', () => {
134
+ const file = { size: 1000, name: 'test.jpg', type: 'image/jpeg' }
135
+ const options: FileValidationOptions = {
136
+ maxFileSize: 2000,
137
+ acceptedMimeTypes: ['image/jpeg'],
138
+ acceptedExtensions: ['.jpg'],
139
+ }
140
+ const result = validateFile(file, options)
141
+ expect(result.valid).toBe(true)
142
+ })
143
+
144
+ it('should fail if any criterion is not met', () => {
145
+ const file = { size: 3000, name: 'test.jpg', type: 'image/jpeg' }
146
+ const options: FileValidationOptions = {
147
+ maxFileSize: 2000,
148
+ acceptedMimeTypes: ['image/jpeg'],
149
+ acceptedExtensions: ['.jpg'],
150
+ }
151
+ const result = validateFile(file, options)
152
+ expect(result.valid).toBe(false)
153
+ })
154
+ })
155
+ })
156
+
157
+ describe('formatFileSize', () => {
158
+ it('should format 0 bytes', () => {
159
+ expect(formatFileSize(0)).toBe('0 Bytes')
160
+ })
161
+
162
+ it('should format bytes', () => {
163
+ expect(formatFileSize(500)).toBe('500 Bytes')
164
+ expect(formatFileSize(1023)).toBe('1023 Bytes')
165
+ })
166
+
167
+ it('should format kilobytes', () => {
168
+ expect(formatFileSize(1024)).toBe('1 KB')
169
+ expect(formatFileSize(2048)).toBe('2 KB')
170
+ expect(formatFileSize(1536)).toBe('1.5 KB')
171
+ })
172
+
173
+ it('should format megabytes', () => {
174
+ expect(formatFileSize(1024 * 1024)).toBe('1 MB')
175
+ expect(formatFileSize(5 * 1024 * 1024)).toBe('5 MB')
176
+ expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB')
177
+ })
178
+
179
+ it('should format gigabytes', () => {
180
+ expect(formatFileSize(1024 * 1024 * 1024)).toBe('1 GB')
181
+ expect(formatFileSize(2.5 * 1024 * 1024 * 1024)).toBe('2.5 GB')
182
+ })
183
+
184
+ it('should round to 2 decimal places', () => {
185
+ expect(formatFileSize(1536)).toBe('1.5 KB')
186
+ expect(formatFileSize(1555)).toBe('1.52 KB')
187
+ expect(formatFileSize(1666)).toBe('1.63 KB')
188
+ })
189
+ })
190
+
191
+ describe('getMimeType', () => {
192
+ it('should return correct MIME type for common extensions', () => {
193
+ expect(getMimeType('test.jpg')).toBe('image/jpeg')
194
+ expect(getMimeType('test.jpeg')).toBe('image/jpeg')
195
+ expect(getMimeType('test.png')).toBe('image/png')
196
+ expect(getMimeType('test.gif')).toBe('image/gif')
197
+ expect(getMimeType('test.pdf')).toBe('application/pdf')
198
+ expect(getMimeType('test.txt')).toBe('text/plain')
199
+ expect(getMimeType('test.html')).toBe('text/html')
200
+ expect(getMimeType('test.json')).toBe('application/json')
201
+ })
202
+
203
+ it('should handle uppercase extensions', () => {
204
+ expect(getMimeType('test.JPG')).toBe('image/jpeg')
205
+ expect(getMimeType('test.PNG')).toBe('image/png')
206
+ })
207
+
208
+ it('should return default MIME type for unknown extensions', () => {
209
+ // Note: .xyz is recognized by mime-types as 'chemical/x-xyz'
210
+ // Using a truly unknown extension instead
211
+ expect(getMimeType('test.unknownext')).toBe('application/octet-stream')
212
+ expect(getMimeType('noextension')).toBe('application/octet-stream')
213
+ })
214
+
215
+ it('should handle files with multiple dots', () => {
216
+ expect(getMimeType('my.test.file.jpg')).toBe('image/jpeg')
217
+ })
218
+ })
219
+
220
+ describe('fileToBuffer', () => {
221
+ it('should convert File to Buffer', async () => {
222
+ // Create a mock File object
223
+ const content = 'test file content'
224
+ const blob = new Blob([content], { type: 'text/plain' })
225
+ const file = new File([blob], 'test.txt', { type: 'text/plain' })
226
+
227
+ const buffer = await fileToBuffer(file)
228
+
229
+ expect(buffer).toBeInstanceOf(Buffer)
230
+ expect(buffer.toString()).toBe(content)
231
+ })
232
+
233
+ it('should convert Blob to Buffer', async () => {
234
+ const content = 'test blob content'
235
+ const blob = new Blob([content], { type: 'text/plain' })
236
+
237
+ const buffer = await fileToBuffer(blob)
238
+
239
+ expect(buffer).toBeInstanceOf(Buffer)
240
+ expect(buffer.toString()).toBe(content)
241
+ })
242
+
243
+ it('should handle binary data', async () => {
244
+ const binaryData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]) // "Hello"
245
+ const blob = new Blob([binaryData])
246
+
247
+ const buffer = await fileToBuffer(blob)
248
+
249
+ expect(buffer).toBeInstanceOf(Buffer)
250
+ expect(buffer.toString()).toBe('Hello')
251
+ })
252
+ })
253
+
254
+ describe('parseFileFromFormData', () => {
255
+ it('should extract file from FormData with default field name', async () => {
256
+ const content = 'test file content'
257
+ const file = new File([content], 'test.txt', { type: 'text/plain' })
258
+ const formData = new FormData()
259
+ formData.append('file', file)
260
+
261
+ const result = await parseFileFromFormData(formData)
262
+
263
+ expect(result).not.toBeNull()
264
+ expect(result!.file).toBeInstanceOf(File)
265
+ expect(result!.file.name).toBe('test.txt')
266
+ expect(result!.buffer).toBeInstanceOf(Buffer)
267
+ expect(result!.buffer.toString()).toBe(content)
268
+ })
269
+
270
+ it('should extract file from FormData with custom field name', async () => {
271
+ const content = 'custom field content'
272
+ const file = new File([content], 'custom.txt', { type: 'text/plain' })
273
+ const formData = new FormData()
274
+ formData.append('customField', file)
275
+
276
+ const result = await parseFileFromFormData(formData, 'customField')
277
+
278
+ expect(result).not.toBeNull()
279
+ expect(result!.file.name).toBe('custom.txt')
280
+ expect(result!.buffer.toString()).toBe(content)
281
+ })
282
+
283
+ it('should return null when field does not exist', async () => {
284
+ const formData = new FormData()
285
+
286
+ const result = await parseFileFromFormData(formData)
287
+
288
+ expect(result).toBeNull()
289
+ })
290
+
291
+ it('should return null when field is not a File', async () => {
292
+ const formData = new FormData()
293
+ formData.append('file', 'not a file')
294
+
295
+ const result = await parseFileFromFormData(formData)
296
+
297
+ expect(result).toBeNull()
298
+ })
299
+
300
+ it('should handle multiple files but only return first', async () => {
301
+ const file1 = new File(['content1'], 'file1.txt')
302
+ const file2 = new File(['content2'], 'file2.txt')
303
+ const formData = new FormData()
304
+ formData.append('file', file1)
305
+ formData.append('file', file2)
306
+
307
+ const result = await parseFileFromFormData(formData)
308
+
309
+ expect(result).not.toBeNull()
310
+ expect(result!.file.name).toBe('file1.txt')
311
+ })
312
+ })
313
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ coverage: {
8
+ provider: 'v8',
9
+ reporter: ['text', 'json', 'html', 'json-summary'],
10
+ include: ['src/**/*.ts'],
11
+ exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts', 'dist/**', 'node_modules/**'],
12
+ },
13
+ },
14
+ })