@api-client/core 0.19.21 → 0.19.23

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 (73) hide show
  1. package/build/src/modeling/ApiModel.d.ts.map +1 -1
  2. package/build/src/modeling/ApiModel.js +37 -13
  3. package/build/src/modeling/ApiModel.js.map +1 -1
  4. package/build/src/modeling/ExposedEntity.d.ts +9 -0
  5. package/build/src/modeling/ExposedEntity.d.ts.map +1 -1
  6. package/build/src/modeling/ExposedEntity.js +90 -14
  7. package/build/src/modeling/ExposedEntity.js.map +1 -1
  8. package/build/src/modeling/actions/Action.js +2 -2
  9. package/build/src/modeling/actions/Action.js.map +1 -1
  10. package/build/src/modeling/rules/AccessRule.d.ts +5 -1
  11. package/build/src/modeling/rules/AccessRule.d.ts.map +1 -1
  12. package/build/src/modeling/rules/AccessRule.js +4 -1
  13. package/build/src/modeling/rules/AccessRule.js.map +1 -1
  14. package/build/src/modeling/rules/AllowAuthenticated.d.ts +4 -1
  15. package/build/src/modeling/rules/AllowAuthenticated.d.ts.map +1 -1
  16. package/build/src/modeling/rules/AllowAuthenticated.js +2 -2
  17. package/build/src/modeling/rules/AllowAuthenticated.js.map +1 -1
  18. package/build/src/modeling/rules/AllowPublic.d.ts +4 -1
  19. package/build/src/modeling/rules/AllowPublic.d.ts.map +1 -1
  20. package/build/src/modeling/rules/AllowPublic.js +2 -2
  21. package/build/src/modeling/rules/AllowPublic.js.map +1 -1
  22. package/build/src/modeling/rules/MatchEmailDomain.d.ts +4 -1
  23. package/build/src/modeling/rules/MatchEmailDomain.d.ts.map +1 -1
  24. package/build/src/modeling/rules/MatchEmailDomain.js +2 -2
  25. package/build/src/modeling/rules/MatchEmailDomain.js.map +1 -1
  26. package/build/src/modeling/rules/MatchResourceOwner.d.ts +4 -1
  27. package/build/src/modeling/rules/MatchResourceOwner.d.ts.map +1 -1
  28. package/build/src/modeling/rules/MatchResourceOwner.js +2 -2
  29. package/build/src/modeling/rules/MatchResourceOwner.js.map +1 -1
  30. package/build/src/modeling/rules/MatchUserProperty.d.ts +4 -1
  31. package/build/src/modeling/rules/MatchUserProperty.d.ts.map +1 -1
  32. package/build/src/modeling/rules/MatchUserProperty.js +2 -2
  33. package/build/src/modeling/rules/MatchUserProperty.js.map +1 -1
  34. package/build/src/modeling/rules/MatchUserRole.d.ts +4 -1
  35. package/build/src/modeling/rules/MatchUserRole.d.ts.map +1 -1
  36. package/build/src/modeling/rules/MatchUserRole.js +2 -2
  37. package/build/src/modeling/rules/MatchUserRole.js.map +1 -1
  38. package/build/src/modeling/rules/index.d.ts +4 -1
  39. package/build/src/modeling/rules/index.d.ts.map +1 -1
  40. package/build/src/modeling/rules/index.js +7 -7
  41. package/build/src/modeling/rules/index.js.map +1 -1
  42. package/build/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +1 -1
  44. package/src/modeling/ApiModel.ts +37 -13
  45. package/src/modeling/ExposedEntity.ts +98 -15
  46. package/src/modeling/actions/Action.ts +2 -2
  47. package/src/modeling/rules/AccessRule.ts +8 -1
  48. package/src/modeling/rules/AllowAuthenticated.ts +5 -2
  49. package/src/modeling/rules/AllowPublic.ts +5 -2
  50. package/src/modeling/rules/MatchEmailDomain.ts +5 -2
  51. package/src/modeling/rules/MatchResourceOwner.ts +5 -2
  52. package/src/modeling/rules/MatchUserProperty.ts +5 -2
  53. package/src/modeling/rules/MatchUserRole.ts +5 -2
  54. package/tests/unit/modeling/actions/Action.spec.ts +13 -10
  55. package/tests/unit/modeling/actions/CreateAction.spec.ts +7 -6
  56. package/tests/unit/modeling/actions/DeleteAction.spec.ts +7 -6
  57. package/tests/unit/modeling/actions/ListAction.spec.ts +5 -4
  58. package/tests/unit/modeling/actions/ReadAction.spec.ts +9 -8
  59. package/tests/unit/modeling/actions/SearchAction.spec.ts +5 -4
  60. package/tests/unit/modeling/actions/UpdateAction.spec.ts +7 -6
  61. package/tests/unit/modeling/actions/helpers.ts +7 -0
  62. package/tests/unit/modeling/api_model.spec.ts +3 -1
  63. package/tests/unit/modeling/api_model_expose_entity.spec.ts +5 -17
  64. package/tests/unit/modeling/exposed_entity.spec.ts +150 -3
  65. package/tests/unit/modeling/exposed_entity_actions.spec.ts +0 -4
  66. package/tests/unit/modeling/rules/AccessRule.spec.ts +6 -5
  67. package/tests/unit/modeling/rules/AllowAuthenticated.spec.ts +4 -3
  68. package/tests/unit/modeling/rules/AllowPublic.spec.ts +4 -3
  69. package/tests/unit/modeling/rules/MatchEmailDomain.spec.ts +6 -5
  70. package/tests/unit/modeling/rules/MatchResourceOwner.spec.ts +7 -6
  71. package/tests/unit/modeling/rules/MatchUserProperty.spec.ts +6 -5
  72. package/tests/unit/modeling/rules/MatchUserRole.spec.ts +6 -5
  73. package/tests/unit/modeling/rules/restoring_rules.spec.ts +19 -21
