@coldiq/mcp 0.1.18 → 0.2.4
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/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +7 -1
- package/dist/client.js.map +1 -1
- package/dist/executor.d.ts +11 -0
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +72 -11
- package/dist/executor.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +57 -8
- package/dist/registry.js.map +1 -1
- package/dist/tools/find-emails.d.ts +2 -7
- package/dist/tools/find-emails.d.ts.map +1 -1
- package/dist/tools/find-emails.js +193 -67
- package/dist/tools/find-emails.js.map +1 -1
- package/dist/tools/find-people.d.ts +3 -2
- package/dist/tools/find-people.d.ts.map +1 -1
- package/dist/tools/find-people.js +65 -7
- package/dist/tools/find-people.js.map +1 -1
- package/dist/tools/get-credit-balance.d.ts +17 -0
- package/dist/tools/get-credit-balance.d.ts.map +1 -0
- package/dist/tools/get-credit-balance.js +20 -0
- package/dist/tools/get-credit-balance.js.map +1 -0
- package/dist/utils/compact-people.d.ts +24 -0
- package/dist/utils/compact-people.d.ts.map +1 -0
- package/dist/utils/compact-people.js +306 -0
- package/dist/utils/compact-people.js.map +1 -0
- package/dist/utils/provider-resolver.d.ts.map +1 -1
- package/dist/utils/provider-resolver.js +15 -1
- package/dist/utils/provider-resolver.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +9 -1
- package/src/executor.ts +89 -17
- package/src/index.ts +8 -0
- package/src/registry.ts +67 -8
- package/src/tools/find-emails.ts +251 -80
- package/src/tools/find-people.ts +70 -7
- package/src/tools/get-credit-balance.ts +24 -0
- package/src/utils/compact-people.ts +318 -0
- package/src/utils/provider-resolver.ts +15 -1
- package/tests/executor.test.ts +165 -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 +198 -7
- package/tests/registry-search-companies.test.ts +46 -7
- package/tests/tools/find-emails.test.ts +267 -1
- package/tests/tools/find-people.test.ts +269 -5
- package/tests/tools/get-credit-balance.test.ts +56 -0
- package/tests/utils/compact-people.test.ts +462 -0
|
@@ -115,10 +115,10 @@ describe('prospeo-search-person', () => {
|
|
|
115
115
|
})).toEqual({
|
|
116
116
|
body: {
|
|
117
117
|
filters: {
|
|
118
|
-
|
|
118
|
+
person_job_title: { include: ['CEO', 'CTO'] },
|
|
119
119
|
person_seniority: { include: ['C-Level'] },
|
|
120
120
|
company: { websites: { include: ['coldiq.com'] } },
|
|
121
|
-
|
|
121
|
+
person_location_search: { include: ['Belgium'] },
|
|
122
122
|
},
|
|
123
123
|
},
|
|
124
124
|
})
|
|
@@ -129,7 +129,7 @@ describe('prospeo-search-person', () => {
|
|
|
129
129
|
const filters = (result.body as Record<string, unknown>).filters as Record<string, unknown>
|
|
130
130
|
expect(filters.person_seniority).toBeUndefined()
|
|
131
131
|
expect(filters.company).toBeUndefined()
|
|
132
|
-
expect(filters.
|
|
132
|
+
expect(filters.person_location_search).toBeUndefined()
|
|
133
133
|
})
|
|
134
134
|
|
|
135
135
|
it('hasResult: true when data array non-empty', () => {
|
|
@@ -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
|
})
|
|
@@ -357,6 +364,18 @@ describe('apollo (find_people)', () => {
|
|
|
357
364
|
expect(body.q_keywords).toBe('growth AI')
|
|
358
365
|
})
|
|
359
366
|
|
|
367
|
+
it('mapParams clamps per_page to 100 when limit exceeds Apollo upstream cap', () => {
|
|
368
|
+
const result = p().mapParams({ job_titles: ['CEO'], limit: 150 })
|
|
369
|
+
const body = result.body as Record<string, unknown>
|
|
370
|
+
expect(body.per_page).toBe(100)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('mapParams passes through per_page when limit is at or below 100', () => {
|
|
374
|
+
const result = p().mapParams({ job_titles: ['CEO'], limit: 50 })
|
|
375
|
+
const body = result.body as Record<string, unknown>
|
|
376
|
+
expect(body.per_page).toBe(50)
|
|
377
|
+
})
|
|
378
|
+
|
|
360
379
|
it('mapParams omits q_keywords when keywords absent', () => {
|
|
361
380
|
const result = p().mapParams({ job_titles: ['CEO'], limit: 10 })
|
|
362
381
|
const body = result.body as Record<string, unknown>
|
|
@@ -459,6 +478,22 @@ describe('leadsfactory (find_people)', () => {
|
|
|
459
478
|
const personas = (result: ReturnType<ReturnType<typeof p>['mapParams']>) =>
|
|
460
479
|
((result.body as Record<string, unknown>).search as Record<string, unknown>).personas as { job_title: string }[]
|
|
461
480
|
|
|
481
|
+
it('isApplicable: true with company_domains', () => {
|
|
482
|
+
expect(p().isApplicable!({ company_domains: ['coldiq.com'], job_titles: ['CEO'] })).toBe(true)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('isApplicable: true with company_linkedin_urls', () => {
|
|
486
|
+
expect(p().isApplicable!({ company_linkedin_urls: ['https://www.linkedin.com/company/coldiq'], job_titles: ['CEO'] })).toBe(true)
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('isApplicable: false without any company identifier (LF upstream 500s on pure prospecting)', () => {
|
|
490
|
+
expect(p().isApplicable!({ job_titles: ['CEO'], seniorities: ['C-Level'], locations: ['FR'] })).toBe(false)
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('isApplicable: false on empty input', () => {
|
|
494
|
+
expect(p().isApplicable!({})).toBe(false)
|
|
495
|
+
})
|
|
496
|
+
|
|
462
497
|
it('same-domain variants collapse to 1 persona with comma-joined titles', () => {
|
|
463
498
|
const result = p().mapParams({ company_domains: ['coldiq.com'], job_titles: ['VP Sales', 'Head of Sales'], limit: 5 })
|
|
464
499
|
expect(personas(result)).toEqual([{ job_title: 'VP Sales, Head of Sales' }])
|
|
@@ -655,6 +690,162 @@ describe('leadsfactory (find_people)', () => {
|
|
|
655
690
|
})
|
|
656
691
|
})
|
|
657
692
|
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
// pdl
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
describe('pdl (find_people)', () => {
|
|
698
|
+
const p = () => get('pdl')
|
|
699
|
+
|
|
700
|
+
it('isApplicable: true with company_domains', () => {
|
|
701
|
+
expect(p().isApplicable!({ company_domains: ['coldiq.com'] })).toBe(true)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
it('isApplicable: true with company_linkedin_urls', () => {
|
|
705
|
+
expect(p().isApplicable!({ company_linkedin_urls: ['https://www.linkedin.com/company/coldiq'] })).toBe(true)
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
it('isApplicable: true with job_titles', () => {
|
|
709
|
+
expect(p().isApplicable!({ job_titles: ['CEO'] })).toBe(true)
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
it('isApplicable: true with seniorities', () => {
|
|
713
|
+
expect(p().isApplicable!({ seniorities: ['c_suite'] })).toBe(true)
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
it('isApplicable: false with only locations (would return entire index)', () => {
|
|
717
|
+
expect(p().isApplicable!({ locations: ['FR'] })).toBe(false)
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it('isApplicable: false with no filters', () => {
|
|
721
|
+
expect(p().isApplicable!({})).toBe(false)
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
it('mapParams maps company_domains to job_company_website term', () => {
|
|
725
|
+
const result = p().mapParams({ company_domains: ['coldiq.com'], job_titles: ['CEO'], limit: 10 })
|
|
726
|
+
const body = result.body as { query: { bool: { must: unknown[] } }; size: number }
|
|
727
|
+
expect(body.query.bool.must).toContainEqual({ terms: { job_company_website: ['coldiq.com'] } })
|
|
728
|
+
expect(body.query.bool.must).toContainEqual({ terms: { job_title: ['CEO'] } })
|
|
729
|
+
expect(body.size).toBe(10)
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
it('mapParams normalizes LinkedIn URLs to PDL canonical short form', () => {
|
|
733
|
+
// PDL indexes URLs as `linkedin.com/company/x` (no protocol, no www, no trailing slash);
|
|
734
|
+
// its `terms` query is exact-match — full URLs from callers must be stripped first.
|
|
735
|
+
const result = p().mapParams({
|
|
736
|
+
company_linkedin_urls: [
|
|
737
|
+
'https://www.linkedin.com/company/coldiq',
|
|
738
|
+
'https://linkedin.com/company/microsoft/',
|
|
739
|
+
'www.linkedin.com/company/aircall',
|
|
740
|
+
],
|
|
741
|
+
job_titles: ['CMO'],
|
|
742
|
+
})
|
|
743
|
+
const body = result.body as { query: { bool: { must: unknown[] } } }
|
|
744
|
+
expect(body.query.bool.must).toContainEqual({
|
|
745
|
+
terms: {
|
|
746
|
+
job_company_linkedin_url: [
|
|
747
|
+
'linkedin.com/company/coldiq',
|
|
748
|
+
'linkedin.com/company/microsoft',
|
|
749
|
+
'linkedin.com/company/aircall',
|
|
750
|
+
],
|
|
751
|
+
},
|
|
752
|
+
})
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
it('mapParams emits both domain and linkedin terms when both provided', () => {
|
|
756
|
+
const result = p().mapParams({
|
|
757
|
+
company_domains: ['coldiq.com'],
|
|
758
|
+
company_linkedin_urls: ['https://www.linkedin.com/company/coldiq'],
|
|
759
|
+
job_titles: ['CEO'],
|
|
760
|
+
})
|
|
761
|
+
const body = result.body as { query: { bool: { must: unknown[] } } }
|
|
762
|
+
expect(body.query.bool.must).toContainEqual({ terms: { job_company_website: ['coldiq.com'] } })
|
|
763
|
+
expect(body.query.bool.must).toContainEqual({
|
|
764
|
+
terms: { job_company_linkedin_url: ['linkedin.com/company/coldiq'] },
|
|
765
|
+
})
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
it('hasResult: true when data array non-empty', () => {
|
|
769
|
+
expect(p().hasResult({ data: [{ full_name: 'Michel Lieben' }] })).toBe(true)
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
it('hasResult: false when data is empty', () => {
|
|
773
|
+
expect(p().hasResult({ data: [] })).toBe(false)
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it('priority is 3', () => {
|
|
777
|
+
expect(p().priority).toBe(3)
|
|
778
|
+
})
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
// ---------------------------------------------------------------------------
|
|
782
|
+
// companyenrich
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
|
|
785
|
+
describe('companyenrich (find_people)', () => {
|
|
786
|
+
const p = () => get('companyenrich')
|
|
787
|
+
|
|
788
|
+
it('isApplicable: true with company_domains', () => {
|
|
789
|
+
expect(p().isApplicable!({ company_domains: ['coldiq.com'], job_titles: ['CEO'] })).toBe(true)
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
it('isApplicable: true with neither company filter (degrades to titles/locations search)', () => {
|
|
793
|
+
expect(p().isApplicable!({ job_titles: ['CEO'] })).toBe(true)
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it('isApplicable: false with only company_linkedin_urls (upstream has no LinkedIn-URL filter)', () => {
|
|
797
|
+
expect(p().isApplicable!({
|
|
798
|
+
company_linkedin_urls: ['https://www.linkedin.com/company/coldiq'],
|
|
799
|
+
job_titles: ['CEO'],
|
|
800
|
+
})).toBe(false)
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
it('isApplicable: true when both linkedin_urls AND domains are present (domains carry the search)', () => {
|
|
804
|
+
expect(p().isApplicable!({
|
|
805
|
+
company_domains: ['coldiq.com'],
|
|
806
|
+
company_linkedin_urls: ['https://www.linkedin.com/company/coldiq'],
|
|
807
|
+
job_titles: ['CEO'],
|
|
808
|
+
})).toBe(true)
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
it('mapParams maps company_domains to filters.companies', () => {
|
|
812
|
+
const result = p().mapParams({
|
|
813
|
+
company_domains: ['coldiq.com', 'microsoft.com'],
|
|
814
|
+
job_titles: ['CEO'],
|
|
815
|
+
locations: ['FR'],
|
|
816
|
+
limit: 10,
|
|
817
|
+
})
|
|
818
|
+
expect(result).toEqual({
|
|
819
|
+
body: {
|
|
820
|
+
filters: {
|
|
821
|
+
jobTitles: ['CEO'],
|
|
822
|
+
companies: ['coldiq.com', 'microsoft.com'],
|
|
823
|
+
countries: ['FR'],
|
|
824
|
+
},
|
|
825
|
+
pageSize: 10,
|
|
826
|
+
},
|
|
827
|
+
})
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
it('mapParams omits filters.companies when no domains', () => {
|
|
831
|
+
const result = p().mapParams({ job_titles: ['CEO'], limit: 25 })
|
|
832
|
+
const body = result.body as { filters: Record<string, unknown> }
|
|
833
|
+
expect(body.filters.companies).toBeUndefined()
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
it('hasResult: true when data array non-empty', () => {
|
|
837
|
+
expect(p().hasResult({ data: [{ name: 'Michel Lieben' }] })).toBe(true)
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
it('hasResult: false when data is empty', () => {
|
|
841
|
+
expect(p().hasResult({ data: [] })).toBe(false)
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
it('priority is 4', () => {
|
|
845
|
+
expect(p().priority).toBe(4)
|
|
846
|
+
})
|
|
847
|
+
})
|
|
848
|
+
|
|
658
849
|
// ---------------------------------------------------------------------------
|
|
659
850
|
// Ordering: find_people providers sorted correctly
|
|
660
851
|
// ---------------------------------------------------------------------------
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { getProviders } from '../src/registry.js'
|
|
2
|
+
import { getProviders, prospeoHeadcountBuckets } from '../src/registry.js'
|
|
3
3
|
|
|
4
4
|
const providers = () => getProviders('search_companies')
|
|
5
5
|
const get = (id: string) => {
|
|
@@ -43,8 +43,8 @@ describe('prospeo-search-company', () => {
|
|
|
43
43
|
filters: {
|
|
44
44
|
company_keywords: { include: ['sales engagement'] },
|
|
45
45
|
company_industry: { include: ['SaaS'] },
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
company_location_search: { include: ['Belgium'] },
|
|
47
|
+
company_headcount_range: ['1-10', '11-20', '21-50', '51-100', '101-200'],
|
|
48
48
|
},
|
|
49
49
|
},
|
|
50
50
|
})
|
|
@@ -54,14 +54,17 @@ describe('prospeo-search-company', () => {
|
|
|
54
54
|
const result = p().mapParams({ keywords: ['outbound'] })
|
|
55
55
|
const filters = (result.body as Record<string, unknown>).filters as Record<string, unknown>
|
|
56
56
|
expect(filters.company_industry).toBeUndefined()
|
|
57
|
-
expect(filters.
|
|
58
|
-
expect(filters.
|
|
57
|
+
expect(filters.company_location_search).toBeUndefined()
|
|
58
|
+
expect(filters.company_headcount_range).toBeUndefined()
|
|
59
59
|
})
|
|
60
60
|
|
|
61
|
-
it('mapParams includes headcount when only min is given', () => {
|
|
61
|
+
it('mapParams includes headcount buckets when only min is given', () => {
|
|
62
62
|
const result = p().mapParams({ keywords: ['crm'], min_employees: 50 })
|
|
63
63
|
const filters = (result.body as Record<string, unknown>).filters as Record<string, unknown>
|
|
64
|
-
expect(filters.
|
|
64
|
+
expect(filters.company_headcount_range).toEqual([
|
|
65
|
+
'21-50', '51-100', '101-200', '201-500', '501-1000',
|
|
66
|
+
'1001-2000', '2001-5000', '5001-10000', '10000+',
|
|
67
|
+
])
|
|
65
68
|
})
|
|
66
69
|
|
|
67
70
|
it('hasResult: true when data array non-empty', () => {
|
|
@@ -250,6 +253,42 @@ describe('theirstack search_companies', () => {
|
|
|
250
253
|
})
|
|
251
254
|
})
|
|
252
255
|
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// prospeoHeadcountBuckets — maps numeric range to Prospeo's fixed buckets
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
describe('prospeoHeadcountBuckets', () => {
|
|
261
|
+
it('returns all overlapping buckets for a finite range', () => {
|
|
262
|
+
expect(prospeoHeadcountBuckets(10, 500)).toEqual([
|
|
263
|
+
'1-10', '11-20', '21-50', '51-100', '101-200', '201-500',
|
|
264
|
+
])
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('treats undefined min as 0 (open lower bound)', () => {
|
|
268
|
+
expect(prospeoHeadcountBuckets(undefined, 50)).toEqual(['1-10', '11-20', '21-50'])
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('treats undefined max as +Infinity (open upper bound)', () => {
|
|
272
|
+
expect(prospeoHeadcountBuckets(2001, undefined)).toEqual([
|
|
273
|
+
'2001-5000', '5001-10000', '10000+',
|
|
274
|
+
])
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('returns single bucket when min === max within a bucket', () => {
|
|
278
|
+
expect(prospeoHeadcountBuckets(75, 75)).toEqual(['51-100'])
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('includes 10000+ when min is above 10000', () => {
|
|
282
|
+
expect(prospeoHeadcountBuckets(15000, undefined)).toEqual(['10000+'])
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('excludes 10000+ when max is exactly 10000', () => {
|
|
286
|
+
const buckets = prospeoHeadcountBuckets(5001, 10000)
|
|
287
|
+
expect(buckets).toEqual(['5001-10000'])
|
|
288
|
+
expect(buckets).not.toContain('10000+')
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
253
292
|
// ---------------------------------------------------------------------------
|
|
254
293
|
// Ordering: search_companies providers sorted correctly
|
|
255
294
|
// ---------------------------------------------------------------------------
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { initClient } from '../../src/client.js'
|
|
3
|
-
import { findEmailsHandler } from '../../src/tools/find-emails.js'
|
|
3
|
+
import { findEmailsHandler, FIND_EMAILS_CHUNK_SIZE } from '../../src/tools/find-emails.js'
|
|
4
4
|
|
|
5
5
|
describe('find_emails handler (bulk)', () => {
|
|
6
6
|
const originalFetch = globalThis.fetch
|
|
@@ -399,6 +399,73 @@ describe('find_emails handler — use_providers', () => {
|
|
|
399
399
|
expect(parsed.data.results[0]).toMatchObject({ provider: 'findymail', email: 'alice@example.com' })
|
|
400
400
|
expect(icypeasCalled).toBe(false)
|
|
401
401
|
})
|
|
402
|
+
|
|
403
|
+
it('pinning a single-only provider (limadata-work-email) routes through Step 4 and succeeds', async () => {
|
|
404
|
+
// Regression: previously FIND_EMAILS_PROVIDERS only listed the 4 bulk providers,
|
|
405
|
+
// so pinning a Step 4 provider was rejected as "unrecognized". Now all 8 are exposed.
|
|
406
|
+
let prospeoCalled = false
|
|
407
|
+
let limadataCalled = false
|
|
408
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
409
|
+
const u = url.toString()
|
|
410
|
+
if (u.includes('/prospeo/bulk-enrich-person')) {
|
|
411
|
+
prospeoCalled = true
|
|
412
|
+
return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
|
|
413
|
+
}
|
|
414
|
+
if (u.includes('/coldiq/find/work-email')) {
|
|
415
|
+
limadataCalled = true
|
|
416
|
+
return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
|
|
417
|
+
}
|
|
418
|
+
return new Response(JSON.stringify({ error: 'unexpected', url: u }), { status: 500 })
|
|
419
|
+
}) as typeof fetch
|
|
420
|
+
|
|
421
|
+
const result = await findEmailsHandler({
|
|
422
|
+
people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
|
|
423
|
+
use_providers: ['limadata-work-email'],
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
expect(result.isError).toBeUndefined()
|
|
427
|
+
expect(prospeoCalled).toBe(false)
|
|
428
|
+
expect(limadataCalled).toBe(true)
|
|
429
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
430
|
+
expect(parsed.data.results[0]).toMatchObject({ provider: 'limadata-work-email', email: 'alice@example.com' })
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('all-failed error after pinning bulk-only providers surfaces single-only providers as remaining options', async () => {
|
|
434
|
+
// Regression: with only 4 providers exposed, after the user pinned all 4 bulk providers
|
|
435
|
+
// and they missed, available_providers was [] — falsely implying no more options existed.
|
|
436
|
+
// Now the error must point them at the single-only providers they could retry with.
|
|
437
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
438
|
+
const u = url.toString()
|
|
439
|
+
if (u.includes('/prospeo/bulk-enrich-person')) {
|
|
440
|
+
return new Response(JSON.stringify({ error: false, results: [] }), { status: 200 })
|
|
441
|
+
}
|
|
442
|
+
if (u.includes('/fullenrich/contact/enrich/bulk')) {
|
|
443
|
+
return new Response(JSON.stringify({}), { status: 200 })
|
|
444
|
+
}
|
|
445
|
+
if (u.includes('/findymail/search/name')) {
|
|
446
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
447
|
+
}
|
|
448
|
+
if (u.includes('/icypeas/email-search')) {
|
|
449
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
450
|
+
}
|
|
451
|
+
return new Response(JSON.stringify({ error: 'unexpected', url: u }), { status: 500 })
|
|
452
|
+
}) as typeof fetch
|
|
453
|
+
|
|
454
|
+
const result = await findEmailsHandler({
|
|
455
|
+
people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
|
|
456
|
+
use_providers: ['prospeo', 'fullenrich', 'findymail', 'icypeas'],
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
expect(result.isError).toBe(true)
|
|
460
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
461
|
+
expect(parsed.available_providers).toEqual([
|
|
462
|
+
'limadata-work-email',
|
|
463
|
+
'blitzapi',
|
|
464
|
+
'limadata-work-email-linkedin',
|
|
465
|
+
'linkupapi',
|
|
466
|
+
])
|
|
467
|
+
expect(parsed.error).toContain('limadata-work-email')
|
|
468
|
+
})
|
|
402
469
|
})
|
|
403
470
|
|
|
404
471
|
describe('find_emails handler — resilience', () => {
|
|
@@ -508,3 +575,202 @@ describe('find_emails handler — resilience', () => {
|
|
|
508
575
|
expect(parsed.data.results[0]).toMatchObject({ id: 'p1', email: 'alice@example.com', provider: 'blitzapi' })
|
|
509
576
|
})
|
|
510
577
|
})
|
|
578
|
+
|
|
579
|
+
describe('find_emails handler — chunking', () => {
|
|
580
|
+
const originalFetch = globalThis.fetch
|
|
581
|
+
|
|
582
|
+
beforeEach(() => {
|
|
583
|
+
initClient('http://test-api.local', 'test-key')
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
afterEach(() => {
|
|
587
|
+
globalThis.fetch = originalFetch
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
function makePeople(n: number): Array<{ id: string; first_name: string; last_name: string; domain: string }> {
|
|
591
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
592
|
+
id: `p${i + 1}`,
|
|
593
|
+
first_name: `First${i + 1}`,
|
|
594
|
+
last_name: `Last${i + 1}`,
|
|
595
|
+
domain: 'example.com',
|
|
596
|
+
}))
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
it('splits 25 people into 3 chunks of ≤10 and issues one Prospeo bulk per chunk', async () => {
|
|
600
|
+
const bulkBodies: Array<{ data: Array<{ identifier: string }> }> = []
|
|
601
|
+
|
|
602
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
603
|
+
const u = url.toString()
|
|
604
|
+
if (u.includes('/prospeo/bulk-enrich-person')) {
|
|
605
|
+
const body = JSON.parse(init!.body as string) as { data: Array<{ identifier: string }> }
|
|
606
|
+
bulkBodies.push(body)
|
|
607
|
+
return new Response(
|
|
608
|
+
JSON.stringify({
|
|
609
|
+
error: false,
|
|
610
|
+
results: body.data.map((d) => ({
|
|
611
|
+
identifier: d.identifier,
|
|
612
|
+
error: false,
|
|
613
|
+
person: { email: { email: `${d.identifier}@example.com` } },
|
|
614
|
+
})),
|
|
615
|
+
total_cost: body.data.length,
|
|
616
|
+
}),
|
|
617
|
+
{ status: 200 },
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
|
|
621
|
+
}) as typeof fetch
|
|
622
|
+
|
|
623
|
+
const result = await findEmailsHandler({ people: makePeople(25) })
|
|
624
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
625
|
+
|
|
626
|
+
expect(bulkBodies.length).toBe(3)
|
|
627
|
+
expect(bulkBodies[0]!.data.length).toBeLessThanOrEqual(FIND_EMAILS_CHUNK_SIZE)
|
|
628
|
+
expect(bulkBodies[1]!.data.length).toBeLessThanOrEqual(FIND_EMAILS_CHUNK_SIZE)
|
|
629
|
+
expect(bulkBodies[2]!.data.length).toBeLessThanOrEqual(FIND_EMAILS_CHUNK_SIZE)
|
|
630
|
+
expect(bulkBodies.flatMap((b) => b.data.map((d) => d.identifier)).sort()).toEqual(
|
|
631
|
+
makePeople(25).map((p) => p.id).sort(),
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
expect(parsed.data.found).toBe(25)
|
|
635
|
+
expect(parsed.data.total).toBe(25)
|
|
636
|
+
expect(parsed._meta.batch_status).toBe('complete')
|
|
637
|
+
expect(parsed._meta.chunk_count).toBe(3)
|
|
638
|
+
expect(parsed._meta.chunk_size).toBe(FIND_EMAILS_CHUNK_SIZE)
|
|
639
|
+
expect(parsed._meta.providers.prospeo).toEqual({ attempted: 3, failed: 0 })
|
|
640
|
+
expect(result.isError).toBeUndefined()
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
it('one chunk Prospeo network failure → batch_status="partial" + isError absent when others succeed', async () => {
|
|
644
|
+
// First Prospeo bulk call simulates a network failure (status 0 via a fetch throw).
|
|
645
|
+
// Remaining chunks succeed. FindyMail picks up the failed chunk's misses.
|
|
646
|
+
let bulkCallCount = 0
|
|
647
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
648
|
+
const u = url.toString()
|
|
649
|
+
if (u.includes('/prospeo/bulk-enrich-person')) {
|
|
650
|
+
bulkCallCount++
|
|
651
|
+
if (bulkCallCount === 1) {
|
|
652
|
+
// Simulate network failure → client returns { ok: false, status: 0 }
|
|
653
|
+
throw new Error('socket hang up')
|
|
654
|
+
}
|
|
655
|
+
const body = JSON.parse(init!.body as string) as { data: Array<{ identifier: string }> }
|
|
656
|
+
return new Response(
|
|
657
|
+
JSON.stringify({
|
|
658
|
+
error: false,
|
|
659
|
+
results: body.data.map((d) => ({
|
|
660
|
+
identifier: d.identifier,
|
|
661
|
+
error: false,
|
|
662
|
+
person: { email: { email: `${d.identifier}@example.com` } },
|
|
663
|
+
})),
|
|
664
|
+
total_cost: body.data.length,
|
|
665
|
+
}),
|
|
666
|
+
{ status: 200 },
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
// FullEnrich create call for the failed chunk's misses — no enrichment_id so it bails fast.
|
|
670
|
+
if (u.includes('/fullenrich/contact/enrich/bulk')) {
|
|
671
|
+
return new Response(JSON.stringify({}), { status: 200 })
|
|
672
|
+
}
|
|
673
|
+
if (u.includes('/findymail/search/name')) {
|
|
674
|
+
return new Response(JSON.stringify({ email: 'recovered@example.com' }), { status: 200 })
|
|
675
|
+
}
|
|
676
|
+
return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
|
|
677
|
+
}) as typeof fetch
|
|
678
|
+
|
|
679
|
+
const result = await findEmailsHandler({ people: makePeople(25) })
|
|
680
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
681
|
+
|
|
682
|
+
expect(parsed._meta.providers.prospeo.attempted).toBe(3)
|
|
683
|
+
expect(parsed._meta.providers.prospeo.failed).toBe(1)
|
|
684
|
+
expect(parsed._meta.batch_status).toBe('partial')
|
|
685
|
+
expect(parsed.data.found).toBeGreaterThan(0)
|
|
686
|
+
expect(result.isError).toBeUndefined()
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
it('every chunk fails → batch_status="failed" and isError:true even unconstrained', async () => {
|
|
690
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
691
|
+
const u = url.toString()
|
|
692
|
+
if (u.includes('/prospeo/bulk-enrich-person')) {
|
|
693
|
+
// 502 → counted as a network-level failure
|
|
694
|
+
return new Response(JSON.stringify({ error: 'upstream down' }), { status: 502 })
|
|
695
|
+
}
|
|
696
|
+
if (u.includes('/fullenrich/contact/enrich/bulk')) {
|
|
697
|
+
return new Response(JSON.stringify({ error: 'down' }), { status: 502 })
|
|
698
|
+
}
|
|
699
|
+
if (u.includes('/findymail/search/name')) {
|
|
700
|
+
return new Response(JSON.stringify({ error: 'down' }), { status: 502 })
|
|
701
|
+
}
|
|
702
|
+
if (u.includes('/icypeas/email-search')) {
|
|
703
|
+
return new Response(JSON.stringify({ error: 'down' }), { status: 502 })
|
|
704
|
+
}
|
|
705
|
+
// Single-only fallbacks: all return 502 so executeWithFallback yields an error.
|
|
706
|
+
return new Response(JSON.stringify({ error: 'down' }), { status: 502 })
|
|
707
|
+
}) as typeof fetch
|
|
708
|
+
|
|
709
|
+
const result = await findEmailsHandler({ people: makePeople(15) })
|
|
710
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
711
|
+
|
|
712
|
+
expect(parsed.data.found).toBe(0)
|
|
713
|
+
expect(parsed._meta.batch_status).toBe('failed')
|
|
714
|
+
expect(parsed._meta.providers.prospeo.failed).toBeGreaterThan(0)
|
|
715
|
+
expect(result.isError).toBe(true)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
it('legitimate "no coverage" (every provider 200s with no email) stays batch_status="complete" and is NOT an error', async () => {
|
|
719
|
+
// Distinguishing failure from no-coverage: providers respond cleanly but find no email.
|
|
720
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
721
|
+
const u = url.toString()
|
|
722
|
+
if (u.includes('/prospeo/bulk-enrich-person')) {
|
|
723
|
+
return new Response(
|
|
724
|
+
JSON.stringify({ error: false, results: [], total_cost: 0 }),
|
|
725
|
+
{ status: 200 },
|
|
726
|
+
)
|
|
727
|
+
}
|
|
728
|
+
if (u.includes('/fullenrich/contact/enrich/bulk')) {
|
|
729
|
+
return new Response(JSON.stringify({}), { status: 200 })
|
|
730
|
+
}
|
|
731
|
+
if (u.includes('/findymail/search/name')) {
|
|
732
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
733
|
+
}
|
|
734
|
+
if (u.includes('/icypeas/email-search')) {
|
|
735
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
736
|
+
}
|
|
737
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
738
|
+
}) as typeof fetch
|
|
739
|
+
|
|
740
|
+
const result = await findEmailsHandler({ people: makePeople(12) })
|
|
741
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
742
|
+
|
|
743
|
+
expect(parsed.data.found).toBe(0)
|
|
744
|
+
expect(parsed._meta.batch_status).toBe('complete')
|
|
745
|
+
expect(parsed._meta.providers.prospeo.failed).toBe(0)
|
|
746
|
+
expect(parsed._meta.providers.findymail.failed).toBe(0)
|
|
747
|
+
expect(result.isError).toBeUndefined()
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
it('small input (≤10) still works and emits _meta without chunking overhead', async () => {
|
|
751
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
752
|
+
const u = url.toString()
|
|
753
|
+
if (u.includes('/prospeo/bulk-enrich-person')) {
|
|
754
|
+
return new Response(
|
|
755
|
+
JSON.stringify({
|
|
756
|
+
error: false,
|
|
757
|
+
results: [{ identifier: 'p1', error: false, person: { email: { email: 'alice@example.com' } } }],
|
|
758
|
+
total_cost: 1,
|
|
759
|
+
}),
|
|
760
|
+
{ status: 200 },
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
|
|
764
|
+
}) as typeof fetch
|
|
765
|
+
|
|
766
|
+
const result = await findEmailsHandler({
|
|
767
|
+
people: [{ id: 'p1', first_name: 'Alice', domain: 'example.com' }],
|
|
768
|
+
})
|
|
769
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
770
|
+
|
|
771
|
+
expect(parsed._meta.chunk_count).toBe(1)
|
|
772
|
+
expect(parsed._meta.batch_status).toBe('complete')
|
|
773
|
+
expect(parsed._meta.providers.prospeo).toEqual({ attempted: 1, failed: 0 })
|
|
774
|
+
expect(result.isError).toBeUndefined()
|
|
775
|
+
})
|
|
776
|
+
})
|