@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/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +37 -3
- package/dist/registry.js.map +1 -1
- package/dist/utils/provider-resolver.js +3 -0
- package/dist/utils/provider-resolver.js.map +1 -1
- package/package.json +2 -4
- package/src/registry.ts +44 -5
- package/src/utils/provider-resolver.ts +3 -0
- package/tests/live/fullenrich-upstream-probe.ts +55 -0
- package/tests/live/pdl-upstream-probe.ts +83 -0
- package/tests/registry-find-people.test.ts +206 -9
- package/tests/tools/find-people.test.ts +19 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coldiq/mcp",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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('
|
|
233
|
-
expect(p().isApplicable).
|
|
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({
|
|
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
|
|
584
|
-
expect(
|
|
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
|
|
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
|
|
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
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
297
|
+
companies_personas: [
|
|
298
|
+
{ company: 'ColdIQ', personas: [[{ contact: { first_name: 'Michel' } }]] },
|
|
299
|
+
],
|
|
287
300
|
}), { status: 200 })
|
|
288
301
|
}
|
|
289
302
|
|