@coldiq/mcp 0.1.19 → 0.2.5

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 (50) 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 +35 -9
  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 +311 -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 +12 -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 +41 -9
  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 +323 -0
  43. package/src/utils/provider-resolver.ts +12 -1
  44. package/tests/executor.test.ts +165 -0
  45. package/tests/registry-find-people.test.ts +39 -7
  46. package/tests/registry-search-companies.test.ts +46 -7
  47. package/tests/tools/find-emails.test.ts +267 -1
  48. package/tests/tools/find-people.test.ts +269 -5
  49. package/tests/tools/get-credit-balance.test.ts +56 -0
  50. package/tests/utils/compact-people.test.ts +487 -0
@@ -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
+ })
@@ -108,9 +108,59 @@ describe('find_people handler', () => {
108
108
  limit: 5,
109
109
  })
110
110
 
111
+ const parsed = JSON.parse(result.content[0].text)
112
+ expect(parsed._meta.provider).toBe('leadsfactory')
113
+ // Compact-by-default: raw `companies_personas` is normalized into `people[]`.
114
+ expect(parsed.data.people).toHaveLength(1)
115
+ expect(parsed.data.people[0]).toMatchObject({
116
+ full_name: 'Michel Lieben',
117
+ first_name: 'Michel',
118
+ last_name: 'Lieben',
119
+ title: 'CEO',
120
+ })
121
+ expect(parsed.data.companies_personas).toBeUndefined()
122
+ } finally {
123
+ lf.async!.timeoutMs = originalTimeout
124
+ lf.async!.pollIntervalMs = originalInterval
125
+ }
126
+ })
127
+
128
+ it('fields=verbose returns the raw LeadsFactory passthrough (companies_personas) unchanged', async () => {
129
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
130
+ const urlStr = url.toString()
131
+ if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
132
+ return new Response(JSON.stringify({ id: 'abc123', nb_jobs_total: 1, progress_percentage: 0 }), { status: 201 })
133
+ }
134
+ if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
135
+ return new Response(JSON.stringify({
136
+ id: 'abc123', status: 'SUCCESSFUL', nb_jobs_total: 1, nb_jobs_complete: 1,
137
+ companies_personas: [
138
+ { company: 'ColdIQ', personas: [[{ contact: { first_name: 'Michel', last_name: 'Lieben', title: 'CEO' } }]] },
139
+ ],
140
+ }), { status: 200 })
141
+ }
142
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
143
+ }) as typeof fetch
144
+
145
+ const { getProviders } = await import('../../src/registry.js')
146
+ const lf = getProviders('find_people').find((p) => p.id === 'leadsfactory')!
147
+ const originalTimeout = lf.async!.timeoutMs
148
+ const originalInterval = lf.async!.pollIntervalMs
149
+
150
+ try {
151
+ lf.async!.timeoutMs = 500
152
+ lf.async!.pollIntervalMs = 20
153
+ const result = await findPeopleHandler({
154
+ company_domains: ['coldiq.com'],
155
+ job_titles: ['CEO'],
156
+ limit: 5,
157
+ fields: 'verbose',
158
+ })
159
+
111
160
  const parsed = JSON.parse(result.content[0].text)
112
161
  expect(parsed._meta.provider).toBe('leadsfactory')
113
162
  expect(parsed.data.companies_personas).toHaveLength(1)
163
+ expect(parsed.data.people).toBeUndefined()
114
164
  } finally {
115
165
  lf.async!.timeoutMs = originalTimeout
116
166
  lf.async!.pollIntervalMs = originalInterval
@@ -169,10 +219,13 @@ describe('find_people handler', () => {
169
219
 
170
220
  const parsed = JSON.parse(result.content[0].text)
171
221
  expect(parsed._meta.provider).toBe('leadsfactory')
172
- expect(parsed.data.companies_personas).toHaveLength(1)
173
- expect(parsed.data.gap_fill.provider).toBe('apollo')
174
- expect(parsed.data.gap_fill.domains).toEqual(['folk.app'])
175
- expect(parsed.data.gap_fill.people).toHaveLength(1)
222
+ // Compact-by-default: LF + Apollo gap-fill records are merged into one people[]
223
+ // and the gap-fill source is surfaced via gap_fill_provider.
224
+ expect(parsed.data.people).toHaveLength(2)
225
+ expect(parsed.data.people.map((p: { full_name: string }) => p.full_name)).toEqual(['Michel Lieben', 'Thibaud Elziere'])
226
+ expect(parsed.data.gap_fill_provider).toBe('apollo')
227
+ expect(parsed.data.companies_personas).toBeUndefined()
228
+ expect(parsed.data.gap_fill).toBeUndefined()
176
229
  } finally {
177
230
  lf.async!.timeoutMs = originalTimeout
178
231
  lf.async!.pollIntervalMs = originalInterval
@@ -224,7 +277,7 @@ describe('find_people handler', () => {
224
277
 
225
278
  const parsed = JSON.parse(result.content[0].text)
226
279
  expect(parsed._meta.provider).toBe('leadsfactory')
227
- expect(parsed.data.gap_fill).toBeUndefined()
280
+ expect(parsed.data.gap_fill_provider).toBeUndefined()
228
281
  expect(apolloCalled).toBe(false)
229
282
  } finally {
230
283
  lf.async!.timeoutMs = originalTimeout
@@ -353,5 +406,216 @@ describe('find_people handler', () => {
353
406
  const parsed = JSON.parse(result.content[0].text)
354
407
  expect(parsed._meta.provider).toBe('apollo')
355
408
  expect(parsed.data.people).toHaveLength(1)
409
+ expect(parsed.data.people[0]).toMatchObject({
410
+ full_name: 'Michel Lieben',
411
+ company_domain: 'coldiq.com',
412
+ })
413
+ })
414
+
415
+ it('reveal=true follows obfuscated Apollo search with bulk-match in chunks of 10', async () => {
416
+ const bulkMatchCalls: Array<{ url: string; body: Record<string, unknown> }> = []
417
+ const obfuscated = (id: string, n: number) => ({
418
+ id,
419
+ first_name: `Person${n}`,
420
+ last_name: `Ar***t${n}`,
421
+ organization: { website_url: 'coldiq.com' },
422
+ })
423
+ const revealed = (id: string, n: number) => ({
424
+ id,
425
+ first_name: `Person${n}`,
426
+ last_name: `Argent${n}`,
427
+ email: `person${n}@coldiq.com`,
428
+ linkedin_url: `https://linkedin.com/in/person${n}`,
429
+ organization: { website_url: 'coldiq.com' },
430
+ })
431
+
432
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
433
+ const urlStr = url.toString()
434
+
435
+ if (urlStr.includes('/apollo/people/search')) {
436
+ return new Response(
437
+ JSON.stringify({ people: Array.from({ length: 12 }, (_, i) => obfuscated(`id_${i}`, i)) }),
438
+ { status: 200 },
439
+ )
440
+ }
441
+
442
+ if (urlStr.includes('/apollo/people/bulk-match')) {
443
+ const body = JSON.parse((opts?.body as string) ?? '{}') as { details: Array<{ id: string }> }
444
+ bulkMatchCalls.push({ url: urlStr, body })
445
+ return new Response(
446
+ JSON.stringify({
447
+ matches: body.details.map((d, idx) => revealed(d.id, parseInt(d.id.replace('id_', ''), 10) || idx)),
448
+ }),
449
+ { status: 200 },
450
+ )
451
+ }
452
+
453
+ // Skip LeadsFactory entirely by pinning apollo via use_providers below.
454
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
455
+ }) as typeof fetch
456
+
457
+ const result = await findPeopleHandler({
458
+ company_domains: ['coldiq.com'],
459
+ job_titles: ['CEO'],
460
+ limit: 12,
461
+ reveal: true,
462
+ use_providers: ['apollo'],
463
+ })
464
+
465
+ const parsed = JSON.parse(result.content[0].text)
466
+ expect(parsed._meta.provider).toBe('apollo')
467
+ expect(parsed.data.revealed).toBe(true)
468
+ expect(parsed.data.people).toHaveLength(12)
469
+ // Chunked at 10 per bulk-match call: 12 IDs → 2 calls (10 + 2)
470
+ expect(bulkMatchCalls).toHaveLength(2)
471
+ expect(bulkMatchCalls[0].body.details).toHaveLength(10)
472
+ expect(bulkMatchCalls[1].body.details).toHaveLength(2)
473
+ // Revealed payload (compacted) carries email + full last name
474
+ expect(parsed.data.people[0].email).toBe('person0@coldiq.com')
475
+ expect(parsed.data.people[0].last_name).toBe('Argent0')
476
+ // reveal_personal_emails flag forwarded
477
+ expect(bulkMatchCalls[0].body.reveal_personal_emails).toBe(true)
478
+ })
479
+
480
+ it('reveal=true does NOT set revealed:true when bulk-match returns only some IDs (partial reveal)', async () => {
481
+ // Apollo's bulk-match can return matches for a subset of submitted IDs (e.g. some
482
+ // people aren't in their database). Setting `revealed: true` in that case would
483
+ // mislead callers — entries still obfuscated would look revealed at the top level.
484
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
485
+ const urlStr = url.toString()
486
+
487
+ if (urlStr.includes('/apollo/people/search')) {
488
+ return new Response(
489
+ JSON.stringify({
490
+ people: [
491
+ { id: 'id_0', first_name: 'A', last_name: 'A***A', organization: { website_url: 'coldiq.com' } },
492
+ { id: 'id_1', first_name: 'B', last_name: 'B***B', organization: { website_url: 'coldiq.com' } },
493
+ { id: 'id_2', first_name: 'C', last_name: 'C***C', organization: { website_url: 'coldiq.com' } },
494
+ ],
495
+ }),
496
+ { status: 200 },
497
+ )
498
+ }
499
+
500
+ if (urlStr.includes('/apollo/people/bulk-match')) {
501
+ const body = JSON.parse((opts?.body as string) ?? '{}') as { details: Array<{ id: string }> }
502
+ // Only return a match for id_0 — id_1 and id_2 stay obfuscated.
503
+ const onlyFirst = body.details
504
+ .filter((d) => d.id === 'id_0')
505
+ .map((d) => ({ id: d.id, first_name: 'A', last_name: 'AlphaReal', email: 'a@coldiq.com', linkedin_url: 'https://linkedin.com/in/a', organization: { website_url: 'coldiq.com' } }))
506
+ return new Response(JSON.stringify({ matches: onlyFirst }), { status: 200 })
507
+ }
508
+
509
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
510
+ }) as typeof fetch
511
+
512
+ const result = await findPeopleHandler({
513
+ company_domains: ['coldiq.com'],
514
+ job_titles: ['CEO'],
515
+ limit: 3,
516
+ reveal: true,
517
+ use_providers: ['apollo'],
518
+ })
519
+
520
+ const parsed = JSON.parse(result.content[0].text)
521
+ expect(parsed._meta.provider).toBe('apollo')
522
+ // Partial reveal: flag must be omitted (compactPayload only includes revealed when === true).
523
+ expect(parsed.data.revealed).toBeUndefined()
524
+ expect(parsed.data.people).toHaveLength(3)
525
+ // id_0 came back revealed; id_1/id_2 stayed obfuscated.
526
+ expect(parsed.data.people[0].last_name).toBe('AlphaReal')
527
+ expect(parsed.data.people[0].email).toBe('a@coldiq.com')
528
+ expect(parsed.data.people[1].last_name).toBe('B***B')
529
+ expect(parsed.data.people[2].last_name).toBe('C***C')
530
+ })
531
+
532
+ it('reveal=false (default) skips bulk-match and returns obfuscated Apollo data unchanged', async () => {
533
+ let bulkMatchCalled = false
534
+
535
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
536
+ const urlStr = url.toString()
537
+
538
+ if (urlStr.includes('/apollo/people/search')) {
539
+ return new Response(
540
+ JSON.stringify({
541
+ people: [{ id: 'id_0', first_name: 'Michel', last_name: 'Li***n', organization: { website_url: 'coldiq.com' } }],
542
+ }),
543
+ { status: 200 },
544
+ )
545
+ }
546
+
547
+ if (urlStr.includes('/apollo/people/bulk-match')) {
548
+ bulkMatchCalled = true
549
+ return new Response(JSON.stringify({ matches: [] }), { status: 200 })
550
+ }
551
+
552
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
553
+ }) as typeof fetch
554
+
555
+ const result = await findPeopleHandler({
556
+ company_domains: ['coldiq.com'],
557
+ job_titles: ['CEO'],
558
+ limit: 5,
559
+ use_providers: ['apollo'],
560
+ })
561
+
562
+ const parsed = JSON.parse(result.content[0].text)
563
+ expect(parsed._meta.provider).toBe('apollo')
564
+ expect(parsed.data.revealed).toBeUndefined()
565
+ // Obfuscated last name from /apollo/people/search is preserved through compaction.
566
+ expect(parsed.data.people[0].last_name).toBe('Li***n')
567
+ expect(bulkMatchCalled).toBe(false)
568
+ })
569
+
570
+ it('reveal=true is a no-op when the matched provider is not Apollo', async () => {
571
+ let bulkMatchCalled = false
572
+
573
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
574
+ const urlStr = url.toString()
575
+
576
+ if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
577
+ return new Response(JSON.stringify({ id: 'abc123', nb_jobs_total: 1, progress_percentage: 0 }), { status: 201 })
578
+ }
579
+
580
+ if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
581
+ return new Response(JSON.stringify({
582
+ id: 'abc123', status: 'SUCCESSFUL', nb_jobs_total: 1, nb_jobs_complete: 1,
583
+ companies_personas: [
584
+ { company: 'ColdIQ', personas: [[{ contact: { first_name: 'Michel', last_name: 'Lieben' } }]] },
585
+ ],
586
+ }), { status: 200 })
587
+ }
588
+
589
+ if (urlStr.includes('/apollo/people/bulk-match')) {
590
+ bulkMatchCalled = true
591
+ return new Response(JSON.stringify({ matches: [] }), { status: 200 })
592
+ }
593
+
594
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
595
+ }) as typeof fetch
596
+
597
+ const { getProviders } = await import('../../src/registry.js')
598
+ const lf = getProviders('find_people').find((p) => p.id === 'leadsfactory')!
599
+ const originalTimeout = lf.async!.timeoutMs
600
+ const originalInterval = lf.async!.pollIntervalMs
601
+
602
+ try {
603
+ lf.async!.timeoutMs = 500
604
+ lf.async!.pollIntervalMs = 20
605
+
606
+ const result = await findPeopleHandler({
607
+ company_domains: ['coldiq.com'],
608
+ job_titles: ['CEO'],
609
+ limit: 5,
610
+ reveal: true,
611
+ })
612
+
613
+ const parsed = JSON.parse(result.content[0].text)
614
+ expect(parsed._meta.provider).toBe('leadsfactory')
615
+ expect(bulkMatchCalled).toBe(false)
616
+ } finally {
617
+ lf.async!.timeoutMs = originalTimeout
618
+ lf.async!.pollIntervalMs = originalInterval
619
+ }
356
620
  })
