@elevasis/core 0.22.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 (112) hide show
  1. package/dist/index.d.ts +2330 -2391
  2. package/dist/index.js +2322 -1147
  3. package/dist/knowledge/index.d.ts +702 -1136
  4. package/dist/knowledge/index.js +9 -9
  5. package/dist/organization-model/index.d.ts +2330 -2391
  6. package/dist/organization-model/index.js +2322 -1147
  7. package/dist/test-utils/index.d.ts +703 -1106
  8. package/dist/test-utils/index.js +1735 -1089
  9. package/package.json +1 -1
  10. package/src/__tests__/template-core-compatibility.test.ts +11 -79
  11. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +360 -98
  12. package/src/business/acquisition/api-schemas.test.ts +2 -2
  13. package/src/business/acquisition/api-schemas.ts +7 -9
  14. package/src/business/acquisition/build-templates.test.ts +4 -4
  15. package/src/business/acquisition/build-templates.ts +72 -30
  16. package/src/business/acquisition/crm-state-actions.test.ts +13 -11
  17. package/src/business/acquisition/types.ts +7 -3
  18. package/src/execution/engine/agent/core/types.ts +1 -1
  19. package/src/execution/engine/workflow/types.ts +2 -2
  20. package/src/knowledge/README.md +8 -7
  21. package/src/knowledge/__tests__/queries.test.ts +74 -73
  22. package/src/knowledge/format.ts +10 -9
  23. package/src/knowledge/index.ts +1 -1
  24. package/src/knowledge/published.ts +1 -1
  25. package/src/knowledge/queries.ts +26 -25
  26. package/src/organization-model/README.md +66 -26
  27. package/src/organization-model/__tests__/content-kinds-registry.test.ts +210 -0
  28. package/src/organization-model/__tests__/defaults.test.ts +72 -98
  29. package/src/organization-model/__tests__/domains/actions.test.ts +56 -0
  30. package/src/organization-model/__tests__/domains/customers.test.ts +299 -295
  31. package/src/organization-model/__tests__/domains/entities.test.ts +56 -0
  32. package/src/organization-model/__tests__/domains/goals.test.ts +493 -479
  33. package/src/organization-model/__tests__/domains/identity.test.ts +280 -279
  34. package/src/organization-model/__tests__/domains/navigation.test.ts +268 -212
  35. package/src/organization-model/__tests__/domains/offerings.test.ts +414 -419
  36. package/src/organization-model/__tests__/domains/policies.test.ts +323 -0
  37. package/src/organization-model/__tests__/domains/resource-mappings.test.ts +271 -271
  38. package/src/organization-model/__tests__/domains/resources.test.ts +159 -37
  39. package/src/organization-model/__tests__/domains/roles.test.ts +147 -86
  40. package/src/organization-model/__tests__/domains/statuses.test.ts +246 -243
  41. package/src/organization-model/__tests__/domains/systems.test.ts +67 -51
  42. package/src/organization-model/__tests__/flatten-additive-merge.test.ts +361 -0
  43. package/src/organization-model/__tests__/foundation.test.ts +74 -102
  44. package/src/organization-model/__tests__/get-resources-for-system.test.ts +144 -0
  45. package/src/organization-model/__tests__/graph.test.ts +899 -71
  46. package/src/organization-model/__tests__/knowledge.test.ts +173 -52
  47. package/src/organization-model/__tests__/lookup-helpers.test.ts +438 -0
  48. package/src/organization-model/__tests__/migration-helpers.test.ts +591 -0
  49. package/src/organization-model/__tests__/prospecting-ssot.test.ts +36 -27
  50. package/src/organization-model/__tests__/recursive-system-schema.test.ts +520 -0
  51. package/src/organization-model/__tests__/resolve.test.ts +174 -23
  52. package/src/organization-model/__tests__/schema.test.ts +291 -114
  53. package/src/organization-model/__tests__/surface-projection.test.ts +207 -97
  54. package/src/organization-model/catalogs/lead-gen.ts +144 -0
  55. package/src/organization-model/content-kinds/config.ts +36 -0
  56. package/src/organization-model/content-kinds/index.ts +74 -0
  57. package/src/organization-model/content-kinds/pipeline.ts +68 -0
  58. package/src/organization-model/content-kinds/registry.ts +44 -0
  59. package/src/organization-model/content-kinds/status.ts +71 -0
  60. package/src/organization-model/content-kinds/template.ts +83 -0
  61. package/src/organization-model/content-kinds/types.ts +117 -0
  62. package/src/organization-model/contracts.ts +13 -3
  63. package/src/organization-model/defaults.ts +488 -96
  64. package/src/organization-model/domains/actions.ts +239 -0
  65. package/src/organization-model/domains/customers.ts +78 -75
  66. package/src/organization-model/domains/entities.ts +144 -0
  67. package/src/organization-model/domains/goals.ts +83 -80
  68. package/src/organization-model/domains/knowledge.ts +74 -16
  69. package/src/organization-model/domains/navigation.ts +107 -384
  70. package/src/organization-model/domains/offerings.ts +71 -66
  71. package/src/organization-model/domains/policies.ts +102 -0
  72. package/src/organization-model/domains/projects.ts +14 -48
  73. package/src/organization-model/domains/prospecting.ts +62 -181
  74. package/src/organization-model/domains/resources.ts +81 -24
  75. package/src/organization-model/domains/roles.ts +13 -10
  76. package/src/organization-model/domains/sales.ts +10 -219
  77. package/src/organization-model/domains/shared.ts +57 -57
  78. package/src/organization-model/domains/statuses.ts +339 -130
  79. package/src/organization-model/domains/systems.ts +186 -29
  80. package/src/organization-model/foundation.ts +54 -67
  81. package/src/organization-model/graph/build.ts +682 -54
  82. package/src/organization-model/graph/link.ts +1 -1
  83. package/src/organization-model/graph/schema.ts +24 -9
  84. package/src/organization-model/graph/types.ts +20 -7
  85. package/src/organization-model/helpers.ts +231 -26
  86. package/src/organization-model/index.ts +116 -5
  87. package/src/organization-model/migration-helpers.ts +249 -0
  88. package/src/organization-model/organization-graph.mdx +16 -15
  89. package/src/organization-model/organization-model.mdx +89 -41
  90. package/src/organization-model/published.ts +120 -18
  91. package/src/organization-model/resolve.ts +117 -54
  92. package/src/organization-model/schema.ts +561 -140
  93. package/src/organization-model/surface-projection.ts +116 -122
  94. package/src/organization-model/types.ts +102 -21
  95. package/src/platform/constants/versions.ts +1 -1
  96. package/src/platform/registry/__tests__/command-view.test.ts +6 -8
  97. package/src/platform/registry/__tests__/resource-link.test.ts +13 -8
  98. package/src/platform/registry/__tests__/resource-registry.integration.test.ts +16 -31
  99. package/src/platform/registry/__tests__/resource-registry.nested-systems.test.ts +245 -0
  100. package/src/platform/registry/__tests__/resource-registry.test.ts +9 -7
  101. package/src/platform/registry/__tests__/validation.test.ts +15 -11
  102. package/src/platform/registry/resource-registry.ts +20 -8
  103. package/src/platform/registry/serialization.ts +7 -7
  104. package/src/platform/registry/types.ts +3 -3
  105. package/src/platform/registry/validation.ts +17 -15
  106. package/src/reference/_generated/contracts.md +362 -99
  107. package/src/reference/glossary.md +18 -18
  108. package/src/supabase/database.types.ts +60 -0
  109. package/src/test-utils/test-utils.test.ts +1 -6
  110. package/src/organization-model/__tests__/domains/operations.test.ts +0 -203
  111. package/src/organization-model/domains/features.ts +0 -31
  112. 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
+ })