@elevasis/core 0.21.0 → 0.23.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.
Files changed (132) hide show
  1. package/dist/index.d.ts +2518 -2169
  2. package/dist/index.js +2495 -1095
  3. package/dist/knowledge/index.d.ts +706 -1044
  4. package/dist/knowledge/index.js +9 -9
  5. package/dist/organization-model/index.d.ts +2518 -2169
  6. package/dist/organization-model/index.js +2495 -1095
  7. package/dist/test-utils/index.d.ts +826 -1014
  8. package/dist/test-utils/index.js +1894 -1032
  9. package/package.json +3 -3
  10. package/src/__tests__/template-core-compatibility.test.ts +11 -79
  11. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +852 -397
  12. package/src/auth/multi-tenancy/permissions.ts +20 -8
  13. package/src/business/README.md +2 -2
  14. package/src/business/acquisition/api-schemas.test.ts +175 -2
  15. package/src/business/acquisition/api-schemas.ts +132 -16
  16. package/src/business/acquisition/build-templates.test.ts +4 -4
  17. package/src/business/acquisition/build-templates.ts +72 -30
  18. package/src/business/acquisition/crm-state-actions.test.ts +13 -11
  19. package/src/business/acquisition/index.ts +12 -0
  20. package/src/business/acquisition/types.ts +7 -3
  21. package/src/business/clients/api-schemas.test.ts +115 -0
  22. package/src/business/clients/api-schemas.ts +158 -0
  23. package/src/business/clients/index.ts +1 -0
  24. package/src/business/deals/api-schemas.ts +8 -0
  25. package/src/business/index.ts +5 -2
  26. package/src/business/projects/types.ts +19 -0
  27. package/src/execution/engine/__tests__/fixtures/test-agents.ts +10 -8
  28. package/src/execution/engine/agent/core/__tests__/agent.test.ts +16 -12
  29. package/src/execution/engine/agent/core/__tests__/error-passthrough.test.ts +4 -3
  30. package/src/execution/engine/agent/core/types.ts +25 -15
  31. package/src/execution/engine/agent/index.ts +6 -4
  32. package/src/execution/engine/agent/reasoning/__tests__/request-builder.test.ts +24 -18
  33. package/src/execution/engine/index.ts +3 -0
  34. package/src/execution/engine/workflow/types.ts +9 -2
  35. package/src/knowledge/README.md +8 -7
  36. package/src/knowledge/__tests__/queries.test.ts +74 -73
  37. package/src/knowledge/format.ts +10 -9
  38. package/src/knowledge/index.ts +1 -1
  39. package/src/knowledge/published.ts +1 -1
  40. package/src/knowledge/queries.ts +26 -25
  41. package/src/organization-model/README.md +73 -26
  42. package/src/organization-model/__tests__/content-kinds-registry.test.ts +210 -0
  43. package/src/organization-model/__tests__/defaults.test.ts +76 -96
  44. package/src/organization-model/__tests__/domains/actions.test.ts +56 -0
  45. package/src/organization-model/__tests__/domains/customers.test.ts +299 -295
  46. package/src/organization-model/__tests__/domains/entities.test.ts +56 -0
  47. package/src/organization-model/__tests__/domains/goals.test.ts +493 -479
  48. package/src/organization-model/__tests__/domains/identity.test.ts +280 -279
  49. package/src/organization-model/__tests__/domains/navigation.test.ts +268 -212
  50. package/src/organization-model/__tests__/domains/offerings.test.ts +414 -419
  51. package/src/organization-model/__tests__/domains/policies.test.ts +323 -0
  52. package/src/organization-model/__tests__/domains/resource-mappings.test.ts +271 -271
  53. package/src/organization-model/__tests__/domains/resources.test.ts +310 -0
  54. package/src/organization-model/__tests__/domains/roles.test.ts +463 -347
  55. package/src/organization-model/__tests__/domains/statuses.test.ts +246 -243
  56. package/src/organization-model/__tests__/domains/systems.test.ts +209 -0
  57. package/src/organization-model/__tests__/flatten-additive-merge.test.ts +361 -0
  58. package/src/organization-model/__tests__/foundation.test.ts +74 -102
  59. package/src/organization-model/__tests__/get-resources-for-system.test.ts +144 -0
  60. package/src/organization-model/__tests__/graph.test.ts +899 -71
  61. package/src/organization-model/__tests__/knowledge.test.ts +209 -49
  62. package/src/organization-model/__tests__/lookup-helpers.test.ts +438 -0
  63. package/src/organization-model/__tests__/migration-helpers.test.ts +591 -0
  64. package/src/organization-model/__tests__/prospecting-ssot.test.ts +36 -27
  65. package/src/organization-model/__tests__/recursive-system-schema.test.ts +520 -0
  66. package/src/organization-model/__tests__/resolve.test.ts +174 -23
  67. package/src/organization-model/__tests__/schema.test.ts +291 -114
  68. package/src/organization-model/__tests__/surface-projection.test.ts +207 -97
  69. package/src/organization-model/catalogs/lead-gen.ts +144 -0
  70. package/src/organization-model/content-kinds/config.ts +36 -0
  71. package/src/organization-model/content-kinds/index.ts +74 -0
  72. package/src/organization-model/content-kinds/pipeline.ts +68 -0
  73. package/src/organization-model/content-kinds/registry.ts +44 -0
  74. package/src/organization-model/content-kinds/status.ts +71 -0
  75. package/src/organization-model/content-kinds/template.ts +83 -0
  76. package/src/organization-model/content-kinds/types.ts +117 -0
  77. package/src/organization-model/contracts.ts +13 -3
  78. package/src/organization-model/defaults.ts +499 -86
  79. package/src/organization-model/domains/actions.ts +239 -0
  80. package/src/organization-model/domains/customers.ts +78 -75
  81. package/src/organization-model/domains/entities.ts +144 -0
  82. package/src/organization-model/domains/goals.ts +83 -80
  83. package/src/organization-model/domains/knowledge.ts +76 -17
  84. package/src/organization-model/domains/navigation.ts +107 -384
  85. package/src/organization-model/domains/offerings.ts +71 -66
  86. package/src/organization-model/domains/policies.ts +102 -0
  87. package/src/organization-model/domains/projects.ts +14 -48
  88. package/src/organization-model/domains/prospecting.ts +62 -181
  89. package/src/organization-model/domains/resources.ts +145 -0
  90. package/src/organization-model/domains/roles.ts +96 -55
  91. package/src/organization-model/domains/sales.ts +10 -219
  92. package/src/organization-model/domains/shared.ts +57 -57
  93. package/src/organization-model/domains/statuses.ts +339 -130
  94. package/src/organization-model/domains/systems.ts +203 -0
  95. package/src/organization-model/foundation.ts +54 -67
  96. package/src/organization-model/graph/build.ts +682 -54
  97. package/src/organization-model/graph/link.ts +1 -1
  98. package/src/organization-model/graph/schema.ts +24 -9
  99. package/src/organization-model/graph/types.ts +20 -7
  100. package/src/organization-model/helpers.ts +231 -26
  101. package/src/organization-model/icons.ts +1 -0
  102. package/src/organization-model/index.ts +118 -5
  103. package/src/organization-model/migration-helpers.ts +249 -0
  104. package/src/organization-model/organization-graph.mdx +16 -15
  105. package/src/organization-model/organization-model.mdx +111 -44
  106. package/src/organization-model/published.ts +172 -19
  107. package/src/organization-model/resolve.ts +117 -54
  108. package/src/organization-model/schema.ts +654 -112
  109. package/src/organization-model/surface-projection.ts +116 -122
  110. package/src/organization-model/types.ts +146 -20
  111. package/src/platform/api/types.ts +38 -35
  112. package/src/platform/constants/versions.ts +1 -1
  113. package/src/platform/registry/__tests__/command-view.test.ts +6 -8
  114. package/src/platform/registry/__tests__/resource-link.test.ts +13 -8
  115. package/src/platform/registry/__tests__/resource-registry.integration.test.ts +16 -31
  116. package/src/platform/registry/__tests__/resource-registry.nested-systems.test.ts +245 -0
  117. package/src/platform/registry/__tests__/resource-registry.test.ts +2053 -2005
  118. package/src/platform/registry/__tests__/validation.test.ts +1347 -1086
  119. package/src/platform/registry/index.ts +14 -0
  120. package/src/platform/registry/resource-registry.ts +52 -2
  121. package/src/platform/registry/serialization.ts +241 -202
  122. package/src/platform/registry/serialized-types.ts +1 -0
  123. package/src/platform/registry/types.ts +411 -361
  124. package/src/platform/registry/validation.ts +745 -513
  125. package/src/projects/api-schemas.ts +290 -267
  126. package/src/reference/_generated/contracts.md +853 -397
  127. package/src/reference/glossary.md +23 -18
  128. package/src/supabase/database.types.ts +181 -0
  129. package/src/test-utils/test-utils.test.ts +1 -6
  130. package/src/organization-model/__tests__/domains/operations.test.ts +0 -203
  131. package/src/organization-model/domains/features.ts +0 -31
  132. package/src/organization-model/domains/operations.ts +0 -85
