@declaro/data 2.0.0-beta.9 → 2.0.0-beta.91

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.
Files changed (109) hide show
  1. package/{LICENSE → LICENSE.md} +1 -1
  2. package/README.md +0 -0
  3. package/dist/browser/index.js +32 -0
  4. package/dist/browser/index.js.map +86 -0
  5. package/dist/node/index.cjs +11547 -0
  6. package/dist/node/index.cjs.map +86 -0
  7. package/dist/node/index.js +11526 -0
  8. package/dist/node/index.js.map +86 -0
  9. package/dist/ts/application/model-controller.d.ts +29 -0
  10. package/dist/ts/application/model-controller.d.ts.map +1 -0
  11. package/dist/ts/application/model-controller.test.d.ts +2 -0
  12. package/dist/ts/application/model-controller.test.d.ts.map +1 -0
  13. package/dist/ts/application/read-only-model-controller.d.ts +20 -0
  14. package/dist/ts/application/read-only-model-controller.d.ts.map +1 -0
  15. package/dist/ts/application/read-only-model-controller.test.d.ts +2 -0
  16. package/dist/ts/application/read-only-model-controller.test.d.ts.map +1 -0
  17. package/dist/ts/domain/events/domain-event.d.ts +41 -0
  18. package/dist/ts/domain/events/domain-event.d.ts.map +1 -0
  19. package/dist/ts/domain/events/domain-event.test.d.ts +2 -0
  20. package/dist/ts/domain/events/domain-event.test.d.ts.map +1 -0
  21. package/dist/ts/domain/events/event-types.d.ts +21 -0
  22. package/dist/ts/domain/events/event-types.d.ts.map +1 -0
  23. package/dist/ts/domain/events/mutation-event.d.ts +6 -0
  24. package/dist/ts/domain/events/mutation-event.d.ts.map +1 -0
  25. package/dist/ts/domain/events/mutation-event.test.d.ts +2 -0
  26. package/dist/ts/domain/events/mutation-event.test.d.ts.map +1 -0
  27. package/dist/ts/domain/events/query-event.d.ts +6 -0
  28. package/dist/ts/domain/events/query-event.d.ts.map +1 -0
  29. package/dist/ts/domain/events/query-event.test.d.ts +2 -0
  30. package/dist/ts/domain/events/query-event.test.d.ts.map +1 -0
  31. package/dist/ts/domain/events/request-event.d.ts +11 -0
  32. package/dist/ts/domain/events/request-event.d.ts.map +1 -0
  33. package/dist/ts/domain/events/request-event.test.d.ts +2 -0
  34. package/dist/ts/domain/events/request-event.test.d.ts.map +1 -0
  35. package/dist/ts/domain/interfaces/repository.d.ts +84 -0
  36. package/dist/ts/domain/interfaces/repository.d.ts.map +1 -0
  37. package/dist/ts/domain/models/pagination.d.ts +28 -0
  38. package/dist/ts/domain/models/pagination.d.ts.map +1 -0
  39. package/dist/ts/domain/services/base-model-service.d.ts +22 -0
  40. package/dist/ts/domain/services/base-model-service.d.ts.map +1 -0
  41. package/dist/ts/domain/services/model-service-args.d.ts +9 -0
  42. package/dist/ts/domain/services/model-service-args.d.ts.map +1 -0
  43. package/dist/ts/domain/services/model-service.d.ts +42 -0
  44. package/dist/ts/domain/services/model-service.d.ts.map +1 -0
  45. package/dist/ts/domain/services/model-service.test.d.ts +2 -0
  46. package/dist/ts/domain/services/model-service.test.d.ts.map +1 -0
  47. package/dist/ts/domain/services/read-only-model-service.d.ts +40 -0
  48. package/dist/ts/domain/services/read-only-model-service.d.ts.map +1 -0
  49. package/dist/ts/domain/services/read-only-model-service.test.d.ts +2 -0
  50. package/dist/ts/domain/services/read-only-model-service.test.d.ts.map +1 -0
  51. package/dist/ts/index.d.ts +18 -0
  52. package/dist/ts/index.d.ts.map +1 -0
  53. package/dist/ts/shared/utils/schema-inference.d.ts +23 -0
  54. package/dist/ts/shared/utils/schema-inference.d.ts.map +1 -0
  55. package/dist/ts/shared/utils/schema-inheritance.d.ts +24 -0
  56. package/dist/ts/shared/utils/schema-inheritance.d.ts.map +1 -0
  57. package/dist/ts/test/domain/services/model-service.test.d.ts +1 -0
  58. package/dist/ts/test/domain/services/model-service.test.d.ts.map +1 -0
  59. package/dist/ts/test/mock/models/mock-book-models.d.ts +42 -0
  60. package/dist/ts/test/mock/models/mock-book-models.d.ts.map +1 -0
  61. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts +36 -0
  62. package/dist/ts/test/mock/repositories/mock-memory-repository.d.ts.map +1 -0
  63. package/dist/ts/test/mock/repositories/mock-memory-repository.test.d.ts +2 -0
  64. package/dist/ts/test/mock/repositories/mock-memory-repository.test.d.ts.map +1 -0
  65. package/package.json +45 -42
  66. package/src/application/model-controller.test.ts +488 -0
  67. package/src/application/model-controller.ts +92 -0
  68. package/src/application/read-only-model-controller.test.ts +327 -0
  69. package/src/application/read-only-model-controller.ts +61 -0
  70. package/src/domain/events/domain-event.test.ts +82 -0
  71. package/src/domain/events/domain-event.ts +69 -0
  72. package/src/domain/events/event-types.ts +21 -0
  73. package/src/domain/events/mutation-event.test.ts +38 -0
  74. package/src/domain/events/mutation-event.ts +8 -0
  75. package/src/domain/events/query-event.test.ts +28 -0
  76. package/src/domain/events/query-event.ts +8 -0
  77. package/src/domain/events/request-event.test.ts +38 -0
  78. package/src/domain/events/request-event.ts +32 -0
  79. package/src/domain/interfaces/repository.ts +107 -0
  80. package/src/domain/models/pagination.ts +28 -0
  81. package/src/domain/services/base-model-service.ts +50 -0
  82. package/src/domain/services/model-service-args.ts +9 -0
  83. package/src/domain/services/model-service.test.ts +631 -0
  84. package/src/domain/services/model-service.ts +322 -0
  85. package/src/domain/services/read-only-model-service.test.ts +296 -0
  86. package/src/domain/services/read-only-model-service.ts +133 -0
  87. package/src/index.ts +17 -4
  88. package/src/shared/utils/schema-inference.ts +26 -0
  89. package/src/shared/utils/schema-inheritance.ts +28 -0
  90. package/src/test/domain/services/model-service.test.ts +0 -0
  91. package/src/test/mock/models/mock-book-models.ts +78 -0
  92. package/src/test/mock/repositories/mock-memory-repository.test.ts +715 -0
  93. package/src/test/mock/repositories/mock-memory-repository.ts +235 -0
  94. package/dist/databaseConnection.d.ts +0 -24
  95. package/dist/datastoreAbstract.d.ts +0 -37
  96. package/dist/declaro-data.cjs +0 -1
  97. package/dist/declaro-data.mjs +0 -250
  98. package/dist/hydrateEntity.d.ts +0 -8
  99. package/dist/index.d.ts +0 -4
  100. package/dist/serverConnection.d.ts +0 -15
  101. package/dist/trackedStatus.d.ts +0 -15
  102. package/src/databaseConnection.ts +0 -137
  103. package/src/datastoreAbstract.ts +0 -190
  104. package/src/hydrateEntity.ts +0 -36
  105. package/src/placeholder.test.ts +0 -7
  106. package/src/serverConnection.ts +0 -74
  107. package/src/trackedStatus.ts +0 -35
  108. package/tsconfig.json +0 -10
  109. package/vite.config.ts +0 -23
