@opensaas/stack-core 0.1.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 +4 -0
- package/README.md +447 -0
- package/dist/access/engine.d.ts +73 -0
- package/dist/access/engine.d.ts.map +1 -0
- package/dist/access/engine.js +244 -0
- package/dist/access/engine.js.map +1 -0
- package/dist/access/field-transforms.d.ts +47 -0
- package/dist/access/field-transforms.d.ts.map +1 -0
- package/dist/access/field-transforms.js +2 -0
- package/dist/access/field-transforms.js.map +1 -0
- package/dist/access/index.d.ts +3 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +2 -0
- package/dist/access/index.js.map +1 -0
- package/dist/access/types.d.ts +83 -0
- package/dist/access/types.d.ts.map +1 -0
- package/dist/access/types.js +2 -0
- package/dist/access/types.js.map +1 -0
- package/dist/config/index.d.ts +39 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +38 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/types.d.ts +413 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/context/index.d.ts +31 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +524 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/nested-operations.d.ts +10 -0
- package/dist/context/nested-operations.d.ts.map +1 -0
- package/dist/context/nested-operations.js +261 -0
- package/dist/context/nested-operations.js.map +1 -0
- package/dist/fields/index.d.ts +78 -0
- package/dist/fields/index.d.ts.map +1 -0
- package/dist/fields/index.js +381 -0
- package/dist/fields/index.js.map +1 -0
- package/dist/hooks/index.d.ts +58 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +79 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/case-utils.d.ts +49 -0
- package/dist/lib/case-utils.d.ts.map +1 -0
- package/dist/lib/case-utils.js +68 -0
- package/dist/lib/case-utils.js.map +1 -0
- package/dist/lib/case-utils.test.d.ts +2 -0
- package/dist/lib/case-utils.test.d.ts.map +1 -0
- package/dist/lib/case-utils.test.js +101 -0
- package/dist/lib/case-utils.test.js.map +1 -0
- package/dist/utils/password.d.ts +81 -0
- package/dist/utils/password.d.ts.map +1 -0
- package/dist/utils/password.js +132 -0
- package/dist/utils/password.js.map +1 -0
- package/dist/validation/schema.d.ts +17 -0
- package/dist/validation/schema.d.ts.map +1 -0
- package/dist/validation/schema.js +42 -0
- package/dist/validation/schema.js.map +1 -0
- package/dist/validation/schema.test.d.ts +2 -0
- package/dist/validation/schema.test.d.ts.map +1 -0
- package/dist/validation/schema.test.js +143 -0
- package/dist/validation/schema.test.js.map +1 -0
- package/docs/type-distribution-fix.md +136 -0
- package/package.json +48 -0
- package/src/access/engine.ts +360 -0
- package/src/access/field-transforms.ts +99 -0
- package/src/access/index.ts +20 -0
- package/src/access/types.ts +103 -0
- package/src/config/index.ts +71 -0
- package/src/config/types.ts +478 -0
- package/src/context/index.ts +814 -0
- package/src/context/nested-operations.ts +412 -0
- package/src/fields/index.ts +438 -0
- package/src/hooks/index.ts +132 -0
- package/src/index.ts +62 -0
- package/src/lib/case-utils.test.ts +127 -0
- package/src/lib/case-utils.ts +74 -0
- package/src/utils/password.ts +147 -0
- package/src/validation/schema.test.ts +171 -0
- package/src/validation/schema.ts +59 -0
- package/tests/access-relationships.test.ts +613 -0
- package/tests/access.test.ts +499 -0
- package/tests/config.test.ts +195 -0
- package/tests/context.test.ts +248 -0
- package/tests/hooks.test.ts +417 -0
- package/tests/password-type-distribution.test.ts +155 -0
- package/tests/password-types.test.ts +147 -0
- package/tests/password.test.ts +249 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { getContext } from '../src/context/index.js'
|
|
3
|
+
import type { OpenSaasConfig } from '../src/config/types.js'
|
|
4
|
+
|
|
5
|
+
describe('getContext', () => {
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
let mockPrisma: any
|
|
8
|
+
let config: OpenSaasConfig
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// Mock Prisma client with all methods needed by context
|
|
12
|
+
mockPrisma = {
|
|
13
|
+
user: {
|
|
14
|
+
findFirst: vi.fn(),
|
|
15
|
+
findUnique: vi.fn(),
|
|
16
|
+
findMany: vi.fn(),
|
|
17
|
+
create: vi.fn(),
|
|
18
|
+
update: vi.fn(),
|
|
19
|
+
delete: vi.fn(),
|
|
20
|
+
count: vi.fn(),
|
|
21
|
+
},
|
|
22
|
+
post: {
|
|
23
|
+
findFirst: vi.fn(),
|
|
24
|
+
findUnique: vi.fn(),
|
|
25
|
+
findMany: vi.fn(),
|
|
26
|
+
create: vi.fn(),
|
|
27
|
+
update: vi.fn(),
|
|
28
|
+
delete: vi.fn(),
|
|
29
|
+
count: vi.fn(),
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Sample config with open access (no access control to simplify tests)
|
|
34
|
+
config = {
|
|
35
|
+
db: {
|
|
36
|
+
provider: 'postgresql',
|
|
37
|
+
url: 'postgresql://localhost:5432/test',
|
|
38
|
+
},
|
|
39
|
+
lists: {
|
|
40
|
+
User: {
|
|
41
|
+
fields: {
|
|
42
|
+
name: { type: 'text' },
|
|
43
|
+
email: { type: 'text', isIndexed: 'unique' },
|
|
44
|
+
},
|
|
45
|
+
access: {
|
|
46
|
+
operation: {
|
|
47
|
+
query: () => true,
|
|
48
|
+
create: () => true,
|
|
49
|
+
update: () => true,
|
|
50
|
+
delete: () => true,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
Post: {
|
|
55
|
+
fields: {
|
|
56
|
+
title: { type: 'text' },
|
|
57
|
+
content: { type: 'text' },
|
|
58
|
+
},
|
|
59
|
+
access: {
|
|
60
|
+
operation: {
|
|
61
|
+
query: () => true,
|
|
62
|
+
create: () => true,
|
|
63
|
+
update: () => true,
|
|
64
|
+
delete: () => true,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should create context with lowercase db keys', async () => {
|
|
73
|
+
const context = getContext(config, mockPrisma, null)
|
|
74
|
+
|
|
75
|
+
expect(context.db).toBeDefined()
|
|
76
|
+
expect(context.db.user).toBeDefined()
|
|
77
|
+
expect(context.db.post).toBeDefined()
|
|
78
|
+
expect(context.session).toBeNull()
|
|
79
|
+
expect(context.prisma).toBe(mockPrisma)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should include session when provided', async () => {
|
|
83
|
+
const session = { userId: '123', role: 'admin' }
|
|
84
|
+
const context = getContext(config, mockPrisma, session)
|
|
85
|
+
|
|
86
|
+
expect(context.session).toEqual(session)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('serverAction', () => {
|
|
90
|
+
it('should create an item', async () => {
|
|
91
|
+
const mockCreatedUser = { id: '1', name: 'John', email: 'john@example.com' }
|
|
92
|
+
mockPrisma.user.create.mockResolvedValue(mockCreatedUser)
|
|
93
|
+
|
|
94
|
+
const context = getContext(config, mockPrisma, null)
|
|
95
|
+
const result = await context.serverAction({
|
|
96
|
+
listKey: 'User',
|
|
97
|
+
action: 'create',
|
|
98
|
+
data: { name: 'John', email: 'john@example.com' },
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(mockPrisma.user.create).toHaveBeenCalledWith({
|
|
102
|
+
data: { name: 'John', email: 'john@example.com' },
|
|
103
|
+
})
|
|
104
|
+
expect(result).toEqual(mockCreatedUser)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('should update an item', async () => {
|
|
108
|
+
const existingUser = { id: '1', name: 'John', email: 'john@example.com' }
|
|
109
|
+
const mockUpdatedUser = { id: '1', name: 'John Updated', email: 'john@example.com' }
|
|
110
|
+
// Update operation first fetches the existing item
|
|
111
|
+
mockPrisma.user.findUnique.mockResolvedValue(existingUser)
|
|
112
|
+
mockPrisma.user.update.mockResolvedValue(mockUpdatedUser)
|
|
113
|
+
|
|
114
|
+
const context = getContext(config, mockPrisma, null)
|
|
115
|
+
const result = await context.serverAction({
|
|
116
|
+
listKey: 'User',
|
|
117
|
+
action: 'update',
|
|
118
|
+
id: '1',
|
|
119
|
+
data: { name: 'John Updated' },
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(mockPrisma.user.findUnique).toHaveBeenCalled()
|
|
123
|
+
expect(mockPrisma.user.update).toHaveBeenCalled()
|
|
124
|
+
expect(result).toEqual(mockUpdatedUser)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('should delete an item', async () => {
|
|
128
|
+
const mockDeletedUser = { id: '1', name: 'John', email: 'john@example.com' }
|
|
129
|
+
// Delete operation first fetches the existing item
|
|
130
|
+
mockPrisma.user.findUnique.mockResolvedValue(mockDeletedUser)
|
|
131
|
+
mockPrisma.user.delete.mockResolvedValue(mockDeletedUser)
|
|
132
|
+
|
|
133
|
+
const context = getContext(config, mockPrisma, null)
|
|
134
|
+
const result = await context.serverAction({
|
|
135
|
+
listKey: 'User',
|
|
136
|
+
action: 'delete',
|
|
137
|
+
id: '1',
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
expect(mockPrisma.user.findUnique).toHaveBeenCalled()
|
|
141
|
+
expect(mockPrisma.user.delete).toHaveBeenCalled()
|
|
142
|
+
expect(result).toEqual(mockDeletedUser)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('should convert listKey to lowercase for db operations', async () => {
|
|
146
|
+
const mockCreatedPost = { id: '1', title: 'Test Post' }
|
|
147
|
+
mockPrisma.post.create.mockResolvedValue(mockCreatedPost)
|
|
148
|
+
|
|
149
|
+
const context = getContext(config, mockPrisma, null)
|
|
150
|
+
await context.serverAction({
|
|
151
|
+
listKey: 'Post',
|
|
152
|
+
action: 'create',
|
|
153
|
+
data: { title: 'Test Post' },
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
expect(mockPrisma.post.create).toHaveBeenCalled()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('should return null for unknown action', async () => {
|
|
160
|
+
const context = getContext(config, mockPrisma, null)
|
|
161
|
+
const result = await context.serverAction({
|
|
162
|
+
listKey: 'User',
|
|
163
|
+
action: 'unknown' as unknown as 'create',
|
|
164
|
+
data: {},
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
expect(result).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('db operations', () => {
|
|
172
|
+
it('should delegate findUnique to prisma with access control', async () => {
|
|
173
|
+
const mockUser = { id: '1', name: 'John', email: 'john@example.com' }
|
|
174
|
+
mockPrisma.user.findFirst.mockResolvedValue(mockUser)
|
|
175
|
+
|
|
176
|
+
const context = getContext(config, mockPrisma, null)
|
|
177
|
+
const result = await context.db.user.findUnique({ where: { id: '1' } })
|
|
178
|
+
|
|
179
|
+
expect(mockPrisma.user.findFirst).toHaveBeenCalled()
|
|
180
|
+
expect(result).toEqual(mockUser)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('should delegate findMany to prisma with access control', async () => {
|
|
184
|
+
const mockUsers = [
|
|
185
|
+
{ id: '1', name: 'John' },
|
|
186
|
+
{ id: '2', name: 'Jane' },
|
|
187
|
+
]
|
|
188
|
+
mockPrisma.user.findMany.mockResolvedValue(mockUsers)
|
|
189
|
+
|
|
190
|
+
const context = getContext(config, mockPrisma, null)
|
|
191
|
+
const result = await context.db.user.findMany()
|
|
192
|
+
|
|
193
|
+
expect(mockPrisma.user.findMany).toHaveBeenCalled()
|
|
194
|
+
expect(result).toEqual(mockUsers)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('should delegate create to prisma with access control and hooks', async () => {
|
|
198
|
+
const mockUser = { id: '1', name: 'John', email: 'john@example.com' }
|
|
199
|
+
mockPrisma.user.create.mockResolvedValue(mockUser)
|
|
200
|
+
|
|
201
|
+
const context = getContext(config, mockPrisma, null)
|
|
202
|
+
const result = await context.db.user.create({
|
|
203
|
+
data: { name: 'John', email: 'john@example.com' },
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
expect(mockPrisma.user.create).toHaveBeenCalled()
|
|
207
|
+
expect(result).toEqual(mockUser)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('should delegate update to prisma with access control and hooks', async () => {
|
|
211
|
+
const existingUser = { id: '1', name: 'John', email: 'john@example.com' }
|
|
212
|
+
const updatedUser = { id: '1', name: 'John Updated', email: 'john@example.com' }
|
|
213
|
+
mockPrisma.user.findUnique.mockResolvedValue(existingUser)
|
|
214
|
+
mockPrisma.user.update.mockResolvedValue(updatedUser)
|
|
215
|
+
|
|
216
|
+
const context = getContext(config, mockPrisma, null)
|
|
217
|
+
const result = await context.db.user.update({
|
|
218
|
+
where: { id: '1' },
|
|
219
|
+
data: { name: 'John Updated' },
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
expect(mockPrisma.user.update).toHaveBeenCalled()
|
|
223
|
+
expect(result).toEqual(updatedUser)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('should delegate delete to prisma with access control and hooks', async () => {
|
|
227
|
+
const mockUser = { id: '1', name: 'John', email: 'john@example.com' }
|
|
228
|
+
mockPrisma.user.findUnique.mockResolvedValue(mockUser)
|
|
229
|
+
mockPrisma.user.delete.mockResolvedValue(mockUser)
|
|
230
|
+
|
|
231
|
+
const context = getContext(config, mockPrisma, null)
|
|
232
|
+
const result = await context.db.user.delete({ where: { id: '1' } })
|
|
233
|
+
|
|
234
|
+
expect(mockPrisma.user.delete).toHaveBeenCalled()
|
|
235
|
+
expect(result).toEqual(mockUser)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('should delegate count to prisma with access control', async () => {
|
|
239
|
+
mockPrisma.user.count.mockResolvedValue(5)
|
|
240
|
+
|
|
241
|
+
const context = getContext(config, mockPrisma, null)
|
|
242
|
+
const result = await context.db.user.count()
|
|
243
|
+
|
|
244
|
+
expect(mockPrisma.user.count).toHaveBeenCalled()
|
|
245
|
+
expect(result).toBe(5)
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
})
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
ValidationError,
|
|
4
|
+
executeResolveInput,
|
|
5
|
+
executeValidateInput,
|
|
6
|
+
executeBeforeOperation,
|
|
7
|
+
executeAfterOperation,
|
|
8
|
+
validateFieldRules,
|
|
9
|
+
} from '../src/hooks/index.js'
|
|
10
|
+
import type { Hooks } from '../src/config/types.js'
|
|
11
|
+
import { text, integer, relationship } from '../src/fields/index.js'
|
|
12
|
+
|
|
13
|
+
describe('Hooks', () => {
|
|
14
|
+
const mockContext = {
|
|
15
|
+
session: null,
|
|
16
|
+
prisma: {},
|
|
17
|
+
db: {},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('ValidationError', () => {
|
|
21
|
+
it('should create validation error with errors array', () => {
|
|
22
|
+
const errors = ['Field is required', 'Invalid email']
|
|
23
|
+
const error = new ValidationError(errors)
|
|
24
|
+
|
|
25
|
+
expect(error).toBeInstanceOf(Error)
|
|
26
|
+
expect(error.name).toBe('ValidationError')
|
|
27
|
+
expect(error.errors).toEqual(errors)
|
|
28
|
+
expect(error.message).toBe('Validation failed: Field is required, Invalid email')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('executeResolveInput', () => {
|
|
33
|
+
it('should return data unchanged when no hook is defined', async () => {
|
|
34
|
+
const data = { name: 'John' }
|
|
35
|
+
|
|
36
|
+
const result = await executeResolveInput(undefined, {
|
|
37
|
+
operation: 'create',
|
|
38
|
+
resolvedData: data,
|
|
39
|
+
context: mockContext,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(result).toEqual(data)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should call resolveInput hook and return modified data', async () => {
|
|
46
|
+
const data = { name: 'john' }
|
|
47
|
+
const hooks: Hooks = {
|
|
48
|
+
resolveInput: vi.fn(async ({ resolvedData }) => ({
|
|
49
|
+
...resolvedData,
|
|
50
|
+
name: resolvedData.name?.toUpperCase(),
|
|
51
|
+
})),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const result = await executeResolveInput(hooks, {
|
|
55
|
+
operation: 'create',
|
|
56
|
+
resolvedData: data,
|
|
57
|
+
context: mockContext,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
expect(result).toEqual({ name: 'JOHN' })
|
|
61
|
+
expect(hooks.resolveInput).toHaveBeenCalled()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should pass operation and context to hook', async () => {
|
|
65
|
+
const hooks: Hooks = {
|
|
66
|
+
resolveInput: vi.fn(async ({ resolvedData }) => resolvedData),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await executeResolveInput(hooks, {
|
|
70
|
+
operation: 'update',
|
|
71
|
+
resolvedData: { name: 'John' },
|
|
72
|
+
context: mockContext,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
expect(hooks.resolveInput).toHaveBeenCalledWith({
|
|
76
|
+
operation: 'update',
|
|
77
|
+
resolvedData: { name: 'John' },
|
|
78
|
+
context: mockContext,
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should pass item to hook on update', async () => {
|
|
83
|
+
const item = { id: '1', name: 'Original' }
|
|
84
|
+
const hooks: Hooks = {
|
|
85
|
+
resolveInput: vi.fn(async ({ resolvedData }) => resolvedData),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await executeResolveInput(hooks, {
|
|
89
|
+
operation: 'update',
|
|
90
|
+
resolvedData: { name: 'Updated' },
|
|
91
|
+
item,
|
|
92
|
+
context: mockContext,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(hooks.resolveInput).toHaveBeenCalledWith({
|
|
96
|
+
operation: 'update',
|
|
97
|
+
resolvedData: { name: 'Updated' },
|
|
98
|
+
item,
|
|
99
|
+
context: mockContext,
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('executeValidateInput', () => {
|
|
105
|
+
it('should not throw when no hook is defined', async () => {
|
|
106
|
+
await expect(
|
|
107
|
+
executeValidateInput(undefined, {
|
|
108
|
+
operation: 'create',
|
|
109
|
+
resolvedData: { name: 'John' },
|
|
110
|
+
context: mockContext,
|
|
111
|
+
}),
|
|
112
|
+
).resolves.not.toThrow()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should not throw when hook does not add errors', async () => {
|
|
116
|
+
const hooks: Hooks = {
|
|
117
|
+
validateInput: vi.fn(async () => {}),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await expect(
|
|
121
|
+
executeValidateInput(hooks, {
|
|
122
|
+
operation: 'create',
|
|
123
|
+
resolvedData: { name: 'John' },
|
|
124
|
+
context: mockContext,
|
|
125
|
+
}),
|
|
126
|
+
).resolves.not.toThrow()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should throw ValidationError when hook adds errors', async () => {
|
|
130
|
+
const hooks: Hooks = {
|
|
131
|
+
validateInput: vi.fn(async ({ addValidationError }) => {
|
|
132
|
+
addValidationError('Name is too short')
|
|
133
|
+
addValidationError('Name contains invalid characters')
|
|
134
|
+
}),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await expect(
|
|
138
|
+
executeValidateInput(hooks, {
|
|
139
|
+
operation: 'create',
|
|
140
|
+
resolvedData: { name: 'J' },
|
|
141
|
+
context: mockContext,
|
|
142
|
+
}),
|
|
143
|
+
).rejects.toThrow(ValidationError)
|
|
144
|
+
|
|
145
|
+
await expect(
|
|
146
|
+
executeValidateInput(hooks, {
|
|
147
|
+
operation: 'create',
|
|
148
|
+
resolvedData: { name: 'J' },
|
|
149
|
+
context: mockContext,
|
|
150
|
+
}),
|
|
151
|
+
).rejects.toThrow('Name is too short')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('should pass addValidationError function to hook', async () => {
|
|
155
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
156
|
+
let addErrorFn: any
|
|
157
|
+
|
|
158
|
+
const hooks: Hooks = {
|
|
159
|
+
validateInput: vi.fn(async ({ addValidationError }) => {
|
|
160
|
+
addErrorFn = addValidationError
|
|
161
|
+
}),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await executeValidateInput(hooks, {
|
|
165
|
+
operation: 'create',
|
|
166
|
+
resolvedData: { name: 'John' },
|
|
167
|
+
context: mockContext,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
expect(typeof addErrorFn).toBe('function')
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('executeBeforeOperation', () => {
|
|
175
|
+
it('should do nothing when no hook is defined', async () => {
|
|
176
|
+
await expect(
|
|
177
|
+
executeBeforeOperation(undefined, {
|
|
178
|
+
operation: 'create',
|
|
179
|
+
context: mockContext,
|
|
180
|
+
}),
|
|
181
|
+
).resolves.not.toThrow()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should call beforeOperation hook', async () => {
|
|
185
|
+
const hooks: Hooks = {
|
|
186
|
+
beforeOperation: vi.fn(async () => {}),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await executeBeforeOperation(hooks, {
|
|
190
|
+
operation: 'create',
|
|
191
|
+
context: mockContext,
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
expect(hooks.beforeOperation).toHaveBeenCalledWith({
|
|
195
|
+
operation: 'create',
|
|
196
|
+
context: mockContext,
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should support all operations', async () => {
|
|
201
|
+
const hooks: Hooks = {
|
|
202
|
+
beforeOperation: vi.fn(async () => {}),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await executeBeforeOperation(hooks, {
|
|
206
|
+
operation: 'create',
|
|
207
|
+
context: mockContext,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
await executeBeforeOperation(hooks, {
|
|
211
|
+
operation: 'update',
|
|
212
|
+
item: { id: '1' },
|
|
213
|
+
context: mockContext,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
await executeBeforeOperation(hooks, {
|
|
217
|
+
operation: 'delete',
|
|
218
|
+
item: { id: '1' },
|
|
219
|
+
context: mockContext,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
expect(hooks.beforeOperation).toHaveBeenCalledTimes(3)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('executeAfterOperation', () => {
|
|
227
|
+
it('should do nothing when no hook is defined', async () => {
|
|
228
|
+
await expect(
|
|
229
|
+
executeAfterOperation(undefined, {
|
|
230
|
+
operation: 'create',
|
|
231
|
+
item: { id: '1' },
|
|
232
|
+
context: mockContext,
|
|
233
|
+
}),
|
|
234
|
+
).resolves.not.toThrow()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('should call afterOperation hook', async () => {
|
|
238
|
+
const item = { id: '1', name: 'John' }
|
|
239
|
+
const hooks: Hooks = {
|
|
240
|
+
afterOperation: vi.fn(async () => {}),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await executeAfterOperation(hooks, {
|
|
244
|
+
operation: 'create',
|
|
245
|
+
item,
|
|
246
|
+
context: mockContext,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
expect(hooks.afterOperation).toHaveBeenCalledWith({
|
|
250
|
+
operation: 'create',
|
|
251
|
+
item,
|
|
252
|
+
context: mockContext,
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe('validateFieldRules', () => {
|
|
258
|
+
describe('required validation', () => {
|
|
259
|
+
it('should add error when required field is missing on create', () => {
|
|
260
|
+
const fieldConfigs = {
|
|
261
|
+
name: text({ validation: { isRequired: true } }),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const result = validateFieldRules({}, fieldConfigs, 'create')
|
|
265
|
+
|
|
266
|
+
expect(result.errors[0]).toContain('Name must be text')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('should not add error when required field is present', () => {
|
|
270
|
+
const fieldConfigs = {
|
|
271
|
+
name: text({ validation: { isRequired: true } }),
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const result = validateFieldRules({ name: 'John' }, fieldConfigs, 'create')
|
|
275
|
+
|
|
276
|
+
expect(result.errors).toHaveLength(0)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('should add error for empty string', () => {
|
|
280
|
+
const fieldConfigs = {
|
|
281
|
+
name: text({ validation: { isRequired: true } }),
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const result = validateFieldRules({ name: '' }, fieldConfigs, 'create')
|
|
285
|
+
|
|
286
|
+
expect(result.errors).toContain('Name is required')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('should only validate updated fields on update', () => {
|
|
290
|
+
const fieldConfigs = {
|
|
291
|
+
name: text({ validation: { isRequired: true } }),
|
|
292
|
+
email: text({ validation: { isRequired: true } }),
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Only updating name, not email
|
|
296
|
+
const result = validateFieldRules({ name: 'John' }, fieldConfigs, 'update')
|
|
297
|
+
|
|
298
|
+
expect(result.errors).toHaveLength(0)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('should validate empty value on update if field is being updated', () => {
|
|
302
|
+
const fieldConfigs = {
|
|
303
|
+
name: text({ validation: { isRequired: true } }),
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const result = validateFieldRules({ name: '' }, fieldConfigs, 'update')
|
|
307
|
+
|
|
308
|
+
expect(result.errors).toContain('Name is required')
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
describe('text length validation', () => {
|
|
313
|
+
it('should add error when text is too short', () => {
|
|
314
|
+
const fieldConfigs = {
|
|
315
|
+
name: text({ validation: { length: { min: 3 } } }),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const result = validateFieldRules({ name: 'Jo' }, fieldConfigs)
|
|
319
|
+
|
|
320
|
+
expect(result.errors).toContain('Name must be at least 3 characters')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('should add error when text is too long', () => {
|
|
324
|
+
const fieldConfigs = {
|
|
325
|
+
name: text({ validation: { length: { max: 10 } } }),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const result = validateFieldRules({ name: 'This is a very long name' }, fieldConfigs)
|
|
329
|
+
|
|
330
|
+
expect(result.errors).toContain('Name must be at most 10 characters')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('should pass when text length is within range', () => {
|
|
334
|
+
const fieldConfigs = {
|
|
335
|
+
name: text({ validation: { length: { min: 3, max: 10 } } }),
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const result = validateFieldRules({ name: 'John' }, fieldConfigs)
|
|
339
|
+
|
|
340
|
+
expect(result.errors).toHaveLength(0)
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
describe('integer validation', () => {
|
|
345
|
+
it('should add error when integer is too small', () => {
|
|
346
|
+
const fieldConfigs = {
|
|
347
|
+
age: integer({ validation: { min: 18 } }),
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const result = validateFieldRules({ age: 15 }, fieldConfigs)
|
|
351
|
+
|
|
352
|
+
expect(result.errors).toContain('Age must be at least 18')
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('should add error when integer is too large', () => {
|
|
356
|
+
const fieldConfigs = {
|
|
357
|
+
age: integer({ validation: { max: 100 } }),
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const result = validateFieldRules({ age: 150 }, fieldConfigs)
|
|
361
|
+
|
|
362
|
+
expect(result.errors).toContain('Age must be at most 100')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('should pass when integer is within range', () => {
|
|
366
|
+
const fieldConfigs = {
|
|
367
|
+
age: integer({ validation: { min: 18, max: 100 } }),
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const result = validateFieldRules({ age: 25 }, fieldConfigs)
|
|
371
|
+
|
|
372
|
+
expect(result.errors).toHaveLength(0)
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
describe('skip validation', () => {
|
|
377
|
+
it('should skip system fields', () => {
|
|
378
|
+
const fieldConfigs = {
|
|
379
|
+
id: text(),
|
|
380
|
+
createdAt: text(),
|
|
381
|
+
updatedAt: text(),
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const result = validateFieldRules({}, fieldConfigs, 'create')
|
|
385
|
+
|
|
386
|
+
expect(result.errors).toHaveLength(0)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('should skip relationship fields', () => {
|
|
390
|
+
const fieldConfigs = {
|
|
391
|
+
author: relationship({ ref: 'User.posts' }),
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const result = validateFieldRules({}, fieldConfigs, 'create')
|
|
395
|
+
|
|
396
|
+
expect(result.errors).toHaveLength(0)
|
|
397
|
+
})
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
describe('multiple errors', () => {
|
|
401
|
+
it('should collect all validation errors', () => {
|
|
402
|
+
const fieldConfigs = {
|
|
403
|
+
name: text({
|
|
404
|
+
validation: { isRequired: true, length: { min: 3 } },
|
|
405
|
+
}),
|
|
406
|
+
age: integer({ validation: { isRequired: true, min: 18 } }),
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const result = validateFieldRules({ name: 'Jo', age: 15 }, fieldConfigs, 'create')
|
|
410
|
+
|
|
411
|
+
expect(result.errors).toHaveLength(2)
|
|
412
|
+
expect(result.errors).toContain('Name must be at least 3 characters')
|
|
413
|
+
expect(result.errors).toContain('Age must be at least 18')
|
|
414
|
+
})
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
})
|