@elevasis/core 0.26.0 → 0.27.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 (34) hide show
  1. package/dist/index.d.ts +5 -5
  2. package/dist/index.js +209 -173
  3. package/dist/knowledge/index.d.ts +21 -21
  4. package/dist/organization-model/index.d.ts +5 -5
  5. package/dist/organization-model/index.js +209 -173
  6. package/dist/test-utils/index.d.ts +2 -2
  7. package/dist/test-utils/index.js +182 -126
  8. package/package.json +1 -1
  9. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +976 -1063
  10. package/src/business/acquisition/api-schemas.test.ts +1962 -1841
  11. package/src/business/acquisition/api-schemas.ts +1461 -1464
  12. package/src/business/acquisition/crm-next-action.test.ts +45 -25
  13. package/src/business/acquisition/crm-next-action.ts +227 -220
  14. package/src/business/acquisition/crm-priority.test.ts +41 -8
  15. package/src/business/acquisition/crm-priority.ts +365 -349
  16. package/src/business/acquisition/crm-state-actions.test.ts +208 -153
  17. package/src/business/acquisition/derive-actions.test.ts +90 -13
  18. package/src/business/acquisition/derive-actions.ts +8 -139
  19. package/src/business/acquisition/ontology-validation.ts +72 -158
  20. package/src/business/pdf/sections/investment.ts +1 -1
  21. package/src/business/pdf/sections/summary-investment.ts +1 -1
  22. package/src/execution/engine/tools/tool-maps.ts +872 -831
  23. package/src/organization-model/__tests__/cross-ref.test.ts +167 -0
  24. package/src/organization-model/__tests__/published-zero-leak.test.ts +60 -1
  25. package/src/organization-model/__tests__/resolve.test.ts +1 -1
  26. package/src/organization-model/__tests__/schema-refinements.test.ts +72 -0
  27. package/src/organization-model/cross-ref.ts +175 -0
  28. package/src/organization-model/domains/branding.ts +6 -6
  29. package/src/organization-model/domains/sales.test.ts +104 -218
  30. package/src/organization-model/domains/sales.ts +212 -375
  31. package/src/organization-model/index.ts +1 -0
  32. package/src/organization-model/schema-refinements.ts +667 -0
  33. package/src/organization-model/schema.ts +8 -715
  34. package/src/reference/_generated/contracts.md +976 -1063
@@ -1,115 +1,212 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { describe, expect, it } from 'vitest'
2
2
  import {
3
- CRM_PIPELINE_DEFINITION,
4
- DEFAULT_CRM_PRIORITY_RULE_CONFIG,
5
- LEAD_GEN_PIPELINE_DEFINITIONS
3
+ LEAD_GEN_PIPELINE_DEFINITIONS,
4
+ type CrmPriorityRuleConfig,
5
+ type StatefulPipelineDefinition
6
6
  } from '../../organization-model/domains/sales'