@@ -0,0 +1,488 @@
1
+ import { AuthValidator, getMockAuthSession, mockAuthConfig, MockAuthService } from '@declaro/auth'
2
+ import { EventManager, PermissionError } from '@declaro/core'
3
+ import { beforeEach, describe, expect, it } from 'bun:test'
4
+ import { ModelService } from '../domain/services/model-service'
5
+ import { MockBookSchema } from '../test/mock/models/mock-book-models'
6
+ import { MockMemoryRepository } from '../test/mock/repositories/mock-memory-repository'
7
+ import { ModelController } from './model-controller'
8
+
9
+ describe('ModelController', () => {
10
+ const namespace = 'books'
11
+ const mockSchema = MockBookSchema
12
+ const authService = new MockAuthService(mockAuthConfig)
13
+
14
+ let repository: MockMemoryRepository<typeof mockSchema>
15
+ let service: ModelService<typeof mockSchema>
16
+ let authValidator: AuthValidator
17
+ let invalidAuthValidator: AuthValidator
18
+ let readAuthValidator: AuthValidator
19
+
20
+ beforeEach(() => {
21
+ repository = new MockMemoryRepository({ schema: mockSchema })
22
+ authValidator = new AuthValidator(
23
+ getMockAuthSession({
24
+ claims: ['books::book.write:all'],
25
+ }),
26
+ authService,
27
+ )
28
+ invalidAuthValidator = new AuthValidator(
29
+ getMockAuthSession({
30
+ claims: ['authors::author.write:all'],
31
+ }),
32
+ authService,
33
+ )
34
+ readAuthValidator = new AuthValidator(
35
+ getMockAuthSession({
36
+ claims: ['books::book.read:all'],
37
+ }),
38
+ authService,
39
+ )
40
+ service = new ModelService({
41
+ repository,
42
+ emitter: new EventManager(),
43
+ schema: mockSchema,
44
+ namespace,
45
+ })
46
+ })
47
+
48
+ it('should create a record if permissions are valid', async () => {
49
+ const controller = new ModelController(service, authValidator)
50
+
51
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
52
+ const record = await controller.create(input)
53
+
54
+ expect(record).toEqual(input)
55
+ })
56
+
57
+ it('should throw PermissionError if permissions are invalid for create', async () => {
58
+ const controller = new ModelController(service, invalidAuthValidator)
59
+
60
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
61
+ await expect(controller.create(input)).rejects.toThrow(PermissionError)
62
+ })
63
+
64
+ it('should update a record if permissions are valid', async () => {
65
+ const controller = new ModelController(service, authValidator)
66
+
67
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
68
+ await repository.create(input)
69
+
70
+ const updatedInput = { title: 'Updated Title', author: 'Updated Author', publishedDate: new Date() }
71
+ const updatedRecord = await controller.update({ id: 42 }, updatedInput)
72
+
73
+ expect(updatedRecord).toEqual({ id: 42, ...updatedInput })
74
+ })
75
+
76
+ it('should throw PermissionError if permissions are invalid for update', async () => {
77
+ const controller = new ModelController(service, invalidAuthValidator)
78
+
79
+ const updatedInput = { title: 'Updated Title', author: 'Updated Author', publishedDate: new Date() }
80
+ await expect(controller.update({ id: 42 }, updatedInput)).rejects.toThrow(PermissionError)
81
+ })
82
+
83
+ it('should remove a record if permissions are valid', async () => {
84
+ const controller = new ModelController(service, authValidator)
85
+
86
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
87
+ await repository.create(input)
88
+
89
+ const removedRecord = await controller.remove({ id: 42 })
90
+
91
+ expect(removedRecord).toEqual(input)
92
+ })
93
+
94
+ it('should throw PermissionError if permissions are invalid for remove', async () => {
95
+ const controller = new ModelController(service, invalidAuthValidator)
96
+
97
+ await expect(controller.remove({ id: 42 })).rejects.toThrow(PermissionError)
98
+ })
99
+
100
+ it('should restore a record if permissions are valid', async () => {
101
+ const controller = new ModelController(service, authValidator)
102
+
103
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
104
+ await repository.create(input)
105
+ await service.remove({ id: 42 })
106
+
107
+ const restoredRecord = await controller.restore({ id: 42 })
108
+
109
+ expect(restoredRecord).toEqual(input)
110
+ })
111
+
112
+ it('should throw PermissionError if permissions are invalid for restore', async () => {
113
+ const controller = new ModelController(service, invalidAuthValidator)
114
+
115
+ await expect(controller.restore({ id: 42 })).rejects.toThrow(PermissionError)
116
+ })
117
+
118
+ // Test inherited search functionality from ReadOnlyModelController
119
+ it('should search records with pagination and sorting', async () => {
120
+ const controller = new ModelController(service, readAuthValidator)
121
+
122
+ // Create test data
123
+ const books = [
124
+ { id: 1, title: 'Book A', author: 'Author 1', publishedDate: new Date('2020-01-01') },
125
+ { id: 2, title: 'Book B', author: 'Author 2', publishedDate: new Date('2021-01-01') },
126
+ { id: 3, title: 'Book C', author: 'Author 3', publishedDate: new Date('2022-01-01') },
127
+ ]
128
+
129
+ for (const book of books) {
130
+ await repository.create(book)
131
+ }
132
+
133
+ // Test search with pagination
134
+ const paginatedResult = await controller.search(
135
+ {},
136
+ {
137
+ pagination: { page: 1, pageSize: 2 },
138
+ },
139
+ )
140
+
141
+ expect(paginatedResult.results).toHaveLength(2)
142
+ expect(paginatedResult.pagination.total).toBe(3)
143
+ expect(paginatedResult.pagination.page).toBe(1)
144
+ expect(paginatedResult.pagination.pageSize).toBe(2)
145
+
146
+ // Test search with sorting
147
+ const sortedResult = await controller.search(
148
+ {},
149
+ {
150
+ sort: [{ title: 'desc' }],
151
+ },
152
+ )
153
+
154
+ expect(sortedResult.results).toHaveLength(3)
155
+ expect(sortedResult.results[0].title).toBe('Book C')
156
+ expect(sortedResult.results[1].title).toBe('Book B')
157
+ expect(sortedResult.results[2].title).toBe('Book A')
158
+
159
+ // Test search with both pagination and sorting
160
+ const combinedResult = await controller.search(
161
+ {},
162
+ {
163
+ pagination: { page: 1, pageSize: 1 },
164
+ sort: [{ title: 'desc' }],
165
+ },
166
+ )
167
+
168
+ expect(combinedResult.results).toHaveLength(1)
169
+ expect(combinedResult.results[0].title).toBe('Book C')
170
+ expect(combinedResult.pagination.total).toBe(3)
171
+ })
172
+
173
+ it('should search records with filters', async () => {
174
+ // Create a repository with a custom filter function for search
175
+ const repositoryWithFilter = new MockMemoryRepository({
176
+ schema: mockSchema,
177
+ filter: (data, filters) => {
178
+ if (filters.text) {
179
+ return data.title.toLowerCase().includes(filters.text.toLowerCase())
180
+ }
181
+ return true
182
+ },
183
+ })
184
+
185
+ const serviceWithFilter = new ModelService({
186
+ repository: repositoryWithFilter,
187
+ emitter: new EventManager(),
188
+ schema: mockSchema,
189
+ namespace,
190
+ })
191
+
192
+ const controller = new ModelController(serviceWithFilter, readAuthValidator)
193
+
194
+ // Create test data
195
+ const books = [
196
+ { id: 1, title: 'Test Book', author: 'Author 1', publishedDate: new Date('2020-01-01') },
197
+ { id: 2, title: 'Another Book', author: 'Author 2', publishedDate: new Date('2021-01-01') },
198
+ ]
199
+
200
+ for (const book of books) {
201
+ await repositoryWithFilter.create(book)
202
+ }
203
+
204
+ // Test search with filter
205
+ const filteredResult = await controller.search({ text: 'Test' })
206
+
207
+ expect(filteredResult.results).toHaveLength(1)
208
+ expect(filteredResult.results[0].title).toBe('Test Book')
209
+ })
210
+
211
+ it('should upsert a record if permissions are valid', async () => {
212
+ const controller = new ModelController(service, authValidator)
213
+
214
+ // Test creating a new record via upsert
215
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
216
+ const upsertedRecord = await controller.upsert(input)
217
+
218
+ expect(upsertedRecord).toEqual(input)
219
+
220
+ // Test updating an existing record via upsert
221
+ const updateInput = { id: 42, title: 'Updated Book', author: 'Updated Author', publishedDate: new Date() }
222
+ const updatedRecord = await controller.upsert(updateInput)
223
+
224
+ expect(updatedRecord).toEqual(updateInput)
225
+ })
226
+
227
+ it('should throw PermissionError if permissions are invalid for upsert', async () => {
228
+ const controller = new ModelController(service, invalidAuthValidator)
229
+
230
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
231
+
232
+ await expect(controller.upsert(input)).rejects.toThrow(PermissionError)
233
+ })
234
+
235
+ it('should bulk upsert records if permissions are valid', async () => {
236
+ const controller = new ModelController(service, authValidator)
237
+
238
+ const inputs = [
239
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date() },
240
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date() },
241
+ ]
242
+
243
+ const upsertedRecords = await controller.bulkUpsert(inputs)
244
+
245
+ expect(upsertedRecords).toHaveLength(2)
246
+ expect(upsertedRecords).toEqual(inputs)
247
+
248
+ // Test updating existing records via bulk upsert
249
+ const updateInputs = [
250
+ { id: 1, title: 'Updated Book 1', author: 'Updated Author 1', publishedDate: new Date() },
251
+ { id: 2, title: 'Updated Book 2', author: 'Updated Author 2', publishedDate: new Date() },
252
+ ]
253
+
254
+ const updatedRecords = await controller.bulkUpsert(updateInputs)
255
+
256
+ expect(updatedRecords).toHaveLength(2)
257
+ expect(updatedRecords).toEqual(updateInputs)
258
+ })
259
+
260
+ it('should throw PermissionError if permissions are invalid for bulkUpsert', async () => {
261
+ const controller = new ModelController(service, invalidAuthValidator)
262
+
263
+ const inputs = [
264
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date() },
265
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date() },
266
+ ]
267
+
268
+ await expect(controller.bulkUpsert(inputs)).rejects.toThrow(PermissionError)
269
+ })
270
+
271
+ describe('upsert and bulkUpsert permission logic', () => {
272
+ it('should allow upsert with create AND update permissions', async () => {
273
+ const createUpdateValidator = new AuthValidator(
274
+ getMockAuthSession({
275
+ claims: ['books::book.create:all', 'books::book.update:all'],
276
+ }),
277
+ authService,
278
+ )
279
+ const controller = new ModelController(service, createUpdateValidator)
280
+
281
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
282
+ const upsertedRecord = await controller.upsert(input)
283
+
284
+ expect(upsertedRecord).toEqual(input)
285
+ })
286
+
287
+ it('should allow bulkUpsert with create AND update permissions', async () => {
288
+ const createUpdateValidator = new AuthValidator(
289
+ getMockAuthSession({
290
+ claims: ['books::book.create:all', 'books::book.update:all'],
291
+ }),
292
+ authService,
293
+ )
294
+ const controller = new ModelController(service, createUpdateValidator)
295
+
296
+ const inputs = [
297
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date() },
298
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date() },
299
+ ]
300
+
301
+ const upsertedRecords = await controller.bulkUpsert(inputs)
302
+
303
+ expect(upsertedRecords).toEqual(inputs)
304
+ })
305
+
306
+ it('should reject upsert with only create permission', async () => {
307
+ const createOnlyValidator = new AuthValidator(
308
+ getMockAuthSession({
309
+ claims: ['books::book.create:all'],
310
+ }),
311
+ authService,
312
+ )
313
+ const controller = new ModelController(service, createOnlyValidator)
314
+
315
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
316
+
317
+ await expect(controller.upsert(input)).rejects.toThrow(PermissionError)
318
+ })
319
+
320
+ it('should reject upsert with only update permission', async () => {
321
+ const updateOnlyValidator = new AuthValidator(
322
+ getMockAuthSession({
323
+ claims: ['books::book.update:all'],
324
+ }),
325
+ authService,
326
+ )
327
+ const controller = new ModelController(service, updateOnlyValidator)
328
+
329
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
330
+
331
+ await expect(controller.upsert(input)).rejects.toThrow(PermissionError)
332
+ })
333
+
334
+ it('should reject bulkUpsert with only create permission', async () => {
335
+ const createOnlyValidator = new AuthValidator(
336
+ getMockAuthSession({
337
+ claims: ['books::book.create:all'],
338
+ }),
339
+ authService,
340
+ )
341
+ const controller = new ModelController(service, createOnlyValidator)
342
+
343
+ const inputs = [
344
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date() },
345
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date() },
346
+ ]
347
+
348
+ await expect(controller.bulkUpsert(inputs)).rejects.toThrow(PermissionError)
349
+ })
350
+
351
+ it('should reject bulkUpsert with only update permission', async () => {
352
+ const updateOnlyValidator = new AuthValidator(
353
+ getMockAuthSession({
354
+ claims: ['books::book.update:all'],
355
+ }),
356
+ authService,
357
+ )
358
+ const controller = new ModelController(service, updateOnlyValidator)
359
+
360
+ const inputs = [
361
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date() },
362
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date() },
363
+ ]
364
+
365
+ await expect(controller.bulkUpsert(inputs)).rejects.toThrow(PermissionError)
366
+ })
367
+
368
+ it('should allow upsert and bulkUpsert with write permission', async () => {
369
+ // This is already tested in the main tests, but including here for completeness
370
+ const controller = new ModelController(service, authValidator) // authValidator has write:all
371
+
372
+ // Test upsert
373
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
374
+ const upsertedRecord = await controller.upsert(input)
375
+ expect(upsertedRecord).toEqual(input)
376
+
377
+ // Test bulkUpsert
378
+ const inputs = [
379
+ { id: 1, title: 'Book 1', author: 'Author 1', publishedDate: new Date() },
380
+ { id: 2, title: 'Book 2', author: 'Author 2', publishedDate: new Date() },
381
+ ]
382
+ const upsertedRecords = await controller.bulkUpsert(inputs)
383
+ expect(upsertedRecords).toEqual(inputs)
384
+ })
385
+ })
386
+
387
+ describe('granular permission testing', () => {
388
+ it('should allow create with specific create permission', async () => {
389
+ const createOnlyValidator = new AuthValidator(
390
+ getMockAuthSession({
391
+ claims: ['books::book.create:all'],
392
+ }),
393
+ authService,
394
+ )
395
+ const controller = new ModelController(service, createOnlyValidator)
396
+
397
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
398
+ const record = await controller.create(input)
399
+
400
+ expect(record).toEqual(input)
401
+ })
402
+
403
+ it('should allow update with specific update permission', async () => {
404
+ const updateOnlyValidator = new AuthValidator(
405
+ getMockAuthSession({
406
+ claims: ['books::book.update:all'],
407
+ }),
408
+ authService,
409
+ )
410
+ const controller = new ModelController(service, updateOnlyValidator)
411
+
412
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
413
+ await repository.create(input)
414
+
415
+ const updatedInput = { title: 'Updated Title', author: 'Updated Author', publishedDate: new Date() }
416
+ const updatedRecord = await controller.update({ id: 42 }, updatedInput)
417
+
418
+ expect(updatedRecord).toEqual({ id: 42, ...updatedInput })
419
+ })
420
+
421
+ it('should allow remove with specific remove permission', async () => {
422
+ const removeOnlyValidator = new AuthValidator(
423
+ getMockAuthSession({
424
+ claims: ['books::book.remove:all'],
425
+ }),
426
+ authService,
427
+ )
428
+ const controller = new ModelController(service, removeOnlyValidator)
429
+
430
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
431
+ await repository.create(input)
432
+
433
+ const removedRecord = await controller.remove({ id: 42 })
434
+
435
+ expect(removedRecord).toEqual(input)
436
+ })
437
+
438
+ it('should allow restore with specific restore permission', async () => {
439
+ const restoreOnlyValidator = new AuthValidator(
440
+ getMockAuthSession({
441
+ claims: ['books::book.restore:all'],
442
+ }),
443
+ authService,
444
+ )
445
+ const controller = new ModelController(service, restoreOnlyValidator)
446
+
447
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
448
+ await repository.create(input)
449
+ await service.remove({ id: 42 })
450
+
451
+ const restoredRecord = await controller.restore({ id: 42 })
452
+
453
+ expect(restoredRecord).toEqual(input)
454
+ })
455
+
456
+ it('should reject operations with wrong namespace permissions', async () => {
457
+ const wrongNamespaceValidator = new AuthValidator(
458
+ getMockAuthSession({
459
+ claims: ['users::user.write:all'], // Wrong namespace
460
+ }),
461
+ authService,
462
+ )
463
+ const controller = new ModelController(service, wrongNamespaceValidator)
464
+
465
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
466
+
467
+ await expect(controller.create(input)).rejects.toThrow(PermissionError)
468
+ await expect(controller.upsert(input)).rejects.toThrow(PermissionError)
469
+ await expect(controller.bulkUpsert([input])).rejects.toThrow(PermissionError)
470
+ })
471
+
472
+ it('should reject operations with wrong resource permissions', async () => {
473
+ const wrongResourceValidator = new AuthValidator(
474
+ getMockAuthSession({
475
+ claims: ['books::author.write:all'], // Wrong resource
476
+ }),
477
+ authService,
478
+ )
479
+ const controller = new ModelController(service, wrongResourceValidator)
480
+
481
+ const input = { id: 42, title: 'Test Book', author: 'Author Name', publishedDate: new Date() }
482
+
483
+ await expect(controller.create(input)).rejects.toThrow(PermissionError)
484
+ await expect(controller.upsert(input)).rejects.toThrow(PermissionError)
485
+ await expect(controller.bulkUpsert([input])).rejects.toThrow(PermissionError)
486
+ })
487
+ })
488
+ })
@@ -0,0 +1,92 @@
1
+ import type { AuthValidator } from '@declaro/auth'
2
+ import { PermissionValidator, type AnyModelSchema } from '@declaro/core'
3
+ import type { ModelService, ICreateOptions, IUpdateOptions } from '../domain/services/model-service'
4
+ import type { InferDetail, InferInput, InferLookup, InferSummary } from '../shared/utils/schema-inference'
5
+ import { ReadOnlyModelController } from './read-only-model-controller'
6
+
7
+ export class ModelController<TSchema extends AnyModelSchema> extends ReadOnlyModelController<TSchema> {
8
+ constructor(protected readonly service: ModelService<TSchema>, protected readonly authValidator: AuthValidator) {
9
+ super(service, authValidator)
10
+ }
11
+
12
+ async create(input: InferInput<TSchema>): Promise<InferDetail<TSchema>> {
13
+ this.authValidator.validatePermissions((v) =>
14
+ v.someOf([
15
+ this.service.getDescriptor('create', '*').toString(),
16
+ this.service.getDescriptor('write', '*').toString(),
17
+ ]),
18
+ )
19
+ return this.service.create(input)
20
+ }
21
+
22
+ async update(lookup: InferLookup<TSchema>, input: InferInput<TSchema>): Promise<InferDetail<TSchema>> {
23
+ this.authValidator.validatePermissions((v) =>
24
+ v.someOf([
25
+ this.service.getDescriptor('update', '*').toString(),
26
+ this.service.getDescriptor('write', '*').toString(),
27
+ ]),
28
+ )
29
+ return this.service.update(lookup, input)
30
+ }
31
+
32
+ async remove(lookup: InferLookup<TSchema>): Promise<InferSummary<TSchema>> {
33
+ this.authValidator.validatePermissions((v) =>
34
+ v.someOf([
35
+ this.service.getDescriptor('remove', '*').toString(),
36
+ this.service.getDescriptor('write', '*').toString(),
37
+ ]),
38
+ )
39
+ return this.service.remove(lookup)
40
+ }
41
+
42
+ async restore(lookup: InferLookup<TSchema>): Promise<InferSummary<TSchema>> {
43
+ this.authValidator.validatePermissions((v) =>
44
+ v.someOf([
45
+ this.service.getDescriptor('restore', '*').toString(),
46
+ this.service.getDescriptor('write', '*').toString(),
47
+ ]),
48
+ )
49
+ return this.service.restore(lookup)
50
+ }
51
+
52
+ /**
53
+ * Upserts a record (creates if it doesn't exist, updates if it does).
54
+ * @param input The input data for the upsert operation.
55
+ * @param options Optional create or update options.
56
+ * @returns The upserted record.
57
+ */
58
+ async upsert(input: InferInput<TSchema>, options?: ICreateOptions | IUpdateOptions): Promise<InferDetail<TSchema>> {
59
+ // Create nested validator for (create AND update) permissions
60
+ const createAndUpdateValidator = PermissionValidator.create().allOf([
61
+ this.service.getDescriptor('create', '*').toString(),
62
+ this.service.getDescriptor('update', '*').toString(),
63
+ ])
64
+
65
+ this.authValidator.validatePermissions((v) =>
66
+ v.someOf([createAndUpdateValidator, this.service.getDescriptor('write', '*').toString()]),
67
+ )
68
+ return this.service.upsert(input, options)
69
+ }
70
+
71
+ /**
72
+ * Bulk upserts multiple records (creates if they don't exist, updates if they do).
73
+ * @param inputs Array of input data for the bulk upsert operation.
74
+ * @param options Optional create or update options.
75
+ * @returns Array of upserted records.
76
+ */
77
+ async bulkUpsert(
78
+ inputs: InferInput<TSchema>[],
79
+ options?: ICreateOptions | IUpdateOptions,
80
+ ): Promise<InferDetail<TSchema>[]> {
81
+ // Create nested validator for (create AND update) permissions
82
+ const createAndUpdateValidator = PermissionValidator.create().allOf([
83
+ this.service.getDescriptor('create', '*').toString(),
84
+ this.service.getDescriptor('update', '*').toString(),
85
+ ])
86
+
87
+ this.authValidator.validatePermissions((v) =>
88
+ v.someOf([createAndUpdateValidator, this.service.getDescriptor('write', '*').toString()]),
89
+ )
90
+ return this.service.bulkUpsert(inputs, options)
91
+ }
92
+ }