@@ -1,419 +1,414 @@
1
- import { describe, expect, it } from 'vitest'
2
- import {
3
- OfferingsDomainSchema,
4
- ProductSchema,
5
- PricingModelSchema,
6
- DEFAULT_ORGANIZATION_MODEL_OFFERINGS
7
- } from '../../domains/offerings'
8
- import { resolveOrganizationModel } from '../../resolve'
9
-
10
- // ---------------------------------------------------------------------------
11
- // Group 1: ProductSchema — positive parse
12
- // ---------------------------------------------------------------------------
13
-
14
- describe('ProductSchema — positive parse', () => {
15
- it('accepts a fully-populated product', () => {
16
- const result = ProductSchema.safeParse({
17
- id: 'product-starter-plan',
18
- name: 'Starter Plan',
19
- description: 'Full-service automation for small teams.',
20
- pricingModel: 'subscription',
21
- price: 299,
22
- currency: 'USD',
23
- targetSegmentIds: ['seg-smb'],
24
- deliveryFeatureId: 'crm'
25
- })
26
- expect(result.success).toBe(true)
27
- })
28
-
29
- it('accepts a minimal product — only id required', () => {
30
- const result = ProductSchema.safeParse({ id: 'product-minimal' })
31
- expect(result.success).toBe(true)
32
- if (result.success) {
33
- expect(result.data.id).toBe('product-minimal')
34
- expect(result.data.name).toBe('')
35
- expect(result.data.description).toBe('')
36
- expect(result.data.pricingModel).toBe('custom')
37
- expect(result.data.price).toBe(0)
38
- expect(result.data.currency).toBe('USD')
39
- expect(result.data.targetSegmentIds).toEqual([])
40
- expect(result.data.deliveryFeatureId).toBeUndefined()
41
- }
42
- })
43
-
44
- it('trims whitespace from id and name', () => {
45
- const result = ProductSchema.safeParse({
46
- id: ' product-trim ',
47
- name: ' Trimmed Name '
48
- })
49
- expect(result.success).toBe(true)
50
- if (result.success) {
51
- expect(result.data.id).toBe('product-trim')
52
- expect(result.data.name).toBe('Trimmed Name')
53
- }
54
- })
55
-
56
- it('accepts price of 0 (free tier)', () => {
57
- const result = ProductSchema.safeParse({
58
- id: 'product-free',
59
- pricingModel: 'subscription',
60
- price: 0
61
- })
62
- expect(result.success).toBe(true)
63
- if (result.success) {
64
- expect(result.data.price).toBe(0)
65
- }
66
- })
67
-
68
- it('accepts all four pricing model values', () => {
69
- const models = ['one-time', 'subscription', 'usage-based', 'custom'] as const
70
- for (const pricingModel of models) {
71
- const result = ProductSchema.safeParse({ id: `product-${pricingModel}`, pricingModel })
72
- expect(result.success).toBe(true)
73
- }
74
- })
75
- })
76
-
77
- // ---------------------------------------------------------------------------
78
- // Group 2: ProductSchema — default values
79
- // ---------------------------------------------------------------------------
80
-
81
- describe('ProductSchema — default values', () => {
82
- it('pricingModel defaults to "custom"', () => {
83
- const result = ProductSchema.safeParse({ id: 'product-defaults' })
84
- expect(result.success).toBe(true)
85
- if (result.success) {
86
- expect(result.data.pricingModel).toBe('custom')
87
- }
88
- })
89
-
90
- it('price defaults to 0', () => {
91
- const result = ProductSchema.safeParse({ id: 'product-defaults' })
92
- expect(result.success).toBe(true)
93
- if (result.success) {
94
- expect(result.data.price).toBe(0)
95
- }
96
- })
97
-
98
- it('currency defaults to "USD"', () => {
99
- const result = ProductSchema.safeParse({ id: 'product-defaults' })
100
- expect(result.success).toBe(true)
101
- if (result.success) {
102
- expect(result.data.currency).toBe('USD')
103
- }
104
- })
105
-
106
- it('targetSegmentIds defaults to empty array', () => {
107
- const result = ProductSchema.safeParse({ id: 'product-defaults' })
108
- expect(result.success).toBe(true)
109
- if (result.success) {
110
- expect(result.data.targetSegmentIds).toEqual([])
111
- }
112
- })
113
-
114
- it('deliveryFeatureId defaults to undefined (field is optional)', () => {
115
- const result = ProductSchema.safeParse({ id: 'product-defaults' })
116
- expect(result.success).toBe(true)
117
- if (result.success) {
118
- expect(result.data.deliveryFeatureId).toBeUndefined()
119
- }
120
- })
121
- })
122
-
123
- // ---------------------------------------------------------------------------
124
- // Group 3: ProductSchema — negative parse (wrong types / constraints)
125
- // ---------------------------------------------------------------------------
126
-
127
- describe('ProductSchema — negative parse', () => {
128
- it('rejects missing id', () => {
129
- const result = ProductSchema.safeParse({ name: 'No ID Product' })
130
- expect(result.success).toBe(false)
131
- })
132
-
133
- it('rejects empty string id', () => {
134
- const result = ProductSchema.safeParse({ id: '' })
135
- expect(result.success).toBe(false)
136
- })
137
-
138
- it('rejects whitespace-only id (trims to empty string)', () => {
139
- const result = ProductSchema.safeParse({ id: ' ' })
140
- expect(result.success).toBe(false)
141
- })
142
-
143
- it('rejects an unknown pricingModel value', () => {
144
- const result = ProductSchema.safeParse({ id: 'product-bad-model', pricingModel: 'freemium' })
145
- expect(result.success).toBe(false)
146
- })
147
-
148
- it('rejects price below 0', () => {
149
- const result = ProductSchema.safeParse({ id: 'product-negative-price', price: -1 })
150
- expect(result.success).toBe(false)
151
- })
152
-
153
- it('rejects price as a string', () => {
154
- const result = ProductSchema.safeParse({ id: 'product-string-price', price: '299' })
155
- expect(result.success).toBe(false)
156
- })
157
-
158
- it('rejects targetSegmentIds as a non-array value', () => {
159
- const result = ProductSchema.safeParse({ id: 'product-bad-segments', targetSegmentIds: 'seg-a' })
160
- expect(result.success).toBe(false)
161
- })
162
- })
163
-
164
- // ---------------------------------------------------------------------------
165
- // Group 4: PricingModelSchema — enum coverage
166
- // ---------------------------------------------------------------------------
167
-
168
- describe('PricingModelSchema — enum', () => {
169
- it('has exactly four values', () => {
170
- const result = PricingModelSchema.safeParse('one-time')
171
- expect(result.success).toBe(true)
172
- // Verify the full enum options list matches expected set
173
- expect(PricingModelSchema.options).toEqual(['one-time', 'subscription', 'usage-based', 'custom'])
174
- })
175
-
176
- it('rejects an empty string', () => {
177
- const result = PricingModelSchema.safeParse('')
178
- expect(result.success).toBe(false)
179
- })
180
-
181
- it('rejects a numeric value', () => {
182
- const result = PricingModelSchema.safeParse(42)
183
- expect(result.success).toBe(false)
184
- })
185
- })
186
-
187
- // ---------------------------------------------------------------------------
188
- // Group 5: OfferingsDomainSchema — structural
189
- // ---------------------------------------------------------------------------
190
-
191
- describe('OfferingsDomainSchema — structural', () => {
192
- it('accepts an empty products array', () => {
193
- const result = OfferingsDomainSchema.safeParse({ products: [] })
194
- expect(result.success).toBe(true)
195
- })
196
-
197
- it('defaults products to empty array when key is omitted', () => {
198
- const result = OfferingsDomainSchema.safeParse({})
199
- expect(result.success).toBe(true)
200
- if (result.success) {
201
- expect(result.data.products).toEqual([])
202
- }
203
- })
204
-
205
- it('accepts multiple valid products', () => {
206
- const result = OfferingsDomainSchema.safeParse({
207
- products: [
208
- { id: 'product-a', name: 'Product A', pricingModel: 'subscription' },
209
- { id: 'product-b', name: 'Product B', pricingModel: 'one-time', price: 999 }
210
- ]
211
- })
212
- expect(result.success).toBe(true)
213
- if (result.success) {
214
- expect(result.data.products).toHaveLength(2)
215
- }
216
- })
217
-
218
- it('rejects products as a non-array value', () => {
219
- const result = OfferingsDomainSchema.safeParse({ products: 'not an array' })
220
- expect(result.success).toBe(false)
221
- })
222
-
223
- it('DEFAULT_ORGANIZATION_MODEL_OFFERINGS constant matches schema parse of empty object', () => {
224
- const result = OfferingsDomainSchema.safeParse({})
225
- expect(result.success).toBe(true)
226
- if (result.success) {
227
- expect(result.data).toEqual(DEFAULT_ORGANIZATION_MODEL_OFFERINGS)
228
- }
229
- })
230
- })
231
-
232
- // ---------------------------------------------------------------------------
233
- // Group 6: Cross-ref validation via resolveOrganizationModel
234
- // ---------------------------------------------------------------------------
235
-
236
- describe('resolveOrganizationModel offerings cross-ref (targetSegmentIds)', () => {
237
- it('passes when targetSegmentIds references a declared customer segment', () => {
238
- expect(() =>
239
- resolveOrganizationModel({
240
- customers: {
241
- segments: [{ id: 'seg-agencies', name: 'SMB Agencies' }]
242
- },
243
- offerings: {
244
- products: [
245
- {
246
- id: 'product-starter',
247
- name: 'Starter Plan',
248
- pricingModel: 'subscription',
249
- targetSegmentIds: ['seg-agencies']
250
- }
251
- ]
252
- }
253
- })
254
- ).not.toThrow()
255
- })
256
-
257
- it('throws when targetSegmentIds references a non-existent segment', () => {
258
- expect(() =>
259
- resolveOrganizationModel({
260
- customers: { segments: [] },
261
- offerings: {
262
- products: [
263
- {
264
- id: 'product-bad-ref',
265
- targetSegmentIds: ['nonexistent-segment']
266
- }
267
- ]
268
- }
269
- })
270
- ).toThrow()
271
- })
272
-
273
- it('throws with a message referencing the unknown segment id', () => {
274
- let errorMessage = ''
275
- try {
276
- resolveOrganizationModel({
277
- customers: { segments: [] },
278
- offerings: {
279
- products: [
280
- {
281
- id: 'product-path-check',
282
- targetSegmentIds: ['ghost-segment']
283
- }
284
- ]
285
- }
286
- })
287
- } catch (e) {
288
- errorMessage = String(e)
289
- }
290
- expect(errorMessage).toContain('ghost-segment')
291
- })
292
-
293
- it('passes when targetSegmentIds is empty (no refs required)', () => {
294
- expect(() =>
295
- resolveOrganizationModel({
296
- offerings: {
297
- products: [{ id: 'product-no-targets', targetSegmentIds: [] }]
298
- }
299
- })
300
- ).not.toThrow()
301
- })
302
-
303
- it('passes when multiple products each reference a valid segment', () => {
304
- expect(() =>
305
- resolveOrganizationModel({
306
- customers: {
307
- segments: [
308
- { id: 'seg-a', name: 'Segment A' },
309
- { id: 'seg-b', name: 'Segment B' }
310
- ]
311
- },
312
- offerings: {
313
- products: [
314
- { id: 'product-1', targetSegmentIds: ['seg-a'] },
315
- { id: 'product-2', targetSegmentIds: ['seg-a', 'seg-b'] }
316
- ]
317
- }
318
- })
319
- ).not.toThrow()
320
- })
321
- })
322
-
323
- // ---------------------------------------------------------------------------
324
- // Group 7: Cross-ref validation deliveryFeatureId
325
- // ---------------------------------------------------------------------------
326
-
327
- describe('resolveOrganizationModel — offerings cross-ref (deliveryFeatureId)', () => {
328
- it('passes when deliveryFeatureId references a declared feature', () => {
329
- // 'crm' is in DEFAULT_ORGANIZATION_MODEL.features
330
- expect(() =>
331
- resolveOrganizationModel({
332
- offerings: {
333
- products: [
334
- {
335
- id: 'product-with-delivery',
336
- deliveryFeatureId: 'crm'
337
- }
338
- ]
339
- }
340
- })
341
- ).not.toThrow()
342
- })
343
-
344
- it('throws when deliveryFeatureId references a non-existent feature', () => {
345
- expect(() =>
346
- resolveOrganizationModel({
347
- offerings: {
348
- products: [
349
- {
350
- id: 'product-bad-delivery',
351
- deliveryFeatureId: 'nonexistent-feature'
352
- }
353
- ]
354
- }
355
- })
356
- ).toThrow()
357
- })
358
-
359
- it('throws with a message referencing the unknown feature id', () => {
360
- let errorMessage = ''
361
- try {
362
- resolveOrganizationModel({
363
- offerings: {
364
- products: [
365
- {
366
- id: 'product-delivery-path',
367
- deliveryFeatureId: 'ghost-feature'
368
- }
369
- ]
370
- }
371
- })
372
- } catch (e) {
373
- errorMessage = String(e)
374
- }
375
- expect(errorMessage).toContain('ghost-feature')
376
- })
377
-
378
- it('passes when deliveryFeatureId is absent (field is optional)', () => {
379
- expect(() =>
380
- resolveOrganizationModel({
381
- offerings: {
382
- products: [{ id: 'product-no-delivery' }]
383
- }
384
- })
385
- ).not.toThrow()
386
- })
387
- })
388
-
389
- // ---------------------------------------------------------------------------
390
- // Group 8: Integration — resolveOrganizationModel general
391
- // ---------------------------------------------------------------------------
392
-
393
- describe('resolveOrganizationModel offerings domain integration', () => {
394
- it('merges partial offerings override and preserves empty products default', () => {
395
- const model = resolveOrganizationModel({ offerings: { products: [] } })
396
- expect(model.offerings.products).toEqual([])
397
- })
398
-
399
- it('omitting offerings key entirely resolves to default empty products', () => {
400
- const model = resolveOrganizationModel({})
401
- expect(model.offerings).toBeDefined()
402
- expect(model.offerings.products).toEqual([])
403
- })
404
-
405
- it('does not bleed offerings changes into other top-level domains', () => {
406
- const model = resolveOrganizationModel({
407
- customers: {
408
- segments: [{ id: 'seg-isolation' }]
409
- },
410
- offerings: {
411
- products: [{ id: 'product-isolation', targetSegmentIds: ['seg-isolation'] }]
412
- }
413
- })
414
- expect(model.identity).toBeDefined()
415
- expect(model.customers).toBeDefined()
416
- expect(model.features).toBeDefined()
417
- expect(model.navigation).toBeDefined()
418
- })
419
- })
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ OfferingsDomainSchema,
4
+ ProductSchema,
5
+ PricingModelSchema,
6
+ DEFAULT_ORGANIZATION_MODEL_OFFERINGS
7
+ } from '../../domains/offerings'
8
+ import { resolveOrganizationModel } from '../../resolve'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Group 1: ProductSchema — positive parse
12
+ // ---------------------------------------------------------------------------
13
+
14
+ describe('ProductSchema — positive parse', () => {
15
+ it('accepts a fully-populated product', () => {
16
+ const result = ProductSchema.safeParse({
17
+ id: 'product-starter-plan',
18
+ order: 10,
19
+ name: 'Starter Plan',
20
+ description: 'Full-service automation for small teams.',
21
+ pricingModel: 'subscription',
22
+ price: 299,
23
+ currency: 'USD',
24
+ targetSegmentIds: ['seg-smb'],
25
+ deliveryFeatureId: 'sales.crm'
26
+ })
27
+ expect(result.success).toBe(true)
28
+ })
29
+
30
+ it('accepts a minimal product — only id and order required', () => {
31
+ const result = ProductSchema.safeParse({ id: 'product-minimal', order: 10 })
32
+ expect(result.success).toBe(true)
33
+ if (result.success) {
34
+ expect(result.data.id).toBe('product-minimal')
35
+ expect(result.data.name).toBe('')
36
+ expect(result.data.description).toBe('')
37
+ expect(result.data.pricingModel).toBe('custom')
38
+ expect(result.data.price).toBe(0)
39
+ expect(result.data.currency).toBe('USD')
40
+ expect(result.data.targetSegmentIds).toEqual([])
41
+ expect(result.data.deliveryFeatureId).toBeUndefined()
42
+ }
43
+ })
44
+
45
+ it('trims whitespace from id and name', () => {
46
+ const result = ProductSchema.safeParse({
47
+ id: ' product-trim ',
48
+ order: 10,
49
+ name: ' Trimmed Name '
50
+ })
51
+ expect(result.success).toBe(true)
52
+ if (result.success) {
53
+ expect(result.data.id).toBe('product-trim')
54
+ expect(result.data.name).toBe('Trimmed Name')
55
+ }
56
+ })
57
+
58
+ it('accepts price of 0 (free tier)', () => {
59
+ const result = ProductSchema.safeParse({
60
+ id: 'product-free',
61
+ order: 10,
62
+ pricingModel: 'subscription',
63
+ price: 0
64
+ })
65
+ expect(result.success).toBe(true)
66
+ if (result.success) {
67
+ expect(result.data.price).toBe(0)
68
+ }
69
+ })
70
+
71
+ it('accepts all four pricing model values', () => {
72
+ const models = ['one-time', 'subscription', 'usage-based', 'custom'] as const
73
+ for (const pricingModel of models) {
74
+ const result = ProductSchema.safeParse({ id: `product-${pricingModel}`, order: 10, pricingModel })
75
+ expect(result.success).toBe(true)
76
+ }
77
+ })
78
+ })
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Group 2: ProductSchema — default values
82
+ // ---------------------------------------------------------------------------
83
+
84
+ describe('ProductSchema — default values', () => {
85
+ it('pricingModel defaults to "custom"', () => {
86
+ const result = ProductSchema.safeParse({ id: 'product-defaults', order: 10 })
87
+ expect(result.success).toBe(true)
88
+ if (result.success) {
89
+ expect(result.data.pricingModel).toBe('custom')
90
+ }
91
+ })
92
+
93
+ it('price defaults to 0', () => {
94
+ const result = ProductSchema.safeParse({ id: 'product-defaults', order: 10 })
95
+ expect(result.success).toBe(true)
96
+ if (result.success) {
97
+ expect(result.data.price).toBe(0)
98
+ }
99
+ })
100
+
101
+ it('currency defaults to "USD"', () => {
102
+ const result = ProductSchema.safeParse({ id: 'product-defaults', order: 10 })
103
+ expect(result.success).toBe(true)
104
+ if (result.success) {
105
+ expect(result.data.currency).toBe('USD')
106
+ }
107
+ })
108
+
109
+ it('targetSegmentIds defaults to empty array', () => {
110
+ const result = ProductSchema.safeParse({ id: 'product-defaults', order: 10 })
111
+ expect(result.success).toBe(true)
112
+ if (result.success) {
113
+ expect(result.data.targetSegmentIds).toEqual([])
114
+ }
115
+ })
116
+
117
+ it('deliveryFeatureId defaults to undefined (field is optional)', () => {
118
+ const result = ProductSchema.safeParse({ id: 'product-defaults', order: 10 })
119
+ expect(result.success).toBe(true)
120
+ if (result.success) {
121
+ expect(result.data.deliveryFeatureId).toBeUndefined()
122
+ }
123
+ })
124
+ })
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Group 3: ProductSchema — negative parse (wrong types / constraints)
128
+ // ---------------------------------------------------------------------------
129
+
130
+ describe('ProductSchema — negative parse', () => {
131
+ it('rejects missing id', () => {
132
+ const result = ProductSchema.safeParse({ order: 10, name: 'No ID Product' })
133
+ expect(result.success).toBe(false)
134
+ })
135
+
136
+ it('rejects empty string id', () => {
137
+ const result = ProductSchema.safeParse({ id: '', order: 10 })
138
+ expect(result.success).toBe(false)
139
+ })
140
+
141
+ it('rejects whitespace-only id (trims to empty string)', () => {
142
+ const result = ProductSchema.safeParse({ id: ' ', order: 10 })
143
+ expect(result.success).toBe(false)
144
+ })
145
+
146
+ it('rejects an unknown pricingModel value', () => {
147
+ const result = ProductSchema.safeParse({ id: 'product-bad-model', order: 10, pricingModel: 'freemium' })
148
+ expect(result.success).toBe(false)
149
+ })
150
+
151
+ it('rejects price below 0', () => {
152
+ const result = ProductSchema.safeParse({ id: 'product-negative-price', order: 10, price: -1 })
153
+ expect(result.success).toBe(false)
154
+ })
155
+
156
+ it('rejects price as a string', () => {
157
+ const result = ProductSchema.safeParse({ id: 'product-string-price', order: 10, price: '299' })
158
+ expect(result.success).toBe(false)
159
+ })
160
+
161
+ it('rejects targetSegmentIds as a non-array value', () => {
162
+ const result = ProductSchema.safeParse({ id: 'product-bad-segments', order: 10, targetSegmentIds: 'seg-a' })
163
+ expect(result.success).toBe(false)
164
+ })
165
+ })
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Group 4: PricingModelSchema — enum coverage
169
+ // ---------------------------------------------------------------------------
170
+
171
+ describe('PricingModelSchema — enum', () => {
172
+ it('has exactly four values', () => {
173
+ const result = PricingModelSchema.safeParse('one-time')
174
+ expect(result.success).toBe(true)
175
+ // Verify the full enum options list matches expected set
176
+ expect(PricingModelSchema.options).toEqual(['one-time', 'subscription', 'usage-based', 'custom'])
177
+ })
178
+
179
+ it('rejects an empty string', () => {
180
+ const result = PricingModelSchema.safeParse('')
181
+ expect(result.success).toBe(false)
182
+ })
183
+
184
+ it('rejects a numeric value', () => {
185
+ const result = PricingModelSchema.safeParse(42)
186
+ expect(result.success).toBe(false)
187
+ })
188
+ })
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Group 5: OfferingsDomainSchema — structural
192
+ // ---------------------------------------------------------------------------
193
+
194
+ describe('OfferingsDomainSchema — structural', () => {
195
+ it('accepts an empty products record', () => {
196
+ const result = OfferingsDomainSchema.safeParse({})
197
+ expect(result.success).toBe(true)
198
+ })
199
+
200
+ it('defaults products to empty record when omitted', () => {
201
+ const result = OfferingsDomainSchema.safeParse({})
202
+ expect(result.success).toBe(true)
203
+ if (result.success) {
204
+ expect(result.data).toEqual({})
205
+ }
206
+ })
207
+
208
+ it('accepts multiple valid products', () => {
209
+ const result = OfferingsDomainSchema.safeParse({
210
+ 'product-a': { id: 'product-a', order: 10, name: 'Product A', pricingModel: 'subscription' },
211
+ 'product-b': { id: 'product-b', order: 20, name: 'Product B', pricingModel: 'one-time', price: 999 }
212
+ })
213
+ expect(result.success).toBe(true)
214
+ if (result.success) {
215
+ expect(Object.keys(result.data)).toHaveLength(2)
216
+ }
217
+ })
218
+
219
+ it('rejects a record where entry id does not match map key', () => {
220
+ const result = OfferingsDomainSchema.safeParse({
221
+ 'product-a': { id: 'product-b', order: 10, name: 'Mismatched' }
222
+ })
223
+ expect(result.success).toBe(false)
224
+ })
225
+
226
+ it('DEFAULT_ORGANIZATION_MODEL_OFFERINGS constant matches schema parse of empty object', () => {
227
+ const result = OfferingsDomainSchema.safeParse({})
228
+ expect(result.success).toBe(true)
229
+ if (result.success) {
230
+ expect(result.data).toEqual(DEFAULT_ORGANIZATION_MODEL_OFFERINGS)
231
+ }
232
+ })
233
+ })
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Group 6: Cross-ref validation via resolveOrganizationModel
237
+ // ---------------------------------------------------------------------------
238
+
239
+ describe('resolveOrganizationModel — offerings cross-ref (targetSegmentIds)', () => {
240
+ it('passes when targetSegmentIds references a declared customer segment', () => {
241
+ expect(() =>
242
+ resolveOrganizationModel({
243
+ customers: {
244
+ 'seg-agencies': { id: 'seg-agencies', order: 10, name: 'SMB Agencies' }
245
+ },
246
+ offerings: {
247
+ 'product-starter': {
248
+ id: 'product-starter',
249
+ order: 10,
250
+ name: 'Starter Plan',
251
+ pricingModel: 'subscription',
252
+ targetSegmentIds: ['seg-agencies']
253
+ }
254
+ }
255
+ })
256
+ ).not.toThrow()
257
+ })
258
+
259
+ it('throws when targetSegmentIds references a non-existent segment', () => {
260
+ expect(() =>
261
+ resolveOrganizationModel({
262
+ customers: {},
263
+ offerings: {
264
+ 'product-bad-ref': {
265
+ id: 'product-bad-ref',
266
+ order: 10,
267
+ targetSegmentIds: ['nonexistent-segment']
268
+ }
269
+ }
270
+ })
271
+ ).toThrow()
272
+ })
273
+
274
+ it('throws with a message referencing the unknown segment id', () => {
275
+ let errorMessage = ''
276
+ try {
277
+ resolveOrganizationModel({
278
+ customers: {},
279
+ offerings: {
280
+ 'product-path-check': {
281
+ id: 'product-path-check',
282
+ order: 10,
283
+ targetSegmentIds: ['ghost-segment']
284
+ }
285
+ }
286
+ })
287
+ } catch (e) {
288
+ errorMessage = String(e)
289
+ }
290
+ expect(errorMessage).toContain('ghost-segment')
291
+ })
292
+
293
+ it('passes when targetSegmentIds is empty (no refs required)', () => {
294
+ expect(() =>
295
+ resolveOrganizationModel({
296
+ offerings: {
297
+ 'product-no-targets': { id: 'product-no-targets', order: 10, targetSegmentIds: [] }
298
+ }
299
+ })
300
+ ).not.toThrow()
301
+ })
302
+
303
+ it('passes when multiple products each reference a valid segment', () => {
304
+ expect(() =>
305
+ resolveOrganizationModel({
306
+ customers: {
307
+ 'seg-a': { id: 'seg-a', order: 10, name: 'Segment A' },
308
+ 'seg-b': { id: 'seg-b', order: 20, name: 'Segment B' }
309
+ },
310
+ offerings: {
311
+ 'product-1': { id: 'product-1', order: 10, targetSegmentIds: ['seg-a'] },
312
+ 'product-2': { id: 'product-2', order: 20, targetSegmentIds: ['seg-a', 'seg-b'] }
313
+ }
314
+ })
315
+ ).not.toThrow()
316
+ })
317
+ })
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Group 7: Cross-ref validation — deliveryFeatureId
321
+ // ---------------------------------------------------------------------------
322
+
323
+ describe('resolveOrganizationModel — offerings cross-ref (deliveryFeatureId)', () => {
324
+ it('passes when deliveryFeatureId references a declared system', () => {
325
+ // 'sales.crm' is in DEFAULT_ORGANIZATION_MODEL.systems
326
+ expect(() =>
327
+ resolveOrganizationModel({
328
+ offerings: {
329
+ 'product-with-delivery': {
330
+ id: 'product-with-delivery',
331
+ order: 10,
332
+ deliveryFeatureId: 'sales.crm'
333
+ }
334
+ }
335
+ })
336
+ ).not.toThrow()
337
+ })
338
+
339
+ it('throws when deliveryFeatureId references a non-existent system', () => {
340
+ expect(() =>
341
+ resolveOrganizationModel({
342
+ offerings: {
343
+ 'product-bad-delivery': {
344
+ id: 'product-bad-delivery',
345
+ order: 10,
346
+ deliveryFeatureId: 'nonexistent-feature'
347
+ }
348
+ }
349
+ })
350
+ ).toThrow()
351
+ })
352
+
353
+ it('throws with a message referencing the unknown system id', () => {
354
+ let errorMessage = ''
355
+ try {
356
+ resolveOrganizationModel({
357
+ offerings: {
358
+ 'product-delivery-path': {
359
+ id: 'product-delivery-path',
360
+ order: 10,
361
+ deliveryFeatureId: 'ghost-feature'
362
+ }
363
+ }
364
+ })
365
+ } catch (e) {
366
+ errorMessage = String(e)
367
+ }
368
+ expect(errorMessage).toContain('ghost-feature')
369
+ })
370
+
371
+ it('passes when deliveryFeatureId is absent (field is optional)', () => {
372
+ expect(() =>
373
+ resolveOrganizationModel({
374
+ offerings: {
375
+ 'product-no-delivery': { id: 'product-no-delivery', order: 10 }
376
+ }
377
+ })
378
+ ).not.toThrow()
379
+ })
380
+ })
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Group 8: Integration — resolveOrganizationModel general
384
+ // ---------------------------------------------------------------------------
385
+
386
+ describe('resolveOrganizationModel — offerings domain integration', () => {
387
+ it('merges partial offerings override and preserves empty products default', () => {
388
+ const model = resolveOrganizationModel({ offerings: {} })
389
+ expect(Object.keys(model.offerings)).toHaveLength(0)
390
+ })
391
+
392
+ it('omitting offerings key entirely resolves to default empty products', () => {
393
+ const model = resolveOrganizationModel({})
394
+ expect(model.offerings).toBeDefined()
395
+ expect(Object.keys(model.offerings)).toHaveLength(0)
396
+ })
397
+
398
+ it('does not bleed offerings changes into other top-level domains', () => {
399
+ const model = resolveOrganizationModel({
400
+ customers: {
401
+ 'seg-isolation': { id: 'seg-isolation', order: 10 }
402
+ },
403
+ offerings: {
404
+ 'product-isolation': { id: 'product-isolation', order: 10, targetSegmentIds: ['seg-isolation'] }
405
+ }
406
+ })
407
+ expect(model.identity).toBeDefined()
408
+ expect(model.customers).toBeDefined()
409
+ expect(model.systems).toBeDefined()
410
+ // Phase 4 (D8): model.navigation removed from top-level OM.
411
+ expect(model.navigation).toBeDefined()
412
+ expect(model.knowledge).toBeDefined()
413
+ })
414
+ })