@api-client/core 0.19.24 → 0.19.26

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 (102) hide show
  1. package/build/src/index.d.ts +3 -1
  2. package/build/src/index.d.ts.map +1 -1
  3. package/build/src/index.js +3 -1
  4. package/build/src/index.js.map +1 -1
  5. package/build/src/mocking/ModelingMock.d.ts +2 -0
  6. package/build/src/mocking/ModelingMock.d.ts.map +1 -1
  7. package/build/src/mocking/ModelingMock.js +2 -0
  8. package/build/src/mocking/ModelingMock.js.map +1 -1
  9. package/build/src/mocking/lib/Deployment.d.ts +16 -0
  10. package/build/src/mocking/lib/Deployment.d.ts.map +1 -0
  11. package/build/src/mocking/lib/Deployment.js +76 -0
  12. package/build/src/mocking/lib/Deployment.js.map +1 -0
  13. package/build/src/modeling/Bindings.d.ts +4 -0
  14. package/build/src/modeling/Bindings.d.ts.map +1 -1
  15. package/build/src/modeling/Bindings.js.map +1 -1
  16. package/build/src/modeling/DataFormat.d.ts +1 -1
  17. package/build/src/modeling/DataFormat.d.ts.map +1 -1
  18. package/build/src/modeling/DataFormat.js +2 -0
  19. package/build/src/modeling/DataFormat.js.map +1 -1
  20. package/build/src/modeling/DomainAssociation.d.ts +7 -0
  21. package/build/src/modeling/DomainAssociation.d.ts.map +1 -1
  22. package/build/src/modeling/DomainAssociation.js +10 -0
  23. package/build/src/modeling/DomainAssociation.js.map +1 -1
  24. package/build/src/modeling/DomainEntity.d.ts +9 -1
  25. package/build/src/modeling/DomainEntity.d.ts.map +1 -1
  26. package/build/src/modeling/DomainEntity.js +26 -1
  27. package/build/src/modeling/DomainEntity.js.map +1 -1
  28. package/build/src/modeling/ExposedEntity.d.ts +12 -1
  29. package/build/src/modeling/ExposedEntity.d.ts.map +1 -1
  30. package/build/src/modeling/ExposedEntity.js +24 -1
  31. package/build/src/modeling/ExposedEntity.js.map +1 -1
  32. package/build/src/modeling/RuntimeApiModel.d.ts +52 -0
  33. package/build/src/modeling/RuntimeApiModel.d.ts.map +1 -0
  34. package/build/src/modeling/RuntimeApiModel.js +85 -0
  35. package/build/src/modeling/RuntimeApiModel.js.map +1 -0
  36. package/build/src/modeling/actions/index.d.ts +10 -0
  37. package/build/src/modeling/actions/index.d.ts.map +1 -1
  38. package/build/src/modeling/actions/index.js +30 -0
  39. package/build/src/modeling/actions/index.js.map +1 -1
  40. package/build/src/modeling/index.d.ts.map +1 -1
  41. package/build/src/modeling/index.js +1 -0
  42. package/build/src/modeling/index.js.map +1 -1
  43. package/build/src/modeling/types.d.ts +25 -1
  44. package/build/src/modeling/types.d.ts.map +1 -1
  45. package/build/src/modeling/types.js.map +1 -1
  46. package/build/src/models/kinds.d.ts +1 -0
  47. package/build/src/models/kinds.d.ts.map +1 -1
  48. package/build/src/models/kinds.js +1 -0
  49. package/build/src/models/kinds.js.map +1 -1
  50. package/build/src/models/store/Deployment.d.ts +53 -16
  51. package/build/src/models/store/Deployment.d.ts.map +1 -1
  52. package/build/src/models/store/Deployment.js +92 -34
  53. package/build/src/models/store/Deployment.js.map +1 -1
  54. package/build/src/sdk/DataCatalogSdk.d.ts.map +1 -1
  55. package/build/src/sdk/DataCatalogSdk.js +22 -179
  56. package/build/src/sdk/DataCatalogSdk.js.map +1 -1
  57. package/build/src/sdk/DeploymentsSdk.d.ts +48 -0
  58. package/build/src/sdk/DeploymentsSdk.d.ts.map +1 -0
  59. package/build/src/sdk/DeploymentsSdk.js +94 -0
  60. package/build/src/sdk/DeploymentsSdk.js.map +1 -0
  61. package/build/src/sdk/RouteBuilder.d.ts +2 -0
  62. package/build/src/sdk/RouteBuilder.d.ts.map +1 -1
  63. package/build/src/sdk/RouteBuilder.js +6 -0
  64. package/build/src/sdk/RouteBuilder.js.map +1 -1
  65. package/build/src/sdk/Sdk.d.ts +2 -0
  66. package/build/src/sdk/Sdk.d.ts.map +1 -1
  67. package/build/src/sdk/Sdk.js +2 -0
  68. package/build/src/sdk/Sdk.js.map +1 -1
  69. package/build/src/sdk/SdkBase.d.ts +19 -1
  70. package/build/src/sdk/SdkBase.d.ts.map +1 -1
  71. package/build/src/sdk/SdkBase.js +31 -1
  72. package/build/src/sdk/SdkBase.js.map +1 -1
  73. package/build/src/sdk/SdkMock.d.ts +9 -0
  74. package/build/src/sdk/SdkMock.d.ts.map +1 -1
  75. package/build/src/sdk/SdkMock.js +73 -0
  76. package/build/src/sdk/SdkMock.js.map +1 -1
  77. package/build/tsconfig.tsbuildinfo +1 -1
  78. package/package.json +2 -1
  79. package/src/matchit.d.ts +19 -0
  80. package/src/mocking/ModelingMock.ts +2 -0
  81. package/src/mocking/lib/Deployment.ts +88 -0
  82. package/src/modeling/Bindings.ts +4 -0
  83. package/src/modeling/DataFormat.ts +4 -0
  84. package/src/modeling/DomainAssociation.ts +11 -0
  85. package/src/modeling/DomainEntity.ts +30 -1
  86. package/src/modeling/ExposedEntity.ts +26 -1
  87. package/src/modeling/RuntimeApiModel.ts +137 -0
  88. package/src/modeling/types.ts +26 -1
  89. package/src/models/kinds.ts +1 -0
  90. package/src/models/store/Deployment.ts +122 -45
  91. package/src/sdk/DataCatalogSdk.ts +22 -176
  92. package/src/sdk/DeploymentsSdk.ts +123 -0
  93. package/src/sdk/RouteBuilder.ts +8 -0
  94. package/src/sdk/Sdk.ts +3 -0
  95. package/src/sdk/SdkBase.ts +35 -3
  96. package/src/sdk/SdkMock.ts +103 -0
  97. package/tests/unit/modeling/RuntimeApiModel.spec.ts +122 -0
  98. package/tests/unit/modeling/actions/index.spec.ts +113 -0
  99. package/tests/unit/modeling/domain_asociation.spec.ts +28 -0
  100. package/tests/unit/modeling/domain_entity_parents.spec.ts +49 -0
  101. package/tests/unit/modeling/exposed_entity_actions.spec.ts +47 -0
  102. package/tests/unit/models/store/Deployment.spec.ts +108 -44
