@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.
- package/dist/index.d.ts +5 -5
- package/dist/index.js +209 -173
- package/dist/knowledge/index.d.ts +21 -21
- package/dist/organization-model/index.d.ts +5 -5
- package/dist/organization-model/index.js +209 -173
- package/dist/test-utils/index.d.ts +2 -2
- package/dist/test-utils/index.js +182 -126
- package/package.json +1 -1
- package/src/_gen/__tests__/__snapshots__/contracts.md.snap +976 -1063
- package/src/business/acquisition/api-schemas.test.ts +1962 -1841
- package/src/business/acquisition/api-schemas.ts +1461 -1464
- package/src/business/acquisition/crm-next-action.test.ts +45 -25
- package/src/business/acquisition/crm-next-action.ts +227 -220
- package/src/business/acquisition/crm-priority.test.ts +41 -8
- package/src/business/acquisition/crm-priority.ts +365 -349
- package/src/business/acquisition/crm-state-actions.test.ts +208 -153
- package/src/business/acquisition/derive-actions.test.ts +90 -13
- package/src/business/acquisition/derive-actions.ts +8 -139
- package/src/business/acquisition/ontology-validation.ts +72 -158
- package/src/business/pdf/sections/investment.ts +1 -1
- package/src/business/pdf/sections/summary-investment.ts +1 -1
- package/src/execution/engine/tools/tool-maps.ts +872 -831
- package/src/organization-model/__tests__/cross-ref.test.ts +167 -0
- package/src/organization-model/__tests__/published-zero-leak.test.ts +60 -1
- package/src/organization-model/__tests__/resolve.test.ts +1 -1
- package/src/organization-model/__tests__/schema-refinements.test.ts +72 -0
- package/src/organization-model/cross-ref.ts +175 -0
- package/src/organization-model/domains/branding.ts +6 -6
- package/src/organization-model/domains/sales.test.ts +104 -218
- package/src/organization-model/domains/sales.ts +212 -375
- package/src/organization-model/index.ts +1 -0
- package/src/organization-model/schema-refinements.ts +667 -0
- package/src/organization-model/schema.ts +8 -715
- 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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
LEAD_GEN_PIPELINE_DEFINITIONS,
|
|
4
|
+
type CrmPriorityRuleConfig,
|
|
5
|
+
type StatefulPipelineDefinition
|
|
6
6
|
} from '../../organization-model/domains/sales'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
+
compileBusinessOntologyValidationIndex,
|
|
59
60
|
CRM_PIPELINE_CATALOG_ONTOLOGY_ID,
|
|
60
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
expect(DealStageSchema.safeParse('
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
it('
|
|
111
|
-
expect(CrmStateKeySchema.safeParse('custom_state').success).toBe(
|
|
112
|
-
expect(CrmStateKeySchema.safeParse('').success).toBe(
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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('
|
|
127
|
-
const
|
|
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
|
-
|
|
130
|
-
|
|
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('
|
|
134
|
-
|
|
135
|
-
expect(
|
|
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('
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
//
|
|
357
|
+
// CrmTransitionItemRequestSchema
|
|
146
358
|
// ---------------------------------------------------------------------------
|
|
147
|
-
|
|
148
|
-
describe('
|
|
149
|
-
const valid = {
|
|
150
|
-
pipelineKey: '
|
|
151
|
-
stageKey: 'interested',
|
|
152
|
-
stateKey: null
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
it('accepts
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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(
|
|
276
|
-
expect(CrmTransitionItemRequestSchema.safeParse({ ...valid, stateKey: 'unknown_state' }).success).toBe(
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
})
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
it('
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
expect(
|
|
359
|
-
})
|
|
360
|
-
|
|
361
|
-
it('
|
|
362
|
-
expect(CreateDealNoteRequestSchema.safeParse({ body: '
|
|
363
|
-
})
|
|
364
|
-
|
|
365
|
-
it('rejects
|
|
366
|
-
expect(CreateDealNoteRequestSchema.safeParse({ body: '
|
|
367
|
-
})
|
|
368
|
-
|
|
369
|
-
it('
|
|
370
|
-
expect(CreateDealNoteRequestSchema.safeParse({ body: '
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
})
|
|
398
|
-
|
|
399
|
-
it('rejects an invalid
|
|
400
|
-
expect(CreateDealTaskRequestSchema.safeParse({ ...valid,
|
|
401
|
-
})
|
|
402
|
-
|
|
403
|
-
it('accepts
|
|
404
|
-
expect(CreateDealTaskRequestSchema.safeParse({ ...valid, dueAt:
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
it('
|
|
408
|
-
expect(CreateDealTaskRequestSchema.safeParse({ ...valid,
|
|
409
|
-
})
|
|
410
|
-
|
|
411
|
-
it('
|
|
412
|
-
expect(CreateDealTaskRequestSchema.safeParse({ ...valid,
|
|
413
|
-
})
|
|
414
|
-
|
|
415
|
-
it('accepts
|
|
416
|
-
expect(CreateDealTaskRequestSchema.safeParse({ ...valid, assigneeUserId:
|
|
417
|
-
})
|
|
418
|
-
|
|
419
|
-
it('rejects
|
|
420
|
-
expect(CreateDealTaskRequestSchema.safeParse({
|
|
421
|
-
})
|
|
422
|
-
|
|
423
|
-
it('
|
|
424
|
-
expect(CreateDealTaskRequestSchema.safeParse({
|
|
425
|
-
})
|
|
426
|
-
|
|
427
|
-
it('rejects
|
|
428
|
-
expect(CreateDealTaskRequestSchema.safeParse({
|
|
429
|
-
})
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
expect(
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
it('rejects
|
|
467
|
-
expect(ListDealsQuerySchema.safeParse({ limit: '
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
expect(ListDealsQuerySchema.safeParse({
|
|
477
|
-
})
|
|
478
|
-
|
|
479
|
-
it('
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
})
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
expect(
|
|
512
|
-
})
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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('
|
|
1034
|
-
|
|
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('
|
|
1039
|
-
expect(
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
})
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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('
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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(
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
expect(
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
+
})
|