@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.
Files changed (52) hide show
  1. package/dist/client.d.ts +2 -0
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +7 -1
  4. package/dist/client.js.map +1 -1
  5. package/dist/executor.d.ts +11 -0
  6. package/dist/executor.d.ts.map +1 -1
  7. package/dist/executor.js +72 -11
  8. package/dist/executor.js.map +1 -1
  9. package/dist/index.js +2 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/registry.d.ts +1 -0
  12. package/dist/registry.d.ts.map +1 -1
  13. package/dist/registry.js +57 -8
  14. package/dist/registry.js.map +1 -1
  15. package/dist/tools/find-emails.d.ts +2 -7
  16. package/dist/tools/find-emails.d.ts.map +1 -1
  17. package/dist/tools/find-emails.js +193 -67
  18. package/dist/tools/find-emails.js.map +1 -1
  19. package/dist/tools/find-people.d.ts +3 -2
  20. package/dist/tools/find-people.d.ts.map +1 -1
  21. package/dist/tools/find-people.js +65 -7
  22. package/dist/tools/find-people.js.map +1 -1
  23. package/dist/tools/get-credit-balance.d.ts +17 -0
  24. package/dist/tools/get-credit-balance.d.ts.map +1 -0
  25. package/dist/tools/get-credit-balance.js +20 -0
  26. package/dist/tools/get-credit-balance.js.map +1 -0
  27. package/dist/utils/compact-people.d.ts +24 -0
  28. package/dist/utils/compact-people.d.ts.map +1 -0
  29. package/dist/utils/compact-people.js +306 -0
  30. package/dist/utils/compact-people.js.map +1 -0
  31. package/dist/utils/provider-resolver.d.ts.map +1 -1
  32. package/dist/utils/provider-resolver.js +15 -1
  33. package/dist/utils/provider-resolver.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/client.ts +9 -1
  36. package/src/executor.ts +89 -17
  37. package/src/index.ts +8 -0
  38. package/src/registry.ts +67 -8
  39. package/src/tools/find-emails.ts +251 -80
  40. package/src/tools/find-people.ts +70 -7
  41. package/src/tools/get-credit-balance.ts +24 -0
  42. package/src/utils/compact-people.ts +318 -0
  43. package/src/utils/provider-resolver.ts +15 -1
  44. package/tests/executor.test.ts +165 -0
  45. package/tests/live/fullenrich-upstream-probe.ts +55 -0
  46. package/tests/live/pdl-upstream-probe.ts +83 -0
  47. package/tests/registry-find-people.test.ts +198 -7
  48. package/tests/registry-search-companies.test.ts +46 -7
  49. package/tests/tools/find-emails.test.ts +267 -1
  50. package/tests/tools/find-people.test.ts +269 -5
  51. package/tests/tools/get-credit-balance.test.ts +56 -0
  52. 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
- job_title: { include: ['CEO', 'CTO'] },
118
+ person_job_title: { include: ['CEO', 'CTO'] },
119
119
  person_seniority: { include: ['C-Level'] },
120
120
  company: { websites: { include: ['coldiq.com'] } },
121
- person_location: { include: ['Belgium'] },
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.person_location).toBeUndefined()
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('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
  })
@@ -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
- company_country: { include: ['Belgium'] },
47
- company_headcount_custom: { min: 10, max: 200 },
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.company_country).toBeUndefined()
58
- expect(filters.company_headcount_custom).toBeUndefined()
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.company_headcount_custom).toEqual({ min: 50, max: undefined })
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
+ })