@coldiq/mcp 0.1.19 → 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 +34 -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 +12 -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 +40 -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 +12 -1
- package/tests/executor.test.ts +165 -0
- package/tests/registry-find-people.test.ts +31 -3
- 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
|
@@ -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
|
-
|
|
173
|
-
|
|
174
|
-
expect(parsed.data.
|
|
175
|
-
expect(parsed.data.
|
|
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.
|
|
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
|
+
})
|