@opensaas/stack-core 0.6.2 → 0.9.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,421 @@
1
+ import { describe, test, expect } from 'vitest'
2
+ import { virtual } from '../src/fields/index.js'
3
+
4
+ // Mock Decimal class for testing
5
+ class Decimal {
6
+ value: number
7
+
8
+ constructor(value: number | string) {
9
+ this.value = typeof value === 'string' ? parseFloat(value) : value
10
+ }
11
+
12
+ times(other: Decimal | number): Decimal {
13
+ const otherValue = other instanceof Decimal ? other.value : other
14
+ return new Decimal(this.value * otherValue)
15
+ }
16
+
17
+ plus(other: Decimal | number): Decimal {
18
+ const otherValue = other instanceof Decimal ? other.value : other
19
+ return new Decimal(this.value + otherValue)
20
+ }
21
+
22
+ toString(): string {
23
+ return this.value.toString()
24
+ }
25
+
26
+ static get name() {
27
+ return 'Decimal'
28
+ }
29
+ }
30
+
31
+ // Mock custom class for testing
32
+ class CustomType {
33
+ data: string
34
+
35
+ constructor(data: string) {
36
+ this.data = data
37
+ }
38
+
39
+ static get name() {
40
+ return 'CustomType'
41
+ }
42
+ }
43
+
44
+ describe('Virtual Fields with TypeDescriptor', () => {
45
+ describe('primitive type strings', () => {
46
+ test('accepts string type', () => {
47
+ const field = virtual({
48
+ type: 'string',
49
+ hooks: {
50
+ resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`,
51
+ },
52
+ })
53
+
54
+ expect(field.type).toBe('virtual')
55
+ expect(field.virtual).toBe(true)
56
+ expect(field.outputType).toBe('string')
57
+ })
58
+
59
+ test('accepts number type', () => {
60
+ const field = virtual({
61
+ type: 'number',
62
+ hooks: {
63
+ resolveOutput: ({ item }) => item.count * 2,
64
+ },
65
+ })
66
+
67
+ expect(field.outputType).toBe('number')
68
+ })
69
+
70
+ test('accepts boolean type', () => {
71
+ const field = virtual({
72
+ type: 'boolean',
73
+ hooks: {
74
+ resolveOutput: ({ item }) => item.isActive,
75
+ },
76
+ })
77
+
78
+ expect(field.outputType).toBe('boolean')
79
+ })
80
+
81
+ test('accepts Date type', () => {
82
+ const field = virtual({
83
+ type: 'Date',
84
+ hooks: {
85
+ resolveOutput: ({ item }) => new Date(item.createdAt),
86
+ },
87
+ })
88
+
89
+ expect(field.outputType).toBe('Date')
90
+ })
91
+
92
+ test('accepts array types', () => {
93
+ const field = virtual({
94
+ type: 'string[]',
95
+ hooks: {
96
+ resolveOutput: ({ item }) => item.tags.split(','),
97
+ },
98
+ })
99
+
100
+ expect(field.outputType).toBe('string[]')
101
+ })
102
+ })
103
+
104
+ describe('import string types', () => {
105
+ test('accepts import string format', () => {
106
+ const field = virtual({
107
+ type: "import('decimal.js').Decimal",
108
+ hooks: {
109
+ resolveOutput: ({ item }) => new Decimal(item.price),
110
+ },
111
+ })
112
+
113
+ expect(field.outputType).toBe("import('decimal.js').Decimal")
114
+ })
115
+
116
+ test('generates TypeScript imports from import string', () => {
117
+ const field = virtual({
118
+ type: "import('decimal.js').Decimal",
119
+ hooks: {
120
+ resolveOutput: ({ item }) => new Decimal(item.price),
121
+ },
122
+ })
123
+
124
+ expect(field.getTypeScriptImports).toBeDefined()
125
+ const imports = field.getTypeScriptImports!()
126
+ expect(imports).toHaveLength(1)
127
+ expect(imports[0]).toEqual({
128
+ names: ['Decimal'],
129
+ from: 'decimal.js',
130
+ typeOnly: true,
131
+ })
132
+ })
133
+
134
+ test('handles complex import paths', () => {
135
+ const field = virtual({
136
+ type: "import('@myorg/custom-types').MyCustomType",
137
+ hooks: {
138
+ resolveOutput: ({ item }) => item.customData,
139
+ },
140
+ })
141
+
142
+ expect(field.outputType).toBe("import('@myorg/custom-types').MyCustomType")
143
+ const imports = field.getTypeScriptImports!()
144
+ expect(imports[0]).toEqual({
145
+ names: ['MyCustomType'],
146
+ from: '@myorg/custom-types',
147
+ typeOnly: true,
148
+ })
149
+ })
150
+ })
151
+
152
+ describe('type descriptor objects', () => {
153
+ test('accepts type descriptor with class constructor', () => {
154
+ const field = virtual({
155
+ type: { value: Decimal, from: 'decimal.js' },
156
+ hooks: {
157
+ resolveOutput: ({ item }) => new Decimal(item.price).times(item.quantity),
158
+ },
159
+ })
160
+
161
+ expect(field.outputType).toBe("import('decimal.js').Decimal")
162
+ })
163
+
164
+ test('generates TypeScript imports from type descriptor', () => {
165
+ const field = virtual({
166
+ type: { value: Decimal, from: 'decimal.js' },
167
+ hooks: {
168
+ resolveOutput: ({ item }) => new Decimal(item.price),
169
+ },
170
+ })
171
+
172
+ expect(field.getTypeScriptImports).toBeDefined()
173
+ const imports = field.getTypeScriptImports!()
174
+ expect(imports).toHaveLength(1)
175
+ expect(imports[0]).toEqual({
176
+ names: ['Decimal'],
177
+ from: 'decimal.js',
178
+ typeOnly: true,
179
+ })
180
+ })
181
+
182
+ test('uses custom name when provided', () => {
183
+ const field = virtual({
184
+ type: { value: Decimal, from: 'decimal.js', name: 'CustomDecimal' },
185
+ hooks: {
186
+ resolveOutput: ({ item }) => new Decimal(item.value),
187
+ },
188
+ })
189
+
190
+ expect(field.outputType).toBe("import('decimal.js').CustomDecimal")
191
+ const imports = field.getTypeScriptImports!()
192
+ expect(imports[0]).toEqual({
193
+ names: ['CustomDecimal'],
194
+ from: 'decimal.js',
195
+ typeOnly: true,
196
+ })
197
+ })
198
+
199
+ test('works with custom classes', () => {
200
+ const field = virtual({
201
+ type: { value: CustomType, from: './types' },
202
+ hooks: {
203
+ resolveOutput: ({ item }) => new CustomType(item.data),
204
+ },
205
+ })
206
+
207
+ expect(field.outputType).toBe("import('./types').CustomType")
208
+ const imports = field.getTypeScriptImports!()
209
+ expect(imports[0]).toEqual({
210
+ names: ['CustomType'],
211
+ from: './types',
212
+ typeOnly: true,
213
+ })
214
+ })
215
+ })
216
+
217
+ describe('getTypeScriptType', () => {
218
+ test('returns correct type for primitive string', () => {
219
+ const field = virtual({
220
+ type: 'string',
221
+ hooks: {
222
+ resolveOutput: ({ item }) => item.name,
223
+ },
224
+ })
225
+
226
+ const tsType = field.getTypeScriptType!()
227
+ expect(tsType.type).toBe('string')
228
+ expect(tsType.optional).toBe(false)
229
+ })
230
+
231
+ test('returns correct type for import string', () => {
232
+ const field = virtual({
233
+ type: "import('decimal.js').Decimal",
234
+ hooks: {
235
+ resolveOutput: ({ item }) => new Decimal(item.value),
236
+ },
237
+ })
238
+
239
+ const tsType = field.getTypeScriptType!()
240
+ expect(tsType.type).toBe("import('decimal.js').Decimal")
241
+ expect(tsType.optional).toBe(false)
242
+ })
243
+
244
+ test('returns correct type for type descriptor', () => {
245
+ const field = virtual({
246
+ type: { value: Decimal, from: 'decimal.js' },
247
+ hooks: {
248
+ resolveOutput: ({ item }) => new Decimal(item.value),
249
+ },
250
+ })
251
+
252
+ const tsType = field.getTypeScriptType!()
253
+ expect(tsType.type).toBe("import('decimal.js').Decimal")
254
+ expect(tsType.optional).toBe(false)
255
+ })
256
+
257
+ test('virtual fields are never optional', () => {
258
+ const field = virtual({
259
+ type: 'string',
260
+ hooks: {
261
+ resolveOutput: ({ item }) => item.value || 'default',
262
+ },
263
+ })
264
+
265
+ const tsType = field.getTypeScriptType!()
266
+ expect(tsType.optional).toBe(false)
267
+ })
268
+ })
269
+
270
+ describe('validation', () => {
271
+ test('throws error when resolveOutput hook is missing', () => {
272
+ expect(() => {
273
+ virtual({
274
+ type: 'string',
275
+ // @ts-expect-error - Testing missing hook
276
+ hooks: {},
277
+ })
278
+ }).toThrow('Virtual fields must provide a resolveOutput hook')
279
+ })
280
+
281
+ test('throws error when hooks are completely missing', () => {
282
+ expect(() => {
283
+ // @ts-expect-error - Testing missing hooks
284
+ virtual({
285
+ type: 'string',
286
+ })
287
+ }).toThrow('Virtual fields must provide a resolveOutput hook')
288
+ })
289
+ })
290
+
291
+ describe('getPrismaType', () => {
292
+ test('returns undefined to skip database column creation', () => {
293
+ const field = virtual({
294
+ type: 'string',
295
+ hooks: {
296
+ resolveOutput: ({ item }) => item.computed,
297
+ },
298
+ })
299
+
300
+ expect(field.getPrismaType).toBeUndefined()
301
+ })
302
+ })
303
+
304
+ describe('getZodSchema', () => {
305
+ test('returns z.never() to prevent input validation', () => {
306
+ const field = virtual({
307
+ type: 'string',
308
+ hooks: {
309
+ resolveOutput: ({ item }) => item.computed,
310
+ },
311
+ })
312
+
313
+ const schema = field.getZodSchema!()
314
+ expect(() => schema.parse('anything')).toThrow()
315
+ })
316
+ })
317
+
318
+ describe('real-world use cases', () => {
319
+ test('Decimal type for financial calculations', () => {
320
+ const field = virtual({
321
+ type: { value: Decimal, from: 'decimal.js' },
322
+ hooks: {
323
+ resolveOutput: ({ item }) => {
324
+ // Calculate total from price and quantity with decimal precision
325
+ return new Decimal(item.price).times(item.quantity)
326
+ },
327
+ },
328
+ })
329
+
330
+ expect(field.type).toBe('virtual')
331
+ expect(field.outputType).toBe("import('decimal.js').Decimal")
332
+
333
+ const imports = field.getTypeScriptImports!()
334
+ expect(imports).toEqual([
335
+ {
336
+ names: ['Decimal'],
337
+ from: 'decimal.js',
338
+ typeOnly: true,
339
+ },
340
+ ])
341
+ })
342
+
343
+ test('Complex computed field with multiple operations', () => {
344
+ const field = virtual({
345
+ type: { value: Decimal, from: 'decimal.js' },
346
+ hooks: {
347
+ resolveOutput: ({ item }) => {
348
+ // Calculate tax amount
349
+ const subtotal = new Decimal(item.subtotal)
350
+ const taxRate = new Decimal(item.taxRate)
351
+ return subtotal.times(taxRate)
352
+ },
353
+ },
354
+ })
355
+
356
+ expect(field.outputType).toBe("import('decimal.js').Decimal")
357
+ })
358
+
359
+ test('Full name concatenation (primitive type)', () => {
360
+ const field = virtual({
361
+ type: 'string',
362
+ hooks: {
363
+ resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`,
364
+ },
365
+ })
366
+
367
+ expect(field.outputType).toBe('string')
368
+ expect(field.getTypeScriptImports).toBeUndefined()
369
+ })
370
+
371
+ test('Array transformation', () => {
372
+ const field = virtual({
373
+ type: 'string[]',
374
+ hooks: {
375
+ resolveOutput: ({ item }) => {
376
+ // Split comma-separated tags into array
377
+ return item.tags ? item.tags.split(',').map((t: string) => t.trim()) : []
378
+ },
379
+ },
380
+ })
381
+
382
+ expect(field.outputType).toBe('string[]')
383
+ })
384
+ })
385
+
386
+ describe('backwards compatibility', () => {
387
+ test('existing primitive type strings still work', () => {
388
+ const field = virtual({
389
+ type: 'string',
390
+ hooks: {
391
+ resolveOutput: ({ item }) => item.value,
392
+ },
393
+ })
394
+
395
+ expect(field.type).toBe('virtual')
396
+ expect(field.outputType).toBe('string')
397
+ expect(field.getTypeScriptImports).toBeUndefined()
398
+ })
399
+
400
+ test('existing field configuration is preserved', () => {
401
+ const field = virtual({
402
+ type: 'number',
403
+ hooks: {
404
+ resolveOutput: ({ item }) => item.count,
405
+ resolveInput: async ({ inputValue }) => inputValue,
406
+ },
407
+ access: {
408
+ read: () => true,
409
+ },
410
+ ui: {
411
+ displayMode: 'readonly',
412
+ },
413
+ })
414
+
415
+ expect(field.hooks?.resolveOutput).toBeDefined()
416
+ expect(field.hooks?.resolveInput).toBeDefined()
417
+ expect(field.access).toBeDefined()
418
+ expect(field.ui).toBeDefined()
419
+ })
420
+ })
421
+ })