7
- import { CrmPriorityOverrideSchema, evaluateCrmDealPriority, resolveCrmPriorityRuleConfig } from './crm-priority'
8
- import {
9
- AddCompaniesToListRequestSchema,
10
- AddContactsToListRequestSchema,
11
- AcqArtifactOwnerKindSchema,
12
- AcqListDetailResponseSchema,
13
- AcqContactResponseSchema,
14
- AcqContactStatusSchema,
15
- AcqEmailValidSchema,
16
- AcqListResponseSchema,
17
- AcqListStatusResponseSchema,
18
- BuildPlanSnapshotSchema,
19
- CreateArtifactRequestSchema,
20
- CreateCompanyRequestSchema,
21
- CreateContactRequestSchema,
22
- CreateDealNoteRequestSchema,
23
- CreateDealTaskRequestSchema,
24
- CrmStageKeySchema,
25
- CrmStateKeySchema,
26
- CrmTransitionItemRequestSchema,
27
- CreateListRequestSchema,
28
- DealDetailResponseSchema,
29
- DealListItemSchema,
30
- DealListResponseSchema,
31
- DealNoteResponseSchema,
32
- DealStageSchema,
33
- DealTaskResponseSchema,
34
- ExecuteActionRequestSchema,
35
- IcpRubricSchema,
36
- GetListQuerySchema,
37
- ListArtifactsQuerySchema,
38
- ListCompaniesQuerySchema,
39
- ListContactsQuerySchema,
40
- ListDealsQuerySchema,
41
- ListDealTasksDueQuerySchema,
42
- ListMembersQuerySchema,
43
- ListReadQuerySchema,
44
- ListRecordsQuerySchema,
45
- ListStatusSchema,
46
- PipelineStageSchema,
47
- ScrapingConfigSchema,
48
- TransitionDealStateRequestSchema,
49
- TransitionItemRequestSchema,
50
- UpdateCompanyRequestSchema,
51
- UpdateContactRequestSchema,
52
- UpdateListConfigRequestSchema,
53
- UpdateListRequestSchema,
54
- UpdateListStatusRequestSchema
7
+ import { DEFAULT_ORGANIZATION_MODEL } from '../../organization-model/defaults'
8
+ import type { OrganizationModel } from '../../organization-model/types'
9
+ import { CrmPriorityOverrideSchema, evaluateCrmDealPriority, resolveCrmPriorityRuleConfig } from './crm-priority'
10
+ import {
11
+ AddCompaniesToListRequestSchema,
12
+ AddContactsToListRequestSchema,
13
+ AcqArtifactOwnerKindSchema,
14
+ AcqListDetailResponseSchema,
15
+ AcqContactResponseSchema,
16
+ AcqContactStatusSchema,
17
+ AcqEmailValidSchema,
18
+ AcqListResponseSchema,
19
+ AcqListStatusResponseSchema,
20
+ BuildPlanSnapshotSchema,
21
+ CreateArtifactRequestSchema,
22
+ CreateCompanyRequestSchema,
23
+ CreateContactRequestSchema,
24
+ CreateDealNoteRequestSchema,
25
+ CreateDealTaskRequestSchema,
26
+ CrmStateKeySchema,
27
+ CrmTransitionItemRequestSchema,
28
+ CreateListRequestSchema,
29
+ DealDetailResponseSchema,
30
+ DealListItemSchema,
31
+ DealListResponseSchema,
32
+ DealNoteResponseSchema,
33
+ DealStageSchema,
34
+ DealTaskResponseSchema,
35
+ ExecuteActionRequestSchema,
36
+ IcpRubricSchema,
37
+ GetListQuerySchema,
38
+ ListArtifactsQuerySchema,
39
+ ListCompaniesQuerySchema,
40
+ ListContactsQuerySchema,
41
+ ListDealsQuerySchema,
42
+ ListDealTasksDueQuerySchema,
43
+ ListMembersQuerySchema,
44
+ ListReadQuerySchema,
45
+ ListRecordsQuerySchema,
46
+ ListStatusSchema,
47
+ PipelineStageSchema,
48
+ ScrapingConfigSchema,
49
+ TransitionDealStateRequestSchema,
50
+ TransitionItemRequestSchema,
51
+ UpdateCompanyRequestSchema,
52
+ UpdateContactRequestSchema,
53
+ UpdateListConfigRequestSchema,
54
+ UpdateListRequestSchema,
55
+ UpdateListStatusRequestSchema
55
56
  } from './api-schemas'
56
57
  import { createBuildPlanSnapshotFromTemplateId } from './build-templates'
57
58
  import {
58
- BUSINESS_ONTOLOGY_VALIDATION_INDEX,
59
+ compileBusinessOntologyValidationIndex,
59
60
  CRM_PIPELINE_CATALOG_ONTOLOGY_ID,
60
- CRM_STAGE_KEYS_FROM_ONTOLOGY,
61
- LEAD_GEN_STAGE_CATALOG_ONTOLOGY_ID,
62
- LEAD_GEN_STAGE_KEYS_FROM_ONTOLOGY,
63
- getLeadGenStageCatalogFromOntology
61
+ LEAD_GEN_STAGE_CATALOG_ONTOLOGY_ID
64
62
  } from './ontology-validation'
65
-
66
- // ---------------------------------------------------------------------------
67
- // Helpers
68
- // ---------------------------------------------------------------------------
69
-
70
- const VALID_UUID = '00000000-0000-4000-8000-000000000001'
63
+
64
+ const CRM_PIPELINE_DEFINITION: StatefulPipelineDefinition = {
65
+ pipelineKey: 'crm',
66
+ label: 'CRM',
67
+ entityKey: 'crm.deal',
68
+ stages: [
69
+ {
70
+ stageKey: 'interested',
71
+ label: 'Interested',
72
+ color: 'blue',
73
+ states: [
74
+ { stateKey: 'discovery_replied', label: 'Discovery Replied' },
75
+ { stateKey: 'discovery_link_sent', label: 'Discovery Link Sent' },
76
+ { stateKey: 'discovery_nudging', label: 'Discovery Nudging' },
77
+ { stateKey: 'discovery_booking_cancelled', label: 'Discovery Booking Cancelled' },
78
+ { stateKey: 'reply_sent', label: 'Reply Sent' },
79
+ { stateKey: 'followup_1_sent', label: 'Follow-up 1 Sent' },
80
+ { stateKey: 'followup_2_sent', label: 'Follow-up 2 Sent' },
81
+ { stateKey: 'followup_3_sent', label: 'Follow-up 3 Sent' }
82
+ ]
83
+ },
84
+ { stageKey: 'proposal', label: 'Proposal', color: 'yellow', states: [] },
85
+ { stageKey: 'closing', label: 'Closing', color: 'orange', states: [] },
86
+ { stageKey: 'closed_won', label: 'Closed Won', color: 'green', states: [] },
87
+ { stageKey: 'closed_lost', label: 'Closed Lost', color: 'red', states: [] },
88
+ { stageKey: 'nurturing', label: 'Nurturing', color: 'grape', states: [] }
89
+ ]
90
+ }
91
+
92
+ const DEFAULT_CRM_PRIORITY_RULE_CONFIG: CrmPriorityRuleConfig = {
93
+ buckets: [
94
+ { bucketKey: 'needs_response', label: 'Needs Response', rank: 10, color: 'red' },
95
+ { bucketKey: 'follow_up_due', label: 'Follow-up Due', rank: 20, color: 'orange' },
96
+ { bucketKey: 'waiting', label: 'Waiting', rank: 30, color: 'blue' },
97
+ { bucketKey: 'stale', label: 'Stale', rank: 40, color: 'gray' },
98
+ { bucketKey: 'closed_low', label: 'Closed', rank: 50, color: 'dark' }
99
+ ],
100
+ closedStageKeys: ['closed_won', 'closed_lost'],
101
+ followUpAfterDaysByStateKey: {
102
+ discovery_link_sent: 3,
103
+ discovery_nudging: 2,
104
+ reply_sent: 3,
105
+ followup_1_sent: 3,
106
+ followup_2_sent: 5,
107
+ followup_3_sent: 7
108
+ },
109
+ staleAfterDays: 14
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Minimal test model fixture
114
+ // ---------------------------------------------------------------------------
115
+ // Builds an OrganizationModel that contains the CRM pipeline catalog exactly as
116
+ // @repo/elevasis-core authors it in canonicalOrganizationModel (sales.crm system,
117
+ // ontology.catalogTypes['sales.crm:catalog/crm.pipeline']). Derived inline from
118
+ // CRM_PIPELINE_DEFINITION so packages/core tests have no dependency on @repo/elevasis-core.
119
+ function buildMinimalCrmModel(): OrganizationModel {
120
+ const crmCatalogEntries = Object.fromEntries(
121
+ CRM_PIPELINE_DEFINITION.stages.map((stage, idx) => [
122
+ stage.stageKey,
123
+ {
124
+ key: stage.stageKey,
125
+ label: stage.label,
126
+ order: (idx + 1) * 10,
127
+ color: stage.color ?? 'gray',
128
+ states: stage.states
129
+ }
130
+ ])
131
+ )
132
+ return {
133
+ ...DEFAULT_ORGANIZATION_MODEL,
134
+ systems: {
135
+ sales: {
136
+ id: 'sales',
137
+ label: 'Sales',
138
+ order: 60,
139
+ systems: {
140
+ crm: {
141
+ id: 'sales.crm',
142
+ label: 'CRM',
143
+ order: 80,
144
+ ontology: {
145
+ catalogTypes: {
146
+ 'sales.crm:catalog/crm.pipeline': {
147
+ id: 'sales.crm:catalog/crm.pipeline',
148
+ label: 'CRM',
149
+ ownerSystemId: 'sales.crm',
150
+ kind: 'pipeline',
151
+ entries: crmCatalogEntries
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Helpers
164
+ // ---------------------------------------------------------------------------
165
+
166
+ const VALID_UUID = '00000000-0000-4000-8000-000000000001'
71
167
  const ISO_TS = '2026-04-27T12:34:56.000Z'
72
168
  const VALID_COMPANY_STAGE_KEY = 'qualified'
73
169
  const VALID_CONTACT_STAGE_KEY = 'verified'
74
170
  const PRIORITY = {
75
- bucketKey: 'waiting' as const,
76
- rank: 30,
77
- label: 'Waiting',
78
- color: 'blue',
79
- reason: 'No immediate response or follow-up is due.',
80
- latestActivityAt: ISO_TS,
81
- nextActionAt: null
82
- }
83
-
84
- // ---------------------------------------------------------------------------
85
- // DealStageSchema
86
- // ---------------------------------------------------------------------------
87
-
171
+ bucketKey: 'waiting' as const,
172
+ rank: 30,
173
+ label: 'Waiting',
174
+ color: 'blue',
175
+ reason: 'No immediate response or follow-up is due.',
176
+ latestActivityAt: ISO_TS,
177
+ nextActionAt: null
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // DealStageSchema
182
+ // ---------------------------------------------------------------------------
183
+
88
184
  describe('DealStageSchema', () => {
89
185
  const crmStageKeys = CRM_PIPELINE_DEFINITION.stages.map((stage) => stage.stageKey)
90
- const crmStateKeys = CRM_PIPELINE_DEFINITION.stages.flatMap((stage) => stage.states.map((state) => state.stateKey))
91
-
92
- it('derives CRM stage keys from CRM_PIPELINE_DEFINITION', () => {
93
- expect(CrmStageKeySchema.options).toEqual(crmStageKeys)
94
- expect(DealStageSchema.options).toEqual(crmStageKeys)
95
- })
96
-
97
- it('derives CRM state keys from CRM_PIPELINE_DEFINITION', () => {
98
- expect(CrmStateKeySchema.options).toEqual(crmStateKeys)
99
- })
100
-
101
- it.each(crmStageKeys)('accepts canonical stage "%s"', (stage) => {
102
- expect(DealStageSchema.safeParse(stage).success).toBe(true)
103
- })
104
-
105
- it('rejects an unknown stage value', () => {
106
- expect(DealStageSchema.safeParse('open').success).toBe(false)
107
- expect(DealStageSchema.safeParse('').success).toBe(false)
108
- })
109
-
110
- it('rejects unknown CRM state values', () => {
111
- expect(CrmStateKeySchema.safeParse('custom_state').success).toBe(false)
112
- expect(CrmStateKeySchema.safeParse('').success).toBe(false)
186
+
187
+ // DealStageSchema / CrmStageKeySchema / CrmStateKeySchema are now transport schemas
188
+ // (z.string().trim().min(1)). Closed-catalog membership is model-owned and enforced
189
+ // caller-side via model-injected validators. See api-schemas.ts comment.
190
+ it('canonical CRM stages from @repo/elevasis-core are the expected 6-stage pipeline', () => {
191
+ expect(crmStageKeys).toEqual(['interested', 'proposal', 'closing', 'closed_won', 'closed_lost', 'nurturing'])
192
+ })
193
+
194
+ it.each(crmStageKeys)('accepts canonical stage "%s"', (stage) => {
195
+ expect(DealStageSchema.safeParse(stage).success).toBe(true)
196
+ })
197
+
198
+ // Transport contract: any non-empty string is accepted; closed-catalog enforcement is caller-side.
199
+ it('accepts any non-empty stage string (closed catalog is model-owned), rejects empty', () => {
200
+ expect(DealStageSchema.safeParse('open').success).toBe(true)
201
+ expect(DealStageSchema.safeParse('custom_stage').success).toBe(true)
202
+ expect(DealStageSchema.safeParse('').success).toBe(false)
203
+ })
204
+
205
+ // Transport contract: any non-empty state string is accepted; closed-catalog enforcement is caller-side.
206
+ it('accepts any non-empty CRM state string (closed catalog is model-owned), rejects empty', () => {
207
+ expect(CrmStateKeySchema.safeParse('custom_state').success).toBe(true)
208
+ expect(CrmStateKeySchema.safeParse('discovery_replied').success).toBe(true)
209
+ expect(CrmStateKeySchema.safeParse('').success).toBe(false)
113
210
  })
114
211
  })
115
212
 
@@ -118,435 +215,458 @@ describe('DealStageSchema', () => {
118
215
  // ---------------------------------------------------------------------------
119
216
 
120
217
  describe('business ontology validation bridge', () => {
121
- it('compiles CRM and lead-gen catalog bridge records into the ontology index', () => {
122
- expect(BUSINESS_ONTOLOGY_VALIDATION_INDEX.ontology.catalogTypes[CRM_PIPELINE_CATALOG_ONTOLOGY_ID]).toBeDefined()
123
- expect(BUSINESS_ONTOLOGY_VALIDATION_INDEX.ontology.catalogTypes[LEAD_GEN_STAGE_CATALOG_ONTOLOGY_ID]).toBeDefined()
218
+ // compileBusinessOntologyValidationIndex now requires a model arg no default singleton.
219
+ // The canonical Elevasis CRM pipeline catalog is model-owned (authored in @repo/elevasis-core
220
+ // canonicalOrganizationModel at 'sales.crm:catalog/crm.pipeline'). Tests here use a
221
+ // minimal inline model derived from CRM_PIPELINE_DEFINITION so packages/core stays
222
+ // independent of @repo/elevasis-core.
223
+ it('compiles CRM and lead-gen catalog bridge records from a model-injected CRM catalog', () => {
224
+ const model = buildMinimalCrmModel()
225
+ const index = compileBusinessOntologyValidationIndex(model)
226
+ expect(index.ontology.catalogTypes[CRM_PIPELINE_CATALOG_ONTOLOGY_ID]).toBeDefined()
227
+ expect(index.ontology.catalogTypes[LEAD_GEN_STAGE_CATALOG_ONTOLOGY_ID]).toBeDefined()
228
+ })
229
+
230
+ it('model-injected index supplies crmPipelineCatalog and leadGenStageCatalog', () => {
231
+ const model = buildMinimalCrmModel()
232
+ const index = compileBusinessOntologyValidationIndex(model)
233
+ expect(index.crmPipelineCatalog).toBeDefined()
234
+ expect(index.leadGenStageCatalog).toBeDefined()
235
+ })
236
+
237
+ it('does not publish Elevasis lead-gen action ids through the generic core ontology bridge', () => {
238
+ // Leak guard: a model that contains no Elevasis-specific lead-gen action types must
239
+ // produce an index whose actionTypesByLegacyId does not include the old singleton
240
+ // legacy IDs ('send_reply', 'lead-gen.company.source'). These IDs were only present
241
+ // in the removed module-level singleton that pre-compiled against a hard-coded tenant
242
+ // model. If this test fails, the de-leak has been reverted.
243
+ const model = buildMinimalCrmModel()
244
+ const index = compileBusinessOntologyValidationIndex(model)
245
+ expect(index.actionTypesByLegacyId['send_reply']).toBeUndefined()
246
+ expect(index.actionTypesByLegacyId['lead-gen.company.source']).toBeUndefined()
247
+ })
248
+ })
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // TransitionItemRequestSchema
252
+ // ---------------------------------------------------------------------------
253
+
254
+ describe('TransitionItemRequestSchema', () => {
255
+ const valid = {
256
+ pipelineKey: 'lead-gen',
257
+ stageKey: 'interested',
258
+ stateKey: null
259
+ }
260
+
261
+ it('accepts a minimal valid payload', () => {
262
+ expect(TransitionItemRequestSchema.safeParse(valid).success).toBe(true)
263
+ })
264
+
265
+ it('accepts stateKey as null', () => {
266
+ const result = TransitionItemRequestSchema.safeParse({ ...valid, stateKey: null })
267
+ expect(result.success).toBe(true)
124
268
  })
125
269
 
126
- it('keeps CRM schema stage validation aligned to the ontology catalog and legacy pipeline constant', () => {
127
- const legacyStageKeys = CRM_PIPELINE_DEFINITION.stages.map((stage) => stage.stageKey)
270
+ it('accepts stateKey as a string', () => {
271
+ const result = TransitionItemRequestSchema.safeParse({ ...valid, stateKey: 'discovery_replied' })
272
+ expect(result.success).toBe(true)
273
+ })
274
+
275
+ it('accepts stateKey as undefined (optional)', () => {
276
+ const { stateKey: _omit, ...withoutState } = valid
277
+ const result = TransitionItemRequestSchema.safeParse(withoutState)
278
+ expect(result.success).toBe(true)
279
+ })
128
280
 
129
- expect(CRM_STAGE_KEYS_FROM_ONTOLOGY).toEqual(legacyStageKeys)
130
- expect(CrmStageKeySchema.options).toEqual(CRM_STAGE_KEYS_FROM_ONTOLOGY)
281
+ it('accepts a valid ISO datetime for expectedUpdatedAt', () => {
282
+ const result = TransitionItemRequestSchema.safeParse({ ...valid, expectedUpdatedAt: ISO_TS })
283
+ expect(result.success).toBe(true)
131
284
  })
132
285
 
133
- it('keeps the generic core lead-gen catalog bridge vacuous until a model-owned catalog is supplied', () => {
134
- expect(LEAD_GEN_STAGE_KEYS_FROM_ONTOLOGY).toEqual([])
135
- expect(getLeadGenStageCatalogFromOntology()).toEqual({})
286
+ it('rejects a non-ISO datetime for expectedUpdatedAt', () => {
287
+ const result = TransitionItemRequestSchema.safeParse({ ...valid, expectedUpdatedAt: 'not-a-date' })
288
+ expect(result.success).toBe(false)
136
289
  })
137
290
 
138
- it('does not publish Elevasis lead-gen action ids through the generic core ontology bridge', () => {
139
- expect(BUSINESS_ONTOLOGY_VALIDATION_INDEX.actionTypesByLegacyId['send_reply']).toBeUndefined()
140
- expect(BUSINESS_ONTOLOGY_VALIDATION_INDEX.actionTypesByLegacyId['lead-gen.company.source']).toBeUndefined()
291
+ it('accepts all canonical CRM deal stages', () => {
292
+ for (const stageKey of CRM_PIPELINE_DEFINITION.stages.map((stage) => stage.stageKey)) {
293
+ expect(TransitionItemRequestSchema.safeParse({ pipelineKey: 'crm', stageKey, stateKey: null }).success).toBe(true)
294
+ }
295
+ })
296
+
297
+ it('accepts catalog-derived CRM state keys', () => {
298
+ for (const stateKey of CRM_PIPELINE_DEFINITION.stages.flatMap((stage) =>
299
+ stage.states.map((state) => state.stateKey)
300
+ )) {
301
+ expect(TransitionItemRequestSchema.safeParse({ ...valid, stateKey }).success).toBe(true)
302
+ }
303
+ })
304
+
305
+ it('accepts lead-gen pipeline stage/state pairs from LEAD_GEN_PIPELINE_DEFINITIONS', () => {
306
+ for (const pipelineDefinitions of Object.values(LEAD_GEN_PIPELINE_DEFINITIONS)) {
307
+ for (const pipeline of pipelineDefinitions) {
308
+ for (const stage of pipeline.stages) {
309
+ for (const state of stage.states) {
310
+ expect(
311
+ TransitionItemRequestSchema.safeParse({
312
+ pipelineKey: pipeline.pipelineKey,
313
+ stageKey: stage.stageKey,
314
+ stateKey: state.stateKey
315
+ }).success
316
+ ).toBe(true)
317
+ }
318
+ }
319
+ }
320
+ }
321
+ })
322
+
323
+ it('rejects an empty pipelineKey', () => {
324
+ const result = TransitionItemRequestSchema.safeParse({ ...valid, pipelineKey: '' })
325
+ expect(result.success).toBe(false)
326
+ })
327
+
328
+ it('rejects an empty stageKey', () => {
329
+ const result = TransitionItemRequestSchema.safeParse({ ...valid, stageKey: '' })
330
+ expect(result.success).toBe(false)
331
+ })
332
+
333
+ it('accepts unknown non-empty stage and state keys for generic substrate transitions', () => {
334
+ expect(TransitionItemRequestSchema.safeParse({ ...valid, stageKey: 'custom_stage' }).success).toBe(true)
335
+ expect(TransitionItemRequestSchema.safeParse({ ...valid, stateKey: 'custom_state' }).success).toBe(true)
336
+ })
337
+
338
+ it('rejects unknown top-level fields (strict mode)', () => {
339
+ const result = TransitionItemRequestSchema.safeParse({ ...valid, unknownField: 'x' })
340
+ expect(result.success).toBe(false)
341
+ })
342
+
343
+ it('rejects missing pipelineKey', () => {
344
+ const { pipelineKey: _omit, ...missing } = valid
345
+ const result = TransitionItemRequestSchema.safeParse(missing)
346
+ expect(result.success).toBe(false)
347
+ })
348
+
349
+ it('rejects missing stageKey', () => {
350
+ const { stageKey: _omit, ...missing } = valid
351
+ const result = TransitionItemRequestSchema.safeParse(missing)
352
+ expect(result.success).toBe(false)
141
353
  })
142
354
  })
143
355
 
144
356
  // ---------------------------------------------------------------------------
145
- // TransitionItemRequestSchema
357
+ // CrmTransitionItemRequestSchema
146
358
  // ---------------------------------------------------------------------------
147
-
148
- describe('TransitionItemRequestSchema', () => {
149
- const valid = {
150
- pipelineKey: 'lead-gen',
151
- stageKey: 'interested',
152
- stateKey: null
153
- }
154
-
155
- it('accepts a minimal valid payload', () => {
156
- expect(TransitionItemRequestSchema.safeParse(valid).success).toBe(true)
157
- })
158
-
159
- it('accepts stateKey as null', () => {
160
- const result = TransitionItemRequestSchema.safeParse({ ...valid, stateKey: null })
161
- expect(result.success).toBe(true)
162
- })
163
-
164
- it('accepts stateKey as a string', () => {
165
- const result = TransitionItemRequestSchema.safeParse({ ...valid, stateKey: 'discovery_replied' })
166
- expect(result.success).toBe(true)
167
- })
168
-
169
- it('accepts stateKey as undefined (optional)', () => {
170
- const { stateKey: _omit, ...withoutState } = valid
171
- const result = TransitionItemRequestSchema.safeParse(withoutState)
172
- expect(result.success).toBe(true)
173
- })
174
-
175
- it('accepts a valid ISO datetime for expectedUpdatedAt', () => {
176
- const result = TransitionItemRequestSchema.safeParse({ ...valid, expectedUpdatedAt: ISO_TS })
177
- expect(result.success).toBe(true)
178
- })
179
-
180
- it('rejects a non-ISO datetime for expectedUpdatedAt', () => {
181
- const result = TransitionItemRequestSchema.safeParse({ ...valid, expectedUpdatedAt: 'not-a-date' })
182
- expect(result.success).toBe(false)
183
- })
184
-
185
- it('accepts all canonical CRM deal stages', () => {
186
- for (const stageKey of CRM_PIPELINE_DEFINITION.stages.map((stage) => stage.stageKey)) {
187
- expect(TransitionItemRequestSchema.safeParse({ pipelineKey: 'crm', stageKey, stateKey: null }).success).toBe(true)
188
- }
189
- })
190
-
191
- it('accepts catalog-derived CRM state keys', () => {
192
- for (const stateKey of CRM_PIPELINE_DEFINITION.stages.flatMap((stage) =>
193
- stage.states.map((state) => state.stateKey)
194
- )) {
195
- expect(TransitionItemRequestSchema.safeParse({ ...valid, stateKey }).success).toBe(true)
196
- }
197
- })
198
-
199
- it('accepts lead-gen pipeline stage/state pairs from LEAD_GEN_PIPELINE_DEFINITIONS', () => {
200
- for (const pipelineDefinitions of Object.values(LEAD_GEN_PIPELINE_DEFINITIONS)) {
201
- for (const pipeline of pipelineDefinitions) {
202
- for (const stage of pipeline.stages) {
203
- for (const state of stage.states) {
204
- expect(
205
- TransitionItemRequestSchema.safeParse({
206
- pipelineKey: pipeline.pipelineKey,
207
- stageKey: stage.stageKey,
208
- stateKey: state.stateKey
209
- }).success
210
- ).toBe(true)
211
- }
212
- }
213
- }
214
- }
215
- })
216
-
217
- it('rejects an empty pipelineKey', () => {
218
- const result = TransitionItemRequestSchema.safeParse({ ...valid, pipelineKey: '' })
219
- expect(result.success).toBe(false)
220
- })
221
-
222
- it('rejects an empty stageKey', () => {
223
- const result = TransitionItemRequestSchema.safeParse({ ...valid, stageKey: '' })
224
- expect(result.success).toBe(false)
225
- })
226
-
227
- it('accepts unknown non-empty stage and state keys for generic substrate transitions', () => {
228
- expect(TransitionItemRequestSchema.safeParse({ ...valid, stageKey: 'custom_stage' }).success).toBe(true)
229
- expect(TransitionItemRequestSchema.safeParse({ ...valid, stateKey: 'custom_state' }).success).toBe(true)
230
- })
231
-
232
- it('rejects unknown top-level fields (strict mode)', () => {
233
- const result = TransitionItemRequestSchema.safeParse({ ...valid, unknownField: 'x' })
234
- expect(result.success).toBe(false)
235
- })
236
-
237
- it('rejects missing pipelineKey', () => {
238
- const { pipelineKey: _omit, ...missing } = valid
239
- const result = TransitionItemRequestSchema.safeParse(missing)
240
- expect(result.success).toBe(false)
241
- })
242
-
243
- it('rejects missing stageKey', () => {
244
- const { stageKey: _omit, ...missing } = valid
245
- const result = TransitionItemRequestSchema.safeParse(missing)
246
- expect(result.success).toBe(false)
247
- })
248
- })
249
-
250
- // ---------------------------------------------------------------------------
251
- // CrmTransitionItemRequestSchema
252
- // ---------------------------------------------------------------------------
253
-
254
- describe('CrmTransitionItemRequestSchema', () => {
255
- const valid = {
256
- pipelineKey: 'crm',
257
- stageKey: 'interested',
258
- stateKey: null
259
- }
260
-
261
- it('accepts catalog-derived CRM stage and state keys', () => {
262
- for (const stageKey of CRM_PIPELINE_DEFINITION.stages.map((stage) => stage.stageKey)) {
263
- expect(CrmTransitionItemRequestSchema.safeParse({ ...valid, stageKey }).success).toBe(true)
264
- }
265
-
266
- for (const stateKey of CRM_PIPELINE_DEFINITION.stages.flatMap((stage) =>
267
- stage.states.map((state) => state.stateKey)
268
- )) {
269
- expect(CrmTransitionItemRequestSchema.safeParse({ ...valid, stateKey }).success).toBe(true)
270
- }
271
- })
272
-
273
- it('accepts generic non-empty pipeline keys but rejects unknown CRM stage/state keys', () => {
359
+
360
+ describe('CrmTransitionItemRequestSchema', () => {
361
+ const valid = {
362
+ pipelineKey: 'crm',
363
+ stageKey: 'interested',
364
+ stateKey: null
365
+ }
366
+
367
+ it('accepts catalog-derived CRM stage and state keys', () => {
368
+ for (const stageKey of CRM_PIPELINE_DEFINITION.stages.map((stage) => stage.stageKey)) {
369
+ expect(CrmTransitionItemRequestSchema.safeParse({ ...valid, stageKey }).success).toBe(true)
370
+ }
371
+
372
+ for (const stateKey of CRM_PIPELINE_DEFINITION.stages.flatMap((stage) =>
373
+ stage.states.map((state) => state.stateKey)
374
+ )) {
375
+ expect(CrmTransitionItemRequestSchema.safeParse({ ...valid, stateKey }).success).toBe(true)
376
+ }
377
+ })
378
+
379
+ // Transport contract: CrmStageKeySchema/CrmStateKeySchema are now z.string().trim().min(1).
380
+ // Unknown non-empty stage/state keys are accepted at the schema layer; closed-catalog
381
+ // membership is enforced caller-side via model-injected validators.
382
+ it('accepts generic non-empty pipeline keys and any non-empty CRM stage/state keys (transport contract)', () => {
274
383
  expect(CrmTransitionItemRequestSchema.safeParse({ ...valid, pipelineKey: 'lead-gen' }).success).toBe(true)
275
- expect(CrmTransitionItemRequestSchema.safeParse({ ...valid, stageKey: 'unknown_stage' }).success).toBe(false)
276
- expect(CrmTransitionItemRequestSchema.safeParse({ ...valid, stateKey: 'unknown_state' }).success).toBe(false)
277
- })
278
- })
279
-
280
- // ---------------------------------------------------------------------------
281
- // TransitionDealStateRequestSchema
282
- // ---------------------------------------------------------------------------
283
-
284
- describe('TransitionDealStateRequestSchema', () => {
285
- it('accepts catalog-derived CRM state keys', () => {
286
- for (const stateKey of CRM_PIPELINE_DEFINITION.stages.flatMap((stage) =>
287
- stage.states.map((state) => state.stateKey)
288
- )) {
289
- expect(TransitionDealStateRequestSchema.safeParse({ stateKey }).success).toBe(true)
290
- }
291
- })
292
-
293
- it('rejects unknown state values', () => {
294
- expect(TransitionDealStateRequestSchema.safeParse({ stateKey: 'unknown_state' }).success).toBe(false)
295
- expect(TransitionDealStateRequestSchema.safeParse({ stateKey: '' }).success).toBe(false)
296
- })
297
-
298
- it('preserves strict request schema behavior', () => {
299
- expect(
300
- TransitionDealStateRequestSchema.safeParse({
301
- stateKey: CRM_PIPELINE_DEFINITION.stages[0]?.states[0]?.stateKey,
302
- extra: 'x'
303
- }).success
304
- ).toBe(false)
305
- })
306
- })
307
-
308
- // ---------------------------------------------------------------------------
309
- // ExecuteActionRequestSchema
310
- // ---------------------------------------------------------------------------
311
-
312
- describe('ExecuteActionRequestSchema', () => {
313
- it('accepts an empty object (payload is optional)', () => {
314
- expect(ExecuteActionRequestSchema.safeParse({}).success).toBe(true)
315
- })
316
-
317
- it('accepts a payload record with arbitrary string keys', () => {
318
- const result = ExecuteActionRequestSchema.safeParse({
319
- payload: { channel: 'email', count: 5, nested: { ok: true } }
320
- })
321
- expect(result.success).toBe(true)
322
- })
323
-
324
- it('accepts an empty payload record', () => {
325
- expect(ExecuteActionRequestSchema.safeParse({ payload: {} }).success).toBe(true)
326
- })
327
-
328
- it('rejects unknown top-level fields (strict mode)', () => {
329
- const result = ExecuteActionRequestSchema.safeParse({ payload: {}, extra: 'bad' })
330
- expect(result.success).toBe(false)
331
- })
332
- })
333
-
334
- // ---------------------------------------------------------------------------
335
- // CreateDealNoteRequestSchema
336
- // ---------------------------------------------------------------------------
337
-
338
- describe('CreateDealNoteRequestSchema', () => {
339
- it('accepts a valid body', () => {
340
- expect(CreateDealNoteRequestSchema.safeParse({ body: 'Hello' }).success).toBe(true)
341
- })
342
-
343
- it('accepts a single character after trim', () => {
344
- expect(CreateDealNoteRequestSchema.safeParse({ body: 'a' }).success).toBe(true)
345
- })
346
-
347
- it('trims whitespace and accepts content that remains non-empty', () => {
348
- const result = CreateDealNoteRequestSchema.safeParse({ body: ' note ' })
349
- expect(result.success).toBe(true)
350
- if (result.success) expect(result.data.body).toBe('note')
351
- })
352
-
353
- it('rejects a whitespace-only string (empty after trim)', () => {
354
- expect(CreateDealNoteRequestSchema.safeParse({ body: ' ' }).success).toBe(false)
355
- })
356
-
357
- it('rejects an empty string', () => {
358
- expect(CreateDealNoteRequestSchema.safeParse({ body: '' }).success).toBe(false)
359
- })
360
-
361
- it('accepts a body at the max length boundary (10000 chars)', () => {
362
- expect(CreateDealNoteRequestSchema.safeParse({ body: 'a'.repeat(10000) }).success).toBe(true)
363
- })
364
-
365
- it('rejects a body exceeding the max length (10001 chars)', () => {
366
- expect(CreateDealNoteRequestSchema.safeParse({ body: 'a'.repeat(10001) }).success).toBe(false)
367
- })
368
-
369
- it('rejects unknown top-level fields (strict mode)', () => {
370
- expect(CreateDealNoteRequestSchema.safeParse({ body: 'note', extra: 'x' }).success).toBe(false)
371
- })
372
- })
373
-
374
- // ---------------------------------------------------------------------------
375
- // CreateDealTaskRequestSchema
376
- // ---------------------------------------------------------------------------
377
-
378
- describe('CreateDealTaskRequestSchema', () => {
379
- const valid = { title: 'Follow up call' }
380
-
381
- it('accepts a minimal valid payload (title only)', () => {
382
- expect(CreateDealTaskRequestSchema.safeParse(valid).success).toBe(true)
383
- })
384
-
385
- it('kind is optional and accepts all valid values', () => {
386
- for (const kind of ['call', 'email', 'meeting', 'other'] as const) {
387
- expect(CreateDealTaskRequestSchema.safeParse({ ...valid, kind }).success).toBe(true)
388
- }
389
- })
390
-
391
- it('rejects an invalid kind value', () => {
392
- expect(CreateDealTaskRequestSchema.safeParse({ ...valid, kind: 'sms' }).success).toBe(false)
393
- })
394
-
395
- it('accepts a valid ISO datetime for dueAt', () => {
396
- expect(CreateDealTaskRequestSchema.safeParse({ ...valid, dueAt: ISO_TS }).success).toBe(true)
397
- })
398
-
399
- it('rejects an invalid datetime for dueAt', () => {
400
- expect(CreateDealTaskRequestSchema.safeParse({ ...valid, dueAt: 'tomorrow' }).success).toBe(false)
401
- })
402
-
403
- it('accepts null for dueAt', () => {
404
- expect(CreateDealTaskRequestSchema.safeParse({ ...valid, dueAt: null }).success).toBe(true)
405
- })
406
-
407
- it('accepts a valid UUID for assigneeUserId', () => {
408
- expect(CreateDealTaskRequestSchema.safeParse({ ...valid, assigneeUserId: VALID_UUID }).success).toBe(true)
409
- })
410
-
411
- it('rejects a non-UUID for assigneeUserId', () => {
412
- expect(CreateDealTaskRequestSchema.safeParse({ ...valid, assigneeUserId: 'not-a-uuid' }).success).toBe(false)
413
- })
414
-
415
- it('accepts null for assigneeUserId', () => {
416
- expect(CreateDealTaskRequestSchema.safeParse({ ...valid, assigneeUserId: null }).success).toBe(true)
417
- })
418
-
419
- it('rejects an empty title', () => {
420
- expect(CreateDealTaskRequestSchema.safeParse({ title: '' }).success).toBe(false)
421
- })
422
-
423
- it('rejects a title exceeding 255 chars', () => {
424
- expect(CreateDealTaskRequestSchema.safeParse({ title: 'a'.repeat(256) }).success).toBe(false)
425
- })
426
-
427
- it('rejects unknown top-level fields (strict mode)', () => {
428
- expect(CreateDealTaskRequestSchema.safeParse({ ...valid, bogus: true }).success).toBe(false)
429
- })
430
- })
431
-
432
- // ---------------------------------------------------------------------------
433
- // ListDealsQuerySchema
434
- // ---------------------------------------------------------------------------
435
-
436
- describe('ListDealsQuerySchema', () => {
437
- it('accepts an empty query (all defaults applied)', () => {
438
- const result = ListDealsQuerySchema.safeParse({})
439
- expect(result.success).toBe(true)
440
- if (result.success) {
441
- expect(result.data.limit).toBe(50)
442
- expect(result.data.offset).toBe(0)
443
- }
444
- })
445
-
446
- it('coerces limit from string "50" to number 50', () => {
447
- const result = ListDealsQuerySchema.safeParse({ limit: '50' })
448
- expect(result.success).toBe(true)
449
- if (result.success) expect(result.data.limit).toBe(50)
450
- })
451
-
452
- it('coerces offset from string "20" to number 20', () => {
453
- const result = ListDealsQuerySchema.safeParse({ offset: '20' })
454
- expect(result.success).toBe(true)
455
- if (result.success) expect(result.data.offset).toBe(20)
456
- })
457
-
458
- it('rejects non-numeric string for limit', () => {
459
- expect(ListDealsQuerySchema.safeParse({ limit: 'abc' }).success).toBe(false)
460
- })
461
-
462
- it('rejects non-numeric string for offset', () => {
463
- expect(ListDealsQuerySchema.safeParse({ offset: 'abc' }).success).toBe(false)
464
- })
465
-
466
- it('rejects zero or negative limit (must be positive)', () => {
467
- expect(ListDealsQuerySchema.safeParse({ limit: '0' }).success).toBe(false)
468
- expect(ListDealsQuerySchema.safeParse({ limit: '-1' }).success).toBe(false)
469
- })
470
-
471
- it('rejects a negative offset', () => {
472
- expect(ListDealsQuerySchema.safeParse({ offset: '-1' }).success).toBe(false)
473
- })
474
-
475
- it('accepts zero offset', () => {
476
- expect(ListDealsQuerySchema.safeParse({ offset: '0' }).success).toBe(true)
477
- })
478
-
479
- it('accepts a valid stage filter with search and pagination', () => {
480
- const result = ListDealsQuerySchema.safeParse({
481
- stage: 'interested',
482
- search: 'acme',
483
- limit: '25',
484
- offset: '10'
485
- })
486
- expect(result.success).toBe(true)
487
- if (result.success) {
488
- expect(result.data.stage).toBe('interested')
489
- expect(result.data.search).toBe('acme')
490
- expect(result.data.limit).toBe(25)
491
- expect(result.data.offset).toBe(10)
492
- }
493
- })
494
-
495
- it('rejects an invalid stage value', () => {
496
- expect(ListDealsQuerySchema.safeParse({ stage: 'pipeline' }).success).toBe(false)
497
- })
498
-
499
- it('rejects unknown query fields (strict mode)', () => {
500
- expect(ListDealsQuerySchema.safeParse({ unknownParam: 'x' }).success).toBe(false)
501
- })
502
- })
503
-
504
- // ---------------------------------------------------------------------------
505
- // UpdateContactRequestSchema
506
- // ---------------------------------------------------------------------------
507
-
508
- describe('UpdateContactRequestSchema', () => {
509
- it('rejects an object with no fields provided', () => {
510
- const result = UpdateContactRequestSchema.safeParse({})
511
- expect(result.success).toBe(false)
512
- })
513
-
514
- it('accepts a single field: firstName', () => {
515
- expect(UpdateContactRequestSchema.safeParse({ firstName: 'Alice' }).success).toBe(true)
516
- })
517
-
518
- it('accepts a single field: emailValid', () => {
519
- for (const v of ['VALID', 'INVALID', 'RISKY', 'UNKNOWN'] as const) {
520
- expect(UpdateContactRequestSchema.safeParse({ emailValid: v }).success).toBe(true)
521
- }
522
- })
523
-
524
- it('rejects an invalid emailValid value', () => {
525
- expect(UpdateContactRequestSchema.safeParse({ emailValid: 'maybe' }).success).toBe(false)
526
- })
527
-
528
- it('accepts a valid UUID for companyId', () => {
529
- expect(UpdateContactRequestSchema.safeParse({ companyId: VALID_UUID }).success).toBe(true)
530
- })
531
-
532
- it('rejects a non-UUID for companyId', () => {
533
- expect(UpdateContactRequestSchema.safeParse({ companyId: 'bad-id' }).success).toBe(false)
534
- })
535
-
536
- it('accepts a valid LinkedIn URL', () => {
537
- expect(UpdateContactRequestSchema.safeParse({ linkedinUrl: 'https://linkedin.com/in/alice' }).success).toBe(true)
538
- })
539
-
540
- it('rejects a non-URL for linkedinUrl', () => {
541
- expect(UpdateContactRequestSchema.safeParse({ linkedinUrl: 'not-a-url' }).success).toBe(false)
542
- })
543
-
544
- it('accepts multiple fields at once', () => {
545
- expect(UpdateContactRequestSchema.safeParse({ firstName: 'Alice', lastName: 'Smith', title: 'CEO' }).success).toBe(
546
- true
547
- )
548
- })
549
-
384
+ expect(CrmTransitionItemRequestSchema.safeParse({ ...valid, stageKey: 'unknown_stage' }).success).toBe(true)
385
+ expect(CrmTransitionItemRequestSchema.safeParse({ ...valid, stateKey: 'unknown_state' }).success).toBe(true)
386
+ })
387
+
388
+ it('still rejects empty stage and state keys (transport minimum length)', () => {
389
+ expect(CrmTransitionItemRequestSchema.safeParse({ ...valid, stageKey: '' }).success).toBe(false)
390
+ expect(CrmTransitionItemRequestSchema.safeParse({ ...valid, stateKey: '' }).success).toBe(false)
391
+ })
392
+ })
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // TransitionDealStateRequestSchema
396
+ // ---------------------------------------------------------------------------
397
+
398
+ describe('TransitionDealStateRequestSchema', () => {
399
+ it('accepts catalog-derived CRM state keys', () => {
400
+ for (const stateKey of CRM_PIPELINE_DEFINITION.stages.flatMap((stage) =>
401
+ stage.states.map((state) => state.stateKey)
402
+ )) {
403
+ expect(TransitionDealStateRequestSchema.safeParse({ stateKey }).success).toBe(true)
404
+ }
405
+ })
406
+
407
+ // Transport contract: CrmStateKeySchema is now z.string().trim().min(1).
408
+ // Unknown non-empty state keys are accepted at the schema layer; closed-catalog
409
+ // enforcement is caller-side via model-injected validators.
410
+ it('accepts any non-empty state string (transport contract), rejects empty', () => {
411
+ expect(TransitionDealStateRequestSchema.safeParse({ stateKey: 'unknown_state' }).success).toBe(true)
412
+ expect(TransitionDealStateRequestSchema.safeParse({ stateKey: '' }).success).toBe(false)
413
+ })
414
+
415
+ it('preserves strict request schema behavior', () => {
416
+ expect(
417
+ TransitionDealStateRequestSchema.safeParse({
418
+ stateKey: CRM_PIPELINE_DEFINITION.stages[0]?.states[0]?.stateKey,
419
+ extra: 'x'
420
+ }).success
421
+ ).toBe(false)
422
+ })
423
+ })
424
+
425
+ // ---------------------------------------------------------------------------
426
+ // ExecuteActionRequestSchema
427
+ // ---------------------------------------------------------------------------
428
+
429
+ describe('ExecuteActionRequestSchema', () => {
430
+ it('accepts an empty object (payload is optional)', () => {
431
+ expect(ExecuteActionRequestSchema.safeParse({}).success).toBe(true)
432
+ })
433
+
434
+ it('accepts a payload record with arbitrary string keys', () => {
435
+ const result = ExecuteActionRequestSchema.safeParse({
436
+ payload: { channel: 'email', count: 5, nested: { ok: true } }
437
+ })
438
+ expect(result.success).toBe(true)
439
+ })
440
+
441
+ it('accepts an empty payload record', () => {
442
+ expect(ExecuteActionRequestSchema.safeParse({ payload: {} }).success).toBe(true)
443
+ })
444
+
445
+ it('rejects unknown top-level fields (strict mode)', () => {
446
+ const result = ExecuteActionRequestSchema.safeParse({ payload: {}, extra: 'bad' })
447
+ expect(result.success).toBe(false)
448
+ })
449
+ })
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // CreateDealNoteRequestSchema
453
+ // ---------------------------------------------------------------------------
454
+
455
+ describe('CreateDealNoteRequestSchema', () => {
456
+ it('accepts a valid body', () => {
457
+ expect(CreateDealNoteRequestSchema.safeParse({ body: 'Hello' }).success).toBe(true)
458
+ })
459
+
460
+ it('accepts a single character after trim', () => {
461
+ expect(CreateDealNoteRequestSchema.safeParse({ body: 'a' }).success).toBe(true)
462
+ })
463
+
464
+ it('trims whitespace and accepts content that remains non-empty', () => {
465
+ const result = CreateDealNoteRequestSchema.safeParse({ body: ' note ' })
466
+ expect(result.success).toBe(true)
467
+ if (result.success) expect(result.data.body).toBe('note')
468
+ })
469
+
470
+ it('rejects a whitespace-only string (empty after trim)', () => {
471
+ expect(CreateDealNoteRequestSchema.safeParse({ body: ' ' }).success).toBe(false)
472
+ })
473
+
474
+ it('rejects an empty string', () => {
475
+ expect(CreateDealNoteRequestSchema.safeParse({ body: '' }).success).toBe(false)
476
+ })
477
+
478
+ it('accepts a body at the max length boundary (10000 chars)', () => {
479
+ expect(CreateDealNoteRequestSchema.safeParse({ body: 'a'.repeat(10000) }).success).toBe(true)
480
+ })
481
+
482
+ it('rejects a body exceeding the max length (10001 chars)', () => {
483
+ expect(CreateDealNoteRequestSchema.safeParse({ body: 'a'.repeat(10001) }).success).toBe(false)
484
+ })
485
+
486
+ it('rejects unknown top-level fields (strict mode)', () => {
487
+ expect(CreateDealNoteRequestSchema.safeParse({ body: 'note', extra: 'x' }).success).toBe(false)
488
+ })
489
+ })
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // CreateDealTaskRequestSchema
493
+ // ---------------------------------------------------------------------------
494
+
495
+ describe('CreateDealTaskRequestSchema', () => {
496
+ const valid = { title: 'Follow up call' }
497
+
498
+ it('accepts a minimal valid payload (title only)', () => {
499
+ expect(CreateDealTaskRequestSchema.safeParse(valid).success).toBe(true)
500
+ })
501
+
502
+ it('kind is optional and accepts all valid values', () => {
503
+ for (const kind of ['call', 'email', 'meeting', 'other'] as const) {
504
+ expect(CreateDealTaskRequestSchema.safeParse({ ...valid, kind }).success).toBe(true)
505
+ }
506
+ })
507
+
508
+ it('rejects an invalid kind value', () => {
509
+ expect(CreateDealTaskRequestSchema.safeParse({ ...valid, kind: 'sms' }).success).toBe(false)
510
+ })
511
+
512
+ it('accepts a valid ISO datetime for dueAt', () => {
513
+ expect(CreateDealTaskRequestSchema.safeParse({ ...valid, dueAt: ISO_TS }).success).toBe(true)
514
+ })
515
+
516
+ it('rejects an invalid datetime for dueAt', () => {
517
+ expect(CreateDealTaskRequestSchema.safeParse({ ...valid, dueAt: 'tomorrow' }).success).toBe(false)
518
+ })
519
+
520
+ it('accepts null for dueAt', () => {
521
+ expect(CreateDealTaskRequestSchema.safeParse({ ...valid, dueAt: null }).success).toBe(true)
522
+ })
523
+
524
+ it('accepts a valid UUID for assigneeUserId', () => {
525
+ expect(CreateDealTaskRequestSchema.safeParse({ ...valid, assigneeUserId: VALID_UUID }).success).toBe(true)
526
+ })
527
+
528
+ it('rejects a non-UUID for assigneeUserId', () => {
529
+ expect(CreateDealTaskRequestSchema.safeParse({ ...valid, assigneeUserId: 'not-a-uuid' }).success).toBe(false)
530
+ })
531
+
532
+ it('accepts null for assigneeUserId', () => {
533
+ expect(CreateDealTaskRequestSchema.safeParse({ ...valid, assigneeUserId: null }).success).toBe(true)
534
+ })
535
+
536
+ it('rejects an empty title', () => {
537
+ expect(CreateDealTaskRequestSchema.safeParse({ title: '' }).success).toBe(false)
538
+ })
539
+
540
+ it('rejects a title exceeding 255 chars', () => {
541
+ expect(CreateDealTaskRequestSchema.safeParse({ title: 'a'.repeat(256) }).success).toBe(false)
542
+ })
543
+
544
+ it('rejects unknown top-level fields (strict mode)', () => {
545
+ expect(CreateDealTaskRequestSchema.safeParse({ ...valid, bogus: true }).success).toBe(false)
546
+ })
547
+ })
548
+
549
+ // ---------------------------------------------------------------------------
550
+ // ListDealsQuerySchema
551
+ // ---------------------------------------------------------------------------
552
+
553
+ describe('ListDealsQuerySchema', () => {
554
+ it('accepts an empty query (all defaults applied)', () => {
555
+ const result = ListDealsQuerySchema.safeParse({})
556
+ expect(result.success).toBe(true)
557
+ if (result.success) {
558
+ expect(result.data.limit).toBe(50)
559
+ expect(result.data.offset).toBe(0)
560
+ }
561
+ })
562
+
563
+ it('coerces limit from string "50" to number 50', () => {
564
+ const result = ListDealsQuerySchema.safeParse({ limit: '50' })
565
+ expect(result.success).toBe(true)
566
+ if (result.success) expect(result.data.limit).toBe(50)
567
+ })
568
+
569
+ it('coerces offset from string "20" to number 20', () => {
570
+ const result = ListDealsQuerySchema.safeParse({ offset: '20' })
571
+ expect(result.success).toBe(true)
572
+ if (result.success) expect(result.data.offset).toBe(20)
573
+ })
574
+
575
+ it('rejects non-numeric string for limit', () => {
576
+ expect(ListDealsQuerySchema.safeParse({ limit: 'abc' }).success).toBe(false)
577
+ })
578
+
579
+ it('rejects non-numeric string for offset', () => {
580
+ expect(ListDealsQuerySchema.safeParse({ offset: 'abc' }).success).toBe(false)
581
+ })
582
+
583
+ it('rejects zero or negative limit (must be positive)', () => {
584
+ expect(ListDealsQuerySchema.safeParse({ limit: '0' }).success).toBe(false)
585
+ expect(ListDealsQuerySchema.safeParse({ limit: '-1' }).success).toBe(false)
586
+ })
587
+
588
+ it('rejects a negative offset', () => {
589
+ expect(ListDealsQuerySchema.safeParse({ offset: '-1' }).success).toBe(false)
590
+ })
591
+
592
+ it('accepts zero offset', () => {
593
+ expect(ListDealsQuerySchema.safeParse({ offset: '0' }).success).toBe(true)
594
+ })
595
+
596
+ it('accepts a valid stage filter with search and pagination', () => {
597
+ const result = ListDealsQuerySchema.safeParse({
598
+ stage: 'interested',
599
+ search: 'acme',
600
+ limit: '25',
601
+ offset: '10'
602
+ })
603
+ expect(result.success).toBe(true)
604
+ if (result.success) {
605
+ expect(result.data.stage).toBe('interested')
606
+ expect(result.data.search).toBe('acme')
607
+ expect(result.data.limit).toBe(25)
608
+ expect(result.data.offset).toBe(10)
609
+ }
610
+ })
611
+
612
+ // Transport contract: DealStageSchema is now z.string().trim().min(1).
613
+ // Any non-empty stage string is accepted; closed-catalog enforcement is caller-side.
614
+ it('accepts any non-empty stage string (transport contract), rejects empty', () => {
615
+ expect(ListDealsQuerySchema.safeParse({ stage: 'pipeline' }).success).toBe(true)
616
+ expect(ListDealsQuerySchema.safeParse({ stage: '' }).success).toBe(false)
617
+ })
618
+
619
+ it('rejects unknown query fields (strict mode)', () => {
620
+ expect(ListDealsQuerySchema.safeParse({ unknownParam: 'x' }).success).toBe(false)
621
+ })
622
+ })
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // UpdateContactRequestSchema
626
+ // ---------------------------------------------------------------------------
627
+
628
+ describe('UpdateContactRequestSchema', () => {
629
+ it('rejects an object with no fields provided', () => {
630
+ const result = UpdateContactRequestSchema.safeParse({})
631
+ expect(result.success).toBe(false)
632
+ })
633
+
634
+ it('accepts a single field: firstName', () => {
635
+ expect(UpdateContactRequestSchema.safeParse({ firstName: 'Alice' }).success).toBe(true)
636
+ })
637
+
638
+ it('accepts a single field: emailValid', () => {
639
+ for (const v of ['VALID', 'INVALID', 'RISKY', 'UNKNOWN'] as const) {
640
+ expect(UpdateContactRequestSchema.safeParse({ emailValid: v }).success).toBe(true)
641
+ }
642
+ })
643
+
644
+ it('rejects an invalid emailValid value', () => {
645
+ expect(UpdateContactRequestSchema.safeParse({ emailValid: 'maybe' }).success).toBe(false)
646
+ })
647
+
648
+ it('accepts a valid UUID for companyId', () => {
649
+ expect(UpdateContactRequestSchema.safeParse({ companyId: VALID_UUID }).success).toBe(true)
650
+ })
651
+
652
+ it('rejects a non-UUID for companyId', () => {
653
+ expect(UpdateContactRequestSchema.safeParse({ companyId: 'bad-id' }).success).toBe(false)
654
+ })
655
+
656
+ it('accepts a valid LinkedIn URL', () => {
657
+ expect(UpdateContactRequestSchema.safeParse({ linkedinUrl: 'https://linkedin.com/in/alice' }).success).toBe(true)
658
+ })
659
+
660
+ it('rejects a non-URL for linkedinUrl', () => {
661
+ expect(UpdateContactRequestSchema.safeParse({ linkedinUrl: 'not-a-url' }).success).toBe(false)
662
+ })
663
+
664
+ it('accepts multiple fields at once', () => {
665
+ expect(UpdateContactRequestSchema.safeParse({ firstName: 'Alice', lastName: 'Smith', title: 'CEO' }).success).toBe(
666
+ true
667
+ )
668
+ })
669
+
550
670
  it('accepts processingState keyed by the stage catalog', () => {
551
671
  const result = UpdateContactRequestSchema.safeParse({
552
672
  processingState: {
@@ -554,938 +674,939 @@ describe('UpdateContactRequestSchema', () => {
554
674
  status: 'no_result'
555
675
  }
556
676
  }
557
- })
558
-
559
- expect(result.success).toBe(true)
560
- })
561
-
562
- it('accepts deprecated pipelineStatus as a compatibility no-op', () => {
563
- expect(UpdateContactRequestSchema.safeParse({ pipelineStatus: 'emailed' }).success).toBe(true)
564
- })
565
-
566
- it('rejects unknown top-level fields (strict mode)', () => {
567
- expect(UpdateContactRequestSchema.safeParse({ firstName: 'Alice', unknown: 'x' }).success).toBe(false)
568
- })
569
-
570
- it('rejects an empty string for firstName (min 1 after trim)', () => {
571
- expect(UpdateContactRequestSchema.safeParse({ firstName: '' }).success).toBe(false)
572
- })
573
-
574
- it('rejects a firstName exceeding 255 chars', () => {
575
- expect(UpdateContactRequestSchema.safeParse({ firstName: 'a'.repeat(256) }).success).toBe(false)
576
- })
577
- })
578
-
579
- // ---------------------------------------------------------------------------
580
- // CRM priority override contract
581
- // ---------------------------------------------------------------------------
582
-
583
- describe('CrmPriorityOverrideSchema', () => {
584
- it('accepts a valid partial organization override', () => {
585
- const result = CrmPriorityOverrideSchema.safeParse({
586
- enabled: true,
587
- staleAfterDays: 10,
588
- bucketOrder: ['needs_response', 'follow_up_due', 'stale', 'waiting', 'closed_low'],
589
- buckets: {
590
- needs_response: { label: 'Reply Now', color: 'red', rank: 5 },
591
- waiting: { label: 'Pending' }
592
- },
593
- followUpAfterDaysByStateKey: {
594
- discovery_link_sent: 2,
595
- custom_state: 4
596
- },
597
- closedStageKeys: ['closed_won', 'closed_lost']
598
- })
599
-
600
- expect(result.success).toBe(true)
601
- })
602
-
603
- it('rejects needsResponseStateKeys and needsResponseActivityTypes (removed fields)', () => {
604
- expect(CrmPriorityOverrideSchema.safeParse({ needsResponseStateKeys: ['x'] }).success).toBe(false)
605
- expect(CrmPriorityOverrideSchema.safeParse({ needsResponseActivityTypes: ['x'] }).success).toBe(false)
606
- })
607
-
608
- it('accepts sparse partial overrides', () => {
609
- const result = CrmPriorityOverrideSchema.safeParse({
610
- staleAfterDays: 21,
611
- buckets: {
612
- stale: { color: 'yellow' }
613
- }
614
- })
615
-
616
- expect(result.success).toBe(true)
617
- })
618
-
619
- it('rejects invalid unknown input shapes', () => {
620
- expect(CrmPriorityOverrideSchema.safeParse('bad').success).toBe(false)
621
- expect(CrmPriorityOverrideSchema.safeParse({ staleAfterDays: -1 }).success).toBe(false)
622
- expect(CrmPriorityOverrideSchema.safeParse({ buckets: { invalid_bucket: { label: 'Bad' } } }).success).toBe(false)
623
- })
624
- })
625
-
626
- describe('resolveCrmPriorityRuleConfig', () => {
627
- it('merges valid organization config overrides with default rules', () => {
628
- const resolved = resolveCrmPriorityRuleConfig({
629
- crm: {
630
- priority: {
631
- staleAfterDays: 7,
632
- bucketOrder: ['stale', 'needs_response'],
633
- buckets: {
634
- stale: { label: 'Dormant', color: 'yellow' },
635
- needs_response: { rank: 3 }
636
- },
637
- followUpAfterDaysByStateKey: { discovery_link_sent: 1 },
638
- closedStageKeys: ['won', 'lost']
639
- }
640
- }
641
- })
642
-
643
- expect(resolved.enabled).toBe(true)
644
- expect(resolved.staleAfterDays).toBe(7)
645
- expect(resolved.closedStageKeys).toEqual(['won', 'lost'])
646
- expect(resolved.followUpAfterDaysByStateKey.discovery_link_sent).toBe(1)
647
- expect(resolved.followUpAfterDaysByStateKey.reply_sent).toBe(
648
- DEFAULT_CRM_PRIORITY_RULE_CONFIG.followUpAfterDaysByStateKey.reply_sent
649
- )
650
-
651
- const staleBucket = resolved.buckets.find((bucket) => bucket.bucketKey === 'stale')
652
- expect(staleBucket).toMatchObject({ label: 'Dormant', color: 'yellow', rank: 10 })
653
-
654
- const needsResponseBucket = resolved.buckets.find((bucket) => bucket.bucketKey === 'needs_response')
655
- expect(needsResponseBucket?.rank).toBe(3)
656
- })
657
-
658
- it('falls back to defaults for missing or invalid input', () => {
659
- expect(resolveCrmPriorityRuleConfig(undefined)).toMatchObject({
660
- enabled: true,
661
- staleAfterDays: DEFAULT_CRM_PRIORITY_RULE_CONFIG.staleAfterDays,
662
- closedStageKeys: DEFAULT_CRM_PRIORITY_RULE_CONFIG.closedStageKeys
663
- })
664
-
665
- expect(resolveCrmPriorityRuleConfig({ staleAfterDays: 0 })).toMatchObject({
666
- enabled: true,
667
- staleAfterDays: DEFAULT_CRM_PRIORITY_RULE_CONFIG.staleAfterDays,
668
- closedStageKeys: DEFAULT_CRM_PRIORITY_RULE_CONFIG.closedStageKeys
669
- })
670
- })
671
- })
672
-
673
- // ---------------------------------------------------------------------------
674
- // Response schemas — smoke-level forward-compat checks
675
- // ---------------------------------------------------------------------------
676
-
677
- describe('evaluateCrmDealPriority', () => {
678
- const now = '2026-04-30T12:00:00.000Z'
679
- const baseInput = {
680
- stage_key: 'interested',
681
- state_key: null,
682
- activity_log: [],
683
- updated_at: '2026-04-29T12:00:00.000Z',
684
- created_at: '2026-04-01T12:00:00.000Z'
685
- }
686
-
687
- it('returns needs_response when the lead replied last (ownership us)', () => {
688
- // A reply_received inbound event makes ownership === 'us' → needs_response
689
- const priority = evaluateCrmDealPriority(
690
- {
691
- ...baseInput,
692
- state_key: 'discovery_replied',
693
- activity_log: [{ type: 'reply_received', timestamp: '2026-04-29T10:00:00.000Z' }]
694
- },
695
- { now }
696
- )
697
- expect(priority.bucketKey).toBe('needs_response')
698
- expect(priority.rank).toBe(10)
699
- expect(priority.reason).toBe('Lead replied last — we owe the next move.')
700
- })
701
-
702
- it('returns follow_up_due when configured follow-up window has elapsed', () => {
703
- const priority = evaluateCrmDealPriority(
704
- {
705
- ...baseInput,
706
- state_key: 'discovery_link_sent',
707
- activity_log: [{ type: 'action_taken', timestamp: '2026-04-25T12:00:00.000Z', actionKey: 'send_link' }],
708
- updated_at: '2026-04-25T12:00:00.000Z'
709
- },
710
- { now }
711
- )
712
-
713
- expect(priority.bucketKey).toBe('follow_up_due')
714
- expect(priority.nextActionAt).toBe('2026-04-28T12:00:00.000Z')
715
- })
716
-
717
- it('returns waiting when the next action is still in the future', () => {
718
- const priority = evaluateCrmDealPriority(
719
- {
720
- ...baseInput,
721
- state_key: 'discovery_link_sent',
722
- activity_log: [{ type: 'action_taken', timestamp: '2026-04-29T12:00:00.000Z', actionKey: 'send_link' }],
723
- updated_at: '2026-04-29T12:00:00.000Z'
724
- },
725
- { now }
726
- )
727
-
728
- expect(priority.bucketKey).toBe('waiting')
729
- expect(priority.nextActionAt).toBe('2026-05-02T12:00:00.000Z')
730
- })
731
-
732
- it('returns stale when there is no recent meaningful activity', () => {
733
- const priority = evaluateCrmDealPriority(
734
- { ...baseInput, updated_at: '2026-04-01T12:00:00.000Z', created_at: '2026-04-01T12:00:00.000Z' },
735
- { now }
736
- )
737
-
738
- expect(priority.bucketKey).toBe('stale')
739
- })
740
-
741
- it('returns closed_low for closed stages', () => {
742
- const priority = evaluateCrmDealPriority({ ...baseInput, stage_key: 'closed_lost' }, { now })
743
- expect(priority.bucketKey).toBe('closed_low')
744
- expect(priority.rank).toBe(50)
745
- })
746
-
747
- it('ignores malformed activity entries without throwing', () => {
748
- const priority = evaluateCrmDealPriority(
749
- {
750
- ...baseInput,
751
- activity_log: [null, 'bad', { type: 'reply_received', timestamp: 'not-a-date' }, { other: true }],
752
- updated_at: '2026-04-29T12:00:00.000Z'
753
- },
754
- { now }
755
- )
756
-
757
- expect(priority.bucketKey).toBe('waiting')
758
- expect(priority.latestActivityAt).toBe('2026-04-29T12:00:00.000Z')
759
- })
760
-
761
- it('returns a neutral waiting priority when CRM priority is disabled', () => {
762
- const config = resolveCrmPriorityRuleConfig({
763
- enabled: false,
764
- buckets: {
765
- waiting: { label: 'Priority Off', color: 'gray', rank: 999 }
766
- }
767
- })
768
- const priority = evaluateCrmDealPriority(
769
- {
770
- ...baseInput,
771
- state_key: 'discovery_replied',
772
- activity_log: [{ type: 'reply_received', timestamp: '2026-04-30T10:00:00.000Z' }]
773
- },
774
- { config, now }
775
- )
776
-
777
- expect(priority).toMatchObject({
778
- bucketKey: 'waiting',
779
- label: 'Priority Off',
780
- color: 'gray',
781
- rank: 999,
782
- reason: 'CRM priority evaluation is disabled.',
783
- nextActionAt: null
784
- })
785
- })
786
- })
787
-
788
- describe('DealListItemSchema', () => {
789
- it('accepts a deal with a derived priority object', () => {
790
- const result = DealListItemSchema.safeParse({
791
- id: VALID_UUID,
792
- organization_id: VALID_UUID,
793
- contact_id: null,
794
- contact_email: 'test@example.com',
795
- pipeline_key: 'crm',
796
- stage_key: 'interested',
797
- state_key: 'discovery_link_sent',
798
- activity_log: [],
799
- discovery_data: null,
800
- discovery_submitted_at: null,
801
- discovery_submitted_by: null,
802
- proposal_data: null,
803
- proposal_sent_at: null,
804
- proposal_pdf_url: null,
805
- signature_envelope_id: null,
806
- source_list_id: null,
807
- source_type: null,
808
- initial_fee: null,
809
- monthly_fee: null,
810
- closed_lost_at: null,
811
- closed_lost_reason: null,
812
- created_at: ISO_TS,
813
- updated_at: ISO_TS,
814
- priority: PRIORITY,
815
- ownership: null,
816
- nextAction: null,
817
- contact: null
818
- })
819
-
820
- expect(result.success).toBe(true)
821
- })
822
- })
823
-
824
- describe('DealDetailResponseSchema (forward-compat)', () => {
825
- const baseDeal = {
826
- id: VALID_UUID,
827
- organization_id: VALID_UUID,
828
- contact_id: null,
829
- contact_email: 'test@example.com',
830
- pipeline_key: 'crm',
831
- stage_key: null,
832
- state_key: null,
833
- activity_log: [],
834
- discovery_data: null,
835
- discovery_submitted_at: null,
836
- discovery_submitted_by: null,
837
- proposal_data: null,
838
- proposal_sent_at: null,
839
- proposal_pdf_url: null,
840
- signature_envelope_id: null,
841
- source_list_id: null,
842
- source_type: null,
843
- initial_fee: null,
844
- monthly_fee: null,
845
- closed_lost_at: null,
846
- closed_lost_reason: null,
847
- created_at: ISO_TS,
848
- updated_at: ISO_TS,
849
- priority: PRIORITY,
850
- ownership: null,
851
- nextAction: null,
852
- contact: null,
853
- conversation: {
854
- messages: []
855
- }
856
- }
857
-
858
- it('accepts a deal with null contact and an empty conversation', () => {
859
- expect(DealDetailResponseSchema.safeParse(baseDeal).success).toBe(true)
860
- })
861
-
862
- it('accepts conversation messages with preview-derived bodies', () => {
863
- const withConversation = {
864
- ...baseDeal,
865
- conversation: {
866
- messages: [
867
- {
868
- id: 'message-1',
869
- direction: 'inbound',
870
- fromEmail: 'lead@example.com',
871
- toEmail: 'sender@example.com',
872
- subject: 'Re: quick thought',
873
- body: 'Sure, send it over.',
874
- sentAt: ISO_TS
875
- }
876
- ]
877
- }
878
- }
879
- expect(DealDetailResponseSchema.safeParse(withConversation).success).toBe(true)
880
- })
881
-
882
- it('accepts a deal with nested contact that has null company', () => {
883
- const withContact = {
884
- ...baseDeal,
885
- contact: {
886
- id: VALID_UUID,
887
- first_name: 'Alice',
888
- last_name: 'Smith',
889
- email: 'alice@example.com',
890
- title: null,
891
- headline: null,
892
- linkedin_url: null,
893
- processing_state: null,
894
- enrichment_data: null,
895
- company: null
896
- }
897
- }
898
- expect(DealDetailResponseSchema.safeParse(withContact).success).toBe(true)
899
- })
900
-
901
- it('accepts thin client lineage refs on deal detail responses', () => {
902
- expect(
903
- DealDetailResponseSchema.safeParse({
904
- ...baseDeal,
905
- client_id: VALID_UUID,
906
- lineage: {
907
- list: null,
908
- projects: [],
909
- client: {
910
- id: VALID_UUID,
911
- name: 'Acme Client',
912
- status: 'active'
913
- }
914
- }
915
- }).success
916
- ).toBe(true)
917
- })
918
-
919
- it('accepts extra unknown fields at top level (not strict)', () => {
920
- expect(DealDetailResponseSchema.safeParse({ ...baseDeal, futureField: 'value' }).success).toBe(true)
921
- })
922
- })
923
-
924
- describe('DealListResponseSchema', () => {
925
- it('accepts a valid paginated list response', () => {
926
- const result = DealListResponseSchema.safeParse({
927
- data: [],
928
- total: 0,
929
- limit: 50,
930
- offset: 0
931
- })
932
- expect(result.success).toBe(true)
933
- })
934
-
935
- it('rejects non-integer total', () => {
936
- expect(DealListResponseSchema.safeParse({ data: [], total: 1.5, limit: 50, offset: 0 }).success).toBe(false)
937
- })
938
- })
939
-
940
- describe('DealNoteResponseSchema', () => {
941
- it('accepts a valid note response', () => {
942
- const result = DealNoteResponseSchema.safeParse({
943
- id: VALID_UUID,
944
- dealId: VALID_UUID,
945
- organizationId: VALID_UUID,
946
- authorUserId: null,
947
- body: 'A note',
948
- createdAt: ISO_TS,
949
- updatedAt: ISO_TS
950
- })
951
- expect(result.success).toBe(true)
952
- })
953
-
954
- it('accepts extra fields (not strict)', () => {
955
- const result = DealNoteResponseSchema.safeParse({
956
- id: VALID_UUID,
957
- dealId: VALID_UUID,
958
- organizationId: VALID_UUID,
959
- authorUserId: null,
960
- body: 'A note',
961
- createdAt: ISO_TS,
962
- updatedAt: ISO_TS,
963
- futureField: 'ignored'
964
- })
965
- expect(result.success).toBe(true)
966
- })
967
- })
968
-
969
- describe('DealTaskResponseSchema', () => {
970
- it('accepts a valid task response', () => {
971
- const result = DealTaskResponseSchema.safeParse({
972
- id: VALID_UUID,
973
- organizationId: VALID_UUID,
974
- dealId: VALID_UUID,
975
- title: 'Call prospect',
976
- description: null,
977
- kind: 'call',
978
- dueAt: null,
979
- assigneeUserId: null,
980
- completedAt: null,
981
- completedByUserId: null,
982
- createdAt: ISO_TS,
983
- updatedAt: ISO_TS,
984
- createdByUserId: null
985
- })
986
- expect(result.success).toBe(true)
987
- })
988
-
989
- it('rejects an invalid kind in task response', () => {
990
- const result = DealTaskResponseSchema.safeParse({
991
- id: VALID_UUID,
992
- organizationId: VALID_UUID,
993
- dealId: VALID_UUID,
994
- title: 'Call',
995
- description: null,
996
- kind: 'text_message',
997
- dueAt: null,
998
- assigneeUserId: null,
999
- completedAt: null,
1000
- completedByUserId: null,
1001
- createdAt: ISO_TS,
1002
- updatedAt: ISO_TS,
1003
- createdByUserId: null
1004
- })
1005
- expect(result.success).toBe(false)
1006
- })
1007
- })
1008
-
1009
- // ---------------------------------------------------------------------------
1010
- // ListStatusSchema
1011
- // ---------------------------------------------------------------------------
1012
-
1013
- describe('ListStatusSchema', () => {
1014
- it.each(['draft', 'enriching', 'launched', 'closing', 'archived'])('accepts "%s"', (status) => {
1015
- expect(ListStatusSchema.safeParse(status).success).toBe(true)
1016
- })
1017
-
1018
- it('rejects unknown status', () => {
1019
- expect(ListStatusSchema.safeParse('active').success).toBe(false)
1020
- })
1021
- })
1022
-
1023
- // ---------------------------------------------------------------------------
1024
- // PipelineStageSchema
1025
- // ---------------------------------------------------------------------------
1026
-
1027
- describe('PipelineStageSchema', () => {
1028
- it('accepts non-empty model-owned stage keys', () => {
1029
- expect(PipelineStageSchema.safeParse({ key: VALID_COMPANY_STAGE_KEY }).success).toBe(true)
1030
- expect(PipelineStageSchema.safeParse({ key: 'tenant-custom-stage' }).success).toBe(true)
677
+ })
678
+
679
+ expect(result.success).toBe(true)
1031
680
  })
1032
681
 
1033
- it('rejects an empty stage key', () => {
1034
- const result = PipelineStageSchema.safeParse({ key: '' })
1035
- expect(result.success).toBe(false)
682
+ it('accepts deprecated pipelineStatus as a compatibility no-op', () => {
683
+ expect(UpdateContactRequestSchema.safeParse({ pipelineStatus: 'emailed' }).success).toBe(true)
1036
684
  })
1037
-
1038
- it('accepts optional label, enabled, and order fields', () => {
1039
- expect(PipelineStageSchema.safeParse({ key: 'scraped', label: 'Scraped', enabled: true, order: 1 }).success).toBe(
1040
- true
1041
- )
1042
- })
1043
- })
1044
-
1045
- // ---------------------------------------------------------------------------
1046
- // BuildPlanSnapshotSchema
1047
- // ---------------------------------------------------------------------------
1048
-
1049
- describe('BuildPlanSnapshotSchema', () => {
1050
- const validSnapshot = createBuildPlanSnapshotFromTemplateId('dtc-subscription-apollo-clickup')
1051
-
1052
- it('accepts a snapshot generated from a prospecting build template', () => {
1053
- expect(validSnapshot).not.toBeNull()
1054
- expect(BuildPlanSnapshotSchema.safeParse(validSnapshot).success).toBe(true)
1055
- })
1056
-
1057
- it('accepts custom non-empty step stage keys at the transport boundary', () => {
1058
- const result = BuildPlanSnapshotSchema.safeParse({
1059
- ...validSnapshot,
1060
- steps: [{ ...validSnapshot!.steps[0], stageKey: 'made-up-stage' }]
685
+
686
+ it('rejects unknown top-level fields (strict mode)', () => {
687
+ expect(UpdateContactRequestSchema.safeParse({ firstName: 'Alice', unknown: 'x' }).success).toBe(false)
688
+ })
689
+
690
+ it('rejects an empty string for firstName (min 1 after trim)', () => {
691
+ expect(UpdateContactRequestSchema.safeParse({ firstName: '' }).success).toBe(false)
692
+ })
693
+
694
+ it('rejects a firstName exceeding 255 chars', () => {
695
+ expect(UpdateContactRequestSchema.safeParse({ firstName: 'a'.repeat(256) }).success).toBe(false)
696
+ })
697
+ })
698
+
699
+ // ---------------------------------------------------------------------------
700
+ // CRM priority override contract
701
+ // ---------------------------------------------------------------------------
702
+
703
+ describe('CrmPriorityOverrideSchema', () => {
704
+ it('accepts a valid partial organization override', () => {
705
+ const result = CrmPriorityOverrideSchema.safeParse({
706
+ enabled: true,
707
+ staleAfterDays: 10,
708
+ bucketOrder: ['needs_response', 'follow_up_due', 'stale', 'waiting', 'closed_low'],
709
+ buckets: {
710
+ needs_response: { label: 'Reply Now', color: 'red', rank: 5 },
711
+ waiting: { label: 'Pending' }
712
+ },
713
+ followUpAfterDaysByStateKey: {
714
+ discovery_link_sent: 2,
715
+ custom_state: 4
716
+ },
717
+ closedStageKeys: ['closed_won', 'closed_lost']
1061
718
  })
1062
719
 
1063
720
  expect(result.success).toBe(true)
1064
721
  })
1065
722
 
1066
- it('accepts custom non-empty action keys at the transport boundary', () => {
1067
- const result = BuildPlanSnapshotSchema.safeParse({
1068
- ...validSnapshot,
1069
- steps: [{ ...validSnapshot!.steps[0], actionKey: 'lead-gen.missing.action' }]
723
+ it('rejects needsResponseStateKeys and needsResponseActivityTypes (removed fields)', () => {
724
+ expect(CrmPriorityOverrideSchema.safeParse({ needsResponseStateKeys: ['x'] }).success).toBe(false)
725
+ expect(CrmPriorityOverrideSchema.safeParse({ needsResponseActivityTypes: ['x'] }).success).toBe(false)
726
+ })
727
+
728
+ it('accepts sparse partial overrides', () => {
729
+ const result = CrmPriorityOverrideSchema.safeParse({
730
+ staleAfterDays: 21,
731
+ buckets: {
732
+ stale: { color: 'yellow' }
733
+ }
1070
734
  })
1071
735
 
1072
736
  expect(result.success).toBe(true)
1073
737
  })
1074
-
1075
- it('rejects duplicate step ids', () => {
1076
- const first = validSnapshot!.steps[0]!
1077
- const second = validSnapshot!.steps[1]!
1078
- const rest = validSnapshot!.steps.slice(2)
1079
- const result = BuildPlanSnapshotSchema.safeParse({
1080
- ...validSnapshot,
1081
- steps: [first, { ...second, id: first.id }, ...rest]
1082
- })
1083
-
1084
- expect(result.success).toBe(false)
1085
- })
1086
-
1087
- it('rejects dependsOn references to unknown step ids', () => {
1088
- const result = BuildPlanSnapshotSchema.safeParse({
1089
- ...validSnapshot,
1090
- steps: [{ ...validSnapshot!.steps[0], dependsOn: ['missing-step'] }]
1091
- })
1092
-
1093
- expect(result.success).toBe(false)
1094
- })
1095
- })
1096
-
1097
- // ---------------------------------------------------------------------------
1098
- // ScrapingConfigSchema
1099
- // ---------------------------------------------------------------------------
1100
-
1101
- describe('ScrapingConfigSchema', () => {
1102
- it('accepts an empty object (all fields optional)', () => {
1103
- expect(ScrapingConfigSchema.safeParse({}).success).toBe(true)
1104
- })
1105
-
1106
- it('accepts a fully populated config', () => {
1107
- expect(
1108
- ScrapingConfigSchema.safeParse({
1109
- vertical: 'SaaS',
1110
- geography: 'USA',
1111
- size: '10-50',
1112
- apifyInput: { actorId: 'test' }
1113
- }).success
1114
- ).toBe(true)
1115
- })
1116
-
1117
- it('rejects a vertical exceeding 255 chars', () => {
1118
- expect(ScrapingConfigSchema.safeParse({ vertical: 'a'.repeat(256) }).success).toBe(false)
1119
- })
1120
- })
1121
-
1122
- // ---------------------------------------------------------------------------
1123
- // IcpRubricSchema
1124
- // ---------------------------------------------------------------------------
1125
-
1126
- describe('IcpRubricSchema', () => {
1127
- it('accepts an empty object', () => {
1128
- expect(IcpRubricSchema.safeParse({}).success).toBe(true)
1129
- })
1130
-
1131
- it('accepts valid minRating boundary values 0 and 5', () => {
1132
- expect(IcpRubricSchema.safeParse({ minRating: 0 }).success).toBe(true)
1133
- expect(IcpRubricSchema.safeParse({ minRating: 5 }).success).toBe(true)
1134
- })
1135
-
1136
- it('rejects minRating above 5', () => {
1137
- expect(IcpRubricSchema.safeParse({ minRating: 5.1 }).success).toBe(false)
1138
- })
1139
-
1140
- it('rejects negative minRating', () => {
1141
- expect(IcpRubricSchema.safeParse({ minRating: -1 }).success).toBe(false)
1142
- })
1143
- })
1144
-
1145
- // ---------------------------------------------------------------------------
1146
- // CreateListRequestSchema
1147
- // ---------------------------------------------------------------------------
1148
-
1149
- describe('CreateListRequestSchema', () => {
1150
- it('accepts a minimal valid payload (name only)', () => {
1151
- expect(CreateListRequestSchema.safeParse({ name: 'My List' }).success).toBe(true)
1152
- })
1153
-
1154
- it('rejects an empty name', () => {
1155
- expect(CreateListRequestSchema.safeParse({ name: '' }).success).toBe(false)
1156
- })
1157
-
1158
- it('rejects a name exceeding 255 chars', () => {
1159
- expect(CreateListRequestSchema.safeParse({ name: 'a'.repeat(256) }).success).toBe(false)
1160
- })
1161
-
1162
- it('accepts an optional status from ListStatusSchema', () => {
1163
- expect(CreateListRequestSchema.safeParse({ name: 'X', status: 'draft' }).success).toBe(true)
1164
- })
1165
-
1166
- it('accepts a known prospecting build template id', () => {
1167
- const result = CreateListRequestSchema.safeParse({
1168
- name: 'DTC Subscription Brands',
1169
- buildTemplateId: 'dtc-subscription-apollo-clickup'
1170
- })
1171
-
1172
- expect(result.success).toBe(true)
1173
- })
1174
-
1175
- it('rejects an unknown prospecting build template id', () => {
1176
- expect(CreateListRequestSchema.safeParse({ name: 'X', buildTemplateId: 'not-a-template' }).success).toBe(false)
1177
- })
1178
-
1179
- it('rejects an invalid status', () => {
1180
- expect(CreateListRequestSchema.safeParse({ name: 'X', status: 'active' }).success).toBe(false)
1181
- })
1182
-
1183
- it('rejects unknown fields (strict mode)', () => {
1184
- expect(CreateListRequestSchema.safeParse({ name: 'X', bogus: true }).success).toBe(false)
1185
- })
1186
-
1187
- it('accepts nested scrapingConfig, icp, and pipelineConfig', () => {
1188
- expect(
1189
- CreateListRequestSchema.safeParse({
1190
- name: 'SaaS List',
1191
- scrapingConfig: { vertical: 'SaaS' },
1192
- icp: { minReviewCount: 5 },
1193
- pipelineConfig: { stages: [] }
1194
- }).success
1195
- ).toBe(true)
1196
- })
1197
- })
1198
-
1199
- // ---------------------------------------------------------------------------
1200
- // UpdateListRequestSchema
1201
- // ---------------------------------------------------------------------------
1202
-
1203
- describe('UpdateListRequestSchema', () => {
1204
- it('rejects an object with none of the required fields', () => {
1205
- const result = UpdateListRequestSchema.safeParse({})
1206
- expect(result.success).toBe(false)
1207
- })
1208
-
1209
- it('accepts providing only name', () => {
1210
- expect(UpdateListRequestSchema.safeParse({ name: 'New Name' }).success).toBe(true)
1211
- })
1212
-
1213
- it('accepts providing only description', () => {
1214
- expect(UpdateListRequestSchema.safeParse({ description: 'Desc' }).success).toBe(true)
1215
- })
1216
-
1217
- it('accepts providing only batchIds', () => {
1218
- expect(UpdateListRequestSchema.safeParse({ batchIds: ['batch-1'] }).success).toBe(true)
1219
- })
1220
-
1221
- it('accepts buildTemplateId when the change is explicitly confirmed', () => {
1222
- expect(
1223
- UpdateListRequestSchema.safeParse({
1224
- buildTemplateId: 'dtc-subscription-apollo-clickup',
1225
- confirmBuildTemplateChange: true
1226
- }).success
1227
- ).toBe(true)
1228
- })
1229
-
1230
- it('rejects buildTemplateId without explicit confirmation', () => {
1231
- expect(
1232
- UpdateListRequestSchema.safeParse({
1233
- buildTemplateId: 'dtc-subscription-apollo-clickup'
1234
- }).success
1235
- ).toBe(false)
1236
- })
1237
-
1238
- it('rejects unknown fields (strict mode)', () => {
1239
- expect(UpdateListRequestSchema.safeParse({ name: 'X', extra: 'bad' }).success).toBe(false)
1240
- })
1241
- })
1242
-
1243
- // ---------------------------------------------------------------------------
1244
- // UpdateListStatusRequestSchema
1245
- // ---------------------------------------------------------------------------
1246
-
1247
- describe('UpdateListStatusRequestSchema', () => {
1248
- it('accepts a valid status', () => {
1249
- expect(UpdateListStatusRequestSchema.safeParse({ status: 'launched' }).success).toBe(true)
1250
- })
1251
-
1252
- it('rejects an invalid status', () => {
1253
- expect(UpdateListStatusRequestSchema.safeParse({ status: 'active' }).success).toBe(false)
1254
- })
1255
-
1256
- it('rejects missing status field', () => {
1257
- expect(UpdateListStatusRequestSchema.safeParse({}).success).toBe(false)
1258
- })
1259
-
1260
- it('rejects unknown fields (strict mode)', () => {
1261
- expect(UpdateListStatusRequestSchema.safeParse({ status: 'draft', extra: 'x' }).success).toBe(false)
1262
- })
1263
- })
1264
-
1265
- // ---------------------------------------------------------------------------
1266
- // UpdateListConfigRequestSchema
1267
- // ---------------------------------------------------------------------------
1268
-
1269
- describe('UpdateListConfigRequestSchema', () => {
1270
- it('rejects an object with none of the config fields', () => {
1271
- expect(UpdateListConfigRequestSchema.safeParse({}).success).toBe(false)
1272
- })
1273
-
1274
- it('accepts providing only scrapingConfig', () => {
1275
- expect(UpdateListConfigRequestSchema.safeParse({ scrapingConfig: { vertical: 'SaaS' } }).success).toBe(true)
1276
- })
1277
-
1278
- it('accepts providing only icp', () => {
1279
- expect(UpdateListConfigRequestSchema.safeParse({ icp: { minReviewCount: 3 } }).success).toBe(true)
1280
- })
1281
-
1282
- it('accepts providing only pipelineConfig', () => {
1283
- expect(UpdateListConfigRequestSchema.safeParse({ pipelineConfig: { stages: [] } }).success).toBe(true)
1284
- })
1285
-
1286
- it('rejects unknown fields (strict mode)', () => {
1287
- expect(UpdateListConfigRequestSchema.safeParse({ scrapingConfig: {}, bogus: true }).success).toBe(false)
1288
- })
1289
- })
1290
-
1291
- // ---------------------------------------------------------------------------
1292
- // AddCompaniesToListRequestSchema / AddContactsToListRequestSchema
1293
- // ---------------------------------------------------------------------------
1294
-
1295
- describe('AddCompaniesToListRequestSchema', () => {
1296
- it('accepts a valid list with one UUID', () => {
1297
- expect(AddCompaniesToListRequestSchema.safeParse({ companyIds: [VALID_UUID] }).success).toBe(true)
1298
- })
1299
-
1300
- it('rejects an empty array (min 1)', () => {
1301
- expect(AddCompaniesToListRequestSchema.safeParse({ companyIds: [] }).success).toBe(false)
1302
- })
1303
-
1304
- it('rejects more than 1000 IDs (max 1000)', () => {
1305
- const ids = Array.from({ length: 1001 }, (_, i) => `00000000-0000-0000-0000-${String(i).padStart(12, '0')}`)
1306
- expect(AddCompaniesToListRequestSchema.safeParse({ companyIds: ids }).success).toBe(false)
1307
- })
1308
-
1309
- it('rejects non-UUID entries', () => {
1310
- expect(AddCompaniesToListRequestSchema.safeParse({ companyIds: ['not-a-uuid'] }).success).toBe(false)
1311
- })
1312
- })
1313
-
1314
- describe('AddContactsToListRequestSchema', () => {
1315
- it('accepts a valid list with one UUID', () => {
1316
- expect(AddContactsToListRequestSchema.safeParse({ contactIds: [VALID_UUID] }).success).toBe(true)
1317
- })
1318
-
1319
- it('rejects an empty array (min 1)', () => {
1320
- expect(AddContactsToListRequestSchema.safeParse({ contactIds: [] }).success).toBe(false)
1321
- })
1322
- })
1323
-
1324
- // ---------------------------------------------------------------------------
1325
- // ListCompaniesQuerySchema
1326
- // ---------------------------------------------------------------------------
1327
-
1328
- describe('ListCompaniesQuerySchema', () => {
1329
- it('accepts an empty query (defaults applied)', () => {
1330
- const result = ListCompaniesQuerySchema.safeParse({})
1331
- expect(result.success).toBe(true)
1332
- if (result.success) {
1333
- expect(result.data.limit).toBe(50)
1334
- expect(result.data.offset).toBe(0)
1335
- }
1336
- })
1337
-
1338
- it('coerces limit from string "100" to number 100', () => {
1339
- const result = ListCompaniesQuerySchema.safeParse({ limit: '100' })
1340
- expect(result.success).toBe(true)
1341
- if (result.success) expect(result.data.limit).toBe(100)
1342
- })
1343
-
1344
- it('rejects limit exceeding 5000', () => {
1345
- expect(ListCompaniesQuerySchema.safeParse({ limit: '5001' }).success).toBe(false)
1346
- })
1347
-
1348
- it('rejects limit less than 1', () => {
1349
- expect(ListCompaniesQuerySchema.safeParse({ limit: '0' }).success).toBe(false)
1350
- })
1351
-
1352
- it('accepts a valid status filter', () => {
1353
- expect(ListCompaniesQuerySchema.safeParse({ status: 'active' }).success).toBe(true)
1354
- expect(ListCompaniesQuerySchema.safeParse({ status: 'invalid' }).success).toBe(true)
1355
- })
1356
-
1357
- it('rejects an invalid status', () => {
1358
- expect(ListCompaniesQuerySchema.safeParse({ status: 'pending' }).success).toBe(false)
1359
- })
1360
-
1361
- it('accepts includeAll as boolean-like strings', () => {
1362
- const r1 = ListCompaniesQuerySchema.safeParse({ includeAll: 'true' })
1363
- expect(r1.success).toBe(true)
1364
- if (r1.success) expect(r1.data.includeAll).toBe(true)
1365
-
1366
- const r2 = ListCompaniesQuerySchema.safeParse({ includeAll: 'false' })
1367
- expect(r2.success).toBe(true)
1368
- if (r2.success) expect(r2.data.includeAll).toBe(false)
1369
- })
1370
-
1371
- it('rejects unknown fields (strict mode)', () => {
1372
- expect(ListCompaniesQuerySchema.safeParse({ unknownField: 'x' }).success).toBe(false)
1373
- })
1374
- })
1375
-
1376
- // ---------------------------------------------------------------------------
1377
- // ListContactsQuerySchema
1378
- // ---------------------------------------------------------------------------
1379
-
1380
- describe('ListContactsQuerySchema', () => {
1381
- it('accepts an empty query with defaults', () => {
1382
- const result = ListContactsQuerySchema.safeParse({})
1383
- expect(result.success).toBe(true)
1384
- if (result.success) {
1385
- expect(result.data.limit).toBe(5000)
1386
- expect(result.data.offset).toBe(0)
1387
- }
1388
- })
1389
-
1390
- it('accepts openingLineIsNull as "true"', () => {
1391
- const result = ListContactsQuerySchema.safeParse({ openingLineIsNull: 'true' })
1392
- expect(result.success).toBe(true)
1393
- if (result.success) expect(result.data.openingLineIsNull).toBe(true)
1394
- })
1395
- })
1396
-
1397
- // ---------------------------------------------------------------------------
1398
- // ListDealTasksDueQuerySchema
1399
- // ---------------------------------------------------------------------------
1400
-
1401
- describe('ListDealTasksDueQuerySchema', () => {
1402
- it('accepts an empty query', () => {
1403
- expect(ListDealTasksDueQuerySchema.safeParse({}).success).toBe(true)
1404
- })
1405
-
1406
- it.each(['overdue', 'today', 'today_and_overdue', 'upcoming'])('accepts window "%s"', (window) => {
1407
- expect(ListDealTasksDueQuerySchema.safeParse({ window }).success).toBe(true)
1408
- })
1409
-
1410
- it('rejects an invalid window value', () => {
1411
- expect(ListDealTasksDueQuerySchema.safeParse({ window: 'this_week' }).success).toBe(false)
1412
- })
1413
-
1414
- it('accepts a valid UUID for assigneeUserId', () => {
1415
- expect(ListDealTasksDueQuerySchema.safeParse({ assigneeUserId: VALID_UUID }).success).toBe(true)
1416
- })
1417
-
1418
- it('rejects unknown fields (strict mode)', () => {
1419
- expect(ListDealTasksDueQuerySchema.safeParse({ window: 'today', extra: 'x' }).success).toBe(false)
1420
- })
1421
- })
1422
-
1423
- // ---------------------------------------------------------------------------
1424
- // CreateCompanyRequestSchema
1425
- // ---------------------------------------------------------------------------
1426
-
1427
- describe('CreateCompanyRequestSchema', () => {
1428
- it('accepts a minimal payload (name only)', () => {
1429
- expect(CreateCompanyRequestSchema.safeParse({ name: 'Acme' }).success).toBe(true)
1430
- })
1431
-
1432
- it('rejects an empty name', () => {
1433
- expect(CreateCompanyRequestSchema.safeParse({ name: '' }).success).toBe(false)
1434
- })
1435
-
1436
- it('rejects a non-URL for linkedinUrl', () => {
1437
- expect(CreateCompanyRequestSchema.safeParse({ name: 'Acme', linkedinUrl: 'linkedin.com/co/acme' }).success).toBe(
1438
- false
1439
- )
1440
- })
1441
-
1442
- it('accepts a valid URL for linkedinUrl', () => {
1443
- expect(
1444
- CreateCompanyRequestSchema.safeParse({ name: 'Acme', linkedinUrl: 'https://linkedin.com/company/acme' }).success
1445
- ).toBe(true)
1446
- })
1447
-
1448
- it('rejects unknown fields (strict mode)', () => {
1449
- expect(CreateCompanyRequestSchema.safeParse({ name: 'Acme', bogus: true }).success).toBe(false)
1450
- })
1451
- })
1452
-
1453
- // ---------------------------------------------------------------------------
1454
- // UpdateCompanyRequestSchema
1455
- // ---------------------------------------------------------------------------
1456
-
1457
- describe('UpdateCompanyRequestSchema', () => {
1458
- it('rejects an object with no fields (at-least-one refine)', () => {
1459
- expect(UpdateCompanyRequestSchema.safeParse({}).success).toBe(false)
1460
- })
1461
-
1462
- it('accepts providing only name', () => {
1463
- expect(UpdateCompanyRequestSchema.safeParse({ name: 'Acme Corp' }).success).toBe(true)
1464
- })
1465
-
1466
- it('accepts providing only status', () => {
1467
- expect(UpdateCompanyRequestSchema.safeParse({ status: 'active' }).success).toBe(true)
1468
- expect(UpdateCompanyRequestSchema.safeParse({ status: 'invalid' }).success).toBe(true)
1469
- })
1470
-
1471
- it('accepts processingState keyed by the stage catalog', () => {
1472
- const result = UpdateCompanyRequestSchema.safeParse({
1473
- processingState: {
1474
- [VALID_COMPANY_STAGE_KEY]: {
1475
- status: 'success',
1476
- data: { score: 92 }
738
+
739
+ it('rejects invalid unknown input shapes', () => {
740
+ expect(CrmPriorityOverrideSchema.safeParse('bad').success).toBe(false)
741
+ expect(CrmPriorityOverrideSchema.safeParse({ staleAfterDays: -1 }).success).toBe(false)
742
+ expect(CrmPriorityOverrideSchema.safeParse({ buckets: { invalid_bucket: { label: 'Bad' } } }).success).toBe(false)
743
+ })
744
+ })
745
+
746
+ describe('resolveCrmPriorityRuleConfig', () => {
747
+ it('merges valid organization config overrides with generic core rules', () => {
748
+ const resolved = resolveCrmPriorityRuleConfig({
749
+ crm: {
750
+ priority: {
751
+ staleAfterDays: 7,
752
+ bucketOrder: ['stale', 'needs_response'],
753
+ buckets: {
754
+ stale: { label: 'Dormant', color: 'yellow' },
755
+ needs_response: { rank: 3 }
756
+ },
757
+ followUpAfterDaysByStateKey: { discovery_link_sent: 1 },
758
+ closedStageKeys: ['won', 'lost']
1477
759
  }
1478
- }
1479
- })
1480
-
1481
- expect(result.success).toBe(true)
1482
- })
1483
-
1484
- it('accepts deprecated pipelineStatus as a compatibility no-op', () => {
1485
- expect(UpdateCompanyRequestSchema.safeParse({ pipelineStatus: 'emailed' }).success).toBe(true)
1486
- })
1487
-
1488
- it('rejects empty processingState keys', () => {
760
+ }
761
+ })
762
+
763
+ expect(resolved.enabled).toBe(true)
764
+ expect(resolved.staleAfterDays).toBe(7)
765
+ expect(resolved.closedStageKeys).toEqual(['won', 'lost'])
766
+ expect(resolved.followUpAfterDaysByStateKey.discovery_link_sent).toBe(1)
767
+ expect(resolved.followUpAfterDaysByStateKey.reply_sent).toBeUndefined()
768
+
769
+ const staleBucket = resolved.buckets.find((bucket) => bucket.bucketKey === 'stale')
770
+ expect(staleBucket).toMatchObject({ label: 'Dormant', color: 'yellow', rank: 10 })
771
+
772
+ const needsResponseBucket = resolved.buckets.find((bucket) => bucket.bucketKey === 'needs_response')
773
+ expect(needsResponseBucket?.rank).toBe(3)
774
+ })
775
+
776
+ it('falls back to generic defaults for missing or invalid input', () => {
777
+ expect(resolveCrmPriorityRuleConfig(undefined)).toMatchObject({
778
+ enabled: true,
779
+ staleAfterDays: DEFAULT_CRM_PRIORITY_RULE_CONFIG.staleAfterDays,
780
+ closedStageKeys: []
781
+ })
782
+
783
+ expect(resolveCrmPriorityRuleConfig({ staleAfterDays: 0 })).toMatchObject({
784
+ enabled: true,
785
+ staleAfterDays: DEFAULT_CRM_PRIORITY_RULE_CONFIG.staleAfterDays,
786
+ closedStageKeys: []
787
+ })
788
+ })
789
+ })
790
+
791
+ // ---------------------------------------------------------------------------
792
+ // Response schemas — smoke-level forward-compat checks
793
+ // ---------------------------------------------------------------------------
794
+
795
+ describe('evaluateCrmDealPriority', () => {
796
+ const now = '2026-04-30T12:00:00.000Z'
797
+ const baseInput = {
798
+ stage_key: 'interested',
799
+ state_key: null,
800
+ activity_log: [],
801
+ updated_at: '2026-04-29T12:00:00.000Z',
802
+ created_at: '2026-04-01T12:00:00.000Z'
803
+ }
804
+
805
+ it('returns needs_response when the lead replied last (ownership us)', () => {
806
+ // A reply_received inbound event makes ownership === 'us' → needs_response
807
+ const priority = evaluateCrmDealPriority(
808
+ {
809
+ ...baseInput,
810
+ state_key: 'discovery_replied',
811
+ activity_log: [{ type: 'reply_received', timestamp: '2026-04-29T10:00:00.000Z' }]
812
+ },
813
+ { now, config: DEFAULT_CRM_PRIORITY_RULE_CONFIG }
814
+ )
815
+ expect(priority.bucketKey).toBe('needs_response')
816
+ expect(priority.rank).toBe(10)
817
+ expect(priority.reason).toBe('Lead replied last — we owe the next move.')
818
+ })
819
+
820
+ it('returns follow_up_due when configured follow-up window has elapsed', () => {
821
+ const priority = evaluateCrmDealPriority(
822
+ {
823
+ ...baseInput,
824
+ state_key: 'discovery_link_sent',
825
+ activity_log: [{ type: 'action_taken', timestamp: '2026-04-25T12:00:00.000Z', actionKey: 'send_link' }],
826
+ updated_at: '2026-04-25T12:00:00.000Z'
827
+ },
828
+ { now, config: DEFAULT_CRM_PRIORITY_RULE_CONFIG }
829
+ )
830
+
831
+ expect(priority.bucketKey).toBe('follow_up_due')
832
+ expect(priority.nextActionAt).toBe('2026-04-28T12:00:00.000Z')
833
+ })
834
+
835
+ it('returns waiting when the next action is still in the future', () => {
836
+ const priority = evaluateCrmDealPriority(
837
+ {
838
+ ...baseInput,
839
+ state_key: 'discovery_link_sent',
840
+ activity_log: [{ type: 'action_taken', timestamp: '2026-04-29T12:00:00.000Z', actionKey: 'send_link' }],
841
+ updated_at: '2026-04-29T12:00:00.000Z'
842
+ },
843
+ { now, config: DEFAULT_CRM_PRIORITY_RULE_CONFIG }
844
+ )
845
+
846
+ expect(priority.bucketKey).toBe('waiting')
847
+ expect(priority.nextActionAt).toBe('2026-05-02T12:00:00.000Z')
848
+ })
849
+
850
+ it('returns stale when there is no recent meaningful activity', () => {
851
+ const priority = evaluateCrmDealPriority(
852
+ { ...baseInput, updated_at: '2026-04-01T12:00:00.000Z', created_at: '2026-04-01T12:00:00.000Z' },
853
+ { now, config: DEFAULT_CRM_PRIORITY_RULE_CONFIG }
854
+ )
855
+
856
+ expect(priority.bucketKey).toBe('stale')
857
+ })
858
+
859
+ it('returns closed_low for closed stages', () => {
860
+ const priority = evaluateCrmDealPriority(
861
+ { ...baseInput, stage_key: 'closed_lost' },
862
+ { now, config: DEFAULT_CRM_PRIORITY_RULE_CONFIG }
863
+ )
864
+ expect(priority.bucketKey).toBe('closed_low')
865
+ expect(priority.rank).toBe(50)
866
+ })
867
+
868
+ it('ignores malformed activity entries without throwing', () => {
869
+ const priority = evaluateCrmDealPriority(
870
+ {
871
+ ...baseInput,
872
+ activity_log: [null, 'bad', { type: 'reply_received', timestamp: 'not-a-date' }, { other: true }],
873
+ updated_at: '2026-04-29T12:00:00.000Z'
874
+ },
875
+ { now }
876
+ )
877
+
878
+ expect(priority.bucketKey).toBe('waiting')
879
+ expect(priority.latestActivityAt).toBe('2026-04-29T12:00:00.000Z')
880
+ })
881
+
882
+ it('returns a neutral waiting priority when CRM priority is disabled', () => {
883
+ const config = resolveCrmPriorityRuleConfig({
884
+ enabled: false,
885
+ buckets: {
886
+ waiting: { label: 'Priority Off', color: 'gray', rank: 999 }
887
+ }
888
+ })
889
+ const priority = evaluateCrmDealPriority(
890
+ {
891
+ ...baseInput,
892
+ state_key: 'discovery_replied',
893
+ activity_log: [{ type: 'reply_received', timestamp: '2026-04-30T10:00:00.000Z' }]
894
+ },
895
+ { config, now }
896
+ )
897
+
898
+ expect(priority).toMatchObject({
899
+ bucketKey: 'waiting',
900
+ label: 'Priority Off',
901
+ color: 'gray',
902
+ rank: 999,
903
+ reason: 'CRM priority evaluation is disabled.',
904
+ nextActionAt: null
905
+ })
906
+ })
907
+ })
908
+
909
+ describe('DealListItemSchema', () => {
910
+ it('accepts a deal with a derived priority object', () => {
911
+ const result = DealListItemSchema.safeParse({
912
+ id: VALID_UUID,
913
+ organization_id: VALID_UUID,
914
+ contact_id: null,
915
+ contact_email: 'test@example.com',
916
+ pipeline_key: 'crm',
917
+ stage_key: 'interested',
918
+ state_key: 'discovery_link_sent',
919
+ activity_log: [],
920
+ discovery_data: null,
921
+ discovery_submitted_at: null,
922
+ discovery_submitted_by: null,
923
+ proposal_data: null,
924
+ proposal_sent_at: null,
925
+ proposal_pdf_url: null,
926
+ signature_envelope_id: null,
927
+ source_list_id: null,
928
+ source_type: null,
929
+ initial_fee: null,
930
+ monthly_fee: null,
931
+ closed_lost_at: null,
932
+ closed_lost_reason: null,
933
+ created_at: ISO_TS,
934
+ updated_at: ISO_TS,
935
+ priority: PRIORITY,
936
+ ownership: null,
937
+ nextAction: null,
938
+ contact: null
939
+ })
940
+
941
+ expect(result.success).toBe(true)
942
+ })
943
+ })
944
+
945
+ describe('DealDetailResponseSchema (forward-compat)', () => {
946
+ const baseDeal = {
947
+ id: VALID_UUID,
948
+ organization_id: VALID_UUID,
949
+ contact_id: null,
950
+ contact_email: 'test@example.com',
951
+ pipeline_key: 'crm',
952
+ stage_key: null,
953
+ state_key: null,
954
+ activity_log: [],
955
+ discovery_data: null,
956
+ discovery_submitted_at: null,
957
+ discovery_submitted_by: null,
958
+ proposal_data: null,
959
+ proposal_sent_at: null,
960
+ proposal_pdf_url: null,
961
+ signature_envelope_id: null,
962
+ source_list_id: null,
963
+ source_type: null,
964
+ initial_fee: null,
965
+ monthly_fee: null,
966
+ closed_lost_at: null,
967
+ closed_lost_reason: null,
968
+ created_at: ISO_TS,
969
+ updated_at: ISO_TS,
970
+ priority: PRIORITY,
971
+ ownership: null,
972
+ nextAction: null,
973
+ contact: null,
974
+ conversation: {
975
+ messages: []
976
+ }
977
+ }
978
+
979
+ it('accepts a deal with null contact and an empty conversation', () => {
980
+ expect(DealDetailResponseSchema.safeParse(baseDeal).success).toBe(true)
981
+ })
982
+
983
+ it('accepts conversation messages with preview-derived bodies', () => {
984
+ const withConversation = {
985
+ ...baseDeal,
986
+ conversation: {
987
+ messages: [
988
+ {
989
+ id: 'message-1',
990
+ direction: 'inbound',
991
+ fromEmail: 'lead@example.com',
992
+ toEmail: 'sender@example.com',
993
+ subject: 'Re: quick thought',
994
+ body: 'Sure, send it over.',
995
+ sentAt: ISO_TS
996
+ }
997
+ ]
998
+ }
999
+ }
1000
+ expect(DealDetailResponseSchema.safeParse(withConversation).success).toBe(true)
1001
+ })
1002
+
1003
+ it('accepts a deal with nested contact that has null company', () => {
1004
+ const withContact = {
1005
+ ...baseDeal,
1006
+ contact: {
1007
+ id: VALID_UUID,
1008
+ first_name: 'Alice',
1009
+ last_name: 'Smith',
1010
+ email: 'alice@example.com',
1011
+ title: null,
1012
+ headline: null,
1013
+ linkedin_url: null,
1014
+ processing_state: null,
1015
+ enrichment_data: null,
1016
+ company: null
1017
+ }
1018
+ }
1019
+ expect(DealDetailResponseSchema.safeParse(withContact).success).toBe(true)
1020
+ })
1021
+
1022
+ it('accepts thin client lineage refs on deal detail responses', () => {
1023
+ expect(
1024
+ DealDetailResponseSchema.safeParse({
1025
+ ...baseDeal,
1026
+ client_id: VALID_UUID,
1027
+ lineage: {
1028
+ list: null,
1029
+ projects: [],
1030
+ client: {
1031
+ id: VALID_UUID,
1032
+ name: 'Acme Client',
1033
+ status: 'active'
1034
+ }
1035
+ }
1036
+ }).success
1037
+ ).toBe(true)
1038
+ })
1039
+
1040
+ it('accepts extra unknown fields at top level (not strict)', () => {
1041
+ expect(DealDetailResponseSchema.safeParse({ ...baseDeal, futureField: 'value' }).success).toBe(true)
1042
+ })
1043
+ })
1044
+
1045
+ describe('DealListResponseSchema', () => {
1046
+ it('accepts a valid paginated list response', () => {
1047
+ const result = DealListResponseSchema.safeParse({
1048
+ data: [],
1049
+ total: 0,
1050
+ limit: 50,
1051
+ offset: 0
1052
+ })
1053
+ expect(result.success).toBe(true)
1054
+ })
1055
+
1056
+ it('rejects non-integer total', () => {
1057
+ expect(DealListResponseSchema.safeParse({ data: [], total: 1.5, limit: 50, offset: 0 }).success).toBe(false)
1058
+ })
1059
+ })
1060
+
1061
+ describe('DealNoteResponseSchema', () => {
1062
+ it('accepts a valid note response', () => {
1063
+ const result = DealNoteResponseSchema.safeParse({
1064
+ id: VALID_UUID,
1065
+ dealId: VALID_UUID,
1066
+ organizationId: VALID_UUID,
1067
+ authorUserId: null,
1068
+ body: 'A note',
1069
+ createdAt: ISO_TS,
1070
+ updatedAt: ISO_TS
1071
+ })
1072
+ expect(result.success).toBe(true)
1073
+ })
1074
+
1075
+ it('accepts extra fields (not strict)', () => {
1076
+ const result = DealNoteResponseSchema.safeParse({
1077
+ id: VALID_UUID,
1078
+ dealId: VALID_UUID,
1079
+ organizationId: VALID_UUID,
1080
+ authorUserId: null,
1081
+ body: 'A note',
1082
+ createdAt: ISO_TS,
1083
+ updatedAt: ISO_TS,
1084
+ futureField: 'ignored'
1085
+ })
1086
+ expect(result.success).toBe(true)
1087
+ })
1088
+ })
1089
+
1090
+ describe('DealTaskResponseSchema', () => {
1091
+ it('accepts a valid task response', () => {
1092
+ const result = DealTaskResponseSchema.safeParse({
1093
+ id: VALID_UUID,
1094
+ organizationId: VALID_UUID,
1095
+ dealId: VALID_UUID,
1096
+ title: 'Call prospect',
1097
+ description: null,
1098
+ kind: 'call',
1099
+ dueAt: null,
1100
+ assigneeUserId: null,
1101
+ completedAt: null,
1102
+ completedByUserId: null,
1103
+ createdAt: ISO_TS,
1104
+ updatedAt: ISO_TS,
1105
+ createdByUserId: null
1106
+ })
1107
+ expect(result.success).toBe(true)
1108
+ })
1109
+
1110
+ it('rejects an invalid kind in task response', () => {
1111
+ const result = DealTaskResponseSchema.safeParse({
1112
+ id: VALID_UUID,
1113
+ organizationId: VALID_UUID,
1114
+ dealId: VALID_UUID,
1115
+ title: 'Call',
1116
+ description: null,
1117
+ kind: 'text_message',
1118
+ dueAt: null,
1119
+ assigneeUserId: null,
1120
+ completedAt: null,
1121
+ completedByUserId: null,
1122
+ createdAt: ISO_TS,
1123
+ updatedAt: ISO_TS,
1124
+ createdByUserId: null
1125
+ })
1126
+ expect(result.success).toBe(false)
1127
+ })
1128
+ })
1129
+
1130
+ // ---------------------------------------------------------------------------
1131
+ // ListStatusSchema
1132
+ // ---------------------------------------------------------------------------
1133
+
1134
+ describe('ListStatusSchema', () => {
1135
+ it.each(['draft', 'enriching', 'launched', 'closing', 'archived'])('accepts "%s"', (status) => {
1136
+ expect(ListStatusSchema.safeParse(status).success).toBe(true)
1137
+ })
1138
+
1139
+ it('rejects unknown status', () => {
1140
+ expect(ListStatusSchema.safeParse('active').success).toBe(false)
1141
+ })
1142
+ })
1143
+
1144
+ // ---------------------------------------------------------------------------
1145
+ // PipelineStageSchema
1146
+ // ---------------------------------------------------------------------------
1147
+
1148
+ describe('PipelineStageSchema', () => {
1149
+ it('accepts non-empty model-owned stage keys', () => {
1150
+ expect(PipelineStageSchema.safeParse({ key: VALID_COMPANY_STAGE_KEY }).success).toBe(true)
1151
+ expect(PipelineStageSchema.safeParse({ key: 'tenant-custom-stage' }).success).toBe(true)
1152
+ })
1153
+
1154
+ it('rejects an empty stage key', () => {
1155
+ const result = PipelineStageSchema.safeParse({ key: '' })
1156
+ expect(result.success).toBe(false)
1157
+ })
1158
+
1159
+ it('accepts optional label, enabled, and order fields', () => {
1160
+ expect(PipelineStageSchema.safeParse({ key: 'scraped', label: 'Scraped', enabled: true, order: 1 }).success).toBe(
1161
+ true
1162
+ )
1163
+ })
1164
+ })
1165
+
1166
+ // ---------------------------------------------------------------------------
1167
+ // BuildPlanSnapshotSchema
1168
+ // ---------------------------------------------------------------------------
1169
+
1170
+ describe('BuildPlanSnapshotSchema', () => {
1171
+ const validSnapshot = createBuildPlanSnapshotFromTemplateId('dtc-subscription-apollo-clickup')
1172
+
1173
+ it('accepts a snapshot generated from a prospecting build template', () => {
1174
+ expect(validSnapshot).not.toBeNull()
1175
+ expect(BuildPlanSnapshotSchema.safeParse(validSnapshot).success).toBe(true)
1176
+ })
1177
+
1178
+ it('accepts custom non-empty step stage keys at the transport boundary', () => {
1179
+ const result = BuildPlanSnapshotSchema.safeParse({
1180
+ ...validSnapshot,
1181
+ steps: [{ ...validSnapshot!.steps[0], stageKey: 'made-up-stage' }]
1182
+ })
1183
+
1184
+ expect(result.success).toBe(true)
1185
+ })
1186
+
1187
+ it('accepts custom non-empty action keys at the transport boundary', () => {
1188
+ const result = BuildPlanSnapshotSchema.safeParse({
1189
+ ...validSnapshot,
1190
+ steps: [{ ...validSnapshot!.steps[0], actionKey: 'lead-gen.missing.action' }]
1191
+ })
1192
+
1193
+ expect(result.success).toBe(true)
1194
+ })
1195
+
1196
+ it('rejects duplicate step ids', () => {
1197
+ const first = validSnapshot!.steps[0]!
1198
+ const second = validSnapshot!.steps[1]!
1199
+ const rest = validSnapshot!.steps.slice(2)
1200
+ const result = BuildPlanSnapshotSchema.safeParse({
1201
+ ...validSnapshot,
1202
+ steps: [first, { ...second, id: first.id }, ...rest]
1203
+ })
1204
+
1205
+ expect(result.success).toBe(false)
1206
+ })
1207
+
1208
+ it('rejects dependsOn references to unknown step ids', () => {
1209
+ const result = BuildPlanSnapshotSchema.safeParse({
1210
+ ...validSnapshot,
1211
+ steps: [{ ...validSnapshot!.steps[0], dependsOn: ['missing-step'] }]
1212
+ })
1213
+
1214
+ expect(result.success).toBe(false)
1215
+ })
1216
+ })
1217
+
1218
+ // ---------------------------------------------------------------------------
1219
+ // ScrapingConfigSchema
1220
+ // ---------------------------------------------------------------------------
1221
+
1222
+ describe('ScrapingConfigSchema', () => {
1223
+ it('accepts an empty object (all fields optional)', () => {
1224
+ expect(ScrapingConfigSchema.safeParse({}).success).toBe(true)
1225
+ })
1226
+
1227
+ it('accepts a fully populated config', () => {
1228
+ expect(
1229
+ ScrapingConfigSchema.safeParse({
1230
+ vertical: 'SaaS',
1231
+ geography: 'USA',
1232
+ size: '10-50',
1233
+ apifyInput: { actorId: 'test' }
1234
+ }).success
1235
+ ).toBe(true)
1236
+ })
1237
+
1238
+ it('rejects a vertical exceeding 255 chars', () => {
1239
+ expect(ScrapingConfigSchema.safeParse({ vertical: 'a'.repeat(256) }).success).toBe(false)
1240
+ })
1241
+ })
1242
+
1243
+ // ---------------------------------------------------------------------------
1244
+ // IcpRubricSchema
1245
+ // ---------------------------------------------------------------------------
1246
+
1247
+ describe('IcpRubricSchema', () => {
1248
+ it('accepts an empty object', () => {
1249
+ expect(IcpRubricSchema.safeParse({}).success).toBe(true)
1250
+ })
1251
+
1252
+ it('accepts valid minRating boundary values 0 and 5', () => {
1253
+ expect(IcpRubricSchema.safeParse({ minRating: 0 }).success).toBe(true)
1254
+ expect(IcpRubricSchema.safeParse({ minRating: 5 }).success).toBe(true)
1255
+ })
1256
+
1257
+ it('rejects minRating above 5', () => {
1258
+ expect(IcpRubricSchema.safeParse({ minRating: 5.1 }).success).toBe(false)
1259
+ })
1260
+
1261
+ it('rejects negative minRating', () => {
1262
+ expect(IcpRubricSchema.safeParse({ minRating: -1 }).success).toBe(false)
1263
+ })
1264
+ })
1265
+
1266
+ // ---------------------------------------------------------------------------
1267
+ // CreateListRequestSchema
1268
+ // ---------------------------------------------------------------------------
1269
+
1270
+ describe('CreateListRequestSchema', () => {
1271
+ it('accepts a minimal valid payload (name only)', () => {
1272
+ expect(CreateListRequestSchema.safeParse({ name: 'My List' }).success).toBe(true)
1273
+ })
1274
+
1275
+ it('rejects an empty name', () => {
1276
+ expect(CreateListRequestSchema.safeParse({ name: '' }).success).toBe(false)
1277
+ })
1278
+
1279
+ it('rejects a name exceeding 255 chars', () => {
1280
+ expect(CreateListRequestSchema.safeParse({ name: 'a'.repeat(256) }).success).toBe(false)
1281
+ })
1282
+
1283
+ it('accepts an optional status from ListStatusSchema', () => {
1284
+ expect(CreateListRequestSchema.safeParse({ name: 'X', status: 'draft' }).success).toBe(true)
1285
+ })
1286
+
1287
+ it('accepts a known prospecting build template id', () => {
1288
+ const result = CreateListRequestSchema.safeParse({
1289
+ name: 'DTC Subscription Brands',
1290
+ buildTemplateId: 'dtc-subscription-apollo-clickup'
1291
+ })
1292
+
1293
+ expect(result.success).toBe(true)
1294
+ })
1295
+
1296
+ it('rejects an unknown prospecting build template id', () => {
1297
+ expect(CreateListRequestSchema.safeParse({ name: 'X', buildTemplateId: 'not-a-template' }).success).toBe(false)
1298
+ })
1299
+
1300
+ it('rejects an invalid status', () => {
1301
+ expect(CreateListRequestSchema.safeParse({ name: 'X', status: 'active' }).success).toBe(false)
1302
+ })
1303
+
1304
+ it('rejects unknown fields (strict mode)', () => {
1305
+ expect(CreateListRequestSchema.safeParse({ name: 'X', bogus: true }).success).toBe(false)
1306
+ })
1307
+
1308
+ it('accepts nested scrapingConfig, icp, and pipelineConfig', () => {
1309
+ expect(
1310
+ CreateListRequestSchema.safeParse({
1311
+ name: 'SaaS List',
1312
+ scrapingConfig: { vertical: 'SaaS' },
1313
+ icp: { minReviewCount: 5 },
1314
+ pipelineConfig: { stages: [] }
1315
+ }).success
1316
+ ).toBe(true)
1317
+ })
1318
+ })
1319
+
1320
+ // ---------------------------------------------------------------------------
1321
+ // UpdateListRequestSchema
1322
+ // ---------------------------------------------------------------------------
1323
+
1324
+ describe('UpdateListRequestSchema', () => {
1325
+ it('rejects an object with none of the required fields', () => {
1326
+ const result = UpdateListRequestSchema.safeParse({})
1327
+ expect(result.success).toBe(false)
1328
+ })
1329
+
1330
+ it('accepts providing only name', () => {
1331
+ expect(UpdateListRequestSchema.safeParse({ name: 'New Name' }).success).toBe(true)
1332
+ })
1333
+
1334
+ it('accepts providing only description', () => {
1335
+ expect(UpdateListRequestSchema.safeParse({ description: 'Desc' }).success).toBe(true)
1336
+ })
1337
+
1338
+ it('accepts providing only batchIds', () => {
1339
+ expect(UpdateListRequestSchema.safeParse({ batchIds: ['batch-1'] }).success).toBe(true)
1340
+ })
1341
+
1342
+ it('accepts buildTemplateId when the change is explicitly confirmed', () => {
1343
+ expect(
1344
+ UpdateListRequestSchema.safeParse({
1345
+ buildTemplateId: 'dtc-subscription-apollo-clickup',
1346
+ confirmBuildTemplateChange: true
1347
+ }).success
1348
+ ).toBe(true)
1349
+ })
1350
+
1351
+ it('rejects buildTemplateId without explicit confirmation', () => {
1352
+ expect(
1353
+ UpdateListRequestSchema.safeParse({
1354
+ buildTemplateId: 'dtc-subscription-apollo-clickup'
1355
+ }).success
1356
+ ).toBe(false)
1357
+ })
1358
+
1359
+ it('rejects unknown fields (strict mode)', () => {
1360
+ expect(UpdateListRequestSchema.safeParse({ name: 'X', extra: 'bad' }).success).toBe(false)
1361
+ })
1362
+ })
1363
+
1364
+ // ---------------------------------------------------------------------------
1365
+ // UpdateListStatusRequestSchema
1366
+ // ---------------------------------------------------------------------------
1367
+
1368
+ describe('UpdateListStatusRequestSchema', () => {
1369
+ it('accepts a valid status', () => {
1370
+ expect(UpdateListStatusRequestSchema.safeParse({ status: 'launched' }).success).toBe(true)
1371
+ })
1372
+
1373
+ it('rejects an invalid status', () => {
1374
+ expect(UpdateListStatusRequestSchema.safeParse({ status: 'active' }).success).toBe(false)
1375
+ })
1376
+
1377
+ it('rejects missing status field', () => {
1378
+ expect(UpdateListStatusRequestSchema.safeParse({}).success).toBe(false)
1379
+ })
1380
+
1381
+ it('rejects unknown fields (strict mode)', () => {
1382
+ expect(UpdateListStatusRequestSchema.safeParse({ status: 'draft', extra: 'x' }).success).toBe(false)
1383
+ })
1384
+ })
1385
+
1386
+ // ---------------------------------------------------------------------------
1387
+ // UpdateListConfigRequestSchema
1388
+ // ---------------------------------------------------------------------------
1389
+
1390
+ describe('UpdateListConfigRequestSchema', () => {
1391
+ it('rejects an object with none of the config fields', () => {
1392
+ expect(UpdateListConfigRequestSchema.safeParse({}).success).toBe(false)
1393
+ })
1394
+
1395
+ it('accepts providing only scrapingConfig', () => {
1396
+ expect(UpdateListConfigRequestSchema.safeParse({ scrapingConfig: { vertical: 'SaaS' } }).success).toBe(true)
1397
+ })
1398
+
1399
+ it('accepts providing only icp', () => {
1400
+ expect(UpdateListConfigRequestSchema.safeParse({ icp: { minReviewCount: 3 } }).success).toBe(true)
1401
+ })
1402
+
1403
+ it('accepts providing only pipelineConfig', () => {
1404
+ expect(UpdateListConfigRequestSchema.safeParse({ pipelineConfig: { stages: [] } }).success).toBe(true)
1405
+ })
1406
+
1407
+ it('rejects unknown fields (strict mode)', () => {
1408
+ expect(UpdateListConfigRequestSchema.safeParse({ scrapingConfig: {}, bogus: true }).success).toBe(false)
1409
+ })
1410
+ })
1411
+
1412
+ // ---------------------------------------------------------------------------
1413
+ // AddCompaniesToListRequestSchema / AddContactsToListRequestSchema
1414
+ // ---------------------------------------------------------------------------
1415
+
1416
+ describe('AddCompaniesToListRequestSchema', () => {
1417
+ it('accepts a valid list with one UUID', () => {
1418
+ expect(AddCompaniesToListRequestSchema.safeParse({ companyIds: [VALID_UUID] }).success).toBe(true)
1419
+ })
1420
+
1421
+ it('rejects an empty array (min 1)', () => {
1422
+ expect(AddCompaniesToListRequestSchema.safeParse({ companyIds: [] }).success).toBe(false)
1423
+ })
1424
+
1425
+ it('rejects more than 1000 IDs (max 1000)', () => {
1426
+ const ids = Array.from({ length: 1001 }, (_, i) => `00000000-0000-0000-0000-${String(i).padStart(12, '0')}`)
1427
+ expect(AddCompaniesToListRequestSchema.safeParse({ companyIds: ids }).success).toBe(false)
1428
+ })
1429
+
1430
+ it('rejects non-UUID entries', () => {
1431
+ expect(AddCompaniesToListRequestSchema.safeParse({ companyIds: ['not-a-uuid'] }).success).toBe(false)
1432
+ })
1433
+ })
1434
+
1435
+ describe('AddContactsToListRequestSchema', () => {
1436
+ it('accepts a valid list with one UUID', () => {
1437
+ expect(AddContactsToListRequestSchema.safeParse({ contactIds: [VALID_UUID] }).success).toBe(true)
1438
+ })
1439
+
1440
+ it('rejects an empty array (min 1)', () => {
1441
+ expect(AddContactsToListRequestSchema.safeParse({ contactIds: [] }).success).toBe(false)
1442
+ })
1443
+ })
1444
+
1445
+ // ---------------------------------------------------------------------------
1446
+ // ListCompaniesQuerySchema
1447
+ // ---------------------------------------------------------------------------
1448
+
1449
+ describe('ListCompaniesQuerySchema', () => {
1450
+ it('accepts an empty query (defaults applied)', () => {
1451
+ const result = ListCompaniesQuerySchema.safeParse({})
1452
+ expect(result.success).toBe(true)
1453
+ if (result.success) {
1454
+ expect(result.data.limit).toBe(50)
1455
+ expect(result.data.offset).toBe(0)
1456
+ }
1457
+ })
1458
+
1459
+ it('coerces limit from string "100" to number 100', () => {
1460
+ const result = ListCompaniesQuerySchema.safeParse({ limit: '100' })
1461
+ expect(result.success).toBe(true)
1462
+ if (result.success) expect(result.data.limit).toBe(100)
1463
+ })
1464
+
1465
+ it('rejects limit exceeding 5000', () => {
1466
+ expect(ListCompaniesQuerySchema.safeParse({ limit: '5001' }).success).toBe(false)
1467
+ })
1468
+
1469
+ it('rejects limit less than 1', () => {
1470
+ expect(ListCompaniesQuerySchema.safeParse({ limit: '0' }).success).toBe(false)
1471
+ })
1472
+
1473
+ it('accepts a valid status filter', () => {
1474
+ expect(ListCompaniesQuerySchema.safeParse({ status: 'active' }).success).toBe(true)
1475
+ expect(ListCompaniesQuerySchema.safeParse({ status: 'invalid' }).success).toBe(true)
1476
+ })
1477
+
1478
+ it('rejects an invalid status', () => {
1479
+ expect(ListCompaniesQuerySchema.safeParse({ status: 'pending' }).success).toBe(false)
1480
+ })
1481
+
1482
+ it('accepts includeAll as boolean-like strings', () => {
1483
+ const r1 = ListCompaniesQuerySchema.safeParse({ includeAll: 'true' })
1484
+ expect(r1.success).toBe(true)
1485
+ if (r1.success) expect(r1.data.includeAll).toBe(true)
1486
+
1487
+ const r2 = ListCompaniesQuerySchema.safeParse({ includeAll: 'false' })
1488
+ expect(r2.success).toBe(true)
1489
+ if (r2.success) expect(r2.data.includeAll).toBe(false)
1490
+ })
1491
+
1492
+ it('rejects unknown fields (strict mode)', () => {
1493
+ expect(ListCompaniesQuerySchema.safeParse({ unknownField: 'x' }).success).toBe(false)
1494
+ })
1495
+ })
1496
+
1497
+ // ---------------------------------------------------------------------------
1498
+ // ListContactsQuerySchema
1499
+ // ---------------------------------------------------------------------------
1500
+
1501
+ describe('ListContactsQuerySchema', () => {
1502
+ it('accepts an empty query with defaults', () => {
1503
+ const result = ListContactsQuerySchema.safeParse({})
1504
+ expect(result.success).toBe(true)
1505
+ if (result.success) {
1506
+ expect(result.data.limit).toBe(5000)
1507
+ expect(result.data.offset).toBe(0)
1508
+ }
1509
+ })
1510
+
1511
+ it('accepts openingLineIsNull as "true"', () => {
1512
+ const result = ListContactsQuerySchema.safeParse({ openingLineIsNull: 'true' })
1513
+ expect(result.success).toBe(true)
1514
+ if (result.success) expect(result.data.openingLineIsNull).toBe(true)
1515
+ })
1516
+ })
1517
+
1518
+ // ---------------------------------------------------------------------------
1519
+ // ListDealTasksDueQuerySchema
1520
+ // ---------------------------------------------------------------------------
1521
+
1522
+ describe('ListDealTasksDueQuerySchema', () => {
1523
+ it('accepts an empty query', () => {
1524
+ expect(ListDealTasksDueQuerySchema.safeParse({}).success).toBe(true)
1525
+ })
1526
+
1527
+ it.each(['overdue', 'today', 'today_and_overdue', 'upcoming'])('accepts window "%s"', (window) => {
1528
+ expect(ListDealTasksDueQuerySchema.safeParse({ window }).success).toBe(true)
1529
+ })
1530
+
1531
+ it('rejects an invalid window value', () => {
1532
+ expect(ListDealTasksDueQuerySchema.safeParse({ window: 'this_week' }).success).toBe(false)
1533
+ })
1534
+
1535
+ it('accepts a valid UUID for assigneeUserId', () => {
1536
+ expect(ListDealTasksDueQuerySchema.safeParse({ assigneeUserId: VALID_UUID }).success).toBe(true)
1537
+ })
1538
+
1539
+ it('rejects unknown fields (strict mode)', () => {
1540
+ expect(ListDealTasksDueQuerySchema.safeParse({ window: 'today', extra: 'x' }).success).toBe(false)
1541
+ })
1542
+ })
1543
+
1544
+ // ---------------------------------------------------------------------------
1545
+ // CreateCompanyRequestSchema
1546
+ // ---------------------------------------------------------------------------
1547
+
1548
+ describe('CreateCompanyRequestSchema', () => {
1549
+ it('accepts a minimal payload (name only)', () => {
1550
+ expect(CreateCompanyRequestSchema.safeParse({ name: 'Acme' }).success).toBe(true)
1551
+ })
1552
+
1553
+ it('rejects an empty name', () => {
1554
+ expect(CreateCompanyRequestSchema.safeParse({ name: '' }).success).toBe(false)
1555
+ })
1556
+
1557
+ it('rejects a non-URL for linkedinUrl', () => {
1558
+ expect(CreateCompanyRequestSchema.safeParse({ name: 'Acme', linkedinUrl: 'linkedin.com/co/acme' }).success).toBe(
1559
+ false
1560
+ )
1561
+ })
1562
+
1563
+ it('accepts a valid URL for linkedinUrl', () => {
1564
+ expect(
1565
+ CreateCompanyRequestSchema.safeParse({ name: 'Acme', linkedinUrl: 'https://linkedin.com/company/acme' }).success
1566
+ ).toBe(true)
1567
+ })
1568
+
1569
+ it('rejects unknown fields (strict mode)', () => {
1570
+ expect(CreateCompanyRequestSchema.safeParse({ name: 'Acme', bogus: true }).success).toBe(false)
1571
+ })
1572
+ })
1573
+
1574
+ // ---------------------------------------------------------------------------
1575
+ // UpdateCompanyRequestSchema
1576
+ // ---------------------------------------------------------------------------
1577
+
1578
+ describe('UpdateCompanyRequestSchema', () => {
1579
+ it('rejects an object with no fields (at-least-one refine)', () => {
1580
+ expect(UpdateCompanyRequestSchema.safeParse({}).success).toBe(false)
1581
+ })
1582
+
1583
+ it('accepts providing only name', () => {
1584
+ expect(UpdateCompanyRequestSchema.safeParse({ name: 'Acme Corp' }).success).toBe(true)
1585
+ })
1586
+
1587
+ it('accepts providing only status', () => {
1588
+ expect(UpdateCompanyRequestSchema.safeParse({ status: 'active' }).success).toBe(true)
1589
+ expect(UpdateCompanyRequestSchema.safeParse({ status: 'invalid' }).success).toBe(true)
1590
+ })
1591
+
1592
+ it('accepts processingState keyed by the stage catalog', () => {
1593
+ const result = UpdateCompanyRequestSchema.safeParse({
1594
+ processingState: {
1595
+ [VALID_COMPANY_STAGE_KEY]: {
1596
+ status: 'success',
1597
+ data: { score: 92 }
1598
+ }
1599
+ }
1600
+ })
1601
+
1602
+ expect(result.success).toBe(true)
1603
+ })
1604
+
1605
+ it('accepts deprecated pipelineStatus as a compatibility no-op', () => {
1606
+ expect(UpdateCompanyRequestSchema.safeParse({ pipelineStatus: 'emailed' }).success).toBe(true)
1607
+ })
1608
+
1609
+ it('rejects empty processingState keys', () => {
1489
1610
  expect(
1490
1611
  UpdateCompanyRequestSchema.safeParse({
1491
1612
  processingState: {
@@ -1493,414 +1614,414 @@ describe('UpdateCompanyRequestSchema', () => {
1493
1614
  }
1494
1615
  }).success
1495
1616
  ).toBe(false)
1496
- })
1497
-
1498
- it('accepts numEmployees of 0', () => {
1499
- expect(UpdateCompanyRequestSchema.safeParse({ numEmployees: 0 }).success).toBe(true)
1500
- })
1501
-
1502
- it('rejects negative numEmployees', () => {
1503
- expect(UpdateCompanyRequestSchema.safeParse({ numEmployees: -1 }).success).toBe(false)
1504
- })
1505
-
1506
- it('rejects unknown fields (strict mode)', () => {
1507
- expect(UpdateCompanyRequestSchema.safeParse({ name: 'X', extra: true }).success).toBe(false)
1508
- })
1509
- })
1510
-
1511
- // ---------------------------------------------------------------------------
1512
- // CreateContactRequestSchema
1513
- // ---------------------------------------------------------------------------
1514
-
1515
- describe('CreateContactRequestSchema', () => {
1516
- it('accepts a minimal payload (email only)', () => {
1517
- expect(CreateContactRequestSchema.safeParse({ email: 'test@example.com' }).success).toBe(true)
1518
- })
1519
-
1520
- it('rejects an invalid email', () => {
1521
- expect(CreateContactRequestSchema.safeParse({ email: 'not-an-email' }).success).toBe(false)
1522
- })
1523
-
1524
- it('rejects an empty email', () => {
1525
- expect(CreateContactRequestSchema.safeParse({ email: '' }).success).toBe(false)
1526
- })
1527
-
1528
- it('accepts an optional companyId as UUID', () => {
1529
- expect(CreateContactRequestSchema.safeParse({ email: 'a@b.com', companyId: VALID_UUID }).success).toBe(true)
1530
- })
1531
-
1532
- it('rejects unknown fields (strict mode)', () => {
1533
- expect(CreateContactRequestSchema.safeParse({ email: 'a@b.com', unknown: 'x' }).success).toBe(false)
1534
- })
1535
- })
1536
-
1537
- // ---------------------------------------------------------------------------
1538
- // AcqEmailValidSchema / AcqContactStatusSchema / AcqCompanyStatusSchema
1539
- // ---------------------------------------------------------------------------
1540
-
1541
- describe('AcqEmailValidSchema', () => {
1542
- it.each(['VALID', 'INVALID', 'RISKY', 'UNKNOWN'])('accepts "%s"', (v) => {
1543
- expect(AcqEmailValidSchema.safeParse(v).success).toBe(true)
1544
- })
1545
-
1546
- it('rejects lowercase or unknown value', () => {
1547
- expect(AcqEmailValidSchema.safeParse('valid').success).toBe(false)
1548
- expect(AcqEmailValidSchema.safeParse('pending').success).toBe(false)
1549
- })
1550
- })
1551
-
1552
- describe('AcqContactStatusSchema', () => {
1553
- it.each(['active', 'invalid'])('accepts "%s"', (s) => {
1554
- expect(AcqContactStatusSchema.safeParse(s).success).toBe(true)
1555
- })
1556
-
1557
- it('rejects unknown status', () => {
1558
- expect(AcqContactStatusSchema.safeParse('deleted').success).toBe(false)
1559
- })
1560
- })
1561
-
1562
- // ---------------------------------------------------------------------------
1563
- // AcqArtifactOwnerKindSchema
1564
- // ---------------------------------------------------------------------------
1565
-
1566
- describe('AcqArtifactOwnerKindSchema', () => {
1567
- it.each(['company', 'contact', 'deal', 'list', 'list_member'])('accepts "%s"', (kind) => {
1568
- expect(AcqArtifactOwnerKindSchema.safeParse(kind).success).toBe(true)
1569
- })
1570
-
1571
- it('rejects unknown owner kind', () => {
1572
- expect(AcqArtifactOwnerKindSchema.safeParse('organization').success).toBe(false)
1573
- })
1574
- })
1575
-
1576
- // ---------------------------------------------------------------------------
1577
- // ListArtifactsQuerySchema
1578
- // ---------------------------------------------------------------------------
1579
-
1580
- describe('ListArtifactsQuerySchema', () => {
1581
- it('accepts a valid query', () => {
1582
- expect(ListArtifactsQuerySchema.safeParse({ ownerKind: 'deal', ownerId: VALID_UUID }).success).toBe(true)
1583
- })
1584
-
1585
- it('rejects an invalid ownerKind', () => {
1586
- expect(ListArtifactsQuerySchema.safeParse({ ownerKind: 'org', ownerId: VALID_UUID }).success).toBe(false)
1587
- })
1588
-
1589
- it('rejects a non-UUID ownerId', () => {
1590
- expect(ListArtifactsQuerySchema.safeParse({ ownerKind: 'deal', ownerId: 'not-a-uuid' }).success).toBe(false)
1591
- })
1592
-
1593
- it('rejects unknown fields (strict mode)', () => {
1594
- expect(ListArtifactsQuerySchema.safeParse({ ownerKind: 'deal', ownerId: VALID_UUID, extra: 'x' }).success).toBe(
1595
- false
1596
- )
1597
- })
1598
- })
1599
-
1600
- // ---------------------------------------------------------------------------
1601
- // CreateArtifactRequestSchema
1602
- // ---------------------------------------------------------------------------
1603
-
1604
- describe('CreateArtifactRequestSchema', () => {
1605
- const valid = {
1606
- ownerKind: 'deal' as const,
1607
- ownerId: VALID_UUID,
1608
- kind: 'proposal',
1609
- content: { url: 'https://example.com' }
1610
- }
1611
-
1612
- it('accepts a minimal valid payload', () => {
1613
- expect(CreateArtifactRequestSchema.safeParse(valid).success).toBe(true)
1614
- })
1615
-
1616
- it('accepts an optional sourceExecutionId', () => {
1617
- expect(CreateArtifactRequestSchema.safeParse({ ...valid, sourceExecutionId: VALID_UUID }).success).toBe(true)
1618
- })
1619
-
1620
- it('rejects a non-UUID sourceExecutionId', () => {
1621
- expect(CreateArtifactRequestSchema.safeParse({ ...valid, sourceExecutionId: 'not-a-uuid' }).success).toBe(false)
1622
- })
1623
-
1624
- it('rejects an empty kind', () => {
1625
- expect(CreateArtifactRequestSchema.safeParse({ ...valid, kind: '' }).success).toBe(false)
1626
- })
1627
-
1628
- it('rejects unknown fields (strict mode)', () => {
1629
- expect(CreateArtifactRequestSchema.safeParse({ ...valid, bogus: true }).success).toBe(false)
1630
- })
1631
- })
1632
-
1633
- // ---------------------------------------------------------------------------
1634
- // ListMembersQuerySchema
1635
- // ---------------------------------------------------------------------------
1636
-
1637
- describe('ListMembersQuerySchema', () => {
1638
- it('accepts an empty query with defaults', () => {
1639
- const result = ListMembersQuerySchema.safeParse({})
1640
- expect(result.success).toBe(true)
1641
- if (result.success) {
1642
- expect(result.data.limit).toBe(50)
1643
- expect(result.data.offset).toBe(0)
1644
- }
1645
- })
1646
-
1647
- it('rejects limit exceeding 500', () => {
1648
- expect(ListMembersQuerySchema.safeParse({ limit: '501' }).success).toBe(false)
1649
- })
1650
-
1651
- it('rejects limit less than 1', () => {
1652
- expect(ListMembersQuerySchema.safeParse({ limit: '0' }).success).toBe(false)
1653
- })
1654
-
1655
- it('rejects unknown fields (strict mode)', () => {
1656
- expect(ListMembersQuerySchema.safeParse({ extra: 'x' }).success).toBe(false)
1657
- })
1658
- })
1659
-
1660
- // ---------------------------------------------------------------------------
1661
- // ListReadQuerySchema
1662
- // ---------------------------------------------------------------------------
1663
-
1664
- describe('ListReadQuerySchema', () => {
1665
- it('accepts SDK list filters and coerces pagination', () => {
1666
- const result = ListReadQuerySchema.safeParse({
1667
- status: 'launched',
1668
- batch: 'batch-2026-05',
1669
- vertical: 'veterinary',
1670
- limit: '25',
1671
- offset: '50'
1672
- })
1673
-
1674
- expect(result.success).toBe(true)
1675
- if (result.success) {
1676
- expect(result.data).toMatchObject({ limit: 25, offset: 50 })
1677
- }
1678
- })
1679
-
1680
- it('keeps pagination optional for backward-compatible list reads', () => {
1681
- expect(ListReadQuerySchema.safeParse({}).success).toBe(true)
1682
- })
1683
-
1684
- it('rejects unknown fields (strict mode)', () => {
1685
- expect(ListReadQuerySchema.safeParse({ includeDeals: true }).success).toBe(false)
1686
- })
1687
- })
1688
-
1689
- // ---------------------------------------------------------------------------
1690
- // GetListQuerySchema
1691
- // ---------------------------------------------------------------------------
1692
-
1693
- describe('GetListQuerySchema', () => {
1694
- it('defaults to thin deal refs and omits progress unless requested', () => {
1695
- const result = GetListQuerySchema.safeParse({})
1696
-
1697
- expect(result.success).toBe(true)
1698
- if (result.success) {
1699
- expect(result.data).toEqual({ includeDeals: true, includeProgress: false, dealLimit: 25 })
1700
- }
1701
- })
1702
-
1703
- it('coerces boolean include flags from query strings', () => {
1704
- const result = GetListQuerySchema.safeParse({
1705
- includeDeals: 'false',
1706
- includeProgress: 'true',
1707
- dealLimit: '10'
1708
- })
1709
-
1710
- expect(result.success).toBe(true)
1711
- if (result.success) {
1712
- expect(result.data).toEqual({ includeDeals: false, includeProgress: true, dealLimit: 10 })
1713
- }
1714
- })
1715
-
1716
- it('rejects unknown fields (strict mode)', () => {
1717
- expect(GetListQuerySchema.safeParse({ depth: 2 }).success).toBe(false)
1718
- })
1719
- })
1720
-
1721
- // ---------------------------------------------------------------------------
1722
- // ListRecordsQuerySchema
1723
- // ---------------------------------------------------------------------------
1724
-
1725
- describe('ListRecordsQuerySchema', () => {
1726
- it('accepts contact records for DTC decision-maker enrichment', () => {
1727
- const result = ListRecordsQuerySchema.safeParse({
1728
- entity: 'contact',
1729
- stage: 'decision-makers-enriched'
1730
- })
1731
-
1732
- expect(result.success).toBe(true)
1733
- })
1734
-
1735
- it('keeps company records valid for qualified and uploaded stages', () => {
1736
- expect(ListRecordsQuerySchema.safeParse({ entity: 'company', stage: 'qualified' }).success).toBe(true)
1737
- expect(ListRecordsQuerySchema.safeParse({ entity: 'company', stage: 'uploaded' }).success).toBe(true)
1738
- })
1739
-
1617
+ })
1618
+
1619
+ it('accepts numEmployees of 0', () => {
1620
+ expect(UpdateCompanyRequestSchema.safeParse({ numEmployees: 0 }).success).toBe(true)
1621
+ })
1622
+
1623
+ it('rejects negative numEmployees', () => {
1624
+ expect(UpdateCompanyRequestSchema.safeParse({ numEmployees: -1 }).success).toBe(false)
1625
+ })
1626
+
1627
+ it('rejects unknown fields (strict mode)', () => {
1628
+ expect(UpdateCompanyRequestSchema.safeParse({ name: 'X', extra: true }).success).toBe(false)
1629
+ })
1630
+ })
1631
+
1632
+ // ---------------------------------------------------------------------------
1633
+ // CreateContactRequestSchema
1634
+ // ---------------------------------------------------------------------------
1635
+
1636
+ describe('CreateContactRequestSchema', () => {
1637
+ it('accepts a minimal payload (email only)', () => {
1638
+ expect(CreateContactRequestSchema.safeParse({ email: 'test@example.com' }).success).toBe(true)
1639
+ })
1640
+
1641
+ it('rejects an invalid email', () => {
1642
+ expect(CreateContactRequestSchema.safeParse({ email: 'not-an-email' }).success).toBe(false)
1643
+ })
1644
+
1645
+ it('rejects an empty email', () => {
1646
+ expect(CreateContactRequestSchema.safeParse({ email: '' }).success).toBe(false)
1647
+ })
1648
+
1649
+ it('accepts an optional companyId as UUID', () => {
1650
+ expect(CreateContactRequestSchema.safeParse({ email: 'a@b.com', companyId: VALID_UUID }).success).toBe(true)
1651
+ })
1652
+
1653
+ it('rejects unknown fields (strict mode)', () => {
1654
+ expect(CreateContactRequestSchema.safeParse({ email: 'a@b.com', unknown: 'x' }).success).toBe(false)
1655
+ })
1656
+ })
1657
+
1658
+ // ---------------------------------------------------------------------------
1659
+ // AcqEmailValidSchema / AcqContactStatusSchema / AcqCompanyStatusSchema
1660
+ // ---------------------------------------------------------------------------
1661
+
1662
+ describe('AcqEmailValidSchema', () => {
1663
+ it.each(['VALID', 'INVALID', 'RISKY', 'UNKNOWN'])('accepts "%s"', (v) => {
1664
+ expect(AcqEmailValidSchema.safeParse(v).success).toBe(true)
1665
+ })
1666
+
1667
+ it('rejects lowercase or unknown value', () => {
1668
+ expect(AcqEmailValidSchema.safeParse('valid').success).toBe(false)
1669
+ expect(AcqEmailValidSchema.safeParse('pending').success).toBe(false)
1670
+ })
1671
+ })
1672
+
1673
+ describe('AcqContactStatusSchema', () => {
1674
+ it.each(['active', 'invalid'])('accepts "%s"', (s) => {
1675
+ expect(AcqContactStatusSchema.safeParse(s).success).toBe(true)
1676
+ })
1677
+
1678
+ it('rejects unknown status', () => {
1679
+ expect(AcqContactStatusSchema.safeParse('deleted').success).toBe(false)
1680
+ })
1681
+ })
1682
+
1683
+ // ---------------------------------------------------------------------------
1684
+ // AcqArtifactOwnerKindSchema
1685
+ // ---------------------------------------------------------------------------
1686
+
1687
+ describe('AcqArtifactOwnerKindSchema', () => {
1688
+ it.each(['company', 'contact', 'deal', 'list', 'list_member'])('accepts "%s"', (kind) => {
1689
+ expect(AcqArtifactOwnerKindSchema.safeParse(kind).success).toBe(true)
1690
+ })
1691
+
1692
+ it('rejects unknown owner kind', () => {
1693
+ expect(AcqArtifactOwnerKindSchema.safeParse('organization').success).toBe(false)
1694
+ })
1695
+ })
1696
+
1697
+ // ---------------------------------------------------------------------------
1698
+ // ListArtifactsQuerySchema
1699
+ // ---------------------------------------------------------------------------
1700
+
1701
+ describe('ListArtifactsQuerySchema', () => {
1702
+ it('accepts a valid query', () => {
1703
+ expect(ListArtifactsQuerySchema.safeParse({ ownerKind: 'deal', ownerId: VALID_UUID }).success).toBe(true)
1704
+ })
1705
+
1706
+ it('rejects an invalid ownerKind', () => {
1707
+ expect(ListArtifactsQuerySchema.safeParse({ ownerKind: 'org', ownerId: VALID_UUID }).success).toBe(false)
1708
+ })
1709
+
1710
+ it('rejects a non-UUID ownerId', () => {
1711
+ expect(ListArtifactsQuerySchema.safeParse({ ownerKind: 'deal', ownerId: 'not-a-uuid' }).success).toBe(false)
1712
+ })
1713
+
1714
+ it('rejects unknown fields (strict mode)', () => {
1715
+ expect(ListArtifactsQuerySchema.safeParse({ ownerKind: 'deal', ownerId: VALID_UUID, extra: 'x' }).success).toBe(
1716
+ false
1717
+ )
1718
+ })
1719
+ })
1720
+
1721
+ // ---------------------------------------------------------------------------
1722
+ // CreateArtifactRequestSchema
1723
+ // ---------------------------------------------------------------------------
1724
+
1725
+ describe('CreateArtifactRequestSchema', () => {
1726
+ const valid = {
1727
+ ownerKind: 'deal' as const,
1728
+ ownerId: VALID_UUID,
1729
+ kind: 'proposal',
1730
+ content: { url: 'https://example.com' }
1731
+ }
1732
+
1733
+ it('accepts a minimal valid payload', () => {
1734
+ expect(CreateArtifactRequestSchema.safeParse(valid).success).toBe(true)
1735
+ })
1736
+
1737
+ it('accepts an optional sourceExecutionId', () => {
1738
+ expect(CreateArtifactRequestSchema.safeParse({ ...valid, sourceExecutionId: VALID_UUID }).success).toBe(true)
1739
+ })
1740
+
1741
+ it('rejects a non-UUID sourceExecutionId', () => {
1742
+ expect(CreateArtifactRequestSchema.safeParse({ ...valid, sourceExecutionId: 'not-a-uuid' }).success).toBe(false)
1743
+ })
1744
+
1745
+ it('rejects an empty kind', () => {
1746
+ expect(CreateArtifactRequestSchema.safeParse({ ...valid, kind: '' }).success).toBe(false)
1747
+ })
1748
+
1749
+ it('rejects unknown fields (strict mode)', () => {
1750
+ expect(CreateArtifactRequestSchema.safeParse({ ...valid, bogus: true }).success).toBe(false)
1751
+ })
1752
+ })
1753
+
1754
+ // ---------------------------------------------------------------------------
1755
+ // ListMembersQuerySchema
1756
+ // ---------------------------------------------------------------------------
1757
+
1758
+ describe('ListMembersQuerySchema', () => {
1759
+ it('accepts an empty query with defaults', () => {
1760
+ const result = ListMembersQuerySchema.safeParse({})
1761
+ expect(result.success).toBe(true)
1762
+ if (result.success) {
1763
+ expect(result.data.limit).toBe(50)
1764
+ expect(result.data.offset).toBe(0)
1765
+ }
1766
+ })
1767
+
1768
+ it('rejects limit exceeding 500', () => {
1769
+ expect(ListMembersQuerySchema.safeParse({ limit: '501' }).success).toBe(false)
1770
+ })
1771
+
1772
+ it('rejects limit less than 1', () => {
1773
+ expect(ListMembersQuerySchema.safeParse({ limit: '0' }).success).toBe(false)
1774
+ })
1775
+
1776
+ it('rejects unknown fields (strict mode)', () => {
1777
+ expect(ListMembersQuerySchema.safeParse({ extra: 'x' }).success).toBe(false)
1778
+ })
1779
+ })
1780
+
1781
+ // ---------------------------------------------------------------------------
1782
+ // ListReadQuerySchema
1783
+ // ---------------------------------------------------------------------------
1784
+
1785
+ describe('ListReadQuerySchema', () => {
1786
+ it('accepts SDK list filters and coerces pagination', () => {
1787
+ const result = ListReadQuerySchema.safeParse({
1788
+ status: 'launched',
1789
+ batch: 'batch-2026-05',
1790
+ vertical: 'veterinary',
1791
+ limit: '25',
1792
+ offset: '50'
1793
+ })
1794
+
1795
+ expect(result.success).toBe(true)
1796
+ if (result.success) {
1797
+ expect(result.data).toMatchObject({ limit: 25, offset: 50 })
1798
+ }
1799
+ })
1800
+
1801
+ it('keeps pagination optional for backward-compatible list reads', () => {
1802
+ expect(ListReadQuerySchema.safeParse({}).success).toBe(true)
1803
+ })
1804
+
1805
+ it('rejects unknown fields (strict mode)', () => {
1806
+ expect(ListReadQuerySchema.safeParse({ includeDeals: true }).success).toBe(false)
1807
+ })
1808
+ })
1809
+
1810
+ // ---------------------------------------------------------------------------
1811
+ // GetListQuerySchema
1812
+ // ---------------------------------------------------------------------------
1813
+
1814
+ describe('GetListQuerySchema', () => {
1815
+ it('defaults to thin deal refs and omits progress unless requested', () => {
1816
+ const result = GetListQuerySchema.safeParse({})
1817
+
1818
+ expect(result.success).toBe(true)
1819
+ if (result.success) {
1820
+ expect(result.data).toEqual({ includeDeals: true, includeProgress: false, dealLimit: 25 })
1821
+ }
1822
+ })
1823
+
1824
+ it('coerces boolean include flags from query strings', () => {
1825
+ const result = GetListQuerySchema.safeParse({
1826
+ includeDeals: 'false',
1827
+ includeProgress: 'true',
1828
+ dealLimit: '10'
1829
+ })
1830
+
1831
+ expect(result.success).toBe(true)
1832
+ if (result.success) {
1833
+ expect(result.data).toEqual({ includeDeals: false, includeProgress: true, dealLimit: 10 })
1834
+ }
1835
+ })
1836
+
1837
+ it('rejects unknown fields (strict mode)', () => {
1838
+ expect(GetListQuerySchema.safeParse({ depth: 2 }).success).toBe(false)
1839
+ })
1840
+ })
1841
+
1842
+ // ---------------------------------------------------------------------------
1843
+ // ListRecordsQuerySchema
1844
+ // ---------------------------------------------------------------------------
1845
+
1846
+ describe('ListRecordsQuerySchema', () => {
1847
+ it('accepts contact records for DTC decision-maker enrichment', () => {
1848
+ const result = ListRecordsQuerySchema.safeParse({
1849
+ entity: 'contact',
1850
+ stage: 'decision-makers-enriched'
1851
+ })
1852
+
1853
+ expect(result.success).toBe(true)
1854
+ })
1855
+
1856
+ it('keeps company records valid for qualified and uploaded stages', () => {
1857
+ expect(ListRecordsQuerySchema.safeParse({ entity: 'company', stage: 'qualified' }).success).toBe(true)
1858
+ expect(ListRecordsQuerySchema.safeParse({ entity: 'company', stage: 'uploaded' }).success).toBe(true)
1859
+ })
1860
+
1740
1861
  it('accepts stage/entity combinations at the transport boundary', () => {
1741
1862
  expect(ListRecordsQuerySchema.safeParse({ entity: 'contact', stage: 'qualified' }).success).toBe(true)
1742
1863
  })
1743
1864
  })
1744
-
1745
- // ---------------------------------------------------------------------------
1746
- // AcqListResponseSchema (forward-compat)
1747
- // ---------------------------------------------------------------------------
1748
-
1749
- describe('AcqListResponseSchema (forward-compat)', () => {
1750
- const baseList = {
1751
- id: VALID_UUID,
1752
- organizationId: VALID_UUID,
1753
- name: 'Test List',
1754
- description: null,
1755
- batchIds: [],
1756
- instantlyCampaignId: null,
1757
- status: 'draft' as const,
1758
- metadata: {},
1759
- launchedAt: null,
1760
- completedAt: null,
1761
- createdAt: ISO_TS,
1762
- scrapingConfig: {},
1763
- icp: {},
1764
- pipelineConfig: {}
1765
- }
1766
-
1767
- it('accepts a valid list response', () => {
1768
- expect(AcqListResponseSchema.safeParse(baseList).success).toBe(true)
1769
- })
1770
-
1771
- it('accepts extra fields at top level (not strict)', () => {
1772
- expect(AcqListResponseSchema.safeParse({ ...baseList, futureField: 'extra' }).success).toBe(true)
1773
- })
1774
- })
1775
-
1776
- // ---------------------------------------------------------------------------
1777
- // AcqListDetailResponseSchema
1778
- // ---------------------------------------------------------------------------
1779
-
1780
- describe('AcqListDetailResponseSchema', () => {
1781
- const baseList = {
1782
- id: VALID_UUID,
1783
- organizationId: VALID_UUID,
1784
- name: 'Test List',
1785
- description: null,
1786
- batchIds: [],
1787
- instantlyCampaignId: null,
1788
- status: 'draft' as const,
1789
- metadata: {},
1790
- launchedAt: null,
1791
- completedAt: null,
1792
- createdAt: ISO_TS,
1793
- scrapingConfig: {},
1794
- icp: {},
1795
- pipelineConfig: {}
1796
- }
1797
-
1798
- it('accepts thin lineage deal refs on list detail responses', () => {
1799
- expect(
1800
- AcqListDetailResponseSchema.safeParse({
1801
- ...baseList,
1802
- lineage: {
1803
- deals: {
1804
- total: 1,
1805
- refs: [
1806
- {
1807
- id: VALID_UUID,
1808
- contactEmail: 'lead@example.com',
1809
- stageKey: 'proposal',
1810
- stateKey: null,
1811
- sourceType: 'outreach',
1812
- lastActivityAt: ISO_TS
1813
- }
1814
- ],
1815
- truncated: false
1816
- }
1817
- }
1818
- }).success
1819
- ).toBe(true)
1820
- })
1821
-
1822
- it('accepts optional progress on list detail responses', () => {
1823
- expect(
1824
- AcqListDetailResponseSchema.safeParse({
1825
- ...baseList,
1826
- progress: {
1827
- totalMembers: 0,
1828
- totalCompanies: 0,
1829
- byCompanyStage: {},
1830
- byContactStage: {}
1831
- }
1832
- }).success
1833
- ).toBe(true)
1834
- })
1835
- })
1836
-
1837
- // ---------------------------------------------------------------------------
1838
- // AcqListStatusResponseSchema
1839
- // ---------------------------------------------------------------------------
1840
-
1841
- describe('AcqListStatusResponseSchema', () => {
1842
- it('accepts a portfolio summary across acquisition lists', () => {
1843
- expect(
1844
- AcqListStatusResponseSchema.safeParse({
1845
- totalLists: 1,
1846
- totalCompanies: 10,
1847
- totalContacts: 5,
1848
- totalDeals: 2,
1849
- byStatus: { launched: 1 },
1850
- lists: [
1851
- {
1852
- listId: VALID_UUID,
1853
- name: 'Pipeline',
1854
- status: 'launched',
1855
- totalCompanies: 10,
1856
- totalContacts: 5,
1857
- totalDeals: 2,
1858
- createdAt: ISO_TS
1859
- }
1860
- ]
1861
- }).success
1862
- ).toBe(true)
1863
- })
1864
- })
1865
-
1866
- // ---------------------------------------------------------------------------
1867
- // AcqContactResponseSchema (forward-compat)
1868
- // ---------------------------------------------------------------------------
1869
-
1870
- describe('AcqContactResponseSchema (forward-compat)', () => {
1871
- const baseContact = {
1872
- id: VALID_UUID,
1873
- organizationId: VALID_UUID,
1874
- companyId: null,
1875
- email: 'alice@example.com',
1876
- emailValid: null,
1877
- firstName: null,
1878
- lastName: null,
1879
- linkedinUrl: null,
1880
- title: null,
1881
- headline: null,
1882
- filterReason: null,
1883
- openingLine: null,
1884
- source: null,
1885
- sourceId: null,
1886
- processingState: null,
1887
- enrichmentData: null,
1888
- attioPersonId: null,
1889
- batchId: null,
1890
- status: 'active' as const,
1891
- createdAt: ISO_TS,
1892
- updatedAt: ISO_TS
1893
- }
1894
-
1895
- it('accepts a valid contact response', () => {
1896
- expect(AcqContactResponseSchema.safeParse(baseContact).success).toBe(true)
1897
- })
1898
-
1899
- it('accepts extra fields (not strict)', () => {
1900
- expect(AcqContactResponseSchema.safeParse({ ...baseContact, newField: 'x' }).success).toBe(true)
1901
- })
1902
-
1903
- it('rejects an invalid emailValid value', () => {
1904
- expect(AcqContactResponseSchema.safeParse({ ...baseContact, emailValid: 'BAD' }).success).toBe(false)
1905
- })
1906
- })
1865
+
1866
+ // ---------------------------------------------------------------------------
1867
+ // AcqListResponseSchema (forward-compat)
1868
+ // ---------------------------------------------------------------------------
1869
+
1870
+ describe('AcqListResponseSchema (forward-compat)', () => {
1871
+ const baseList = {
1872
+ id: VALID_UUID,
1873
+ organizationId: VALID_UUID,
1874
+ name: 'Test List',
1875
+ description: null,
1876
+ batchIds: [],
1877
+ instantlyCampaignId: null,
1878
+ status: 'draft' as const,
1879
+ metadata: {},
1880
+ launchedAt: null,
1881
+ completedAt: null,
1882
+ createdAt: ISO_TS,
1883
+ scrapingConfig: {},
1884
+ icp: {},
1885
+ pipelineConfig: {}
1886
+ }
1887
+
1888
+ it('accepts a valid list response', () => {
1889
+ expect(AcqListResponseSchema.safeParse(baseList).success).toBe(true)
1890
+ })
1891
+
1892
+ it('accepts extra fields at top level (not strict)', () => {
1893
+ expect(AcqListResponseSchema.safeParse({ ...baseList, futureField: 'extra' }).success).toBe(true)
1894
+ })
1895
+ })
1896
+
1897
+ // ---------------------------------------------------------------------------
1898
+ // AcqListDetailResponseSchema
1899
+ // ---------------------------------------------------------------------------
1900
+
1901
+ describe('AcqListDetailResponseSchema', () => {
1902
+ const baseList = {
1903
+ id: VALID_UUID,
1904
+ organizationId: VALID_UUID,
1905
+ name: 'Test List',
1906
+ description: null,
1907
+ batchIds: [],
1908
+ instantlyCampaignId: null,
1909
+ status: 'draft' as const,
1910
+ metadata: {},
1911
+ launchedAt: null,
1912
+ completedAt: null,
1913
+ createdAt: ISO_TS,
1914
+ scrapingConfig: {},
1915
+ icp: {},
1916
+ pipelineConfig: {}
1917
+ }
1918
+
1919
+ it('accepts thin lineage deal refs on list detail responses', () => {
1920
+ expect(
1921
+ AcqListDetailResponseSchema.safeParse({
1922
+ ...baseList,
1923
+ lineage: {
1924
+ deals: {
1925
+ total: 1,
1926
+ refs: [
1927
+ {
1928
+ id: VALID_UUID,
1929
+ contactEmail: 'lead@example.com',
1930
+ stageKey: 'proposal',
1931
+ stateKey: null,
1932
+ sourceType: 'outreach',
1933
+ lastActivityAt: ISO_TS
1934
+ }
1935
+ ],
1936
+ truncated: false
1937
+ }
1938
+ }
1939
+ }).success
1940
+ ).toBe(true)
1941
+ })
1942
+
1943
+ it('accepts optional progress on list detail responses', () => {
1944
+ expect(
1945
+ AcqListDetailResponseSchema.safeParse({
1946
+ ...baseList,
1947
+ progress: {
1948
+ totalMembers: 0,
1949
+ totalCompanies: 0,
1950
+ byCompanyStage: {},
1951
+ byContactStage: {}
1952
+ }
1953
+ }).success
1954
+ ).toBe(true)
1955
+ })
1956
+ })
1957
+
1958
+ // ---------------------------------------------------------------------------
1959
+ // AcqListStatusResponseSchema
1960
+ // ---------------------------------------------------------------------------
1961
+
1962
+ describe('AcqListStatusResponseSchema', () => {
1963
+ it('accepts a portfolio summary across acquisition lists', () => {
1964
+ expect(
1965
+ AcqListStatusResponseSchema.safeParse({
1966
+ totalLists: 1,
1967
+ totalCompanies: 10,
1968
+ totalContacts: 5,
1969
+ totalDeals: 2,
1970
+ byStatus: { launched: 1 },
1971
+ lists: [
1972
+ {
1973
+ listId: VALID_UUID,
1974
+ name: 'Pipeline',
1975
+ status: 'launched',
1976
+ totalCompanies: 10,
1977
+ totalContacts: 5,
1978
+ totalDeals: 2,
1979
+ createdAt: ISO_TS
1980
+ }
1981
+ ]
1982
+ }).success
1983
+ ).toBe(true)
1984
+ })
1985
+ })
1986
+
1987
+ // ---------------------------------------------------------------------------
1988
+ // AcqContactResponseSchema (forward-compat)
1989
+ // ---------------------------------------------------------------------------
1990
+
1991
+ describe('AcqContactResponseSchema (forward-compat)', () => {
1992
+ const baseContact = {
1993
+ id: VALID_UUID,
1994
+ organizationId: VALID_UUID,
1995
+ companyId: null,
1996
+ email: 'alice@example.com',
1997
+ emailValid: null,
1998
+ firstName: null,
1999
+ lastName: null,
2000
+ linkedinUrl: null,
2001
+ title: null,
2002
+ headline: null,
2003
+ filterReason: null,
2004
+ openingLine: null,
2005
+ source: null,
2006
+ sourceId: null,
2007
+ processingState: null,
2008
+ enrichmentData: null,
2009
+ attioPersonId: null,
2010
+ batchId: null,
2011
+ status: 'active' as const,
2012
+ createdAt: ISO_TS,
2013
+ updatedAt: ISO_TS
2014
+ }
2015
+
2016
+ it('accepts a valid contact response', () => {
2017
+ expect(AcqContactResponseSchema.safeParse(baseContact).success).toBe(true)
2018
+ })
2019
+
2020
+ it('accepts extra fields (not strict)', () => {
2021
+ expect(AcqContactResponseSchema.safeParse({ ...baseContact, newField: 'x' }).success).toBe(true)
2022
+ })
2023
+
2024
+ it('rejects an invalid emailValid value', () => {
2025
+ expect(AcqContactResponseSchema.safeParse({ ...baseContact, emailValid: 'BAD' }).success).toBe(false)
2026
+ })
2027
+ })