@opensaas/stack-storage 0.21.0 → 0.22.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.
@@ -0,0 +1,308 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ IMAGE_COLUMN_PARTS,
4
+ FILE_COLUMN_PARTS,
5
+ keystoneImageColumnMap,
6
+ keystoneFileColumnMap,
7
+ resolveImageColumnMap,
8
+ resolveFileColumnMap,
9
+ imageColumnDescriptors,
10
+ fileColumnDescriptors,
11
+ imageColumnNames,
12
+ fileColumnNames,
13
+ assembleImageMetadata,
14
+ splitImageMetadata,
15
+ assembleFileMetadata,
16
+ splitFileMetadata,
17
+ } from '../src/utils/multi-column.js'
18
+ import type { ImageMetadata, FileMetadata } from '../src/config/types.js'
19
+
20
+ describe('multi-column mapper', () => {
21
+ describe('column-name maps', () => {
22
+ it('builds Keystone default image column names', () => {
23
+ expect(keystoneImageColumnMap('image')).toEqual({
24
+ url: 'image_url',
25
+ width: 'image_width',
26
+ height: 'image_height',
27
+ filesize: 'image_filesize',
28
+ contentType: 'image_contentType',
29
+ contentDisposition: 'image_contentDisposition',
30
+ pathname: 'image_pathname',
31
+ })
32
+ })
33
+
34
+ it('builds Keystone default file column names', () => {
35
+ expect(keystoneFileColumnMap('doc')).toEqual({
36
+ filename: 'doc_filename',
37
+ filesize: 'doc_filesize',
38
+ url: 'doc_url',
39
+ })
40
+ })
41
+
42
+ it('applies per-part image overrides on top of defaults', () => {
43
+ const map = resolveImageColumnMap('avatar', { url: 'avatar_link', width: 'avatar_w' })
44
+ expect(map.url).toBe('avatar_link')
45
+ expect(map.width).toBe('avatar_w')
46
+ // Un-overridden parts keep the Keystone default.
47
+ expect(map.height).toBe('avatar_height')
48
+ expect(map.pathname).toBe('avatar_pathname')
49
+ })
50
+
51
+ it('applies per-part file overrides on top of defaults', () => {
52
+ const map = resolveFileColumnMap('resume', { url: 'resume_href' })
53
+ expect(map.url).toBe('resume_href')
54
+ expect(map.filename).toBe('resume_filename')
55
+ })
56
+ })
57
+
58
+ describe('descriptors / column names', () => {
59
+ it('emits seven image columns with @map names', () => {
60
+ const map = keystoneImageColumnMap('image')
61
+ const descriptors = imageColumnDescriptors(map)
62
+ expect(descriptors).toHaveLength(7)
63
+ expect(descriptors.map((d) => d.map)).toEqual([
64
+ 'image_url',
65
+ 'image_width',
66
+ 'image_height',
67
+ 'image_filesize',
68
+ 'image_contentType',
69
+ 'image_contentDisposition',
70
+ 'image_pathname',
71
+ ])
72
+ // width/height/filesize are Int, the rest String.
73
+ const byName = Object.fromEntries(descriptors.map((d) => [d.map, d.type]))
74
+ expect(byName.image_width).toBe('Int')
75
+ expect(byName.image_height).toBe('Int')
76
+ expect(byName.image_filesize).toBe('Int')
77
+ expect(byName.image_url).toBe('String')
78
+ expect(byName.image_contentType).toBe('String')
79
+ })
80
+
81
+ it('emits three file columns with @map names', () => {
82
+ const descriptors = fileColumnDescriptors(keystoneFileColumnMap('doc'))
83
+ expect(descriptors.map((d) => d.map)).toEqual(['doc_filename', 'doc_filesize', 'doc_url'])
84
+ })
85
+
86
+ it('lists column names for read stripping', () => {
87
+ expect(imageColumnNames(keystoneImageColumnMap('image'))).toHaveLength(7)
88
+ expect(fileColumnNames(keystoneFileColumnMap('doc'))).toEqual([
89
+ 'doc_filename',
90
+ 'doc_filesize',
91
+ 'doc_url',
92
+ ])
93
+ expect(IMAGE_COLUMN_PARTS).toHaveLength(7)
94
+ expect(FILE_COLUMN_PARTS).toHaveLength(3)
95
+ })
96
+ })
97
+
98
+ describe('image assemble/split round-trip', () => {
99
+ const map = keystoneImageColumnMap('image')
100
+
101
+ it('assembles a fully-populated row into ImageMetadata', () => {
102
+ const row = {
103
+ image_url: 'https://cdn.example.com/photo.jpg',
104
+ image_width: 800,
105
+ image_height: 600,
106
+ image_filesize: 12345,
107
+ image_contentType: 'image/jpeg',
108
+ image_contentDisposition: 'inline',
109
+ image_pathname: 'photo.jpg',
110
+ }
111
+ const meta = assembleImageMetadata(row, map, 'images')
112
+ expect(meta).toEqual({
113
+ filename: 'photo.jpg',
114
+ originalFilename: 'photo.jpg',
115
+ url: 'https://cdn.example.com/photo.jpg',
116
+ mimeType: 'image/jpeg',
117
+ size: 12345,
118
+ width: 800,
119
+ height: 600,
120
+ uploadedAt: '',
121
+ storageProvider: 'images',
122
+ metadata: { contentDisposition: 'inline' },
123
+ })
124
+ })
125
+
126
+ it('round-trips columns → metadata → columns', () => {
127
+ const row = {
128
+ image_url: 'https://cdn.example.com/photo.jpg',
129
+ image_width: 800,
130
+ image_height: 600,
131
+ image_filesize: 12345,
132
+ image_contentType: 'image/jpeg',
133
+ image_contentDisposition: 'inline',
134
+ image_pathname: 'photo.jpg',
135
+ }
136
+ const meta = assembleImageMetadata(row, map, 'images')
137
+ const columns = splitImageMetadata(meta, map)
138
+ expect(columns).toEqual(row)
139
+ })
140
+
141
+ it('assembles a partially-populated row (only image_url) into a valid value', () => {
142
+ const meta = assembleImageMetadata(
143
+ { image_url: 'https://cdn.example.com/only.png' },
144
+ map,
145
+ 'images',
146
+ )
147
+ expect(meta).not.toBeNull()
148
+ expect(meta?.url).toBe('https://cdn.example.com/only.png')
149
+ // Missing pathname falls back to the URL for filename.
150
+ expect(meta?.filename).toBe('https://cdn.example.com/only.png')
151
+ // Missing scalar parts default to 0.
152
+ expect(meta?.width).toBe(0)
153
+ expect(meta?.height).toBe(0)
154
+ expect(meta?.size).toBe(0)
155
+ // Missing contentType defaults to octet-stream; no contentDisposition metadata.
156
+ expect(meta?.mimeType).toBe('application/octet-stream')
157
+ expect(meta?.metadata).toBeUndefined()
158
+ })
159
+
160
+ it('returns null for a row with no url (empty image)', () => {
161
+ expect(assembleImageMetadata({}, map, 'images')).toBeNull()
162
+ expect(
163
+ assembleImageMetadata(
164
+ { image_url: null, image_width: null, image_pathname: null },
165
+ map,
166
+ 'images',
167
+ ),
168
+ ).toBeNull()
169
+ })
170
+
171
+ it('coerces string-typed numeric columns (e.g. from raw SQL) to numbers', () => {
172
+ const meta = assembleImageMetadata(
173
+ { image_url: 'u', image_width: '640', image_height: '480', image_filesize: '999' },
174
+ map,
175
+ 'images',
176
+ )
177
+ expect(meta?.width).toBe(640)
178
+ expect(meta?.height).toBe(480)
179
+ expect(meta?.size).toBe(999)
180
+ })
181
+
182
+ it('splits null metadata into all-null columns (clears the field)', () => {
183
+ expect(splitImageMetadata(null, map)).toEqual({
184
+ image_url: null,
185
+ image_width: null,
186
+ image_height: null,
187
+ image_filesize: null,
188
+ image_contentType: null,
189
+ image_contentDisposition: null,
190
+ image_pathname: null,
191
+ })
192
+ })
193
+
194
+ it('honours per-part @map overrides on round-trip', () => {
195
+ const custom = resolveImageColumnMap('image', { url: 'image_link' })
196
+ const row = { image_link: 'https://x/y.jpg', image_pathname: 'y.jpg' }
197
+ const meta = assembleImageMetadata(row, custom, 'images')
198
+ expect(meta?.url).toBe('https://x/y.jpg')
199
+ const split = splitImageMetadata(meta, custom)
200
+ expect(split.image_link).toBe('https://x/y.jpg')
201
+ })
202
+ })
203
+
204
+ describe('file assemble/split round-trip', () => {
205
+ const map = keystoneFileColumnMap('doc')
206
+
207
+ it('assembles a populated row into FileMetadata', () => {
208
+ const meta = assembleFileMetadata(
209
+ { doc_filename: 'report.pdf', doc_filesize: 4096, doc_url: 'https://cdn/report.pdf' },
210
+ map,
211
+ 'documents',
212
+ )
213
+ expect(meta).toEqual({
214
+ filename: 'report.pdf',
215
+ originalFilename: 'report.pdf',
216
+ url: 'https://cdn/report.pdf',
217
+ mimeType: 'application/octet-stream',
218
+ size: 4096,
219
+ uploadedAt: '',
220
+ storageProvider: 'documents',
221
+ })
222
+ })
223
+
224
+ it('round-trips file columns → metadata → columns', () => {
225
+ const row = {
226
+ doc_filename: 'report.pdf',
227
+ doc_filesize: 4096,
228
+ doc_url: 'https://cdn/report.pdf',
229
+ }
230
+ const meta = assembleFileMetadata(row, map, 'documents')
231
+ expect(splitFileMetadata(meta, map)).toEqual(row)
232
+ })
233
+
234
+ it('assembles a partial row (only url) into a valid value', () => {
235
+ const meta = assembleFileMetadata({ doc_url: 'https://cdn/only.pdf' }, map, 'documents')
236
+ expect(meta).not.toBeNull()
237
+ expect(meta?.url).toBe('https://cdn/only.pdf')
238
+ expect(meta?.filename).toBe('https://cdn/only.pdf')
239
+ expect(meta?.size).toBe(0)
240
+ })
241
+
242
+ it('assembles a partial row (only filename) into a valid value', () => {
243
+ const meta = assembleFileMetadata({ doc_filename: 'legacy.bin' }, map, 'documents')
244
+ expect(meta).not.toBeNull()
245
+ expect(meta?.filename).toBe('legacy.bin')
246
+ expect(meta?.url).toBe('')
247
+ })
248
+
249
+ it('returns null for an empty file row', () => {
250
+ expect(assembleFileMetadata({}, map, 'documents')).toBeNull()
251
+ expect(
252
+ assembleFileMetadata({ doc_url: null, doc_filename: null }, map, 'documents'),
253
+ ).toBeNull()
254
+ })
255
+
256
+ it('splits null metadata into all-null columns', () => {
257
+ expect(splitFileMetadata(null, map)).toEqual({
258
+ doc_filename: null,
259
+ doc_filesize: null,
260
+ doc_url: null,
261
+ })
262
+ })
263
+ })
264
+
265
+ describe('typed round-trips with real metadata objects', () => {
266
+ it('image: a freshly-uploaded ImageMetadata splits and re-assembles consistently', () => {
267
+ const map = keystoneImageColumnMap('hero')
268
+ const uploaded: ImageMetadata = {
269
+ filename: 'abc123.webp',
270
+ originalFilename: 'hero.webp',
271
+ url: '/uploads/abc123.webp',
272
+ mimeType: 'image/webp',
273
+ size: 2048,
274
+ width: 1200,
275
+ height: 630,
276
+ uploadedAt: new Date().toISOString(),
277
+ storageProvider: 'images',
278
+ }
279
+ const cols = splitImageMetadata(uploaded, map)
280
+ const back = assembleImageMetadata(cols, map, 'images')
281
+ expect(back?.url).toBe(uploaded.url)
282
+ expect(back?.width).toBe(uploaded.width)
283
+ expect(back?.height).toBe(uploaded.height)
284
+ expect(back?.size).toBe(uploaded.size)
285
+ expect(back?.mimeType).toBe(uploaded.mimeType)
286
+ // pathname stores the storage key (our filename).
287
+ expect(back?.filename).toBe(uploaded.filename)
288
+ })
289
+
290
+ it('file: a freshly-uploaded FileMetadata splits and re-assembles consistently', () => {
291
+ const map = keystoneFileColumnMap('attachment')
292
+ const uploaded: FileMetadata = {
293
+ filename: 'xyz.pdf',
294
+ originalFilename: 'invoice.pdf',
295
+ url: '/uploads/xyz.pdf',
296
+ mimeType: 'application/pdf',
297
+ size: 9999,
298
+ uploadedAt: new Date().toISOString(),
299
+ storageProvider: 'documents',
300
+ }
301
+ const cols = splitFileMetadata(uploaded, map)
302
+ const back = assembleFileMetadata(cols, map, 'documents')
303
+ expect(back?.url).toBe(uploaded.url)
304
+ expect(back?.size).toBe(uploaded.size)
305
+ expect(back?.filename).toBe(uploaded.filename)
306
+ })
307
+ })
308
+ })