@coldiq/mcp 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coldiq/mcp",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -13,9 +13,7 @@
13
13
  "test": "vitest run",
14
14
  "test:watch": "vitest",
15
15
  "test:gtm": "LIVE_TESTS=1 vitest run tests/gtm",
16
- "test:gtm:one": "LIVE_TESTS=1 vitest run",
17
- "version": "git add package.json package-lock.json",
18
- "postversion": "git commit -m \"chore(mcp): release $npm_package_version\" && git tag mcp-v$npm_package_version && git push && git push --tags"
16
+ "test:gtm:one": "LIVE_TESTS=1 vitest run"
19
17
  },
20
18
  "engines": {
21
19
  "node": ">=18.18"
package/src/registry.ts CHANGED
@@ -1032,11 +1032,23 @@ const findPeopleProviders: ProviderEntry[] = [
1032
1032
  }
1033
1033
  },
1034
1034
  hasResult: (data) => {
1035
- const d = data as Record<string, unknown>
1036
- return (
1037
- isNonEmptyArray(d.companies_personas) ||
1038
- (d.status === 'SUCCESSFUL' && d.nb_jobs_complete != null && (d.nb_jobs_complete as number) > 0)
1039
- )
1035
+ // Each entry in companies_personas is { company, personas: unknown[][], ... }.
1036
+ // The wrapper array is always populated (one entry per requested domain), so
1037
+ // checking only `isNonEmptyArray(d.companies_personas)` returns true even when
1038
+ // every entry's inner `personas` is empty (no contacts found). Count the
1039
+ // actual contacts so the waterfall falls through to apollo on empty results.
1040
+ const d = data as Record<string, unknown>
1041
+ const groups = d.companies_personas as Array<{ personas?: unknown[][] }> | undefined
1042
+ if (Array.isArray(groups)) {
1043
+ const totalContacts = groups.reduce(
1044
+ (sum, g) => sum + ((g.personas ?? []).flat().length),
1045
+ 0,
1046
+ )
1047
+ return totalContacts > 0
1048
+ }
1049
+ // Defensive fallback: if upstream omits companies_personas entirely, trust the
1050
+ // job-completion counter. Not observed in practice; kept for safety.
1051
+ return d.status === 'SUCCESSFUL' && d.nb_jobs_complete != null && (d.nb_jobs_complete as number) > 0
1040
1052
  },
