@elevasis/core 0.22.0 → 0.24.0

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