@opensaas/stack-core 0.20.1 → 0.21.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +72 -0
- package/CLAUDE.md +18 -2
- package/dist/access/access-filter.d.ts +29 -0
- package/dist/access/access-filter.d.ts.map +1 -0
- package/dist/access/access-filter.js +68 -0
- package/dist/access/access-filter.js.map +1 -0
- package/dist/access/engine.d.ts +15 -48
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +14 -280
- package/dist/access/engine.js.map +1 -1
- package/dist/access/field-access.d.ts +44 -0
- package/dist/access/field-access.d.ts.map +1 -0
- package/dist/access/field-access.js +123 -0
- package/dist/access/field-access.js.map +1 -0
- package/dist/access/field-access.test.d.ts +2 -0
- package/dist/access/field-access.test.d.ts.map +1 -0
- package/dist/access/{engine.test.js → field-access.test.js} +2 -2
- package/dist/access/field-access.test.js.map +1 -0
- package/dist/access/field-visibility.d.ts +13 -0
- package/dist/access/field-visibility.d.ts.map +1 -0
- package/dist/access/field-visibility.js +155 -0
- package/dist/access/field-visibility.js.map +1 -0
- package/dist/access/index.d.ts +4 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +8 -1
- package/dist/access/index.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +45 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +49 -0
- package/dist/context/hook-pipeline.d.ts.map +1 -0
- package/dist/context/hook-pipeline.js +75 -0
- package/dist/context/hook-pipeline.js.map +1 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +30 -462
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +72 -68
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +158 -0
- package/dist/context/write-pipeline.d.ts.map +1 -0
- package/dist/context/write-pipeline.js +306 -0
- package/dist/context/write-pipeline.js.map +1 -0
- package/dist/extend.d.ts +3 -0
- package/dist/extend.d.ts.map +1 -0
- package/dist/extend.js +10 -0
- package/dist/extend.js.map +1 -0
- package/dist/fields/index.d.ts +1 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +213 -2
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +20 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +202 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +5 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -10
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +8 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +16 -0
- package/dist/internal.js.map +1 -0
- package/dist/validation/field-config.d.ts +55 -0
- package/dist/validation/field-config.d.ts.map +1 -0
- package/dist/validation/field-config.js +100 -0
- package/dist/validation/field-config.js.map +1 -0
- package/dist/validation/field-config.test.d.ts +2 -0
- package/dist/validation/field-config.test.d.ts.map +1 -0
- package/dist/validation/field-config.test.js +159 -0
- package/dist/validation/field-config.test.js.map +1 -0
- package/package.json +11 -3
- package/src/access/access-filter.ts +97 -0
- package/src/access/engine.ts +13 -396
- package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
- package/src/access/field-access.ts +159 -0
- package/src/access/field-visibility.ts +247 -0
- package/src/access/index.ts +7 -4
- package/src/config/index.ts +1 -0
- package/src/config/types.ts +51 -4
- package/src/context/hook-pipeline.ts +160 -0
- package/src/context/index.ts +29 -667
- package/src/context/nested-operations.ts +142 -111
- package/src/context/write-pipeline.ts +543 -0
- package/src/extend.ts +14 -0
- package/src/fields/index.ts +310 -2
- package/src/hooks/index.ts +227 -0
- package/src/index.ts +27 -90
- package/src/internal.ts +49 -0
- package/src/validation/field-config.test.ts +199 -0
- package/src/validation/field-config.ts +145 -0
- package/tests/access-relationships.test.ts +4 -4
- package/tests/access.test.ts +1 -1
- package/tests/field-hooks.test.ts +410 -0
- package/tests/field-types.test.ts +1 -1
- package/tests/hook-pipeline.test.ts +233 -0
- package/tests/nested-operation-registry.test.ts +206 -0
- package/tests/write-pipeline.test.ts +588 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +43 -1
- package/dist/access/engine.test.d.ts +0 -2
- package/dist/access/engine.test.d.ts.map +0 -1
- package/dist/access/engine.test.js.map +0 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
ValidationError,
|
|
4
|
+
executeFieldResolveInputHooks,
|
|
5
|
+
executeFieldValidateHooks,
|
|
6
|
+
executeFieldBeforeOperationHooks,
|
|
7
|
+
executeFieldAfterOperationHooks,
|
|
8
|
+
} from '../src/hooks/index.js'
|
|
9
|
+
import { text } from '../src/fields/index.js'
|
|
10
|
+
import type { AccessContext } from '../src/access/types.js'
|
|
11
|
+
|
|
12
|
+
const mockContext = {
|
|
13
|
+
session: null,
|
|
14
|
+
prisma: {},
|
|
15
|
+
db: {},
|
|
16
|
+
} as unknown as AccessContext
|
|
17
|
+
|
|
18
|
+
describe('Field-level hook helpers', () => {
|
|
19
|
+
describe('executeFieldResolveInputHooks', () => {
|
|
20
|
+
it('should return data unchanged when no field hook is defined', async () => {
|
|
21
|
+
const fields = { name: text() }
|
|
22
|
+
const resolvedData = { name: 'john' }
|
|
23
|
+
|
|
24
|
+
const result = await executeFieldResolveInputHooks(
|
|
25
|
+
{ name: 'john' },
|
|
26
|
+
resolvedData,
|
|
27
|
+
fields,
|
|
28
|
+
'create',
|
|
29
|
+
mockContext,
|
|
30
|
+
'User',
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
expect(result).toEqual({ name: 'john' })
|
|
34
|
+
// Should not mutate the passed reference
|
|
35
|
+
expect(result).not.toBe(resolvedData)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should transform field values via the field resolveInput hook', async () => {
|
|
39
|
+
const fields = {
|
|
40
|
+
name: text({
|
|
41
|
+
hooks: {
|
|
42
|
+
resolveInput: ({ resolvedData, fieldKey }) =>
|
|
43
|
+
(resolvedData[fieldKey] as string).toUpperCase(),
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result = await executeFieldResolveInputHooks(
|
|
49
|
+
{ name: 'john' },
|
|
50
|
+
{ name: 'john' },
|
|
51
|
+
fields,
|
|
52
|
+
'create',
|
|
53
|
+
mockContext,
|
|
54
|
+
'User',
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
expect(result).toEqual({ name: 'JOHN' })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should pass the correct arguments to the field hook', async () => {
|
|
61
|
+
const resolveInput = vi.fn(({ resolvedData, fieldKey }) => resolvedData[fieldKey])
|
|
62
|
+
const fields = { name: text({ hooks: { resolveInput } }) }
|
|
63
|
+
const item = { id: '1', name: 'old' }
|
|
64
|
+
|
|
65
|
+
await executeFieldResolveInputHooks(
|
|
66
|
+
{ name: 'john' },
|
|
67
|
+
{ name: 'john' },
|
|
68
|
+
fields,
|
|
69
|
+
'update',
|
|
70
|
+
mockContext,
|
|
71
|
+
'User',
|
|
72
|
+
item,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
expect(resolveInput).toHaveBeenCalledTimes(1)
|
|
76
|
+
expect(resolveInput).toHaveBeenCalledWith(
|
|
77
|
+
expect.objectContaining({
|
|
78
|
+
listKey: 'User',
|
|
79
|
+
fieldKey: 'name',
|
|
80
|
+
operation: 'update',
|
|
81
|
+
inputData: { name: 'john' },
|
|
82
|
+
item,
|
|
83
|
+
resolvedData: { name: 'john' },
|
|
84
|
+
context: mockContext,
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should skip fields that are not present in the data', async () => {
|
|
90
|
+
const resolveInput = vi.fn(({ resolvedData, fieldKey }) => resolvedData[fieldKey])
|
|
91
|
+
const fields = { name: text({ hooks: { resolveInput } }) }
|
|
92
|
+
|
|
93
|
+
const result = await executeFieldResolveInputHooks(
|
|
94
|
+
{ other: 'value' },
|
|
95
|
+
{ other: 'value' },
|
|
96
|
+
fields,
|
|
97
|
+
'create',
|
|
98
|
+
mockContext,
|
|
99
|
+
'User',
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
expect(resolveInput).not.toHaveBeenCalled()
|
|
103
|
+
expect(result).toEqual({ other: 'value' })
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('executeFieldValidateHooks', () => {
|
|
108
|
+
it('should not throw when no field hook is defined', async () => {
|
|
109
|
+
const fields = { name: text() }
|
|
110
|
+
|
|
111
|
+
await expect(
|
|
112
|
+
executeFieldValidateHooks(
|
|
113
|
+
{ name: 'john' },
|
|
114
|
+
{ name: 'john' },
|
|
115
|
+
fields,
|
|
116
|
+
'create',
|
|
117
|
+
mockContext,
|
|
118
|
+
'User',
|
|
119
|
+
),
|
|
120
|
+
).resolves.toBeUndefined()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('should accumulate errors from multiple fields and throw a ValidationError', async () => {
|
|
124
|
+
const fields = {
|
|
125
|
+
name: text({
|
|
126
|
+
hooks: {
|
|
127
|
+
validate: ({ addValidationError }) => {
|
|
128
|
+
addValidationError('name is invalid')
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
email: text({
|
|
133
|
+
hooks: {
|
|
134
|
+
validate: ({ addValidationError }) => {
|
|
135
|
+
addValidationError('email is invalid')
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let caught: unknown
|
|
142
|
+
try {
|
|
143
|
+
await executeFieldValidateHooks(
|
|
144
|
+
{ name: 'x', email: 'y' },
|
|
145
|
+
{ name: 'x', email: 'y' },
|
|
146
|
+
fields,
|
|
147
|
+
'create',
|
|
148
|
+
mockContext,
|
|
149
|
+
'User',
|
|
150
|
+
)
|
|
151
|
+
} catch (error) {
|
|
152
|
+
caught = error
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
expect(caught).toBeInstanceOf(ValidationError)
|
|
156
|
+
const validationError = caught as ValidationError
|
|
157
|
+
expect(validationError.errors).toEqual(['name is invalid', 'email is invalid'])
|
|
158
|
+
expect(validationError.fieldErrors).toEqual({
|
|
159
|
+
name: 'name is invalid',
|
|
160
|
+
email: 'email is invalid',
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('should not throw when no validation errors are added', async () => {
|
|
165
|
+
const validate = vi.fn()
|
|
166
|
+
const fields = { name: text({ hooks: { validate } }) }
|
|
167
|
+
|
|
168
|
+
await expect(
|
|
169
|
+
executeFieldValidateHooks(
|
|
170
|
+
{ name: 'john' },
|
|
171
|
+
{ name: 'john' },
|
|
172
|
+
fields,
|
|
173
|
+
'create',
|
|
174
|
+
mockContext,
|
|
175
|
+
'User',
|
|
176
|
+
),
|
|
177
|
+
).resolves.toBeUndefined()
|
|
178
|
+
expect(validate).toHaveBeenCalledTimes(1)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('should pass delete-shaped arguments for delete operations', async () => {
|
|
182
|
+
const validate = vi.fn()
|
|
183
|
+
const fields = { name: text({ hooks: { validate } }) }
|
|
184
|
+
const item = { id: '1', name: 'john' }
|
|
185
|
+
|
|
186
|
+
await executeFieldValidateHooks(
|
|
187
|
+
undefined,
|
|
188
|
+
undefined,
|
|
189
|
+
fields,
|
|
190
|
+
'delete',
|
|
191
|
+
mockContext,
|
|
192
|
+
'User',
|
|
193
|
+
item,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
expect(validate).toHaveBeenCalledWith(
|
|
197
|
+
expect.objectContaining({
|
|
198
|
+
listKey: 'User',
|
|
199
|
+
fieldKey: 'name',
|
|
200
|
+
operation: 'delete',
|
|
201
|
+
item,
|
|
202
|
+
context: mockContext,
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('should support the deprecated validateInput hook', async () => {
|
|
208
|
+
const fields = {
|
|
209
|
+
name: text({
|
|
210
|
+
hooks: {
|
|
211
|
+
validateInput: ({ addValidationError }) => {
|
|
212
|
+
addValidationError('legacy error')
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await expect(
|
|
219
|
+
executeFieldValidateHooks(
|
|
220
|
+
{ name: 'x' },
|
|
221
|
+
{ name: 'x' },
|
|
222
|
+
fields,
|
|
223
|
+
'create',
|
|
224
|
+
mockContext,
|
|
225
|
+
'User',
|
|
226
|
+
),
|
|
227
|
+
).rejects.toBeInstanceOf(ValidationError)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
describe('executeFieldBeforeOperationHooks', () => {
|
|
232
|
+
it('should do nothing when no field hook is defined', async () => {
|
|
233
|
+
const fields = { name: text() }
|
|
234
|
+
|
|
235
|
+
await expect(
|
|
236
|
+
executeFieldBeforeOperationHooks(
|
|
237
|
+
{ name: 'john' },
|
|
238
|
+
{ name: 'john' },
|
|
239
|
+
fields,
|
|
240
|
+
'create',
|
|
241
|
+
mockContext,
|
|
242
|
+
'User',
|
|
243
|
+
),
|
|
244
|
+
).resolves.toBeUndefined()
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('should call the hook with create-shaped arguments', async () => {
|
|
248
|
+
const beforeOperation = vi.fn()
|
|
249
|
+
const fields = { name: text({ hooks: { beforeOperation } }) }
|
|
250
|
+
|
|
251
|
+
await executeFieldBeforeOperationHooks(
|
|
252
|
+
{ name: 'john' },
|
|
253
|
+
{ name: 'john' },
|
|
254
|
+
fields,
|
|
255
|
+
'create',
|
|
256
|
+
mockContext,
|
|
257
|
+
'User',
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
expect(beforeOperation).toHaveBeenCalledWith(
|
|
261
|
+
expect.objectContaining({
|
|
262
|
+
listKey: 'User',
|
|
263
|
+
fieldKey: 'name',
|
|
264
|
+
operation: 'create',
|
|
265
|
+
inputData: { name: 'john' },
|
|
266
|
+
resolvedData: { name: 'john' },
|
|
267
|
+
context: mockContext,
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('should skip fields not present in resolvedData for create/update', async () => {
|
|
273
|
+
const beforeOperation = vi.fn()
|
|
274
|
+
const fields = { name: text({ hooks: { beforeOperation } }) }
|
|
275
|
+
|
|
276
|
+
await executeFieldBeforeOperationHooks(
|
|
277
|
+
{ other: 'value' },
|
|
278
|
+
{ other: 'value' },
|
|
279
|
+
fields,
|
|
280
|
+
'update',
|
|
281
|
+
mockContext,
|
|
282
|
+
'User',
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
expect(beforeOperation).not.toHaveBeenCalled()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('should run the hook for delete even when the field is not in resolvedData', async () => {
|
|
289
|
+
const beforeOperation = vi.fn()
|
|
290
|
+
const fields = { name: text({ hooks: { beforeOperation } }) }
|
|
291
|
+
const item = { id: '1', name: 'john' }
|
|
292
|
+
|
|
293
|
+
await executeFieldBeforeOperationHooks({}, {}, fields, 'delete', mockContext, 'User', item)
|
|
294
|
+
|
|
295
|
+
expect(beforeOperation).toHaveBeenCalledWith(
|
|
296
|
+
expect.objectContaining({
|
|
297
|
+
listKey: 'User',
|
|
298
|
+
fieldKey: 'name',
|
|
299
|
+
operation: 'delete',
|
|
300
|
+
item,
|
|
301
|
+
context: mockContext,
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
describe('executeFieldAfterOperationHooks', () => {
|
|
308
|
+
it('should do nothing when no field hook is defined', async () => {
|
|
309
|
+
const fields = { name: text() }
|
|
310
|
+
|
|
311
|
+
await expect(
|
|
312
|
+
executeFieldAfterOperationHooks(
|
|
313
|
+
{ id: '1', name: 'john' },
|
|
314
|
+
{ name: 'john' },
|
|
315
|
+
{ name: 'john' },
|
|
316
|
+
fields,
|
|
317
|
+
'create',
|
|
318
|
+
mockContext,
|
|
319
|
+
'User',
|
|
320
|
+
),
|
|
321
|
+
).resolves.toBeUndefined()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('should call the hook with create-shaped arguments', async () => {
|
|
325
|
+
const afterOperation = vi.fn()
|
|
326
|
+
const fields = { name: text({ hooks: { afterOperation } }) }
|
|
327
|
+
const item = { id: '1', name: 'john' }
|
|
328
|
+
|
|
329
|
+
await executeFieldAfterOperationHooks(
|
|
330
|
+
item,
|
|
331
|
+
{ name: 'john' },
|
|
332
|
+
{ name: 'john' },
|
|
333
|
+
fields,
|
|
334
|
+
'create',
|
|
335
|
+
mockContext,
|
|
336
|
+
'User',
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
expect(afterOperation).toHaveBeenCalledWith(
|
|
340
|
+
expect.objectContaining({
|
|
341
|
+
listKey: 'User',
|
|
342
|
+
fieldKey: 'name',
|
|
343
|
+
operation: 'create',
|
|
344
|
+
inputData: { name: 'john' },
|
|
345
|
+
item,
|
|
346
|
+
resolvedData: { name: 'john' },
|
|
347
|
+
context: mockContext,
|
|
348
|
+
}),
|
|
349
|
+
)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('should pass originalItem for update operations', async () => {
|
|
353
|
+
const afterOperation = vi.fn()
|
|
354
|
+
const fields = { name: text({ hooks: { afterOperation } }) }
|
|
355
|
+
const updated = { id: '1', name: 'new' }
|
|
356
|
+
const originalItem = { id: '1', name: 'old' }
|
|
357
|
+
|
|
358
|
+
await executeFieldAfterOperationHooks(
|
|
359
|
+
updated,
|
|
360
|
+
{ name: 'new' },
|
|
361
|
+
{ name: 'new' },
|
|
362
|
+
fields,
|
|
363
|
+
'update',
|
|
364
|
+
mockContext,
|
|
365
|
+
'User',
|
|
366
|
+
originalItem,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
expect(afterOperation).toHaveBeenCalledWith(
|
|
370
|
+
expect.objectContaining({
|
|
371
|
+
listKey: 'User',
|
|
372
|
+
fieldKey: 'name',
|
|
373
|
+
operation: 'update',
|
|
374
|
+
inputData: { name: 'new' },
|
|
375
|
+
originalItem,
|
|
376
|
+
item: updated,
|
|
377
|
+
resolvedData: { name: 'new' },
|
|
378
|
+
context: mockContext,
|
|
379
|
+
}),
|
|
380
|
+
)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('should pass delete-shaped arguments for delete operations', async () => {
|
|
384
|
+
const afterOperation = vi.fn()
|
|
385
|
+
const fields = { name: text({ hooks: { afterOperation } }) }
|
|
386
|
+
const originalItem = { id: '1', name: 'john' }
|
|
387
|
+
|
|
388
|
+
await executeFieldAfterOperationHooks(
|
|
389
|
+
originalItem,
|
|
390
|
+
undefined,
|
|
391
|
+
undefined,
|
|
392
|
+
fields,
|
|
393
|
+
'delete',
|
|
394
|
+
mockContext,
|
|
395
|
+
'User',
|
|
396
|
+
originalItem,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
expect(afterOperation).toHaveBeenCalledWith(
|
|
400
|
+
expect.objectContaining({
|
|
401
|
+
listKey: 'User',
|
|
402
|
+
fieldKey: 'name',
|
|
403
|
+
operation: 'delete',
|
|
404
|
+
originalItem,
|
|
405
|
+
context: mockContext,
|
|
406
|
+
}),
|
|
407
|
+
)
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
})
|
|
@@ -580,7 +580,7 @@ describe('Field Types', () => {
|
|
|
580
580
|
|
|
581
581
|
expect(field.resultExtension).toBeDefined()
|
|
582
582
|
expect(field.resultExtension?.outputType).toBe(
|
|
583
|
-
"import('@opensaas/stack-core').HashedPassword",
|
|
583
|
+
"import('@opensaas/stack-core/internal').HashedPassword",
|
|
584
584
|
)
|
|
585
585
|
})
|
|
586
586
|
})
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { hookPipeline } from '../src/context/hook-pipeline.js'
|
|
3
|
+
import { ValidationError } from '../src/hooks/index.js'
|
|
4
|
+
import { text } from '../src/fields/index.js'
|
|
5
|
+
import type { ListConfig } from '../src/config/types.js'
|
|
6
|
+
import type { AccessContext } from '../src/access/types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Unit tests for the Hook Pipeline — the module that owns the transform+validate
|
|
10
|
+
* span: list `resolveInput` → field `resolveInput` → list `validate` → field
|
|
11
|
+
* `validate` → built-in field rules (`validateFieldRules`). These drive
|
|
12
|
+
* `hookPipeline.run` directly (spy hooks + a list config), so the span order and
|
|
13
|
+
* the resolvedData threading become the test surface.
|
|
14
|
+
*
|
|
15
|
+
* Asserted here:
|
|
16
|
+
* - the four hook phases + field rules run in the documented order;
|
|
17
|
+
* - a list/field `validate` hook calling `addValidationError` THROWS
|
|
18
|
+
* ValidationError (validation is never silent);
|
|
19
|
+
* - a built-in field rule failure (isRequired) THROWS ValidationError;
|
|
20
|
+
* - on success the pipeline returns the transformed `resolvedData`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Shared ordered log of phase events for order assertions.
|
|
24
|
+
let events: string[]
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a minimal AccessContext for the pipeline.
|
|
28
|
+
*/
|
|
29
|
+
function makeContext(): AccessContext {
|
|
30
|
+
return {
|
|
31
|
+
session: { userId: 'u1' },
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
|
+
prisma: {} as any,
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
db: {} as any,
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
storage: {} as any,
|
|
38
|
+
plugins: {},
|
|
39
|
+
_isSudo: false,
|
|
40
|
+
_resolveOutputCounter: { depth: 0 },
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A list config with a spy on every span hook, recording its phase into
|
|
46
|
+
* `events`. `title` is a required field so we can exercise built-in field rules.
|
|
47
|
+
*/
|
|
48
|
+
function makeListConfig(opts?: {
|
|
49
|
+
listValidateError?: string
|
|
50
|
+
fieldValidateError?: string
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
|
+
}): ListConfig<any> {
|
|
53
|
+
return {
|
|
54
|
+
fields: {
|
|
55
|
+
title: text({
|
|
56
|
+
validation: { isRequired: true },
|
|
57
|
+
hooks: {
|
|
58
|
+
resolveInput: async ({ resolvedData, fieldKey }) => {
|
|
59
|
+
events.push('field:resolveInput')
|
|
60
|
+
// Uppercase the value so we can assert resolvedData threads through.
|
|
61
|
+
const value = resolvedData[fieldKey]
|
|
62
|
+
return typeof value === 'string' ? value.toUpperCase() : value
|
|
63
|
+
},
|
|
64
|
+
validate: async ({ addValidationError }) => {
|
|
65
|
+
events.push('field:validate')
|
|
66
|
+
if (opts?.fieldValidateError) addValidationError(opts.fieldValidateError)
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
access: {
|
|
72
|
+
operation: { query: () => true, create: () => true, update: () => true },
|
|
73
|
+
},
|
|
74
|
+
hooks: {
|
|
75
|
+
resolveInput: async ({ resolvedData }) => {
|
|
76
|
+
events.push('list:resolveInput')
|
|
77
|
+
return resolvedData
|
|
78
|
+
},
|
|
79
|
+
validate: async ({ addValidationError }) => {
|
|
80
|
+
events.push('list:validate')
|
|
81
|
+
if (opts?.listValidateError) addValidationError(opts.listValidateError)
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
85
|
+
} as ListConfig<any>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
events = []
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('Hook Pipeline — span order', () => {
|
|
93
|
+
it('runs create span phases in order: list resolveInput → field resolveInput → list validate → field validate → field rules', async () => {
|
|
94
|
+
const listConfig = makeListConfig()
|
|
95
|
+
const context = makeContext()
|
|
96
|
+
|
|
97
|
+
const { resolvedData } = await hookPipeline.run({
|
|
98
|
+
operation: 'create',
|
|
99
|
+
listName: 'Post',
|
|
100
|
+
listConfig,
|
|
101
|
+
inputData: { title: 'hi' },
|
|
102
|
+
item: undefined,
|
|
103
|
+
context,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
expect(events).toEqual([
|
|
107
|
+
'list:resolveInput',
|
|
108
|
+
'field:resolveInput',
|
|
109
|
+
'list:validate',
|
|
110
|
+
'field:validate',
|
|
111
|
+
])
|
|
112
|
+
expect(events.indexOf('list:resolveInput')).toBeLessThan(events.indexOf('field:resolveInput'))
|
|
113
|
+
expect(events.indexOf('field:resolveInput')).toBeLessThan(events.indexOf('list:validate'))
|
|
114
|
+
expect(events.indexOf('list:validate')).toBeLessThan(events.indexOf('field:validate'))
|
|
115
|
+
// resolvedData threads through the field resolveInput transform.
|
|
116
|
+
expect(resolvedData).toEqual({ title: 'HI' })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('runs update span phases in order and passes the existing item through', async () => {
|
|
120
|
+
const listConfig = makeListConfig()
|
|
121
|
+
const context = makeContext()
|
|
122
|
+
const existing = { id: '1', title: 'old' }
|
|
123
|
+
|
|
124
|
+
const { resolvedData } = await hookPipeline.run({
|
|
125
|
+
operation: 'update',
|
|
126
|
+
listName: 'Post',
|
|
127
|
+
listConfig,
|
|
128
|
+
inputData: { title: 'new' },
|
|
129
|
+
item: existing,
|
|
130
|
+
context,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
expect(events).toEqual([
|
|
134
|
+
'list:resolveInput',
|
|
135
|
+
'field:resolveInput',
|
|
136
|
+
'list:validate',
|
|
137
|
+
'field:validate',
|
|
138
|
+
])
|
|
139
|
+
expect(resolvedData).toEqual({ title: 'NEW' })
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('Hook Pipeline — validation throws (NOT silent)', () => {
|
|
144
|
+
it('throws ValidationError when a list validate hook calls addValidationError', async () => {
|
|
145
|
+
const listConfig = makeListConfig({ listValidateError: 'list says no' })
|
|
146
|
+
const context = makeContext()
|
|
147
|
+
|
|
148
|
+
await expect(
|
|
149
|
+
hookPipeline.run({
|
|
150
|
+
operation: 'create',
|
|
151
|
+
listName: 'Post',
|
|
152
|
+
listConfig,
|
|
153
|
+
inputData: { title: 'hi' },
|
|
154
|
+
item: undefined,
|
|
155
|
+
context,
|
|
156
|
+
}),
|
|
157
|
+
).rejects.toBeInstanceOf(ValidationError)
|
|
158
|
+
// field:validate and field rules never run after a list validate failure.
|
|
159
|
+
expect(events).not.toContain('field:validate')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('throws ValidationError when a field validate hook calls addValidationError', async () => {
|
|
163
|
+
const listConfig = makeListConfig({ fieldValidateError: 'field says no' })
|
|
164
|
+
const context = makeContext()
|
|
165
|
+
|
|
166
|
+
await expect(
|
|
167
|
+
hookPipeline.run({
|
|
168
|
+
operation: 'create',
|
|
169
|
+
listName: 'Post',
|
|
170
|
+
listConfig,
|
|
171
|
+
inputData: { title: 'hi' },
|
|
172
|
+
item: undefined,
|
|
173
|
+
context,
|
|
174
|
+
}),
|
|
175
|
+
).rejects.toBeInstanceOf(ValidationError)
|
|
176
|
+
expect(events).toContain('field:validate')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('throws ValidationError when a built-in field rule (isRequired) fails', async () => {
|
|
180
|
+
const listConfig = makeListConfig()
|
|
181
|
+
const context = makeContext()
|
|
182
|
+
|
|
183
|
+
await expect(
|
|
184
|
+
hookPipeline.run({
|
|
185
|
+
operation: 'create',
|
|
186
|
+
listName: 'Post',
|
|
187
|
+
listConfig,
|
|
188
|
+
inputData: {}, // title is required but absent
|
|
189
|
+
item: undefined,
|
|
190
|
+
context,
|
|
191
|
+
}),
|
|
192
|
+
).rejects.toBeInstanceOf(ValidationError)
|
|
193
|
+
// The validate hooks ran before the rules threw.
|
|
194
|
+
expect(events).toContain('list:validate')
|
|
195
|
+
expect(events).toContain('field:validate')
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('Hook Pipeline — resolvedData threading', () => {
|
|
200
|
+
it('returns the transformed resolvedData merged with list-level resolveInput changes', async () => {
|
|
201
|
+
const context = makeContext()
|
|
202
|
+
// List resolveInput injects an extra field; field resolveInput uppercases title.
|
|
203
|
+
const listConfig = {
|
|
204
|
+
fields: {
|
|
205
|
+
title: text({
|
|
206
|
+
hooks: {
|
|
207
|
+
resolveInput: async ({ resolvedData, fieldKey }) => {
|
|
208
|
+
const value = resolvedData[fieldKey]
|
|
209
|
+
return typeof value === 'string' ? value.toUpperCase() : value
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
slug: text(),
|
|
214
|
+
},
|
|
215
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
216
|
+
hooks: {
|
|
217
|
+
resolveInput: async ({ resolvedData }) => ({ ...resolvedData, slug: 'auto-slug' }),
|
|
218
|
+
},
|
|
219
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
220
|
+
} as ListConfig<any>
|
|
221
|
+
|
|
222
|
+
const { resolvedData } = await hookPipeline.run({
|
|
223
|
+
operation: 'create',
|
|
224
|
+
listName: 'Post',
|
|
225
|
+
listConfig,
|
|
226
|
+
inputData: { title: 'hello' },
|
|
227
|
+
item: undefined,
|
|
228
|
+
context,
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
expect(resolvedData).toEqual({ title: 'HELLO', slug: 'auto-slug' })
|
|
232
|
+
})
|
|
233
|
+
})
|