1041
1053
  async: {
1042
1054
  pollEndpoint: (id: string) => `/leadsfactory/contact-finder/searches/${id}`,
@@ -1115,12 +1127,27 @@ const findPeopleProviders: ProviderEntry[] = [
1115
1127
  endpoint: '/pdl/person/search',
1116
1128
  method: 'POST',
1117
1129
  priority: 3,
1130
+ // Refuse fully-unbounded queries: PDL accepts an empty `must` and would return its
1131
+ // entire index — turning the waterfall into a "random 25 humans" generator.
1132
+ isApplicable: (input) =>
1133
+ isNonEmptyArray(input.company_domains) ||
1134
+ isNonEmptyArray(input.company_linkedin_urls) ||
1135
+ isNonEmptyArray(input.job_titles) ||
1136
+ isNonEmptyArray(input.seniorities),
1118
1137
  mapParams: (input) => {
1119
1138
  const must: unknown[] = []
1120
1139
  if (isNonEmptyArray(input.job_titles))
1121
1140
  must.push({ terms: { job_title: input.job_titles } })
1122
1141
  if (isNonEmptyArray(input.company_domains))
1123
1142
  must.push({ terms: { job_company_website: input.company_domains } })
1143
+ if (isNonEmptyArray(input.company_linkedin_urls)) {
1144
+ // PDL indexes LinkedIn URLs in canonical short form (`linkedin.com/company/x`)
1145
+ // and `terms` is exact-match — full URLs from callers silently return 0 hits.
1146
+ const normalized = (input.company_linkedin_urls as string[]).map((u) =>
1147
+ u.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, ''),
1148
+ )
1149
+ must.push({ terms: { job_company_linkedin_url: normalized } })
1150
+ }
1124
1151
  if (isNonEmptyArray(input.seniorities))
1125
1152
  must.push({ terms: { job_title_levels: input.seniorities } })
1126
1153
  if (isNonEmptyArray(input.locations))
@@ -1142,10 +1169,16 @@ const findPeopleProviders: ProviderEntry[] = [
1142
1169
  endpoint: '/companyenrich/people/search',
1143
1170
  method: 'POST',
1144
1171
  priority: 4,
1172
+ // Upstream `filters.companies` accepts domains only — no LinkedIn-URL field exists.
1173
+ // Skip when the caller supplied only LinkedIn URLs so we don't fall back to a
1174
+ // titles-only global search that masquerades as company-scoped.
1175
+ isApplicable: (input) =>
1176
+ isNonEmptyArray(input.company_domains) || !isNonEmptyArray(input.company_linkedin_urls),
1145
1177
  mapParams: (input) => ({
1146
1178
  body: {
1147
1179
  filters: {
1148
1180
  jobTitles: input.job_titles,
1181
+ companies: isNonEmptyArray(input.company_domains) ? input.company_domains : undefined,
1149
1182
  countries: input.locations,
1150
1183
  },
1151
1184
  pageSize: (input.limit as number) ?? 25,
@@ -1281,6 +1314,12 @@ const findPeopleProviders: ProviderEntry[] = [
1281
1314
  endpoint: '/fullenrich/people/search',
1282
1315
  method: 'POST',
1283
1316
  priority: 9,
1317
+ // Requires company_domains: the upstream rejects LinkedIn-URL variants as
1318
+ // `error.filters.empty` (despite the schema declaring `current_company_linkedin_urls`,
1319
+ // the live API doesn't honor it). Without any company filter, FullEnrich silently
1320
+ // degrades to a 50k-result titles-only search whose first 25 short-circuit the
1321
+ // waterfall via hasResult.
1322
+ isApplicable: (input) => isNonEmptyArray(input.company_domains),
1284
1323
  mapParams: (input) => ({
1285
1324
  body: {
1286
1325
  current_company_domains: isNonEmptyArray(input.company_domains)
@@ -198,6 +198,9 @@ const GATED_DESCRIPTIONS: Partial<Record<Capability, Partial<Record<string, Gate
198
198
  'prospeo-search-person': { kind: 'requires', fields: 'job_titles or company_domains' },
199
199
  'ai-ark-people': { kind: 'requires', fields: 'job_titles, seniorities, or keywords' },
200
200
  'findymail-search-employees': { kind: 'requires', fields: 'company_domains and job_titles' },
201
+ 'fullenrich-people-search': { kind: 'requires', fields: 'company_domains (upstream does not accept LinkedIn URL filters)' },
202
+ pdl: { kind: 'requires', fields: 'company_domains, company_linkedin_urls, job_titles, or seniorities' },
203
+ companyenrich: { kind: 'incompatible_with', fields: 'company_linkedin_urls without company_domains (upstream supports domain filters only)' },
201
204
  },
202
205
  // -------------------------------------------------------------------------
203
206
  find_email: {
@@ -0,0 +1,55 @@
1
+ // Direct upstream probe — documents what FullEnrich /people/search actually
2
+ // honors as a company filter. The ColdIQ wrapper schema at
3
+ // src/providers/fullenrich/schema.ts declares `current_company_linkedin_urls`,
4
+ // but the live API rejects every shape with `error.filters.empty` (400). Only
5
+ // `current_company_domains` and `current_company_names` actually scope results.
6
+ //
7
+ // This is why the registry mapper for `fullenrich-people-search` is gated
8
+ // behind `company_domains` only — adding the LinkedIn URL field caused the
9
+ // upstream to treat the request as filterless and return a 50k-result global
10
+ // titles-only search, masquerading as company-scoped.
11
+ //
12
+ // Run: FULLENRICH_API_KEY=… npx tsx mcp/tests/live/fullenrich-upstream-probe.ts
13
+
14
+ const KEY = process.env.FULLENRICH_API_KEY
15
+ if (!KEY) { console.error('FULLENRICH_API_KEY required'); process.exit(1) }
16
+ const URL = 'https://app.fullenrich.com/api/v2/people/search'
17
+
18
+ async function call(label: string, body: unknown) {
19
+ const t0 = Date.now()
20
+ const res = await fetch(URL, {
21
+ method: 'POST',
22
+ headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
23
+ body: JSON.stringify(body),
24
+ })
25
+ const data: any = await res.json().catch(async () => ({ raw: await res.text() }))
26
+ const total = data.metadata?.total
27
+ const ok = res.status === 200
28
+ console.log(`[${ok ? 'OK ' : 'ERR'}] ${label.padEnd(60)} status=${res.status} ms=${Date.now() - t0} total=${total ?? '-'} ${ok ? '' : 'err=' + JSON.stringify(data).slice(0, 120)}`)
29
+ }
30
+
31
+ async function main() {
32
+ console.log('=== FullEnrich /people/search filter compatibility probe ===\n')
33
+
34
+ console.log('▸ Known-working filters (should all return 200 with total > 0)')
35
+ await call('current_company_domains', { current_company_domains: [{ value: 'spendesk.com' }], limit: 1 })
36
+ await call('current_company_names', { current_company_names: [{ value: 'Spendesk' }], limit: 1 })
37
+
38
+ console.log('\n▸ LinkedIn URL variants (declared in ColdIQ schema, NOT honored upstream)')
39
+ for (const [field, value] of [
40
+ ['current_company_linkedin_urls', 'https://www.linkedin.com/company/spendesk'],
41
+ ['current_company_linkedin_url', 'https://www.linkedin.com/company/spendesk'],
42
+ ['current_company_linkedin_urls (short)', 'linkedin.com/company/spendesk'],
43
+ ['current_company_linkedin_urls (slug)', 'spendesk'],
44
+ ['current_company_linkedin_ids', 'spendesk'],
45
+ ] as const) {
46
+ const key = field.split(' ')[0]
47
+ await call(field, { [key]: [{ value }], limit: 1 })
48
+ }
49
+
50
+ console.log('\n▸ No-filter baseline (the buggy path: 50k+ titles-only results)')
51
+ await call('titles-only, no company', { current_position_titles: [{ value: 'CEO' }], limit: 1 })
52
+ await call('completely empty (sanity)', { limit: 1 })
53
+ }
54
+
55
+ main().catch((e) => { console.error('FATAL', e); process.exit(1) })
@@ -0,0 +1,83 @@
1
+ // Direct upstream probe — verifies PDL's /person/search accepts
2
+ // `job_company_linkedin_url` as a filterable term, and that the returned people
3
+ // actually work at the company we asked for.
4
+ //
5
+ // PDL's Elasticsearch DSL is permissive: an unknown field name returns 0 hits
6
+ // rather than an error, so a silent miss looks identical to "no data." The
7
+ // probe compares a known-good baseline (job_company_website) against the
8
+ // LinkedIn-URL variant.
9
+ //
10
+ // Run: PDL_API_KEY=… npx tsx mcp/tests/live/pdl-upstream-probe.ts
11
+
12
+ const KEY = process.env.PDL_API_KEY
13
+ if (!KEY) { console.error('PDL_API_KEY required'); process.exit(1) }
14
+ const BASE = 'https://api.peopledatalabs.com/v5'
15
+
16
+ async function search(must: unknown[], size = 3) {
17
+ const t0 = Date.now()
18
+ const res = await fetch(`${BASE}/person/search`, {
19
+ method: 'POST',
20
+ headers: { 'X-API-Key': KEY!, 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({ query: { bool: { must } }, size }),
22
+ signal: AbortSignal.timeout(30_000),
23
+ })
24
+ let data: any
25
+ try { data = await res.json() } catch { data = await res.text() }
26
+ return { ok: res.ok, status: res.status, ms: Date.now() - t0, data }
27
+ }
28
+
29
+ function summarize(label: string, r: any) {
30
+ const data = r.data ?? {}
31
+ const total = data.total ?? '?'
32
+ const items = (data.data ?? []) as Array<any>
33
+ console.log(`\n[${label}] status=${r.status} ms=${r.ms} total=${total} returned=${items.length}`)
34
+ for (const p of items.slice(0, 5)) {
35
+ console.log(` - ${p.full_name} | ${p.job_title} | co=${p.job_company_name} | website=${p.job_company_website} | li=${p.job_company_linkedin_url}`)
36
+ }
37
+ }
38
+
39
+ async function main() {
40
+ console.log('=== PDL upstream probe: verify job_company_linkedin_url ===')
41
+
42
+ // Use a well-known mid-sized B2B SaaS for the comparison. Aircall is a good fit:
43
+ // ~700 employees, indexed by both website and LinkedIn URL in PDL.
44
+ const TARGET_DOMAIN = 'aircall.io'
45
+ const TARGET_LINKEDIN = 'linkedin.com/company/aircall'
46
+
47
+ // Baseline: search by known-working field (job_company_website)
48
+ console.log(`\n▸ Baseline: search by job_company_website = ${TARGET_DOMAIN}`)
49
+ const byDomain = await search([{ terms: { job_company_website: [TARGET_DOMAIN] } }])
50
+ summarize('job_company_website', byDomain)
51
+
52
+ // Test: same search but by LinkedIn URL
53
+ console.log(`\n▸ Test: search by job_company_linkedin_url = ${TARGET_LINKEDIN}`)
54
+ const byLinkedIn = await search([{ terms: { job_company_linkedin_url: [TARGET_LINKEDIN] } }])
55
+ summarize('job_company_linkedin_url', byLinkedIn)
56
+
57
+ const baselineTotal = byDomain.data?.total ?? 0
58
+ const linkedinTotal = byLinkedIn.data?.total ?? 0
59
+
60
+ console.log(`\n=== Result ===`)
61
+ console.log(`baseline (by website): total=${baselineTotal}`)
62
+ console.log(`test (by linkedin_url): total=${linkedinTotal}`)
63
+
64
+ if (linkedinTotal > 0 && baselineTotal > 0) {
65
+ const ratio = linkedinTotal / baselineTotal
66
+ console.log(`ratio = ${ratio.toFixed(2)} (1.0 = same company, both filters work)`)
67
+ if (ratio >= 0.5) {
68
+ console.log('✅ job_company_linkedin_url WORKS — keep the mapping in registry.ts')
69
+ process.exit(0)
70
+ } else {
71
+ console.log('⚠️ linkedin_url returns far fewer matches than website — investigate')
72
+ process.exit(2)
73
+ }
74
+ } else if (linkedinTotal === 0 && baselineTotal > 0) {
75
+ console.log('❌ job_company_linkedin_url silently returns 0 — REMOVE the mapping clause for pdl in registry.ts and keep gate-only')
76
+ process.exit(1)
77
+ } else {
78
+ console.log('⚠️ Baseline itself returned 0 — pick a different test company')
79
+ process.exit(2)
80
+ }
81
+ }
82
+
83
+ main().catch((e) => { console.error('FATAL', e); process.exit(1) })
@@ -229,8 +229,16 @@ describe('ai-ark-people', () => {
229
229
  describe('fullenrich-people-search', () => {
230
230
  const p = () => get('fullenrich-people-search')
231
231
 
232
- it('has no isApplicable gate (always runs)', () => {
233
- expect(p().isApplicable).toBeUndefined()
232
+ it('isApplicable: true with company_domains', () => {
233
+ expect(p().isApplicable!({ company_domains: ['coldiq.com'], job_titles: ['CEO'] })).toBe(true)
234
+ })
235
+
236
+ it('isApplicable: false with only company_linkedin_urls (upstream rejects LinkedIn-URL filters)', () => {
237
+ expect(p().isApplicable!({ company_linkedin_urls: ['https://www.linkedin.com/company/coldiq'], job_titles: ['CEO'] })).toBe(false)
238
+ })
239
+
240
+ it('isApplicable: false without any company identifier (titles-only would degrade to global search)', () => {
241
+ expect(p().isApplicable!({ job_titles: ['CEO'] })).toBe(false)
234
242
  })
235
243
 
236
244
  it('mapParams wraps all fields in {value} objects', () => {
@@ -252,9 +260,8 @@ describe('fullenrich-people-search', () => {
252
260
  })
253
261
 
254
262
  it('mapParams omits undefined fields', () => {
255
- const result = p().mapParams({ job_titles: ['CEO'], limit: 5 })
263
+ const result = p().mapParams({ company_domains: ['coldiq.com'], limit: 5 })
256
264
  const body = result.body as Record<string, unknown>
257
- expect(body.current_company_domains).toBeUndefined()
258
265
  expect(body.current_position_seniority_level).toBeUndefined()
259
266
  expect(body.person_locations).toBeUndefined()
260
267
  })
@@ -580,23 +587,57 @@ describe('leadsfactory (find_people)', () => {
580
587
  expect(body.keywords).toBeUndefined()
581
588
  })
582
589
 
583
- it('hasResult: true when companies_personas is non-empty', () => {
584
- expect(p().hasResult({ companies_personas: [{ company: 'ColdIQ' }] })).toBe(true)
590
+ it('hasResult: true when at least one company has personas with contacts', () => {
591
+ expect(
592
+ p().hasResult({
593
+ companies_personas: [
594
+ { company: 'ColdIQ', personas: [[{ contact: { name: 'Michel' } }]] },
595
+ ],
596
+ }),
597
+ ).toBe(true)
598
+ })
599
+
600
+ it('hasResult: false when all companies have empty personas arrays', () => {
601
+ // The bug scenario: leadsfactory returns SUCCESSFUL but every company's
602
+ // personas: [[]] is empty. Must return false so the waterfall falls
603
+ // through to apollo.
604
+ expect(
605
+ p().hasResult({
606
+ status: 'SUCCESSFUL',
607
+ companies_personas: [
608
+ { company: 'Beamy', personas: [[]] },
609
+ ],
610
+ }),
611
+ ).toBe(false)
612
+ })
613
+
614
+ it('hasResult: true on multi-company partial result (one filled, one empty)', () => {
615
+ // Until we have per-company fallback, a multi-company query that finds
616
+ // contacts for any company keeps the leadsfactory result. Per-company
617
+ // fallback is tracked separately.
618
+ expect(
619
+ p().hasResult({
620
+ companies_personas: [
621
+ { company: 'ColdIQ', personas: [[{ contact: { name: 'Michel' } }]] },
622
+ { company: 'Folk', personas: [[]] },
623
+ ],
624
+ }),
625
+ ).toBe(true)
585
626
  })
586
627
 
587
628
  it('hasResult: false when companies_personas is empty array', () => {
588
629
  expect(p().hasResult({ companies_personas: [] })).toBe(false)
589
630
  })
590
631
 
591
- it('hasResult: true when SUCCESSFUL + nb_jobs_complete > 0 (no companies_personas in response)', () => {
632
+ it('hasResult: true on defensive fallback when companies_personas missing and nb_jobs_complete > 0', () => {
592
633
  expect(p().hasResult({ status: 'SUCCESSFUL', nb_jobs_complete: 3 })).toBe(true)
593
634
  })
594
635
 
595
- it('hasResult: false when SUCCESSFUL + nb_jobs_complete is null (no results)', () => {
636
+ it('hasResult: false on defensive fallback when nb_jobs_complete is null', () => {
596
637
  expect(p().hasResult({ status: 'SUCCESSFUL', nb_jobs_complete: null })).toBe(false)
597
638
  })
598
639
 
599
- it('hasResult: false when SUCCESSFUL + nb_jobs_complete is 0', () => {
640
+ it('hasResult: false on defensive fallback when nb_jobs_complete is 0', () => {
600
641
  expect(p().hasResult({ status: 'SUCCESSFUL', nb_jobs_complete: 0 })).toBe(false)
601
642
  })
602
643
 
@@ -621,6 +662,162 @@ describe('leadsfactory (find_people)', () => {
621
662
  })
622
663
  })
623
664
 
665
+ // ---------------------------------------------------------------------------
666
+ // pdl
667
+ // ---------------------------------------------------------------------------
668
+
669
+ describe('pdl (find_people)', () => {
670
+ const p = () => get('pdl')
671
+
672
+ it('isApplicable: true with company_domains', () => {
673
+ expect(p().isApplicable!({ company_domains: ['coldiq.com'] })).toBe(true)
674
+ })
675
+
676
+ it('isApplicable: true with company_linkedin_urls', () => {
677
+ expect(p().isApplicable!({ company_linkedin_urls: ['https://www.linkedin.com/company/coldiq'] })).toBe(true)
678
+ })
679
+
680
+ it('isApplicable: true with job_titles', () => {
681
+ expect(p().isApplicable!({ job_titles: ['CEO'] })).toBe(true)
682
+ })
683
+
684
+ it('isApplicable: true with seniorities', () => {
685
+ expect(p().isApplicable!({ seniorities: ['c_suite'] })).toBe(true)
686
+ })
687
+
688
+ it('isApplicable: false with only locations (would return entire index)', () => {
689
+ expect(p().isApplicable!({ locations: ['FR'] })).toBe(false)
690
+ })
691
+
692
+ it('isApplicable: false with no filters', () => {
693
+ expect(p().isApplicable!({})).toBe(false)
694
+ })
695
+
696
+ it('mapParams maps company_domains to job_company_website term', () => {
697
+ const result = p().mapParams({ company_domains: ['coldiq.com'], job_titles: ['CEO'], limit: 10 })
698
+ const body = result.body as { query: { bool: { must: unknown[] } }; size: number }
699
+ expect(body.query.bool.must).toContainEqual({ terms: { job_company_website: ['coldiq.com'] } })
700
+ expect(body.query.bool.must).toContainEqual({ terms: { job_title: ['CEO'] } })
701
+ expect(body.size).toBe(10)
702
+ })
703
+
704
+ it('mapParams normalizes LinkedIn URLs to PDL canonical short form', () => {
705
+ // PDL indexes URLs as `linkedin.com/company/x` (no protocol, no www, no trailing slash);
706
+ // its `terms` query is exact-match — full URLs from callers must be stripped first.
707
+ const result = p().mapParams({
708
+ company_linkedin_urls: [
709
+ 'https://www.linkedin.com/company/coldiq',
710
+ 'https://linkedin.com/company/microsoft/',
711
+ 'www.linkedin.com/company/aircall',
712
+ ],
713
+ job_titles: ['CMO'],
714
+ })
715
+ const body = result.body as { query: { bool: { must: unknown[] } } }
716
+ expect(body.query.bool.must).toContainEqual({
717
+ terms: {
718
+ job_company_linkedin_url: [
719
+ 'linkedin.com/company/coldiq',
720
+ 'linkedin.com/company/microsoft',
721
+ 'linkedin.com/company/aircall',
722
+ ],
723
+ },
724
+ })
725
+ })
726
+
727
+ it('mapParams emits both domain and linkedin terms when both provided', () => {
728
+ const result = p().mapParams({
729
+ company_domains: ['coldiq.com'],
730
+ company_linkedin_urls: ['https://www.linkedin.com/company/coldiq'],
731
+ job_titles: ['CEO'],
732
+ })
733
+ const body = result.body as { query: { bool: { must: unknown[] } } }
734
+ expect(body.query.bool.must).toContainEqual({ terms: { job_company_website: ['coldiq.com'] } })
735
+ expect(body.query.bool.must).toContainEqual({
736
+ terms: { job_company_linkedin_url: ['linkedin.com/company/coldiq'] },
737
+ })
738
+ })
739
+
740
+ it('hasResult: true when data array non-empty', () => {
741
+ expect(p().hasResult({ data: [{ full_name: 'Michel Lieben' }] })).toBe(true)
742
+ })
743
+
744
+ it('hasResult: false when data is empty', () => {
745
+ expect(p().hasResult({ data: [] })).toBe(false)
746
+ })
747
+
748
+ it('priority is 3', () => {
749
+ expect(p().priority).toBe(3)
750
+ })
751
+ })
752
+
753
+ // ---------------------------------------------------------------------------
754
+ // companyenrich
755
+ // ---------------------------------------------------------------------------
756
+
757
+ describe('companyenrich (find_people)', () => {
758
+ const p = () => get('companyenrich')
759
+
760
+ it('isApplicable: true with company_domains', () => {
761
+ expect(p().isApplicable!({ company_domains: ['coldiq.com'], job_titles: ['CEO'] })).toBe(true)
762
+ })
763
+
764
+ it('isApplicable: true with neither company filter (degrades to titles/locations search)', () => {
765
+ expect(p().isApplicable!({ job_titles: ['CEO'] })).toBe(true)
766
+ })
767
+
768
+ it('isApplicable: false with only company_linkedin_urls (upstream has no LinkedIn-URL filter)', () => {
769
+ expect(p().isApplicable!({
770
+ company_linkedin_urls: ['https://www.linkedin.com/company/coldiq'],
771
+ job_titles: ['CEO'],
772
+ })).toBe(false)
773
+ })
774
+
775
+ it('isApplicable: true when both linkedin_urls AND domains are present (domains carry the search)', () => {
776
+ expect(p().isApplicable!({
777
+ company_domains: ['coldiq.com'],
778
+ company_linkedin_urls: ['https://www.linkedin.com/company/coldiq'],
779
+ job_titles: ['CEO'],
780
+ })).toBe(true)
781
+ })
782
+
783
+ it('mapParams maps company_domains to filters.companies', () => {
784
+ const result = p().mapParams({
785
+ company_domains: ['coldiq.com', 'microsoft.com'],
786
+ job_titles: ['CEO'],
787
+ locations: ['FR'],
788
+ limit: 10,
789
+ })
790
+ expect(result).toEqual({
791
+ body: {
792
+ filters: {
793
+ jobTitles: ['CEO'],
794
+ companies: ['coldiq.com', 'microsoft.com'],
795
+ countries: ['FR'],
796
+ },
797
+ pageSize: 10,
798
+ },
799
+ })
800
+ })
801
+
802
+ it('mapParams omits filters.companies when no domains', () => {
803
+ const result = p().mapParams({ job_titles: ['CEO'], limit: 25 })
804
+ const body = result.body as { filters: Record<string, unknown> }
805
+ expect(body.filters.companies).toBeUndefined()
806
+ })
807
+
808
+ it('hasResult: true when data array non-empty', () => {
809
+ expect(p().hasResult({ data: [{ name: 'Michel Lieben' }] })).toBe(true)
810
+ })
811
+
812
+ it('hasResult: false when data is empty', () => {
813
+ expect(p().hasResult({ data: [] })).toBe(false)
814
+ })
815
+
816
+ it('priority is 4', () => {
817
+ expect(p().priority).toBe(4)
818
+ })
819
+ })
820
+
624
821
  // ---------------------------------------------------------------------------
625
822
  // Ordering: find_people providers sorted correctly
626
823
  // ---------------------------------------------------------------------------
@@ -84,7 +84,9 @@ describe('find_people handler', () => {
84
84
  status: 'SUCCESSFUL',
85
85
  nb_jobs_total: 1,
86
86
  nb_jobs_complete: 1,
87
- companies_personas: [{ company: 'ColdIQ', contacts: [{ first_name: 'Michel', last_name: 'Lieben', title: 'CEO' }] }],
87
+ companies_personas: [
88
+ { company: 'ColdIQ', personas: [[{ contact: { first_name: 'Michel', last_name: 'Lieben', title: 'CEO' } }]] },
89
+ ],
88
90
  }), { status: 200 })
89
91
  }
90
92
 
@@ -124,12 +126,17 @@ describe('find_people handler', () => {
124
126
  return new Response(JSON.stringify({ id: 'abc123', search_id: 'abc', nb_jobs_total: 2, progress_percentage: 0 }), { status: 201 })
125
127
  }
126
128
 
127
- // LeadsFactory poll — SUCCESSFUL with one miss
129
+ // LeadsFactory poll — SUCCESSFUL with one miss.
130
+ // Real upstream shape: companies_personas[].personas is unknown[][], each
131
+ // inner item wraps a contact object. The hasResult check counts contacts
132
+ // across all entries, so mocks must use this nested shape to match prod.
128
133
  if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
129
134
  return new Response(JSON.stringify({
130
135
  id: 'abc123', search_id: 'abc', status: 'SUCCESSFUL',
131
136
  nb_jobs_total: 2, nb_jobs_complete: 2,
132
- companies_personas: [{ company: 'ColdIQ', contacts: [{ first_name: 'Michel', last_name: 'Lieben' }] }],
137
+ companies_personas: [
138
+ { company: 'ColdIQ', personas: [[{ contact: { first_name: 'Michel', last_name: 'Lieben' } }]] },
139
+ ],
133
140
  no_results_domains: ['folk.app'],
134
141
  }), { status: 200 })
135
142
  }
@@ -185,7 +192,9 @@ describe('find_people handler', () => {
185
192
  if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
186
193
  return new Response(JSON.stringify({
187
194
  id: 'abc123', status: 'SUCCESSFUL', nb_jobs_total: 2, nb_jobs_complete: 2,
188
- companies_personas: [{ company: 'ColdIQ', contacts: [{ first_name: 'Michel' }] }],
195
+ companies_personas: [
196
+ { company: 'ColdIQ', personas: [[{ contact: { first_name: 'Michel' } }]] },
197
+ ],
189
198
  no_results_domains: ['folk.app'],
190
199
  }), { status: 200 })
191
200
  }
@@ -237,7 +246,9 @@ describe('find_people handler', () => {
237
246
  if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
238
247
  return new Response(JSON.stringify({
239
248
  id: 'abc123', status: 'SUCCESSFUL', nb_jobs_total: 2, nb_jobs_complete: 2,
240
- companies_personas: [{ company: 'ColdIQ', contacts: [{ first_name: 'Michel' }] }],
249
+ companies_personas: [
250
+ { company: 'ColdIQ', personas: [[{ contact: { first_name: 'Michel' } }]] },
251
+ ],
241
252
  }), { status: 200 })
242
253
  }
243
254
 
@@ -283,7 +294,9 @@ describe('find_people handler', () => {
283
294
  if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
284
295
  return new Response(JSON.stringify({
285
296
  id: 'abc123', status: 'SUCCESSFUL', nb_jobs_total: 1, nb_jobs_complete: 1,
286
- companies_personas: [{ company: 'ColdIQ', contacts: [{ first_name: 'Michel' }] }],
297
+ companies_personas: [
298
+ { company: 'ColdIQ', personas: [[{ contact: { first_name: 'Michel' } }]] },
299
+ ],
287
300
  }), { status: 200 })
288
301
  }
289
302