357
621
  })
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { getCreditBalanceHandler } from '../../src/tools/get-credit-balance.js'
4
+
5
+ describe('get_credit_balance handler', () => {
6
+ const originalFetch = globalThis.fetch
7
+
8
+ beforeEach(() => {
9
+ initClient('http://test-api.local', 'test-key')
10
+ })
11
+
12
+ afterEach(() => {
13
+ globalThis.fetch = originalFetch
14
+ })
15
+
16
+ it('returns the balance payload on success', async () => {
17
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
18
+ const u = url.toString()
19
+ if (u.endsWith('/v1/me/credits')) {
20
+ return new Response(JSON.stringify({ balance: 250, usedThisMonth: 47 }), { status: 200 })
21
+ }
22
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
23
+ }) as typeof fetch
24
+
25
+ const result = await getCreditBalanceHandler({})
26
+
27
+ expect(result.isError).toBeFalsy()
28
+ const parsed = JSON.parse(result.content[0].text)
29
+ expect(parsed.data).toEqual({ balance: 250, usedThisMonth: 47 })
30
+ })
31
+
32
+ it('sends the API key as a bearer token', async () => {
33
+ let capturedAuth: string | null = null
34
+ globalThis.fetch = vi.fn(async (_url: string | URL | Request, opts?: RequestInit) => {
35
+ const headers = (opts?.headers ?? {}) as Record<string, string>
36
+ capturedAuth = headers.Authorization ?? null
37
+ return new Response(JSON.stringify({ balance: 0, usedThisMonth: 0 }), { status: 200 })
38
+ }) as typeof fetch
39
+
40
+ await getCreditBalanceHandler({})
41
+ expect(capturedAuth).toBe('Bearer test-key')
42
+ })
43
+
44
+ it('returns isError when the API responds with non-2xx', async () => {
45
+ globalThis.fetch = vi.fn(async () => {
46
+ return new Response(JSON.stringify({ error: 'Invalid API key' }), { status: 401 })
47
+ }) as typeof fetch
48
+
49
+ const result = await getCreditBalanceHandler({})
50
+
51
+ expect(result.isError).toBe(true)
52
+ const parsed = JSON.parse(result.content[0].text)
53
+ expect(parsed.status).toBe(401)
54
+ expect(parsed.error).toBe('Failed to fetch credit balance')
55
+ })
56
+ })