package/src/sdk/Sdk.ts CHANGED
@@ -13,6 +13,7 @@ import { OrganizationsSdk } from './OrganizationsSdk.js'
13
13
  import { DataCatalogSdk } from './DataCatalogSdk.js'
14
14
  import { GroupsSdk } from './GroupsSdk.js'
15
15
  import { AiSdk } from './AiSdk.js'
16
+ import { DeploymentsSdk } from './DeploymentsSdk.js'
16
17
 
17
18
  const baseUriSymbol = Symbol('baseUri')
18
19
 
@@ -84,6 +85,8 @@ export abstract class Sdk {
84
85
  groups = new GroupsSdk(this)
85
86
 
86
87
  ai = new AiSdk(this)
88
+
89
+ deployments = new DeploymentsSdk(this)
87
90
  /**
88
91
  * When set it limits log output to minimum.
89
92
  */
@@ -208,7 +208,7 @@ export class SdkBase {
208
208
  }
209
209
 
210
210
  protected readResponseBody(response: IStoreResponse, errorPrefix: string): string {
211
- if (response.status !== 200) {
211
+ if (![200, 202].includes(response.status)) {
212
212
  this.logInvalidResponse(response)
213
213
  throw this.createApiError(`${errorPrefix}${E_RESPONSE_STATUS}${response.status}`, response.body)
214
214
  }
@@ -221,9 +221,15 @@ export class SdkBase {
221
221
  return response.body
222
222
  }
223
223
 
224
- protected readResponseJSON(response: IStoreResponse, errorPrefix: string): unknown {
224
+ /**
225
+ * A helper function that asserts the `body` is set and it is a valid JSON.
226
+ * @param response The response that is expected to have a valid JSON on the `body`
227
+ * @param errorPrefix The error prefix for the errors thrown
228
+ * @returns The valid JSON response object
229
+ */
230
+ protected readResponseJSON<T = unknown>(response: IStoreResponse, errorPrefix: string): T {
225
231
  const body = this.readResponseBody(response, errorPrefix)
226
- let data: unknown
232
+ let data: T
227
233
  try {
228
234
  data = JSON.parse(body)
229
235
  } catch {
@@ -231,4 +237,30 @@ export class SdkBase {
231
237
  }
232
238
  return data
233
239
  }
240
+
241
+ /**
242
+ * A helper function to process List response.
243
+ * The list response is standardised response containing the `items` and `cursor` properties.
244
+ * The list response also returns 200 status code. The logic validates the JSON response,
245
+ * 200 status code, and whether the response the `items` array (can be empty).
246
+ *
247
+ * @param result The read results of a standard List request
248
+ * @param prefix The error prefix to use with the error codes.
249
+ * @returns The valid list results.
250
+ */
251
+ protected processListResponse<T = unknown>(result: IStoreResponse, prefix: string): ContextListResult<T> {
252
+ this.inspectCommonStatusCodes(result)
253
+ // Status check happens in the readResponseBody method.
254
+ const data = this.readResponseJSON<ContextListResult<T>>(result, prefix)
255
+ if (!Array.isArray(data.items)) {
256
+ throw new Exception(`${prefix}${E_RESPONSE_UNKNOWN}`, { code: 'E_RESPONSE_UNKNOWN', status: result.status })
257
+ }
258
+ return data
259
+ }
260
+
261
+ protected assertObjectKind(prefix: string, expected: string, actual?: unknown): void {
262
+ if (expected !== actual) {
263
+ throw new Exception(`${prefix}${E_RESPONSE_UNKNOWN}`, { code: 'E_RESPONSE_UNKNOWN', status: 500 })
264
+ }
265
+ }
234
266
  }
@@ -27,6 +27,9 @@ import type { ForeignDomainDependency } from '../modeling/types.js'
27
27
  import { nanoid } from '../nanoid.js'
28
28
  import type { AiSessionSchema, AiSessionApp } from '../models/AiSession.js'
29
29
  import type { AiMessageSchema } from '../models/AiMessage.js'
30
+ import type { DeploymentSchema } from '../models/store/Deployment.js'
31
+ import { DeploymentEnvironment } from '../models/store/Deployment.js'
32
+ import type { CreateDeploymentPayload } from './DeploymentsSdk.js'
30
33
 
31
34
  export interface MockResult {
32
35
  /**
@@ -1614,6 +1617,106 @@ export class SdkMock {
1614
1617
  },
1615
1618
  }
1616
1619
 
1620
+ /**
1621
+ * Deployments API mocks.
1622
+ */
1623
+ deployments = {
1624
+ list: async (init?: MockListResult, options?: InterceptOptions): Promise<void> => {
1625
+ const { mock } = this
1626
+ const respond = this.createDefaultResponse(
1627
+ 200,
1628
+ { 'content-type': 'application/json' },
1629
+ () => {
1630
+ const obj: ContextListResult<DeploymentSchema> = {
1631
+ items: this.gen.deployments.deployments(init?.size ?? 5),
1632
+ cursor: this.createCursorOption(init),
1633
+ }
1634
+ return JSON.stringify(obj)
1635
+ },
1636
+ init
1637
+ )
1638
+ await mock.add(
1639
+ {
1640
+ match: {
1641
+ uri: RouteBuilder.deployments(':oid'),
1642
+ methods: ['GET'],
1643
+ },
1644
+ respond,
1645
+ },
1646
+ options
1647
+ )
1648
+ },
1649
+ create: async (init?: MockResult, options?: InterceptOptions): Promise<void> => {
1650
+ const { mock } = this
1651
+ const respond = this.createDefaultResponse(
1652
+ 202,
1653
+ { 'content-type': 'application/json' },
1654
+ (req: SerializedRequest) => {
1655
+ const raw = req.body as string
1656
+ const payload = JSON.parse(raw) as CreateDeploymentPayload
1657
+ const obj = this.gen.deployments.deployment({
1658
+ orgId: req.params.oid,
1659
+ apiId: payload.fid,
1660
+ modelVersion: payload.modelVersion,
1661
+ env: payload.env,
1662
+ ...(payload.env === DeploymentEnvironment.PROD ? { version: payload.version } : {}),
1663
+ })
1664
+ return JSON.stringify(obj)
1665
+ },
1666
+ init
1667
+ )
1668
+ await mock.add(
1669
+ {
1670
+ match: {
1671
+ uri: RouteBuilder.deployments(':oid'),
1672
+ methods: ['POST'],
1673
+ },
1674
+ respond,
1675
+ },
1676
+ options
1677
+ )
1678
+ },
1679
+ read: async (init?: MockResult, options?: InterceptOptions): Promise<void> => {
1680
+ const { mock } = this
1681
+ const respond = this.createDefaultResponse(
1682
+ 200,
1683
+ { 'content-type': 'application/json' },
1684
+ (req: SerializedRequest) => {
1685
+ const obj = this.gen.deployments.deployment({
1686
+ key: req.params.id,
1687
+ orgId: req.params.oid,
1688
+ })
1689
+ return JSON.stringify(obj)
1690
+ },
1691
+ init
1692
+ )
1693
+ await mock.add(
1694
+ {
1695
+ match: {
1696
+ uri: RouteBuilder.deployment(':oid', ':id'),
1697
+ methods: ['GET'],
1698
+ },
1699
+ respond,
1700
+ },
1701
+ options
1702
+ )
1703
+ },
1704
+ deactivate: async (init?: MockResult, options?: InterceptOptions): Promise<void> => {
1705
+ const { mock } = this
1706
+ const respond = this.createDefaultResponse(204, undefined, undefined, init)
1707
+ await mock.add(
1708
+ {
1709
+ match: {
1710
+ uri: RouteBuilder.deployment(':oid', ':id'),
1711
+ methods: ['DELETE'],
1712
+ },
1713
+ respond,
1714
+ },
1715
+ options
1716
+ )
1717
+ },
1718
+ }
1719
+
1617
1720
  /**
1618
1721
  * Trash Data Catalog mocks.
1619
1722
  */
@@ -0,0 +1,122 @@
1
+ import { test } from '@japa/runner'
2
+ import { ApiModel } from '../../../src/modeling/ApiModel.js'
3
+ import { RuntimeApiModel, type RuntimeApiModelSchema } from '../../../src/modeling/RuntimeApiModel.js'
4
+ import { DataDomain } from '../../../src/modeling/DataDomain.js'
5
+
6
+ test.group('RuntimeApiModel', () => {
7
+ test('initializes from schema', ({ assert }) => {
8
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
9
+ const model = domain.addModel({ info: { name: 'Test Model' } })
10
+ const entity = model.addEntity({ key: 'ent-1', info: { name: 'User' } })
11
+
12
+ const baseModel = new ApiModel(
13
+ {
14
+ key: 'api-1',
15
+ info: { name: 'Test API' },
16
+ },
17
+ domain
18
+ )
19
+
20
+ const expose = baseModel.exposeEntity({ key: entity.key })
21
+ expose.setResourcePath('/users/{id}')
22
+ expose.addActionFromKind('read')
23
+
24
+ const schema: RuntimeApiModelSchema = {
25
+ ...baseModel.toJSON(),
26
+ routingMap: {
27
+ GET: [{ path: '/users/{id}', lookup: { exposedEntityKey: expose.key, actionKind: 'read' } }],
28
+ },
29
+ }
30
+
31
+ const runtimeModel = new RuntimeApiModel(schema, domain.toJSON())
32
+ assert.deepEqual(runtimeModel.toJSON().routingMap, schema.routingMap)
33
+ }).tags(['@modeling', '@runtime'])
34
+
35
+ test('resolves path with matchit', ({ assert }) => {
36
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
37
+ const schema = {
38
+ key: 'api-1',
39
+ info: { name: 'Test API' },
40
+ exposes: [
41
+ {
42
+ key: 'expose-1',
43
+ entity: { key: 'ent-1', type: 'association' },
44
+ actions: [
45
+ { kind: 'read', type: 'crud' },
46
+ { kind: 'list', type: 'crud' },
47
+ ],
48
+ },
49
+ ],
50
+ routingMap: {
51
+ GET: [
52
+ { path: '/users/{id}', lookup: { exposedEntityKey: 'expose-1', actionKind: 'read' } },
53
+ { path: '/users', lookup: { exposedEntityKey: 'expose-1', actionKind: 'list' } },
54
+ ],
55
+ },
56
+ }
57
+
58
+ const runtimeModel = new RuntimeApiModel(schema as any, domain.toJSON())
59
+
60
+ const listResult = runtimeModel.lookupAction('GET', '/users')
61
+ assert.isOk(listResult)
62
+ assert.equal(listResult!.action.kind, 'list')
63
+ assert.deepEqual(listResult!.params, {})
64
+
65
+ const readResult = runtimeModel.lookupAction('get', '/users/123') // Case insensitivity test
66
+ assert.isOk(readResult)
67
+ assert.equal(readResult!.action.kind, 'read')
68
+ assert.deepEqual(readResult!.params, { id: '123' })
69
+ }).tags(['@modeling', '@runtime'])
70
+
71
+ test('returns undefined for unknown routes', ({ assert }) => {
72
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
73
+ const schema = {
74
+ key: 'api-1',
75
+ info: { name: 'Test API' },
76
+ routingMap: {
77
+ GET: [{ path: '/users', lookup: { exposedEntityKey: 'expose-1', actionKind: 'list' } }],
78
+ },
79
+ }
80
+
81
+ const runtimeModel = new RuntimeApiModel(schema as any, domain.toJSON())
82
+
83
+ // Method not found
84
+ const postResult = runtimeModel.lookupAction('POST', '/users')
85
+ assert.isUndefined(postResult)
86
+
87
+ // Method found, path not found
88
+ const getResult = runtimeModel.lookupAction('GET', '/unknown')
89
+ assert.isUndefined(getResult)
90
+ }).tags(['@modeling', '@runtime'])
91
+
92
+ test('returns undefined when entity or action is missing', ({ assert }) => {
93
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
94
+ const schema = {
95
+ key: 'api-1',
96
+ info: { name: 'Test API' },
97
+ exposes: [
98
+ {
99
+ key: 'expose-1',
100
+ entity: { key: 'ent-1', type: 'association' },
101
+ actions: [{ kind: 'list', type: 'crud' }],
102
+ },
103
+ ],
104
+ routingMap: {
105
+ GET: [
106
+ { path: '/missing-entity', lookup: { exposedEntityKey: 'missing-expose', actionKind: 'list' } },
107
+ { path: '/missing-action', lookup: { exposedEntityKey: 'expose-1', actionKind: 'read' } },
108
+ ],
109
+ },
110
+ }
111
+
112
+ const runtimeModel = new RuntimeApiModel(schema as any, domain.toJSON())
113
+
114
+ // Exposed entity not found
115
+ const missingEntityResult = runtimeModel.lookupAction('GET', '/missing-entity')
116
+ assert.isUndefined(missingEntityResult)
117
+
118
+ // Action not found on entity
119
+ const missingActionResult = runtimeModel.lookupAction('GET', '/missing-action')
120
+ assert.isUndefined(missingActionResult)
121
+ }).tags(['@modeling', '@runtime'])
122
+ })
@@ -0,0 +1,113 @@
1
+ import { test } from '@japa/runner'
2
+ import { createActionFromKind, restoreAction, type ActionKind } from '../../../../src/modeling/actions/index.js'
3
+ import { ListAction } from '../../../../src/modeling/actions/ListAction.js'
4
+ import { ReadAction } from '../../../../src/modeling/actions/ReadAction.js'
5
+ import { CreateAction } from '../../../../src/modeling/actions/CreateAction.js'
6
+ import { UpdateAction } from '../../../../src/modeling/actions/UpdateAction.js'
7
+ import { DeleteAction } from '../../../../src/modeling/actions/DeleteAction.js'
8
+ import { SearchAction } from '../../../../src/modeling/actions/SearchAction.js'
9
+ import { DataDomain } from '../../../../src/modeling/DataDomain.js'
10
+ import { ApiModel } from '../../../../src/modeling/ApiModel.js'
11
+ import type { ExposedEntity } from '../../../../src/modeling/ExposedEntity.js'
12
+
13
+ test.group('modeling/actions/index', (group) => {
14
+ let domain: DataDomain
15
+ let baseModel: ApiModel
16
+ let expose: ExposedEntity
17
+
18
+ group.each.setup(() => {
19
+ domain = new DataDomain({ info: { version: '1.0.0' } })
20
+ const model = domain.addModel({ info: { name: 'Test Model' } })
21
+ const entity = model.addEntity({ key: 'ent-1', info: { name: 'User' } })
22
+
23
+ baseModel = new ApiModel(
24
+ {
25
+ key: 'api-1',
26
+ info: { name: 'Test API' },
27
+ },
28
+ domain
29
+ )
30
+
31
+ expose = baseModel.exposeEntity({ key: entity.key })
32
+ })
33
+
34
+ test('createActionFromKind: creates ListAction', ({ assert }) => {
35
+ const action = createActionFromKind(expose, 'list')
36
+ assert.instanceOf(action, ListAction)
37
+ assert.equal(action.kind, 'list')
38
+ }).tags(['@actions'])
39
+
40
+ test('createActionFromKind: creates ReadAction', ({ assert }) => {
41
+ const action = createActionFromKind(expose, 'read')
42
+ assert.instanceOf(action, ReadAction)
43
+ assert.equal(action.kind, 'read')
44
+ }).tags(['@actions'])
45
+
46
+ test('createActionFromKind: creates CreateAction', ({ assert }) => {
47
+ const action = createActionFromKind(expose, 'create')
48
+ assert.instanceOf(action, CreateAction)
49
+ assert.equal(action.kind, 'create')
50
+ }).tags(['@actions'])
51
+
52
+ test('createActionFromKind: creates UpdateAction', ({ assert }) => {
53
+ const action = createActionFromKind(expose, 'update')
54
+ assert.instanceOf(action, UpdateAction)
55
+ assert.equal(action.kind, 'update')
56
+ }).tags(['@actions'])
57
+
58
+ test('createActionFromKind: creates DeleteAction', ({ assert }) => {
59
+ const action = createActionFromKind(expose, 'delete')
60
+ assert.instanceOf(action, DeleteAction)
61
+ assert.equal(action.kind, 'delete')
62
+ }).tags(['@actions'])
63
+
64
+ test('createActionFromKind: creates SearchAction', ({ assert }) => {
65
+ const action = createActionFromKind(expose, 'search')
66
+ assert.instanceOf(action, SearchAction)
67
+ assert.equal(action.kind, 'search')
68
+ }).tags(['@actions'])
69
+
70
+ test('createActionFromKind: throws exception for unknown action kind', ({ assert }) => {
71
+ assert.throws(() => createActionFromKind(expose, 'unknown' as ActionKind), 'Unknown action kind')
72
+ }).tags(['@actions'])
73
+
74
+ test('restoreAction: restores ListAction', ({ assert }) => {
75
+ const action = restoreAction(expose, { kind: 'list' })
76
+ assert.instanceOf(action, ListAction)
77
+ assert.equal(action.kind, 'list')
78
+ }).tags(['@actions'])
79
+
80
+ test('restoreAction: restores ReadAction', ({ assert }) => {
81
+ const action = restoreAction(expose, { kind: 'read' })
82
+ assert.instanceOf(action, ReadAction)
83
+ assert.equal(action.kind, 'read')
84
+ }).tags(['@actions'])
85
+
86
+ test('restoreAction: restores CreateAction', ({ assert }) => {
87
+ const action = restoreAction(expose, { kind: 'create' })
88
+ assert.instanceOf(action, CreateAction)
89
+ assert.equal(action.kind, 'create')
90
+ }).tags(['@actions'])
91
+
92
+ test('restoreAction: restores UpdateAction', ({ assert }) => {
93
+ const action = restoreAction(expose, { kind: 'update' })
94
+ assert.instanceOf(action, UpdateAction)
95
+ assert.equal(action.kind, 'update')
96
+ }).tags(['@actions'])
97
+
98
+ test('restoreAction: restores DeleteAction', ({ assert }) => {
99
+ const action = restoreAction(expose, { kind: 'delete' })
100
+ assert.instanceOf(action, DeleteAction)
101
+ assert.equal(action.kind, 'delete')
102
+ }).tags(['@actions'])
103
+
104
+ test('restoreAction: restores SearchAction', ({ assert }) => {
105
+ const action = restoreAction(expose, { kind: 'search' })
106
+ assert.instanceOf(action, SearchAction)
107
+ assert.equal(action.kind, 'search')
108
+ }).tags(['@actions'])
109
+
110
+ test('restoreAction: throws exception for unknown action kind', ({ assert }) => {
111
+ assert.throws(() => restoreAction(expose, { kind: 'unknown' }), 'Unknown action kind')
112
+ }).tags(['@actions'])
113
+ })
@@ -794,6 +794,34 @@ test.group('DomainAssociation.readBinding()', () => {
794
794
  })
795
795
  })
796
796
 
797
+ test.group('DomainAssociation.readWebBinding()', () => {
798
+ test('returns undefined if no web binding exists', ({ assert }) => {
799
+ const dataDomain = new DataDomain()
800
+ const association = new DomainAssociation(dataDomain, 'test-parent')
801
+ const webBindings = association.readWebBinding()
802
+ assert.isUndefined(webBindings)
803
+ }).tags(['@modeling', '@association'])
804
+
805
+ test('returns the web binding schema if it exists', ({ assert }) => {
806
+ const dataDomain = new DataDomain()
807
+ const association = new DomainAssociation(dataDomain, 'test-parent', {
808
+ bindings: [{ type: 'web', schema: { hidden: true } }],
809
+ })
810
+ const webBindings = association.readWebBinding()
811
+ assert.deepEqual(webBindings, { hidden: true })
812
+ }).tags(['@modeling', '@association'])
813
+
814
+ test('returns undefined if the web binding exists but has no schema', ({ assert }) => {
815
+ const dataDomain = new DataDomain()
816
+ const association = new DomainAssociation(dataDomain, 'test-parent', {
817
+ // @ts-expect-error Testing undefined schema
818
+ bindings: [{ type: 'web', schema: undefined }],
819
+ })
820
+ const webBindings = association.readWebBinding()
821
+ assert.isUndefined(webBindings)
822
+ }).tags(['@modeling', '@association'])
823
+ })
824
+
797
825
  test.group('DomainAssociation.addSemantic()', () => {
798
826
  test('adds a new semantic to the association', ({ assert }) => {
799
827
  const dataDomain = new DataDomain()
@@ -59,6 +59,55 @@ test.group('DomainEntity.listParents()', () => {
59
59
  })
60
60
  })
61
61
 
62
+ test.group('DomainEntity.listAllParents()', () => {
63
+ test('lists all parent entities recursively upwards (default)', ({ assert }) => {
64
+ const dataDomain = new DataDomain()
65
+ const model = dataDomain.addModel()
66
+ const grandparent = model.addEntity({ key: 'grandparent' })
67
+ const parent = model.addEntity({ key: 'parent' })
68
+ const child = model.addEntity({ key: 'child' })
69
+ parent.addParent(grandparent.key)
70
+ child.addParent(parent.key)
71
+ const parents = child.listAllParents()
72
+ assert.deepEqual(parents, [parent, grandparent])
73
+ })
74
+
75
+ test('lists all parent entities recursively downwards', ({ assert }) => {
76
+ const dataDomain = new DataDomain()
77
+ const model = dataDomain.addModel()
78
+ const grandparent = model.addEntity({ key: 'grandparent' })
79
+ const parent = model.addEntity({ key: 'parent' })
80
+ const child = model.addEntity({ key: 'child' })
81
+ parent.addParent(grandparent.key)
82
+ child.addParent(parent.key)
83
+ const parents = child.listAllParents('down')
84
+ assert.deepEqual(parents, [grandparent, parent])
85
+ })
86
+
87
+ test('handles multiple inheritance properly', ({ assert }) => {
88
+ const dataDomain = new DataDomain()
89
+ const model = dataDomain.addModel()
90
+ const grandparent1 = model.addEntity({ key: 'grandparent1' })
91
+ const grandparent2 = model.addEntity({ key: 'grandparent2' })
92
+ const parent1 = model.addEntity({ key: 'parent1' })
93
+ const parent2 = model.addEntity({ key: 'parent2' })
94
+ const child = model.addEntity({ key: 'child' })
95
+
96
+ parent1.addParent(grandparent1.key)
97
+ parent2.addParent(grandparent2.key)
98
+ child.addParent(parent1.key)
99
+ child.addParent(parent2.key)
100
+
101
+ const parentsUp = child.listAllParents('up')
102
+ // Order based on BFS: parent1, parent2, grandparent1, grandparent2
103
+ assert.deepEqual(parentsUp, [parent1, parent2, grandparent1, grandparent2])
104
+
105
+ const parentsDown = child.listAllParents('down')
106
+ // Order reversed: grandparent2, grandparent1, parent2, parent1
107
+ assert.deepEqual(parentsDown, [grandparent2, grandparent1, parent2, parent1])
108
+ })
109
+ })
110
+
62
111
  test.group('DomainEntity.addParent()', () => {
63
112
  test('adds a parent to the entity', ({ assert }) => {
64
113
  const dataDomain = new DataDomain()
@@ -198,4 +198,51 @@ test.group('ExposedEntity::actions', (group) => {
198
198
  assert.equal(restoredAction.kind, 'search', 'Action kind should be search')
199
199
  assert.instanceOf(restoredAction, SearchAction, 'Action should be an instance of SearchAction')
200
200
  }).tags(['@modeling', '@action', '@restoring'])
201
+
202
+ test('addActionFromKind adds a new action', ({ assert }) => {
203
+ const model = new ApiModel(
204
+ {
205
+ exposes: [
206
+ {
207
+ kind: ExposedEntityKind,
208
+ key: nanoid(),
209
+ entity: { key: entity.key },
210
+ isRoot: true,
211
+ hasCollection: false,
212
+ resourcePath: '',
213
+ actions: [],
214
+ },
215
+ ],
216
+ },
217
+ domain
218
+ )
219
+ const expose = Array.from(model.exposes.values())[0]!
220
+ const action = expose.addActionFromKind('list')
221
+ assert.instanceOf(action, ListAction)
222
+ assert.equal(action.kind, 'list')
223
+ assert.lengthOf(expose.actions, 1)
224
+ assert.strictEqual(expose.actions[0], action)
225
+ }).tags(['@modeling', '@action'])
226
+
227
+ test('addActionFromKind throws if action already exists', ({ assert }) => {
228
+ const model = new ApiModel(
229
+ {
230
+ exposes: [
231
+ {
232
+ kind: ExposedEntityKind,
233
+ key: nanoid(),
234
+ entity: { key: entity.key },
235
+ isRoot: true,
236
+ hasCollection: false,
237
+ resourcePath: '',
238
+ actions: [],
239
+ },
240
+ ],
241
+ },
242
+ domain
243
+ )
244
+ const expose = Array.from(model.exposes.values())[0]!
245
+ expose.addActionFromKind('list')
246
+ assert.throws(() => expose.addActionFromKind('list'), 'Action of kind "list" already exists for this exposure')
247
+ }).tags(['@modeling', '@action'])
201
248
  })