@@ -1,9 +1,10 @@
1
1
  import { test } from '@japa/runner'
2
2
  import { DeleteAction } from '../../../../src/modeling/actions/DeleteAction.js'
3
+ import { mockExposedEntity } from './helpers.js'
3
4
 
4
5
  test.group('DeleteAction', () => {
5
6
  test('initializes with default values', ({ assert }) => {
6
- const action = new DeleteAction({} as any)
7
+ const action = new DeleteAction(mockExposedEntity())
7
8
  assert.equal(action.kind, 'delete')
8
9
  assert.equal(action.strategy, 'soft')
9
10
  assert.equal(action.retentionPeriod, 30)
@@ -11,7 +12,7 @@ test.group('DeleteAction', () => {
11
12
  }).tags(['@modeling', '@action', '@delete-action'])
12
13
 
13
14
  test('initializes with provided values', ({ assert }) => {
14
- const action = new DeleteAction({} as any, {
15
+ const action = new DeleteAction(mockExposedEntity(), {
15
16
  strategy: 'hard',
16
17
  retentionPeriod: 0,
17
18
  accessRule: [{ type: 'allowPublic' }],
@@ -27,7 +28,7 @@ test.group('DeleteAction', () => {
27
28
  test('constructor copies arrays (immutability)', ({ assert }) => {
28
29
  const rules = [{ type: 'allowPublic' }]
29
30
 
30
- const action = new DeleteAction({} as any, {
31
+ const action = new DeleteAction(mockExposedEntity(), {
31
32
  accessRule: rules,
32
33
  })
33
34
 
@@ -40,7 +41,7 @@ test.group('DeleteAction', () => {
40
41
  }).tags(['@modeling', '@action', '@delete-action', '@immutability'])
41
42
 
42
43
  test('toJSON returns valid schema', ({ assert }) => {
43
- const action = new DeleteAction({} as any, {
44
+ const action = new DeleteAction(mockExposedEntity(), {
44
45
  strategy: 'hard',
45
46
  retentionPeriod: 0,
46
47
  })
@@ -53,7 +54,7 @@ test.group('DeleteAction', () => {
53
54
  }).tags(['@modeling', '@action', '@delete-action', '@serialization'])
54
55
 
55
56
  test('notifies change when strategy changes', async ({ assert }) => {
56
- const action = new DeleteAction({} as any)
57
+ const action = new DeleteAction(mockExposedEntity())
57
58
  let notified = false
58
59
  action.addEventListener('change', () => {
59
60
  notified = true
@@ -65,7 +66,7 @@ test.group('DeleteAction', () => {
65
66
  }).tags(['@modeling', '@action', '@delete-action', '@observed'])
66
67
 
67
68
  test('notifies change when retentionPeriod changes', async ({ assert }) => {
68
- const action = new DeleteAction({} as any)
69
+ const action = new DeleteAction(mockExposedEntity())
69
70
  let notified = false
70
71
  action.addEventListener('change', () => {
71
72
  notified = true
@@ -1,9 +1,10 @@
1
1
  import { test } from '@japa/runner'
2
2
  import { ListAction } from '../../../../src/modeling/actions/ListAction.js'
3
+ import { mockExposedEntity } from './helpers.js'
3
4
 
4
5
  test.group('ListAction', () => {
5
6
  test('initializes with default values', ({ assert }) => {
6
- const action = new ListAction({} as any)
7
+ const action = new ListAction(mockExposedEntity())
7
8
  assert.equal(action.kind, 'list')
8
9
  assert.isUndefined(action.cacheTtl)
9
10
  assert.isEmpty(action.accessRule) // Inherited from Action
@@ -12,7 +13,7 @@ test.group('ListAction', () => {
12
13
  test('initializes with provided values', ({ assert }) => {
13
14
  const cacheTtl = 100
14
15
 
15
- const action = new ListAction({} as any, {
16
+ const action = new ListAction(mockExposedEntity(), {
16
17
  cacheTtl,
17
18
  })
18
19
 
@@ -20,7 +21,7 @@ test.group('ListAction', () => {
20
21
  }).tags(['@modeling', '@action', '@list-action'])
21
22
 
22
23
  test('toJSON returns safe copy', ({ assert }) => {
23
- const action = new ListAction({} as any, {
24
+ const action = new ListAction(mockExposedEntity(), {
24
25
  cacheTtl: 100,
25
26
  })
26
27
 
@@ -33,7 +34,7 @@ test.group('ListAction', () => {
33
34
  }).tags(['@modeling', '@action', '@list-action', '@immutability'])
34
35
 
35
36
  test('notifies change when cacheTtl changes', async ({ assert }) => {
36
- const action = new ListAction({} as any)
37
+ const action = new ListAction(mockExposedEntity())
37
38
  let notified = false
38
39
  action.addEventListener('change', () => {
39
40
  notified = true
@@ -1,16 +1,17 @@
1
1
  import { test } from '@japa/runner'
2
2
  import { ReadAction } from '../../../../src/modeling/actions/ReadAction.js'
3
3
  import { AccessRule } from '../../../../src/modeling/rules/index.js'
4
+ import { mockExposedEntity } from './helpers.js'
4
5
 
5
6
  test.group('ReadAction', () => {
6
7
  test('initializes with default values', ({ assert }) => {
7
- const action = new ReadAction({} as any)
8
+ const action = new ReadAction(mockExposedEntity())
8
9
  assert.equal(action.kind, 'read')
9
10
  assert.isEmpty(action.accessRule) // Inherited from Action
10
11
  }).tags(['@modeling', '@action', '@read-action'])
11
12
 
12
13
  test('initializes with inherited values', ({ assert }) => {
13
- const action = new ReadAction({} as any, {
14
+ const action = new ReadAction(mockExposedEntity(), {
14
15
  accessRule: [{ type: 'allowPublic' }],
15
16
  })
16
17
 
@@ -22,7 +23,7 @@ test.group('ReadAction', () => {
22
23
  test('constructor copies arrays (immutability)', ({ assert }) => {
23
24
  const rules = [{ type: 'allowPublic' }]
24
25
 
25
- const action = new ReadAction({} as any, {
26
+ const action = new ReadAction(mockExposedEntity(), {
26
27
  accessRule: rules,
27
28
  })
28
29
 
@@ -35,7 +36,7 @@ test.group('ReadAction', () => {
35
36
  }).tags(['@modeling', '@action', '@read-action', '@immutability'])
36
37
 
37
38
  test('toJSON returns valid schema', ({ assert }) => {
38
- const action = new ReadAction({} as any, {
39
+ const action = new ReadAction(mockExposedEntity(), {
39
40
  accessRule: [{ type: 'allowPublic' }],
40
41
  })
41
42
 
@@ -51,26 +52,26 @@ test.group('ReadAction', () => {
51
52
  }).tags(['@modeling', '@action', '@read-action', '@serialization'])
52
53
 
53
54
  test('notifies change when inherited property changes', async ({ assert }) => {
54
- const action = new ReadAction({} as any)
55
+ const action = new ReadAction(mockExposedEntity())
55
56
  let notified = false
56
57
  action.addEventListener('change', () => {
57
58
  notified = true
58
59
  })
59
60
 
60
61
  // Modify inherited property
61
- action.accessRule = [new AccessRule({ type: 'allowPublic' })]
62
+ action.accessRule = [new AccessRule(mockExposedEntity(), { type: 'allowPublic' })]
62
63
  await Promise.resolve()
63
64
  assert.isTrue(notified)
64
65
  }).tags(['@modeling', '@action', '@read-action', '@observed'])
65
66
 
66
67
  test('notifies change when accessRule value change', async ({ assert }) => {
67
- const action = new ReadAction({} as any)
68
+ const action = new ReadAction(mockExposedEntity())
68
69
  let notified = false
69
70
  action.addEventListener('change', () => {
70
71
  notified = true
71
72
  })
72
73
 
73
- action.accessRule.push(new AccessRule({ type: 'allowPublic' }))
74
+ action.accessRule.push(new AccessRule(mockExposedEntity(), { type: 'allowPublic' }))
74
75
  await Promise.resolve()
75
76
  assert.isTrue(notified)
76
77
  }).tags(['@modeling', '@action', '@read-action', '@observed'])
@@ -1,9 +1,10 @@
1
1
  import { test } from '@japa/runner'
2
2
  import { SearchAction } from '../../../../src/modeling/actions/SearchAction.js'
3
+ import { mockExposedEntity } from './helpers.js'
3
4
 
4
5
  test.group('SearchAction', () => {
5
6
  test('initializes with default values', ({ assert }) => {
6
- const action = new SearchAction({} as any)
7
+ const action = new SearchAction(mockExposedEntity())
7
8
  assert.equal(action.kind, 'search')
8
9
  assert.isUndefined(action.maxAstDepth)
9
10
  assert.isEmpty(action.accessRule)
@@ -11,7 +12,7 @@ test.group('SearchAction', () => {
11
12
 
12
13
  test('initializes with provided values', ({ assert }) => {
13
14
  const maxAstDepth = 10
14
- const action = new SearchAction({} as any, {
15
+ const action = new SearchAction(mockExposedEntity(), {
15
16
  maxAstDepth,
16
17
  })
17
18
 
@@ -20,7 +21,7 @@ test.group('SearchAction', () => {
20
21
  }).tags(['@modeling', '@action', '@search-action'])
21
22
 
22
23
  test('toJSON returns valid schema', ({ assert }) => {
23
- const action = new SearchAction({} as any, {
24
+ const action = new SearchAction(mockExposedEntity(), {
24
25
  maxAstDepth: 10,
25
26
  })
26
27
 
@@ -34,7 +35,7 @@ test.group('SearchAction', () => {
34
35
  }).tags(['@modeling', '@action', '@search-action', '@serialization', '@immutability'])
35
36
 
36
37
  test('notifies change when maxAstDepth changes', async ({ assert }) => {
37
- const action = new SearchAction({} as any)
38
+ const action = new SearchAction(mockExposedEntity())
38
39
  let notified = false
39
40
  action.addEventListener('change', () => {
40
41
  notified = true
@@ -1,9 +1,10 @@
1
1
  import { test } from '@japa/runner'
2
2
  import { UpdateAction } from '../../../../src/modeling/actions/UpdateAction.js'
3
+ import { mockExposedEntity } from './helpers.js'
3
4
 
4
5
  test.group('UpdateAction', () => {
5
6
  test('initializes with default values', ({ assert }) => {
6
- const action = new UpdateAction({} as any)
7
+ const action = new UpdateAction(mockExposedEntity())
7
8
  assert.equal(action.kind, 'update')
8
9
  assert.deepEqual(action.allowedMethods, ['PATCH'])
9
10
  assert.isEmpty(action.accessRule)
@@ -11,7 +12,7 @@ test.group('UpdateAction', () => {
11
12
 
12
13
  test('initializes with provided values', ({ assert }) => {
13
14
  const methods: ('PUT' | 'PATCH')[] = ['PUT', 'PATCH']
14
- const action = new UpdateAction({} as any, {
15
+ const action = new UpdateAction(mockExposedEntity(), {
15
16
  allowedMethods: methods,
16
17
  })
17
18
 
@@ -22,7 +23,7 @@ test.group('UpdateAction', () => {
22
23
  test('constructor copies arrays (immutability)', ({ assert }) => {
23
24
  const methods: ('PUT' | 'PATCH')[] = ['PUT']
24
25
 
25
- const action = new UpdateAction({} as any, {
26
+ const action = new UpdateAction(mockExposedEntity(), {
26
27
  allowedMethods: methods,
27
28
  })
28
29
 
@@ -33,7 +34,7 @@ test.group('UpdateAction', () => {
33
34
  }).tags(['@modeling', '@action', '@update-action', '@immutability'])
34
35
 
35
36
  test('toJSON returns valid schema', ({ assert }) => {
36
- const action = new UpdateAction({} as any, {
37
+ const action = new UpdateAction(mockExposedEntity(), {
37
38
  allowedMethods: ['PUT'],
38
39
  })
39
40
 
@@ -48,7 +49,7 @@ test.group('UpdateAction', () => {
48
49
  }).tags(['@modeling', '@action', '@update-action', '@serialization', '@immutability'])
49
50
 
50
51
  test('notifies change when allowedMethods changes', async ({ assert }) => {
51
- const action = new UpdateAction({} as any)
52
+ const action = new UpdateAction(mockExposedEntity())
52
53
  let notified = false
53
54
  action.addEventListener('change', () => {
54
55
  notified = true
@@ -60,7 +61,7 @@ test.group('UpdateAction', () => {
60
61
  }).tags(['@modeling', '@action', '@update-action', '@observed'])
61
62
 
62
63
  test('notifies change when allowedMethods value change', async ({ assert }) => {
63
- const action = new UpdateAction({} as any)
64
+ const action = new UpdateAction(mockExposedEntity())
64
65
  let notified = false
65
66
  action.addEventListener('change', () => {
66
67
  notified = true
@@ -0,0 +1,7 @@
1
+ import { type ExposedEntity } from '../../../../src/modeling/ExposedEntity.js'
2
+
3
+ export function mockExposedEntity(): ExposedEntity {
4
+ return {
5
+ notifyChange: () => {},
6
+ } as unknown as ExposedEntity
7
+ }
@@ -152,7 +152,7 @@ test.group('ApiModel.constructor()', () => {
152
152
  assert.deepEqual(model.authentication, { strategy: 'UsernamePassword' })
153
153
  assert.deepEqual(model.authorization, { strategy: 'RBAC', roleKey: 'role' })
154
154
  assert.deepEqual(model.session, { secret: 'secret', properties: ['email'] })
155
- assert.deepEqual(model.accessRule, [new AllowPublicAccessRule()])
155
+ assert.deepEqual(model.accessRule, [new AllowPublicAccessRule(model)])
156
156
  assert.deepEqual(model.rateLimiting, new RateLimitingConfiguration())
157
157
  assert.equal(model.termsOfService, 'https://example.com/terms')
158
158
  assert.deepEqual(model.contact, { name: 'John Doe', email: 'john.doe@example.com' })
@@ -223,6 +223,8 @@ test.group('ApiModel.constructor()', () => {
223
223
  })
224
224
 
225
225
  Array.from(model.exposes.values())[0]!.actions[0]!.kind = 'write'
226
+ // there are two microtasks on the notification path.
227
+ await Promise.resolve()
226
228
  await Promise.resolve()
227
229
  assert.isTrue(notified)
228
230
  }).tags(['@modeling', '@api', '@observed'])
@@ -35,20 +35,6 @@ test.group('ApiModel.exposeEntity()', () => {
35
35
  assert.deepEqual(exposedEntity.actions, [])
36
36
  }).tags(['@modeling', '@api'])
37
37
 
38
- test('returns an existing entity if already exposed', ({ assert }) => {
39
- const domain = new DataDomain()
40
- domain.info.version = '1.0.0'
41
- const dm = domain.addModel()
42
- const e1 = dm.addEntity()
43
- const model = new ApiModel()
44
- model.attachDataDomain(domain)
45
- const initialExposedEntity = model.exposeEntity({ key: e1.key })
46
- const retrievedExposedEntity = model.exposeEntity({ key: e1.key })
47
-
48
- assert.deepEqual(retrievedExposedEntity.toJSON(), initialExposedEntity.toJSON())
49
- assert.equal(model.exposes.size, 1)
50
- }).tags(['@modeling', '@api'])
51
-
52
38
  test('notifies change when a new entity is exposed', async ({ assert }) => {
53
39
  const domain = new DataDomain()
54
40
  domain.info.version = '1.0.0'
@@ -65,7 +51,7 @@ test.group('ApiModel.exposeEntity()', () => {
65
51
  assert.isTrue(notified)
66
52
  }).tags(['@modeling', '@api'])
67
53
 
68
- test('does not notify change if entity already exposed', async ({ assert }) => {
54
+ test('throws if entity already exposed', async ({ assert }) => {
69
55
  const domain = new DataDomain()
70
56
  domain.info.version = '1.0.0'
71
57
  const dm = domain.addModel()
@@ -78,9 +64,11 @@ test.group('ApiModel.exposeEntity()', () => {
78
64
  model.addEventListener('change', () => {
79
65
  notified = true
80
66
  })
81
- model.exposeEntity({ key: e1.key }) // Second exposure
67
+ await assert.rejects(async () => {
68
+ model.exposeEntity({ key: e1.key }) // Second exposure
69
+ }, `Entity ${e1.key} is already exposed.`)
82
70
  await Promise.resolve() // Allow microtask to run
83
- assert.isFalse(notified)
71
+ assert.isFalse(notified, 'should not notify change if entity already exposed')
84
72
  }).tags(['@modeling', '@api'])
85
73
 
86
74
  test('exposes nested entities through associations', ({ assert }) => {
@@ -1,5 +1,5 @@
1
1
  import { test } from '@japa/runner'
2
- import { ApiModel, ExposedEntity, type ExposedEntitySchema } from '../../../src/index.js'
2
+ import { ApiModel, DataDomain, ExposedEntity, type ExposedEntitySchema } from '../../../src/index.js'
3
3
  import { AccessRule, RateLimitingConfiguration } from '../../../src/modeling/index.js'
4
4
  import { ExposedEntityKind } from '../../../src/models/kinds.js'
5
5
 
@@ -127,7 +127,9 @@ test.group('ExposedEntity', () => {
127
127
 
128
128
  const ex = Array.from(model.exposes.values())[0]
129
129
  ex.setCollectionPath('items')
130
- await Promise.resolve() // allow ApiModel.notifyChange microtask to run
130
+ // there are two microtasks on the notification path.
131
+ await Promise.resolve()
132
+ await Promise.resolve()
131
133
  assert.isAtLeast(notified, 1)
132
134
  }).tags(['@modeling', '@exposed-entity', '@observed'])
133
135
 
@@ -154,6 +156,8 @@ test.group('ExposedEntity', () => {
154
156
 
155
157
  const ex = Array.from(model.exposes.values())[0]
156
158
  ex.setResourcePath('/products/{productId}')
159
+ // there are two microtasks on the notification path.
160
+ await Promise.resolve()
157
161
  await Promise.resolve()
158
162
  assert.isAtLeast(notified, 1)
159
163
  }).tags(['@modeling', '@exposed-entity', '@observed'])
@@ -304,7 +308,7 @@ test.group('ExposedEntity', () => {
304
308
 
305
309
  test('getAllRules() aggregates rules from entity, parent, and API', ({ assert }) => {
306
310
  const model = new ApiModel()
307
- model.accessRule = [new AccessRule({ type: 'allowPublic' })]
311
+ model.accessRule = [new AccessRule(model, { type: 'allowPublic' })]
308
312
 
309
313
  const rootEx = new ExposedEntity(model, {
310
314
  key: 'root',
@@ -417,3 +421,146 @@ test.group('ExposedEntity', () => {
417
421
  assert.equal(childRules[3].rate, 20)
418
422
  }).tags(['@modeling', '@exposed-entity', '@rate-limiting'])
419
423
  })
424
+
425
+ // ---------------------------------------------------------------------------
426
+ // Helper: build a DataDomain + ApiModel with a single entity already attached.
427
+ // Returns the domain, api model, and the entity key so tests can add properties
428
+ // and then call createPaginationContract() without repeating boilerplate.
429
+ // ---------------------------------------------------------------------------
430
+ function makeFixture() {
431
+ const domain = new DataDomain()
432
+ domain.info.version = '1.0.0'
433
+ const dm = domain.addModel()
434
+ const entity = domain.addEntity(dm.key)
435
+ const model = new ApiModel()
436
+ model.attachDataDomain(domain)
437
+ return { domain, model, entity }
438
+ }
439
+
440
+ test.group('ExposedEntity.createPaginationContract()', () => {
441
+ test('throws when no domain is attached to the API model', ({ assert }) => {
442
+ const model = new ApiModel() // no domain attached
443
+ const ex = new ExposedEntity(model, { entity: { key: 'some-entity' } })
444
+
445
+ assert.throws(() => ex.createPaginationContract(), 'Entity "some-entity" not found')
446
+ }).tags(['@modeling', '@exposed-entity', '@pagination'])
447
+
448
+ test('throws when the entity key does not exist in the domain', ({ assert }) => {
449
+ const { model } = makeFixture()
450
+ const ex = new ExposedEntity(model, { entity: { key: 'non-existent' } })
451
+
452
+ assert.throws(() => ex.createPaginationContract(), 'Entity "non-existent" not found')
453
+ }).tags(['@modeling', '@exposed-entity', '@pagination'])
454
+
455
+ test('creates a fresh paginationContract when one is not yet set', ({ assert }) => {
456
+ const { model, entity } = makeFixture()
457
+ const ex = new ExposedEntity(model, { entity: { key: entity.key } })
458
+
459
+ assert.isUndefined(ex.paginationContract)
460
+ ex.createPaginationContract()
461
+
462
+ assert.isDefined(ex.paginationContract)
463
+ assert.deepEqual(ex.paginationContract!.filterableFields, [])
464
+ assert.deepEqual(ex.paginationContract!.sortableFields, [])
465
+ assert.deepEqual(ex.paginationContract!.searchableFields, [])
466
+ }).tags(['@modeling', '@exposed-entity', '@pagination'])
467
+
468
+ test('indexed props are added to filterableFields and sortableFields', ({ assert }) => {
469
+ const { domain, model, entity } = makeFixture()
470
+ domain.addProperty(entity.key, { key: 'status', index: true })
471
+ domain.addProperty(entity.key, { key: 'createdAt', index: true })
472
+
473
+ const ex = new ExposedEntity(model, { entity: { key: entity.key } })
474
+ ex.createPaginationContract()
475
+
476
+ assert.deepEqual(ex.paginationContract!.filterableFields, ['status', 'createdAt'])
477
+ assert.deepEqual(ex.paginationContract!.sortableFields, ['status', 'createdAt'])
478
+ assert.deepEqual(ex.paginationContract!.searchableFields, [])
479
+ }).tags(['@modeling', '@exposed-entity', '@pagination'])
480
+
481
+ test('search props are added to searchableFields only', ({ assert }) => {
482
+ const { domain, model, entity } = makeFixture()
483
+ domain.addProperty(entity.key, { key: 'name', search: true })
484
+ domain.addProperty(entity.key, { key: 'description', search: true })
485
+
486
+ const ex = new ExposedEntity(model, { entity: { key: entity.key } })
487
+ ex.createPaginationContract()
488
+
489
+ assert.deepEqual(ex.paginationContract!.searchableFields, ['name', 'description'])
490
+ assert.deepEqual(ex.paginationContract!.filterableFields, [])
491
+ assert.deepEqual(ex.paginationContract!.sortableFields, [])
492
+ }).tags(['@modeling', '@exposed-entity', '@pagination'])
493
+
494
+ test('a prop with both index and search appears in all three lists', ({ assert }) => {
495
+ const { domain, model, entity } = makeFixture()
496
+ domain.addProperty(entity.key, { key: 'email', index: true, search: true })
497
+
498
+ const ex = new ExposedEntity(model, { entity: { key: entity.key } })
499
+ ex.createPaginationContract()
500
+
501
+ assert.include(ex.paginationContract!.filterableFields, 'email')
502
+ assert.include(ex.paginationContract!.sortableFields, 'email')
503
+ assert.include(ex.paginationContract!.searchableFields, 'email')
504
+ }).tags(['@modeling', '@exposed-entity', '@pagination'])
505
+
506
+ test('props without index or search are not added to any list', ({ assert }) => {
507
+ const { domain, model, entity } = makeFixture()
508
+ domain.addProperty(entity.key, { key: 'bio' }) // no index, no search
509
+
510
+ const ex = new ExposedEntity(model, { entity: { key: entity.key } })
511
+ ex.createPaginationContract()
512
+
513
+ assert.deepEqual(ex.paginationContract!.filterableFields, [])
514
+ assert.deepEqual(ex.paginationContract!.sortableFields, [])
515
+ assert.deepEqual(ex.paginationContract!.searchableFields, [])
516
+ }).tags(['@modeling', '@exposed-entity', '@pagination'])
517
+
518
+ test('resets existing contract arrays before repopulating (destructive)', ({ assert }) => {
519
+ const { domain, model, entity } = makeFixture()
520
+ domain.addProperty(entity.key, { key: 'title', index: true, search: true })
521
+
522
+ const ex = new ExposedEntity(model, {
523
+ entity: { key: entity.key },
524
+ paginationContract: {
525
+ filterableFields: ['stale-filter'],
526
+ sortableFields: ['stale-sort'],
527
+ searchableFields: ['stale-search'],
528
+ },
529
+ })
530
+
531
+ // Sanity check: pre-existing stale values are present
532
+ assert.include(ex.paginationContract!.filterableFields, 'stale-filter')
533
+
534
+ ex.createPaginationContract()
535
+
536
+ // Stale values are gone; only the freshly scanned fields remain
537
+ assert.deepEqual(ex.paginationContract!.filterableFields, ['title'])
538
+ assert.deepEqual(ex.paginationContract!.sortableFields, ['title'])
539
+ assert.deepEqual(ex.paginationContract!.searchableFields, ['title'])
540
+ }).tags(['@modeling', '@exposed-entity', '@pagination'])
541
+
542
+ test('entity with no properties produces empty contract lists', ({ assert }) => {
543
+ const { model, entity } = makeFixture() // entity has no props
544
+
545
+ const ex = new ExposedEntity(model, { entity: { key: entity.key } })
546
+ ex.createPaginationContract()
547
+
548
+ assert.deepEqual(ex.paginationContract!.filterableFields, [])
549
+ assert.deepEqual(ex.paginationContract!.sortableFields, [])
550
+ assert.deepEqual(ex.paginationContract!.searchableFields, [])
551
+ }).tags(['@modeling', '@exposed-entity', '@pagination'])
552
+
553
+ test('mixed props: only the flagged ones appear in the relevant lists', ({ assert }) => {
554
+ const { domain, model, entity } = makeFixture()
555
+ domain.addProperty(entity.key, { key: 'id', index: true }) // filterable + sortable
556
+ domain.addProperty(entity.key, { key: 'body', search: true }) // searchable
557
+ domain.addProperty(entity.key, { key: 'metadata' }) // neither
558
+
559
+ const ex = new ExposedEntity(model, { entity: { key: entity.key } })
560
+ ex.createPaginationContract()
561
+
562
+ assert.deepEqual(ex.paginationContract!.filterableFields, ['id'])
563
+ assert.deepEqual(ex.paginationContract!.sortableFields, ['id'])
564
+ assert.deepEqual(ex.paginationContract!.searchableFields, ['body'])
565
+ }).tags(['@modeling', '@exposed-entity', '@pagination'])
566
+ })
@@ -33,9 +33,6 @@ test.group('ExposedEntity::actions', (group) => {
33
33
  test('restores a list acton', ({ assert }) => {
34
34
  const action: ListActionSchema = {
35
35
  kind: 'list',
36
- pagination: { kind: '' },
37
- sortableFields: [],
38
- filterableFields: [],
39
36
  }
40
37
  const model = new ApiModel(
41
38
  {
@@ -177,7 +174,6 @@ test.group('ExposedEntity::actions', (group) => {
177
174
  test('restores a search acton', ({ assert }) => {
178
175
  const action: SearchActionSchema = {
179
176
  kind: 'search',
180
- fields: [],
181
177
  }
182
178
  const model = new ApiModel(
183
179
  {
@@ -1,26 +1,27 @@
1
1
  import { test } from '@japa/runner'
2
2
  import { AccessRule, type AccessRuleSchema } from '../../../../src/modeling/index.js'
3
+ import { mockExposedEntity } from '../actions/helpers.js'
3
4
 
4
5
  test.group('AccessRule', () => {
5
6
  test('initializes with default values', ({ assert }) => {
6
- const rule = new AccessRule()
7
+ const rule = new AccessRule(mockExposedEntity())
7
8
  assert.equal(rule.type, '')
8
9
  }).tags(['@modeling', '@access-rule'])
9
10
 
10
11
  test('initializes with provided values', ({ assert }) => {
11
12
  const schema: AccessRuleSchema = { type: 'public' }
12
- const rule = new AccessRule(schema)
13
+ const rule = new AccessRule(mockExposedEntity(), schema)
13
14
  assert.equal(rule.type, 'public')
14
15
  }).tags(['@modeling', '@access-rule'])
15
16
 
16
17
  test('serializes to JSON', ({ assert }) => {
17
- const rule = new AccessRule({ type: 'authenticated' })
18
+ const rule = new AccessRule(mockExposedEntity(), { type: 'authenticated' })
18
19
  const json = rule.toJSON()
19
20
  assert.deepEqual(json, { type: 'authenticated' })
20
21
  }).tags(['@modeling', '@access-rule'])
21
22
 
22
23
  test('notifies change', async ({ assert }) => {
23
- const rule = new AccessRule({ type: 'public' })
24
+ const rule = new AccessRule(mockExposedEntity(), { type: 'public' })
24
25
  let notified = false
25
26
  rule.addEventListener('change', () => {
26
27
  notified = true
@@ -31,7 +32,7 @@ test.group('AccessRule', () => {
31
32
  }).tags(['@modeling', '@access-rule'])
32
33
 
33
34
  test('toJSON returns safe copy (immutability)', ({ assert }) => {
34
- const rule = new AccessRule({ type: 'public' })
35
+ const rule = new AccessRule(mockExposedEntity(), { type: 'public' })
35
36
  const json = rule.toJSON()
36
37
 
37
38
  // Modify JSON (simulate runtime mutation)
@@ -1,21 +1,22 @@
1
1
  import { test } from '@japa/runner'
2
2
  import { AllowAuthenticatedAccessRule } from '../../../../src/modeling/rules/AllowAuthenticated.js'
3
+ import { mockExposedEntity } from '../actions/helpers.js'
3
4
 
4
5
  test.group('AllowAuthenticatedAccessRule', () => {
5
6
  test('initializes with correct type', ({ assert }) => {
6
- const rule = new AllowAuthenticatedAccessRule()
7
+ const rule = new AllowAuthenticatedAccessRule(mockExposedEntity())
7
8
  assert.equal(rule.type, 'allowAuthenticated')
8
9
  }).tags(['@modeling', '@rule', '@allow-authenticated'])
9
10
 
10
11
  test('toJSON returns valid schema', ({ assert }) => {
11
- const rule = new AllowAuthenticatedAccessRule()
12
+ const rule = new AllowAuthenticatedAccessRule(mockExposedEntity())
12
13
  const json = rule.toJSON()
13
14
 
14
15
  assert.equal(json.type, 'allowAuthenticated')
15
16
  }).tags(['@modeling', '@rule', '@allow-authenticated', '@serialization'])
16
17
 
17
18
  test('notifies change', async ({ assert }) => {
18
- const rule = new AllowAuthenticatedAccessRule()
19
+ const rule = new AllowAuthenticatedAccessRule(mockExposedEntity())
19
20
  let notified = false
20
21
  rule.addEventListener('change', () => {
21
22
  notified = true
@@ -1,21 +1,22 @@
1
1
  import { test } from '@japa/runner'
2
2
  import { AllowPublicAccessRule } from '../../../../src/modeling/rules/AllowPublic.js'
3
+ import { mockExposedEntity } from '../actions/helpers.js'
3
4
 
4
5
  test.group('AllowPublicAccessRule', () => {
5
6
  test('initializes with correct type', ({ assert }) => {
6
- const rule = new AllowPublicAccessRule()
7
+ const rule = new AllowPublicAccessRule(mockExposedEntity())
7
8
  assert.equal(rule.type, 'allowPublic')
8
9
  }).tags(['@modeling', '@rule', '@allow-public'])
9
10
 
10
11
  test('toJSON returns valid schema', ({ assert }) => {
11
- const rule = new AllowPublicAccessRule()
12
+ const rule = new AllowPublicAccessRule(mockExposedEntity())
12
13
  const json = rule.toJSON()
13
14
 
14
15
  assert.equal(json.type, 'allowPublic')
15
16
  }).tags(['@modeling', '@rule', '@allow-public', '@serialization'])
16
17
 
17
18
  test('notifies change', async ({ assert }) => {
18
- const rule = new AllowPublicAccessRule()
19
+ const rule = new AllowPublicAccessRule(mockExposedEntity())
19
20
  let notified = false
20
21
  rule.addEventListener('change', () => {
21
22
  notified = true