@opensaas/stack-core 0.1.7 → 0.3.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 +202 -0
- package/CLAUDE.md +46 -1
- package/dist/access/engine.d.ts +6 -5
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +17 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +125 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/types.d.ts +39 -9
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +38 -18
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +34 -14
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +6 -0
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +128 -21
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +5 -3
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +127 -14
- package/dist/context/index.js.map +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +9 -8
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +28 -12
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +16 -0
- package/dist/hooks/index.js.map +1 -1
- package/package.json +3 -4
- package/src/access/engine.test.ts +145 -0
- package/src/access/engine.ts +25 -6
- package/src/access/types.ts +38 -8
- package/src/config/index.ts +46 -19
- package/src/config/plugin-engine.ts +7 -0
- package/src/config/types.ts +149 -18
- package/src/context/index.ts +163 -17
- package/src/fields/index.ts +8 -7
- package/src/hooks/index.ts +63 -20
- package/tests/context.test.ts +38 -6
- package/tests/field-types.test.ts +728 -0
- package/tests/password-type-distribution.test.ts +0 -1
- package/tests/password-types.test.ts +0 -1
- package/tests/plugin-engine.test.ts +1102 -0
- package/tests/sudo.test.ts +0 -1
- package/tsconfig.tsbuildinfo +1 -1
package/src/context/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
executeAfterOperation,
|
|
15
15
|
validateFieldRules,
|
|
16
16
|
ValidationError,
|
|
17
|
+
DatabaseError,
|
|
17
18
|
} from '../hooks/index.js'
|
|
18
19
|
import { processNestedOperations } from './nested-operations.js'
|
|
19
20
|
import { getDbKey } from '../lib/case-utils.js'
|
|
@@ -130,6 +131,69 @@ export type ServerActionProps =
|
|
|
130
131
|
| { listKey: string; action: 'create'; data: Record<string, unknown> }
|
|
131
132
|
| { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
|
|
132
133
|
| { listKey: string; action: 'delete'; id: string }
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse Prisma error and convert to user-friendly DatabaseError
|
|
137
|
+
*/
|
|
138
|
+
function parsePrismaError(error: unknown, listConfig: ListConfig): Error {
|
|
139
|
+
// Check if it's a Prisma error
|
|
140
|
+
if (
|
|
141
|
+
error &&
|
|
142
|
+
typeof error === 'object' &&
|
|
143
|
+
'code' in error &&
|
|
144
|
+
'meta' in error &&
|
|
145
|
+
typeof error.code === 'string'
|
|
146
|
+
) {
|
|
147
|
+
const prismaError = error as { code: string; meta?: { target?: string[] }; message?: string }
|
|
148
|
+
|
|
149
|
+
// Handle unique constraint violation
|
|
150
|
+
if (prismaError.code === 'P2002') {
|
|
151
|
+
const target = prismaError.meta?.target
|
|
152
|
+
const fieldErrors: Record<string, string> = {}
|
|
153
|
+
|
|
154
|
+
if (target && Array.isArray(target)) {
|
|
155
|
+
// Get field names from the constraint target
|
|
156
|
+
for (const fieldName of target) {
|
|
157
|
+
// Get the field config to get a better label
|
|
158
|
+
const fieldConfig = listConfig.fields[fieldName]
|
|
159
|
+
const label = fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
|
|
160
|
+
|
|
161
|
+
if (fieldConfig) {
|
|
162
|
+
fieldErrors[fieldName] = `This ${label.toLowerCase()} is already in use`
|
|
163
|
+
} else {
|
|
164
|
+
fieldErrors[fieldName] = `This value is already in use`
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Create a user-friendly general message
|
|
169
|
+
const fieldLabels = target.map((f) => f.charAt(0).toUpperCase() + f.slice(1)).join(', ')
|
|
170
|
+
return new DatabaseError(
|
|
171
|
+
`${fieldLabels} must be unique. The value you entered is already in use.`,
|
|
172
|
+
fieldErrors,
|
|
173
|
+
prismaError.code,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return new DatabaseError('A record with this value already exists', {}, prismaError.code)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Handle other Prisma errors - return generic message
|
|
181
|
+
return new DatabaseError(
|
|
182
|
+
prismaError.message || 'A database error occurred',
|
|
183
|
+
{},
|
|
184
|
+
prismaError.code,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Not a Prisma error, return as-is if it's already an Error
|
|
189
|
+
if (error instanceof Error) {
|
|
190
|
+
return error
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Unknown error type
|
|
194
|
+
return new Error('An unknown error occurred')
|
|
195
|
+
}
|
|
196
|
+
|
|
133
197
|
/**
|
|
134
198
|
* Create an access-controlled context
|
|
135
199
|
*
|
|
@@ -144,21 +208,23 @@ export function getContext<
|
|
|
144
208
|
>(
|
|
145
209
|
config: TConfig,
|
|
146
210
|
prisma: TPrisma,
|
|
147
|
-
session: Session,
|
|
211
|
+
session: Session | null,
|
|
148
212
|
storage?: StorageUtils,
|
|
149
213
|
_isSudo: boolean = false,
|
|
150
214
|
): {
|
|
151
215
|
db: AccessControlledDB<TPrisma>
|
|
152
|
-
session: Session
|
|
216
|
+
session: Session | null
|
|
153
217
|
prisma: TPrisma
|
|
154
218
|
storage: StorageUtils
|
|
219
|
+
plugins: Record<string, unknown>
|
|
155
220
|
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
156
221
|
_isSudo: boolean
|
|
157
222
|
sudo: () => {
|
|
158
223
|
db: AccessControlledDB<TPrisma>
|
|
159
|
-
session: Session
|
|
224
|
+
session: Session | null
|
|
160
225
|
prisma: TPrisma
|
|
161
226
|
storage: StorageUtils
|
|
227
|
+
plugins: Record<string, unknown>
|
|
162
228
|
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
163
229
|
sudo: () => unknown
|
|
164
230
|
_isSudo: boolean
|
|
@@ -196,6 +262,7 @@ export function getContext<
|
|
|
196
262
|
)
|
|
197
263
|
},
|
|
198
264
|
},
|
|
265
|
+
plugins: {}, // Will be populated with plugin runtime services
|
|
199
266
|
_isSudo,
|
|
200
267
|
}
|
|
201
268
|
|
|
@@ -213,29 +280,107 @@ export function getContext<
|
|
|
213
280
|
}
|
|
214
281
|
}
|
|
215
282
|
|
|
283
|
+
// Execute plugin runtime functions and populate context.plugins
|
|
284
|
+
// Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
|
|
285
|
+
const pluginsToExecute = config._plugins || config.plugins || []
|
|
286
|
+
for (const plugin of pluginsToExecute) {
|
|
287
|
+
if (plugin.runtime) {
|
|
288
|
+
try {
|
|
289
|
+
context.plugins[plugin.name] = plugin.runtime(context)
|
|
290
|
+
} catch (error) {
|
|
291
|
+
console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
|
|
292
|
+
// Continue with other plugins even if one fails
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
216
297
|
// Generic server action handler with discriminated union for type safety
|
|
217
|
-
|
|
298
|
+
// Returns a result object instead of throwing to work properly in Next.js production
|
|
299
|
+
async function serverAction(
|
|
300
|
+
props: ServerActionProps,
|
|
301
|
+
): Promise<
|
|
302
|
+
| { success: true; data: unknown }
|
|
303
|
+
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
|
304
|
+
> {
|
|
218
305
|
const dbKey = getDbKey(props.listKey)
|
|
306
|
+
const listConfig = config.lists[props.listKey]
|
|
307
|
+
|
|
308
|
+
if (!listConfig) {
|
|
309
|
+
return {
|
|
310
|
+
success: false,
|
|
311
|
+
error: `List "${props.listKey}" not found in configuration`,
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
219
315
|
const model = db[dbKey] as {
|
|
220
316
|
create: (args: { data: Record<string, unknown> }) => Promise<unknown>
|
|
221
317
|
update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown>
|
|
222
318
|
delete: (args: { where: { id: string } }) => Promise<unknown>
|
|
223
319
|
}
|
|
224
320
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
})
|
|
236
|
-
|
|
321
|
+
try {
|
|
322
|
+
let result: unknown = null
|
|
323
|
+
|
|
324
|
+
if (props.action === 'create') {
|
|
325
|
+
result = await model.create({ data: props.data })
|
|
326
|
+
} else if (props.action === 'update') {
|
|
327
|
+
result = await model.update({
|
|
328
|
+
where: { id: props.id },
|
|
329
|
+
data: props.data,
|
|
330
|
+
})
|
|
331
|
+
} else if (props.action === 'delete') {
|
|
332
|
+
result = await model.delete({
|
|
333
|
+
where: { id: props.id },
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Check for access denial (null return from access-controlled operations)
|
|
338
|
+
if (result === null) {
|
|
339
|
+
return {
|
|
340
|
+
success: false,
|
|
341
|
+
error: 'Access denied or operation failed',
|
|
342
|
+
}
|
|
343
|
+
}
|
|
237
344
|
|
|
238
|
-
|
|
345
|
+
return {
|
|
346
|
+
success: true,
|
|
347
|
+
data: result,
|
|
348
|
+
}
|
|
349
|
+
} catch (error) {
|
|
350
|
+
// Handle ValidationError (has fieldErrors)
|
|
351
|
+
if (error instanceof ValidationError) {
|
|
352
|
+
return {
|
|
353
|
+
success: false,
|
|
354
|
+
error: error.message,
|
|
355
|
+
fieldErrors: error.fieldErrors,
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Handle DatabaseError (has fieldErrors)
|
|
360
|
+
if (error instanceof DatabaseError) {
|
|
361
|
+
return {
|
|
362
|
+
success: false,
|
|
363
|
+
error: error.message,
|
|
364
|
+
fieldErrors: error.fieldErrors,
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Parse and convert Prisma errors to user-friendly DatabaseError
|
|
369
|
+
const dbError = parsePrismaError(error, listConfig)
|
|
370
|
+
if (dbError instanceof DatabaseError) {
|
|
371
|
+
return {
|
|
372
|
+
success: false,
|
|
373
|
+
error: dbError.message,
|
|
374
|
+
fieldErrors: dbError.fieldErrors,
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Generic error fallback
|
|
379
|
+
return {
|
|
380
|
+
success: false,
|
|
381
|
+
error: dbError.message,
|
|
382
|
+
}
|
|
383
|
+
}
|
|
239
384
|
}
|
|
240
385
|
|
|
241
386
|
// Sudo function - creates a new context that bypasses access control
|
|
@@ -249,6 +394,7 @@ export function getContext<
|
|
|
249
394
|
session,
|
|
250
395
|
prisma,
|
|
251
396
|
storage: context.storage,
|
|
397
|
+
plugins: context.plugins,
|
|
252
398
|
serverAction,
|
|
253
399
|
sudo,
|
|
254
400
|
_isSudo,
|
package/src/fields/index.ts
CHANGED
|
@@ -59,7 +59,7 @@ export function text(options?: Omit<TextField, 'type'>): TextField {
|
|
|
59
59
|
return z.union([withMax, z.undefined()])
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
return !isRequired ? withMax.optional() : withMax
|
|
62
|
+
return !isRequired ? withMax.optional().nullable() : withMax
|
|
63
63
|
},
|
|
64
64
|
getPrismaType: () => {
|
|
65
65
|
const validation = options?.validation
|
|
@@ -122,7 +122,7 @@ export function integer(options?: Omit<IntegerField, 'type'>): IntegerField {
|
|
|
122
122
|
: withMin
|
|
123
123
|
|
|
124
124
|
return !options?.validation?.isRequired || operation === 'update'
|
|
125
|
-
? withMax.optional()
|
|
125
|
+
? withMax.optional().nullable()
|
|
126
126
|
: withMax
|
|
127
127
|
},
|
|
128
128
|
getPrismaType: () => {
|
|
@@ -152,7 +152,7 @@ export function checkbox(options?: Omit<CheckboxField, 'type'>): CheckboxField {
|
|
|
152
152
|
type: 'checkbox',
|
|
153
153
|
...options,
|
|
154
154
|
getZodSchema: () => {
|
|
155
|
-
return z.boolean().optional()
|
|
155
|
+
return z.boolean().optional().nullable()
|
|
156
156
|
},
|
|
157
157
|
getPrismaType: () => {
|
|
158
158
|
const hasDefault = options?.defaultValue !== undefined
|
|
@@ -184,7 +184,7 @@ export function timestamp(options?: Omit<TimestampField, 'type'>): TimestampFiel
|
|
|
184
184
|
type: 'timestamp',
|
|
185
185
|
...options,
|
|
186
186
|
getZodSchema: () => {
|
|
187
|
-
return z.union([z.date(), z.iso.datetime()]).optional()
|
|
187
|
+
return z.union([z.date(), z.iso.datetime()]).optional().nullable()
|
|
188
188
|
},
|
|
189
189
|
getPrismaType: () => {
|
|
190
190
|
let modifiers = '?'
|
|
@@ -347,6 +347,7 @@ export function password(options?: Omit<PasswordField, 'type'>): PasswordField {
|
|
|
347
347
|
message: `${formatFieldName(fieldName)} must be text`,
|
|
348
348
|
})
|
|
349
349
|
.optional()
|
|
350
|
+
.nullable()
|
|
350
351
|
}
|
|
351
352
|
},
|
|
352
353
|
getPrismaType: () => {
|
|
@@ -386,7 +387,7 @@ export function select(options: Omit<SelectField, 'type'>): SelectField {
|
|
|
386
387
|
})
|
|
387
388
|
|
|
388
389
|
if (!options.validation?.isRequired || operation === 'update') {
|
|
389
|
-
schema = schema.optional()
|
|
390
|
+
schema = schema.optional().nullable()
|
|
390
391
|
}
|
|
391
392
|
|
|
392
393
|
return schema
|
|
@@ -499,8 +500,8 @@ export function json(options?: Omit<JsonField, 'type'>): JsonField {
|
|
|
499
500
|
// Required in update mode: can be undefined for partial updates
|
|
500
501
|
return z.union([baseSchema, z.undefined()])
|
|
501
502
|
} else {
|
|
502
|
-
// Not required: can be undefined
|
|
503
|
-
return baseSchema.optional()
|
|
503
|
+
// Not required: can be undefined or null
|
|
504
|
+
return baseSchema.optional().nullable()
|
|
504
505
|
}
|
|
505
506
|
},
|
|
506
507
|
getPrismaType: () => {
|
package/src/hooks/index.ts
CHANGED
|
@@ -18,24 +18,53 @@ export class ValidationError extends Error {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Database error with field-specific error information
|
|
23
|
+
* Used for Prisma errors like unique constraint violations
|
|
24
|
+
*/
|
|
25
|
+
export class DatabaseError extends Error {
|
|
26
|
+
public fieldErrors: Record<string, string>
|
|
27
|
+
public code?: string
|
|
28
|
+
|
|
29
|
+
constructor(message: string, fieldErrors: Record<string, string> = {}, code?: string) {
|
|
30
|
+
super(message)
|
|
31
|
+
this.name = 'DatabaseError'
|
|
32
|
+
this.fieldErrors = fieldErrors
|
|
33
|
+
this.code = code
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
/**
|
|
22
38
|
* Execute resolveInput hook
|
|
23
39
|
* Allows modification of input data before validation
|
|
24
40
|
*/
|
|
25
|
-
export async function executeResolveInput<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
export async function executeResolveInput<
|
|
42
|
+
TOutput = Record<string, unknown>,
|
|
43
|
+
TCreateInput = Record<string, unknown>,
|
|
44
|
+
TUpdateInput = Record<string, unknown>,
|
|
45
|
+
>(
|
|
46
|
+
hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
|
|
47
|
+
args:
|
|
48
|
+
| {
|
|
49
|
+
operation: 'create'
|
|
50
|
+
resolvedData: TCreateInput
|
|
51
|
+
item?: undefined
|
|
52
|
+
context: AccessContext
|
|
53
|
+
}
|
|
54
|
+
| {
|
|
55
|
+
operation: 'update'
|
|
56
|
+
resolvedData: TUpdateInput
|
|
57
|
+
item?: TOutput
|
|
58
|
+
context: AccessContext
|
|
59
|
+
},
|
|
60
|
+
): Promise<TCreateInput | TUpdateInput> {
|
|
34
61
|
if (!hooks?.resolveInput) {
|
|
35
62
|
return args.resolvedData
|
|
36
63
|
}
|
|
37
64
|
|
|
38
|
-
|
|
65
|
+
// Type assertion is safe because we've constrained the args type
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
67
|
+
const result = await hooks.resolveInput(args as any)
|
|
39
68
|
return result
|
|
40
69
|
}
|
|
41
70
|
|
|
@@ -43,12 +72,16 @@ export async function executeResolveInput<T = Record<string, unknown>>(
|
|
|
43
72
|
* Execute validateInput hook
|
|
44
73
|
* Allows custom validation logic
|
|
45
74
|
*/
|
|
46
|
-
export async function executeValidateInput<
|
|
47
|
-
|
|
75
|
+
export async function executeValidateInput<
|
|
76
|
+
TOutput = Record<string, unknown>,
|
|
77
|
+
TCreateInput = Record<string, unknown>,
|
|
78
|
+
TUpdateInput = Record<string, unknown>,
|
|
79
|
+
>(
|
|
80
|
+
hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
|
|
48
81
|
args: {
|
|
49
82
|
operation: 'create' | 'update'
|
|
50
|
-
resolvedData:
|
|
51
|
-
item?:
|
|
83
|
+
resolvedData: TCreateInput | TUpdateInput
|
|
84
|
+
item?: TOutput
|
|
52
85
|
context: AccessContext
|
|
53
86
|
},
|
|
54
87
|
): Promise<void> {
|
|
@@ -76,11 +109,16 @@ export async function executeValidateInput<T = Record<string, unknown>>(
|
|
|
76
109
|
* Execute beforeOperation hook
|
|
77
110
|
* Runs before database operation (cannot modify data)
|
|
78
111
|
*/
|
|
79
|
-
export async function executeBeforeOperation<
|
|
80
|
-
|
|
112
|
+
export async function executeBeforeOperation<
|
|
113
|
+
TOutput = Record<string, unknown>,
|
|
114
|
+
TCreateInput = Record<string, unknown>,
|
|
115
|
+
TUpdateInput = Record<string, unknown>,
|
|
116
|
+
>(
|
|
117
|
+
hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
|
|
81
118
|
args: {
|
|
82
119
|
operation: 'create' | 'update' | 'delete'
|
|
83
|
-
|
|
120
|
+
resolvedData?: TCreateInput | TUpdateInput
|
|
121
|
+
item?: TOutput
|
|
84
122
|
context: AccessContext
|
|
85
123
|
},
|
|
86
124
|
): Promise<void> {
|
|
@@ -95,11 +133,16 @@ export async function executeBeforeOperation<T = Record<string, unknown>>(
|
|
|
95
133
|
* Execute afterOperation hook
|
|
96
134
|
* Runs after database operation
|
|
97
135
|
*/
|
|
98
|
-
export async function executeAfterOperation<
|
|
99
|
-
|
|
136
|
+
export async function executeAfterOperation<
|
|
137
|
+
TOutput = Record<string, unknown>,
|
|
138
|
+
TCreateInput = Record<string, unknown>,
|
|
139
|
+
TUpdateInput = Record<string, unknown>,
|
|
140
|
+
>(
|
|
141
|
+
hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
|
|
100
142
|
args: {
|
|
101
143
|
operation: 'create' | 'update' | 'delete'
|
|
102
|
-
|
|
144
|
+
resolvedData?: TCreateInput | TUpdateInput
|
|
145
|
+
item: TOutput
|
|
103
146
|
context: AccessContext
|
|
104
147
|
},
|
|
105
148
|
): Promise<void> {
|
package/tests/context.test.ts
CHANGED
|
@@ -101,7 +101,7 @@ describe('getContext', () => {
|
|
|
101
101
|
expect(mockPrisma.user.create).toHaveBeenCalledWith({
|
|
102
102
|
data: { name: 'John', email: 'john@example.com' },
|
|
103
103
|
})
|
|
104
|
-
expect(result).toEqual(mockCreatedUser)
|
|
104
|
+
expect(result).toEqual({ success: true, data: mockCreatedUser })
|
|
105
105
|
})
|
|
106
106
|
|
|
107
107
|
it('should update an item', async () => {
|
|
@@ -121,7 +121,7 @@ describe('getContext', () => {
|
|
|
121
121
|
|
|
122
122
|
expect(mockPrisma.user.findUnique).toHaveBeenCalled()
|
|
123
123
|
expect(mockPrisma.user.update).toHaveBeenCalled()
|
|
124
|
-
expect(result).toEqual(mockUpdatedUser)
|
|
124
|
+
expect(result).toEqual({ success: true, data: mockUpdatedUser })
|
|
125
125
|
})
|
|
126
126
|
|
|
127
127
|
it('should delete an item', async () => {
|
|
@@ -139,7 +139,7 @@ describe('getContext', () => {
|
|
|
139
139
|
|
|
140
140
|
expect(mockPrisma.user.findUnique).toHaveBeenCalled()
|
|
141
141
|
expect(mockPrisma.user.delete).toHaveBeenCalled()
|
|
142
|
-
expect(result).toEqual(mockDeletedUser)
|
|
142
|
+
expect(result).toEqual({ success: true, data: mockDeletedUser })
|
|
143
143
|
})
|
|
144
144
|
|
|
145
145
|
it('should convert listKey to lowercase for db operations', async () => {
|
|
@@ -147,16 +147,17 @@ describe('getContext', () => {
|
|
|
147
147
|
mockPrisma.post.create.mockResolvedValue(mockCreatedPost)
|
|
148
148
|
|
|
149
149
|
const context = await getContext(config, mockPrisma, null)
|
|
150
|
-
await context.serverAction({
|
|
150
|
+
const result = await context.serverAction({
|
|
151
151
|
listKey: 'Post',
|
|
152
152
|
action: 'create',
|
|
153
153
|
data: { title: 'Test Post' },
|
|
154
154
|
})
|
|
155
155
|
|
|
156
156
|
expect(mockPrisma.post.create).toHaveBeenCalled()
|
|
157
|
+
expect(result).toEqual({ success: true, data: mockCreatedPost })
|
|
157
158
|
})
|
|
158
159
|
|
|
159
|
-
it('should return
|
|
160
|
+
it('should return error for unknown action', async () => {
|
|
160
161
|
const context = await getContext(config, mockPrisma, null)
|
|
161
162
|
const result = await context.serverAction({
|
|
162
163
|
listKey: 'User',
|
|
@@ -164,7 +165,38 @@ describe('getContext', () => {
|
|
|
164
165
|
data: {},
|
|
165
166
|
})
|
|
166
167
|
|
|
167
|
-
expect(result).
|
|
168
|
+
expect(result).toEqual({ success: false, error: 'Access denied or operation failed' })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should return error for unknown list', async () => {
|
|
172
|
+
const context = await getContext(config, mockPrisma, null)
|
|
173
|
+
const result = await context.serverAction({
|
|
174
|
+
listKey: 'UnknownList',
|
|
175
|
+
action: 'create',
|
|
176
|
+
data: {},
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
expect(result).toEqual({
|
|
180
|
+
success: false,
|
|
181
|
+
error: 'List "UnknownList" not found in configuration',
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('should handle database errors', async () => {
|
|
186
|
+
const dbError = new Error('Database connection failed')
|
|
187
|
+
mockPrisma.user.create.mockRejectedValue(dbError)
|
|
188
|
+
|
|
189
|
+
const context = await getContext(config, mockPrisma, null)
|
|
190
|
+
const result = await context.serverAction({
|
|
191
|
+
listKey: 'User',
|
|
192
|
+
action: 'create',
|
|
193
|
+
data: { name: 'John', email: 'john@example.com' },
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
expect(result).toMatchObject({
|
|
197
|
+
success: false,
|
|
198
|
+
error: 'Database connection failed',
|
|
199
|
+
})
|
|
168
200
|
})
|
|
169
201
|
})
|
|
170
202
|
|