@coldiq/mcp 0.3.11 → 0.3.13

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 (135) hide show
  1. package/dist/client.d.ts.map +1 -1
  2. package/dist/client.js +4 -0
  3. package/dist/client.js.map +1 -1
  4. package/dist/index.js +2 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/session-usage.d.ts +59 -0
  7. package/dist/session-usage.d.ts.map +1 -0
  8. package/dist/session-usage.js +96 -0
  9. package/dist/session-usage.js.map +1 -0
  10. package/dist/tools/enrich-company.d.ts +3 -9
  11. package/dist/tools/enrich-company.d.ts.map +1 -1
  12. package/dist/tools/enrich-company.js +3 -16
  13. package/dist/tools/enrich-company.js.map +1 -1
  14. package/dist/tools/enrich-person.d.ts +3 -9
  15. package/dist/tools/enrich-person.d.ts.map +1 -1
  16. package/dist/tools/enrich-person.js +3 -12
  17. package/dist/tools/enrich-person.js.map +1 -1
  18. package/dist/tools/fetch-page-content.d.ts +3 -9
  19. package/dist/tools/fetch-page-content.d.ts.map +1 -1
  20. package/dist/tools/fetch-page-content.js +3 -12
  21. package/dist/tools/fetch-page-content.js.map +1 -1
  22. package/dist/tools/find-email.d.ts +3 -9
  23. package/dist/tools/find-email.d.ts.map +1 -1
  24. package/dist/tools/find-email.js +3 -40
  25. package/dist/tools/find-email.js.map +1 -1
  26. package/dist/tools/find-emails.d.ts +3 -4
  27. package/dist/tools/find-emails.d.ts.map +1 -1
  28. package/dist/tools/find-emails.js +8 -423
  29. package/dist/tools/find-emails.js.map +1 -1
  30. package/dist/tools/find-influencers.d.ts +3 -9
  31. package/dist/tools/find-influencers.d.ts.map +1 -1
  32. package/dist/tools/find-influencers.js +3 -12
  33. package/dist/tools/find-influencers.js.map +1 -1
  34. package/dist/tools/find-people.d.ts +3 -8
  35. package/dist/tools/find-people.d.ts.map +1 -1
  36. package/dist/tools/find-people.js +3 -98
  37. package/dist/tools/find-people.js.map +1 -1
  38. package/dist/tools/find-phone.d.ts +3 -9
  39. package/dist/tools/find-phone.d.ts.map +1 -1
  40. package/dist/tools/find-phone.js +4 -41
  41. package/dist/tools/find-phone.js.map +1 -1
  42. package/dist/tools/find-signals.d.ts +3 -9
  43. package/dist/tools/find-signals.d.ts.map +1 -1
  44. package/dist/tools/find-signals.js +3 -83
  45. package/dist/tools/find-signals.js.map +1 -1
  46. package/dist/tools/get-place-reviews.d.ts +3 -9
  47. package/dist/tools/get-place-reviews.d.ts.map +1 -1
  48. package/dist/tools/get-place-reviews.js +3 -12
  49. package/dist/tools/get-place-reviews.js.map +1 -1
  50. package/dist/tools/get-session-usage.d.ts +10 -0
  51. package/dist/tools/get-session-usage.d.ts.map +1 -0
  52. package/dist/tools/get-session-usage.js +14 -0
  53. package/dist/tools/get-session-usage.js.map +1 -0
  54. package/dist/tools/search-ads.d.ts +3 -9
  55. package/dist/tools/search-ads.d.ts.map +1 -1
  56. package/dist/tools/search-ads.js +3 -12
  57. package/dist/tools/search-ads.js.map +1 -1
  58. package/dist/tools/search-companies.d.ts +3 -9
  59. package/dist/tools/search-companies.d.ts.map +1 -1
  60. package/dist/tools/search-companies.js +3 -24
  61. package/dist/tools/search-companies.js.map +1 -1
  62. package/dist/tools/search-jobs.d.ts +3 -9
  63. package/dist/tools/search-jobs.d.ts.map +1 -1
  64. package/dist/tools/search-jobs.js +3 -27
  65. package/dist/tools/search-jobs.js.map +1 -1
  66. package/dist/tools/search-places.d.ts +3 -15
  67. package/dist/tools/search-places.d.ts.map +1 -1
  68. package/dist/tools/search-places.js +5 -76
  69. package/dist/tools/search-places.js.map +1 -1
  70. package/dist/tools/search-reddit.d.ts +3 -9
  71. package/dist/tools/search-reddit.d.ts.map +1 -1
  72. package/dist/tools/search-reddit.js +3 -12
  73. package/dist/tools/search-reddit.js.map +1 -1
  74. package/dist/tools/search-seo.d.ts +3 -9
  75. package/dist/tools/search-seo.d.ts.map +1 -1
  76. package/dist/tools/search-seo.js +3 -12
  77. package/dist/tools/search-seo.js.map +1 -1
  78. package/dist/tools/search-web.d.ts +3 -9
  79. package/dist/tools/search-web.d.ts.map +1 -1
  80. package/dist/tools/search-web.js +3 -12
  81. package/dist/tools/search-web.js.map +1 -1
  82. package/dist/tools/verify-email.d.ts +3 -10
  83. package/dist/tools/verify-email.d.ts.map +1 -1
  84. package/dist/tools/verify-email.js +3 -68
  85. package/dist/tools/verify-email.js.map +1 -1
  86. package/dist/verb-client.d.ts +35 -0
  87. package/dist/verb-client.d.ts.map +1 -0
  88. package/dist/verb-client.js +57 -0
  89. package/dist/verb-client.js.map +1 -0
  90. package/package.json +1 -1
  91. package/src/client.ts +4 -0
  92. package/src/index.ts +8 -0
  93. package/src/session-usage.ts +130 -0
  94. package/src/tools/enrich-company.ts +3 -16
  95. package/src/tools/enrich-person.ts +3 -12
  96. package/src/tools/fetch-page-content.ts +3 -12
  97. package/src/tools/find-email.ts +3 -42
  98. package/src/tools/find-emails.ts +8 -506
  99. package/src/tools/find-influencers.ts +3 -12
  100. package/src/tools/find-people.ts +3 -107
  101. package/src/tools/find-phone.ts +4 -42
  102. package/src/tools/find-signals.ts +3 -88
  103. package/src/tools/get-place-reviews.ts +3 -12
  104. package/src/tools/get-session-usage.ts +18 -0
  105. package/src/tools/search-ads.ts +3 -12
  106. package/src/tools/search-companies.ts +3 -23
  107. package/src/tools/search-jobs.ts +3 -30
  108. package/src/tools/search-places.ts +5 -72
  109. package/src/tools/search-reddit.ts +3 -12
  110. package/src/tools/search-seo.ts +3 -12
  111. package/src/tools/search-web.ts +3 -12
  112. package/src/tools/verify-email.ts +3 -76
  113. package/src/verb-client.ts +93 -0
  114. package/tests/executor.test.ts +69 -0
  115. package/tests/session-usage.test.ts +130 -0
  116. package/tests/tools/get-session-usage.test.ts +44 -0
  117. package/tests/verb-client.test.ts +152 -0
  118. package/tests/tools/enrich-company.test.ts +0 -92
  119. package/tests/tools/enrich-person.test.ts +0 -140
  120. package/tests/tools/fetch-page-content.test.ts +0 -108
  121. package/tests/tools/find-email.test.ts +0 -137
  122. package/tests/tools/find-emails.test.ts +0 -818
  123. package/tests/tools/find-influencers.test.ts +0 -91
  124. package/tests/tools/find-people.test.ts +0 -621
  125. package/tests/tools/find-phone.test.ts +0 -100
  126. package/tests/tools/find-signals.test.ts +0 -175
  127. package/tests/tools/get-place-reviews.test.ts +0 -73
  128. package/tests/tools/search-ads.test.ts +0 -182
  129. package/tests/tools/search-companies.test.ts +0 -192
  130. package/tests/tools/search-jobs.test.ts +0 -210
  131. package/tests/tools/search-places.test.ts +0 -210
  132. package/tests/tools/search-reddit.test.ts +0 -194
  133. package/tests/tools/search-seo.test.ts +0 -183
  134. package/tests/tools/search-web.test.ts +0 -79
  135. package/tests/tools/verify-email.test.ts +0 -142
@@ -1,818 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
- import { initClient } from '../../src/client.js'
3
- import { findEmailsHandler, FIND_EMAILS_CHUNK_SIZE } from '../../src/tools/find-emails.js'
4
-
5
- describe('find_emails handler (bulk)', () => {
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('resolves all people from prospeo bulk when all match', async () => {
17
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
18
- const u = url.toString()
19
- if (u.includes('/prospeo/bulk-enrich-person')) {
20
- return new Response(
21
- JSON.stringify({
22
- error: false,
23
- results: [
24
- { identifier: 'p1', error: false, person: { email: { email: 'alice@example.com' } } },
25
- { identifier: 'p2', error: false, person: { email: { email: 'bob@example.com' } } },
26
- ],
27
- total_cost: 2,
28
- }),
29
- { status: 200 },
30
- )
31
- }
32
- return new Response(JSON.stringify({ error: 'unexpected call' }), { status: 500 })
33
- }) as typeof fetch
34
-
35
- const result = await findEmailsHandler({
36
- people: [
37
- { id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' },
38
- { id: 'p2', first_name: 'Bob', last_name: 'Jones', domain: 'example.com' },
39
- ],
40
- })
41
-
42
- const parsed = JSON.parse(result.content[0].text)
43
- expect(parsed.data.found).toBe(2)
44
- expect(parsed.data.total).toBe(2)
45
- expect(parsed.data.results).toEqual([
46
- { id: 'p1', email: 'alice@example.com', provider: 'prospeo', provider_name: 'Prospeo' },
47
- { id: 'p2', email: 'bob@example.com', provider: 'prospeo', provider_name: 'Prospeo' },
48
- ])
49
- })
50
-
51
- it('falls back to FindyMail for prospeo misses', async () => {
52
- let fmCalled = false
53
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
54
- const u = url.toString()
55
- if (u.includes('/prospeo/bulk-enrich-person')) {
56
- return new Response(
57
- JSON.stringify({
58
- error: false,
59
- results: [
60
- { identifier: 'p1', error: true, person: null },
61
- ],
62
- total_cost: 0,
63
- }),
64
- { status: 200 },
65
- )
66
- }
67
- if (u.includes('/findymail/search/name')) {
68
- fmCalled = true
69
- return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
70
- }
71
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
72
- }) as typeof fetch
73
-
74
- const result = await findEmailsHandler({
75
- people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
76
- })
77
-
78
- expect(fmCalled).toBe(true)
79
- const parsed = JSON.parse(result.content[0].text)
80
- expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'findymail', provider_name: 'Findymail' })
81
- })
82
-
83
- it('falls back to IcyPeas when FindyMail also misses', async () => {
84
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
85
- const u = url.toString()
86
- if (u.includes('/prospeo/bulk-enrich-person')) {
87
- return new Response(
88
- JSON.stringify({ error: false, results: [{ identifier: 'p1', error: true, person: null }], total_cost: 0 }),
89
- { status: 200 },
90
- )
91
- }
92
- if (u.includes('/findymail/search/name')) {
93
- return new Response(JSON.stringify({ email: null }), { status: 200 })
94
- }
95
- if (u.includes('/icypeas/email-search')) {
96
- return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
97
- }
98
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
99
- }) as typeof fetch
100
-
101
- const result = await findEmailsHandler({
102
- people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
103
- })
104
-
105
- const parsed = JSON.parse(result.content[0].text)
106
- expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'icypeas', provider_name: 'Icypeas' })
107
- })
108
-
109
- it('returns email:null when all providers miss for a person', async () => {
110
- globalThis.fetch = vi.fn(async () =>
111
- new Response(JSON.stringify({ error: true }), { status: 200 }),
112
- ) as typeof fetch
113
-
114
- const result = await findEmailsHandler({
115
- people: [{ id: 'p1', first_name: 'Unknown', last_name: 'Person', domain: 'nowhere.xyz' }],
116
- })
117
-
118
- const parsed = JSON.parse(result.content[0].text)
119
- expect(parsed.data.found).toBe(0)
120
- expect(parsed.data.results[0]).toEqual({ id: 'p1', email: null, provider: null, provider_name: null })
121
- })
122
-
123
- it('handles mixed results: some from prospeo, some from fallback, some missed', async () => {
124
- // Track findymail call count to serve different results per person
125
- let findymailCalls = 0
126
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
127
- const u = url.toString()
128
- if (u.includes('/prospeo/bulk-enrich-person')) {
129
- return new Response(
130
- JSON.stringify({
131
- error: false,
132
- results: [
133
- { identifier: 'p1', error: false, person: { email: { email: 'alice@a.com' } } },
134
- { identifier: 'p2', error: true, person: null },
135
- { identifier: 'p3', error: true, person: null },
136
- ],
137
- total_cost: 1,
138
- }),
139
- { status: 200 },
140
- )
141
- }
142
- if (u.includes('/findymail/search/name')) {
143
- findymailCalls++
144
- // First miss (p2 or p3): one finds, one doesn't
145
- return new Response(
146
- JSON.stringify({ email: findymailCalls === 1 ? 'bob@b.com' : null }),
147
- { status: 200 },
148
- )
149
- }
150
- if (u.includes('/icypeas/email-search')) {
151
- return new Response(JSON.stringify({ email: null }), { status: 200 })
152
- }
153
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
154
- }) as typeof fetch
155
-
156
- const result = await findEmailsHandler({
157
- people: [
158
- { id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'a.com' },
159
- { id: 'p2', first_name: 'Bob', last_name: 'Jones', domain: 'b.com' },
160
- { id: 'p3', first_name: 'Carol', last_name: 'White', domain: 'c.com' },
161
- ],
162
- })
163
-
164
- const parsed = JSON.parse(result.content[0].text)
165
- expect(parsed.data.found).toBe(2)
166
- expect(parsed.data.results.find((r: EmailResult) => r.id === 'p1')).toMatchObject({ email: 'alice@a.com', provider: 'prospeo' })
167
- // One of p2/p3 found by findymail (order non-deterministic due to Promise.all)
168
- const findymailHit = parsed.data.results.find((r: EmailResult) => r.provider === 'findymail')
169
- expect(findymailHit).toBeDefined()
170
- expect(findymailHit.email).toMatch(/@/)
171
- const missed = parsed.data.results.find((r: EmailResult) => r.email === null)
172
- expect(missed).toBeDefined()
173
- })
174
-
175
- it('uses linkedin_url in prospeo bulk when provided', async () => {
176
- let capturedBulkBody: unknown
177
- globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
178
- const u = url.toString()
179
- if (u.includes('/prospeo/bulk-enrich-person')) {
180
- capturedBulkBody = JSON.parse(init?.body as string)
181
- return new Response(
182
- JSON.stringify({ error: false, results: [], total_cost: 0 }),
183
- { status: 200 },
184
- )
185
- }
186
- // Fallback calls (no domain/name so findymail/icypeas return nothing)
187
- return new Response(JSON.stringify({ email: null }), { status: 200 })
188
- }) as typeof fetch
189
-
190
- await findEmailsHandler({
191
- people: [{ id: 'p1', linkedin_url: 'https://linkedin.com/in/michel-lieben' }],
192
- })
193
-
194
- const body = capturedBulkBody as { data: Array<Record<string, unknown>> }
195
- expect(body.data[0]).toMatchObject({ identifier: 'p1', linkedin_url: 'https://linkedin.com/in/michel-lieben' })
196
- expect(body.data[0].first_name).toBeUndefined()
197
- })
198
-
199
- it('uses FullEnrich batch for Prospeo misses and matches by custom_id', async () => {
200
- vi.useFakeTimers()
201
-
202
- let feCreateBody: unknown
203
- let fePollCalled = false
204
-
205
- globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
206
- const u = url.toString()
207
-
208
- if (u.includes('/prospeo/bulk-enrich-person')) {
209
- return new Response(JSON.stringify({
210
- error: false,
211
- results: [{ identifier: 'p1', error: true, person: null }],
212
- total_cost: 0,
213
- }), { status: 200 })
214
- }
215
-
216
- if (u.includes('/fullenrich/contact/enrich/bulk/abc-fe-123')) {
217
- fePollCalled = true
218
- return new Response(JSON.stringify({
219
- status: 'DONE',
220
- data: [{ custom_id: 'p1', emails: ['alice@example.com'] }],
221
- }), { status: 200 })
222
- }
223
-
224
- if (u.includes('/fullenrich/contact/enrich/bulk')) {
225
- feCreateBody = JSON.parse(init?.body as string)
226
- return new Response(JSON.stringify({ enrichment_id: 'abc-fe-123' }), { status: 200 })
227
- }
228
-
229
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
230
- }) as typeof fetch
231
-
232
- const handlerPromise = findEmailsHandler({
233
- people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
234
- })
235
-
236
- await vi.runAllTimersAsync()
237
- const result = await handlerPromise
238
- vi.useRealTimers()
239
-
240
- expect(fePollCalled).toBe(true)
241
- const body = feCreateBody as { data: Array<Record<string, unknown>> }
242
- expect(body.data[0].custom_id).toBe('p1')
243
- const parsed = JSON.parse(result.content[0].text)
244
- expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'fullenrich', provider_name: 'FullEnrich' })
245
- })
246
-
247
- it('parallel branches: faster FindyMail wins, slower FullEnrich does not overwrite', async () => {
248
- // Step 2 (FullEnrich) and Step 3 (FindyMail/IcyPeas) run concurrently. If FindyMail
249
- // returns an email first, the FullEnrich poll later must NOT overwrite it.
250
- vi.useFakeTimers()
251
-
252
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
253
- const u = url.toString()
254
- if (u.includes('/prospeo/bulk-enrich-person')) {
255
- return new Response(
256
- JSON.stringify({ error: false, results: [{ identifier: 'p1', error: true, person: null }], total_cost: 0 }),
257
- { status: 200 },
258
- )
259
- }
260
- // FindyMail wins immediately
261
- if (u.includes('/findymail/search/name')) {
262
- return new Response(JSON.stringify({ email: 'fm@example.com' }), { status: 200 })
263
- }
264
- // FullEnrich create + later poll succeed but with a different email
265
- if (u.includes('/fullenrich/contact/enrich/bulk/abc-fe-456')) {
266
- return new Response(
267
- JSON.stringify({ status: 'DONE', data: [{ custom_id: 'p1', emails: ['fe@example.com'] }] }),
268
- { status: 200 },
269
- )
270
- }
271
- if (u.includes('/fullenrich/contact/enrich/bulk')) {
272
- return new Response(JSON.stringify({ enrichment_id: 'abc-fe-456' }), { status: 200 })
273
- }
274
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
275
- }) as typeof fetch
276
-
277
- const handlerPromise = findEmailsHandler({
278
- people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
279
- })
280
-
281
- await vi.runAllTimersAsync()
282
- const result = await handlerPromise
283
- vi.useRealTimers()
284
-
285
- const parsed = JSON.parse(result.content[0].text)
286
- // FindyMail won the race — its email + provider must persist
287
- expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'fm@example.com', provider: 'findymail', provider_name: 'Findymail' })
288
- })
289
-
290
- it('gracefully handles prospeo bulk failure and still tries fallbacks', async () => {
291
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
292
- const u = url.toString()
293
- if (u.includes('/prospeo/bulk-enrich-person')) {
294
- return new Response(JSON.stringify({ error: 'service unavailable' }), { status: 502 })
295
- }
296
- if (u.includes('/findymail/search/name')) {
297
- return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
298
- }
299
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
300
- }) as typeof fetch
301
-
302
- const result = await findEmailsHandler({
303
- people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
304
- })
305
-
306
- const parsed = JSON.parse(result.content[0].text)
307
- expect(parsed.data.results[0]).toMatchObject({ email: 'alice@example.com', provider: 'findymail' })
308
- })
309
- })
310
-
311
- interface EmailResult {
312
- id: string
313
- email: string | null
314
- provider: string | null
315
- }
316
-
317
- describe('find_emails handler — use_providers', () => {
318
- const originalFetch = globalThis.fetch
319
-
320
- beforeEach(() => {
321
- initClient('http://test-api.local', 'test-key')
322
- })
323
-
324
- afterEach(() => {
325
- globalThis.fetch = originalFetch
326
- })
327
-
328
- it('Scenario A — pinned to prospeo only, skips fallback providers', async () => {
329
- let findymailCalled = false
330
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
331
- const u = url.toString()
332
- if (u.includes('/findymail')) findymailCalled = true
333
- if (u.includes('/prospeo/bulk-enrich-person')) {
334
- return new Response(JSON.stringify({
335
- error: false,
336
- results: [{ identifier: 'p1', person: { email: { email: 'alice@example.com' } } }],
337
- }), { status: 200 })
338
- }
339
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
340
- }) as typeof fetch
341
-
342
- const result = await findEmailsHandler({
343
- people: [{ id: 'p1', first_name: 'Alice', domain: 'example.com' }],
344
- use_providers: ['prospeo'],
345
- })
346
-
347
- expect(result.isError).toBeUndefined()
348
- expect(findymailCalled).toBe(false)
349
- const parsed = JSON.parse(result.content[0].text)
350
- expect(parsed.data.results[0]).toMatchObject({ provider: 'prospeo' })
351
- })
352
-
353
- it('Scenario H — all pinned providers find nothing → returns isError with pushback message', async () => {
354
- globalThis.fetch = vi.fn(async () =>
355
- new Response(JSON.stringify({ error: false, results: [] }), { status: 200 })
356
- ) as typeof fetch
357
-
358
- const result = await findEmailsHandler({
359
- people: [{ id: 'p1', first_name: 'Unknown', domain: 'nowhere.xyz' }],
360
- use_providers: ['findymail'],
361
- })
362
-
363
- expect(result.isError).toBe(true)
364
- const parsed = JSON.parse(result.content[0].text)
365
- expect(parsed.error).toContain('Couldn\'t fulfill')
366
- expect(parsed.error).toContain('ColdIQ will automatically pick the best tool')
367
- })
368
-
369
- it('Scenario E — unrecognized provider name returns isError', async () => {
370
- const result = await findEmailsHandler({
371
- people: [{ id: 'p1', first_name: 'Alice', domain: 'example.com' }],
372
- use_providers: ['wiza'],
373
- })
374
-
375
- expect(result.isError).toBe(true)
376
- const parsed = JSON.parse(result.content[0].text)
377
- expect(parsed.error).toContain("'wiza' is not a recognized provider")
378
- expect(parsed.error).toContain('ColdIQ will automatically pick the best tool')
379
- })
380
-
381
- it('Scenario G — ordered providers: first succeeds, second skipped', async () => {
382
- let icypeasCalled = false
383
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
384
- const u = url.toString()
385
- if (u.includes('/icypeas')) icypeasCalled = true
386
- if (u.includes('/findymail/search/name')) {
387
- return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
388
- }
389
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
390
- }) as typeof fetch
391
-
392
- const result = await findEmailsHandler({
393
- people: [{ id: 'p1', first_name: 'Alice', domain: 'example.com' }],
394
- use_providers: ['findymail', 'icypeas'],
395
- })
396
-
397
- expect(result.isError).toBeUndefined()
398
- const parsed = JSON.parse(result.content[0].text)
399
- expect(parsed.data.results[0]).toMatchObject({ provider: 'findymail', email: 'alice@example.com' })
400
- expect(icypeasCalled).toBe(false)
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
- })
469
- })
470
-
471
- describe('find_emails handler — resilience', () => {
472
- const originalFetch = globalThis.fetch
473
-
474
- beforeEach(() => {
475
- initClient('http://test-api.local', 'test-key')
476
- })
477
-
478
- afterEach(() => {
479
- globalThis.fetch = originalFetch
480
- })
481
-
482
- it('findymailIcypeasStep completing correctly even when fullEnrichStep throws', async () => {
483
- // Prospeo returns no results.
484
- // FullEnrich create call throws (simulates unexpected error).
485
- // FindyMail finds the email — result should still be populated.
486
-
487
- vi.useFakeTimers()
488
-
489
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
490
- const u = url.toString()
491
- if (u.includes('/prospeo/bulk-enrich-person')) {
492
- return new Response(JSON.stringify({
493
- error: false,
494
- results: [{ identifier: 'p1', error: true, person: null }],
495
- total_cost: 0,
496
- }), { status: 200 })
497
- }
498
- if (u.includes('/fullenrich/contact/enrich/bulk') && !u.includes('/fullenrich/contact/enrich/bulk/')) {
499
- // Throw so fullEnrichStep rejects
500
- throw new Error('FullEnrich network failure')
501
- }
502
- if (u.includes('/findymail/search/name')) {
503
- return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
504
- }
505
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
506
- }) as typeof fetch
507
-
508
- const resultPromise = findEmailsHandler({
509
- people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
510
- })
511
-
512
- // Advance timers past the FullEnrich poll sleep
513
- await vi.runAllTimersAsync()
514
- const result = await resultPromise
515
-
516
- vi.useRealTimers()
517
-
518
- // Despite fullEnrichStep throwing, the handler must succeed
519
- expect(result.isError).toBeFalsy()
520
- const parsed = JSON.parse(result.content[0].text)
521
- expect(parsed.data.found).toBe(1)
522
- expect(parsed.data.results[0]).toMatchObject({ id: 'p1', email: 'alice@example.com', provider: 'findymail' })
523
- })
524
-
525
- it('FullEnrich poll bails after 3 consecutive errors instead of polling to the 45s deadline', async () => {
526
- // Regression: a 502-ing FullEnrich poll used to `continue` every 2s for the full
527
- // 45s deadline (~22 polls) — pure latency on the chunk's critical path with zero
528
- // emails. It must now stop after COLDIQ_MAX_POLL_ERRORS (default 3) consecutive errors.
529
- vi.useFakeTimers()
530
-
531
- let fePollCount = 0
532
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
533
- const u = url.toString()
534
- if (u.includes('/prospeo/bulk-enrich-person')) {
535
- return new Response(
536
- JSON.stringify({ error: false, results: [{ identifier: 'p1', error: true, person: null }], total_cost: 0 }),
537
- { status: 200 },
538
- )
539
- }
540
- // FullEnrich poll (has the id segment) — always 502.
541
- if (u.includes('/fullenrich/contact/enrich/bulk/abc-fe-err')) {
542
- fePollCount++
543
- return new Response(JSON.stringify({ error: 'bad gateway' }), { status: 502 })
544
- }
545
- // FullEnrich create.
546
- if (u.includes('/fullenrich/contact/enrich/bulk')) {
547
- return new Response(JSON.stringify({ enrichment_id: 'abc-fe-err' }), { status: 200 })
548
- }
549
- // FindyMail / IcyPeas / single-fallback all miss cleanly (no email, no failure).
550
- return new Response(JSON.stringify({ email: null }), { status: 200 })
551
- }) as typeof fetch
552
-
553
- const handlerPromise = findEmailsHandler({
554
- people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
555
- })
556
-
557
- await vi.runAllTimersAsync()
558
- const result = await handlerPromise
559
- vi.useRealTimers()
560
-
561
- // Bailed at the 3-error cap, not the ~22 polls that 45000/2000 would have produced.
562
- expect(fePollCount).toBe(3)
563
- const parsed = JSON.parse(result.content[0].text)
564
- expect(parsed._meta.providers.fullenrich).toEqual({ attempted: 1, failed: 1 })
565
- })
566
-
567
- it('falls back to single find_email waterfall when bulk providers all miss (covers limadata/blitzapi/linkupapi gap)', async () => {
568
- // Bulk path: Prospeo + FullEnrich + Findymail + Icypeas all miss.
569
- // Single-only providers (limadata-work-email, blitzapi, limadata-work-email-linkedin, linkupapi)
570
- // should be tried; blitzapi finds the email.
571
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
572
- const u = url.toString()
573
- if (u.includes('/prospeo/bulk-enrich-person')) {
574
- return new Response(
575
- JSON.stringify({ error: false, results: [{ identifier: 'p1', error: true, person: null }], total_cost: 0 }),
576
- { status: 200 },
577
- )
578
- }
579
- if (u.includes('/fullenrich/contact/enrich/bulk')) {
580
- // No enrichment_id returned → fullEnrichStep exits early without polling
581
- return new Response(JSON.stringify({}), { status: 200 })
582
- }
583
- if (u.includes('/findymail/search/name')) {
584
- return new Response(JSON.stringify({ email: null }), { status: 200 })
585
- }
586
- if (u.includes('/icypeas/email-search')) {
587
- return new Response(JSON.stringify({ email: null }), { status: 200 })
588
- }
589
- // Single-only providers — limadata first (per priority), then blitzapi.
590
- if (u.includes('/coldiq/find/work-email-linkedin')) {
591
- return new Response(JSON.stringify({ email: null }), { status: 200 })
592
- }
593
- if (u.includes('/coldiq/find/work-email')) {
594
- return new Response(JSON.stringify({ email: null }), { status: 200 })
595
- }
596
- if (u.includes('/blitzapi/enrichment/find-work-email')) {
597
- return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
598
- }
599
- if (u.includes('/linkupapi/data/mail/finder')) {
600
- return new Response(JSON.stringify({ email: null }), { status: 200 })
601
- }
602
- return new Response(JSON.stringify({ error: 'unexpected', url: u }), { status: 500 })
603
- }) as typeof fetch
604
-
605
- const result = await findEmailsHandler({
606
- people: [{
607
- id: 'p1',
608
- first_name: 'Alice',
609
- last_name: 'Smith',
610
- domain: 'example.com',
611
- linkedin_url: 'https://linkedin.com/in/alice',
612
- }],
613
- })
614
-
615
- const parsed = JSON.parse(result.content[0].text)
616
- expect(parsed.data.found).toBe(1)
617
- expect(parsed.data.results[0]).toMatchObject({ id: 'p1', email: 'alice@example.com', provider: 'blitzapi' })
618
- })
619
- })
620
-
621
- describe('find_emails handler — chunking', () => {
622
- const originalFetch = globalThis.fetch
623
-
624
- beforeEach(() => {
625
- initClient('http://test-api.local', 'test-key')
626
- })
627
-
628
- afterEach(() => {
629
- globalThis.fetch = originalFetch
630
- })
631
-
632
- function makePeople(n: number): Array<{ id: string; first_name: string; last_name: string; domain: string }> {
633
- return Array.from({ length: n }, (_, i) => ({
634
- id: `p${i + 1}`,
635
- first_name: `First${i + 1}`,
636
- last_name: `Last${i + 1}`,
637
- domain: 'example.com',
638
- }))
639
- }
640
-
641
- it('splits 25 people into 3 chunks of ≤10 and issues one Prospeo bulk per chunk', async () => {
642
- const bulkBodies: Array<{ data: Array<{ identifier: string }> }> = []
643
-
644
- globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
645
- const u = url.toString()
646
- if (u.includes('/prospeo/bulk-enrich-person')) {
647
- const body = JSON.parse(init!.body as string) as { data: Array<{ identifier: string }> }
648
- bulkBodies.push(body)
649
- return new Response(
650
- JSON.stringify({
651
- error: false,
652
- results: body.data.map((d) => ({
653
- identifier: d.identifier,
654
- error: false,
655
- person: { email: { email: `${d.identifier}@example.com` } },
656
- })),
657
- total_cost: body.data.length,
658
- }),
659
- { status: 200 },
660
- )
661
- }
662
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
663
- }) as typeof fetch
664
-
665
- const result = await findEmailsHandler({ people: makePeople(25) })
666
- const parsed = JSON.parse(result.content[0].text)
667
-
668
- expect(bulkBodies.length).toBe(3)
669
- expect(bulkBodies[0]!.data.length).toBeLessThanOrEqual(FIND_EMAILS_CHUNK_SIZE)
670
- expect(bulkBodies[1]!.data.length).toBeLessThanOrEqual(FIND_EMAILS_CHUNK_SIZE)
671
- expect(bulkBodies[2]!.data.length).toBeLessThanOrEqual(FIND_EMAILS_CHUNK_SIZE)
672
- expect(bulkBodies.flatMap((b) => b.data.map((d) => d.identifier)).sort()).toEqual(
673
- makePeople(25).map((p) => p.id).sort(),
674
- )
675
-
676
- expect(parsed.data.found).toBe(25)
677
- expect(parsed.data.total).toBe(25)
678
- expect(parsed._meta.batch_status).toBe('complete')
679
- expect(parsed._meta.chunk_count).toBe(3)
680
- expect(parsed._meta.chunk_size).toBe(FIND_EMAILS_CHUNK_SIZE)
681
- expect(parsed._meta.providers.prospeo).toEqual({ attempted: 3, failed: 0 })
682
- expect(result.isError).toBeUndefined()
683
- })
684
-
685
- it('one chunk Prospeo network failure → batch_status="partial" + isError absent when others succeed', async () => {
686
- // First Prospeo bulk call simulates a network failure (status 0 via a fetch throw).
687
- // Remaining chunks succeed. FindyMail picks up the failed chunk's misses.
688
- let bulkCallCount = 0
689
- globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
690
- const u = url.toString()
691
- if (u.includes('/prospeo/bulk-enrich-person')) {
692
- bulkCallCount++
693
- if (bulkCallCount === 1) {
694
- // Simulate network failure → client returns { ok: false, status: 0 }
695
- throw new Error('socket hang up')
696
- }
697
- const body = JSON.parse(init!.body as string) as { data: Array<{ identifier: string }> }
698
- return new Response(
699
- JSON.stringify({
700
- error: false,
701
- results: body.data.map((d) => ({
702
- identifier: d.identifier,
703
- error: false,
704
- person: { email: { email: `${d.identifier}@example.com` } },
705
- })),
706
- total_cost: body.data.length,
707
- }),
708
- { status: 200 },
709
- )
710
- }
711
- // FullEnrich create call for the failed chunk's misses — no enrichment_id so it bails fast.
712
- if (u.includes('/fullenrich/contact/enrich/bulk')) {
713
- return new Response(JSON.stringify({}), { status: 200 })
714
- }
715
- if (u.includes('/findymail/search/name')) {
716
- return new Response(JSON.stringify({ email: 'recovered@example.com' }), { status: 200 })
717
- }
718
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
719
- }) as typeof fetch
720
-
721
- const result = await findEmailsHandler({ people: makePeople(25) })
722
- const parsed = JSON.parse(result.content[0].text)
723
-
724
- expect(parsed._meta.providers.prospeo.attempted).toBe(3)
725
- expect(parsed._meta.providers.prospeo.failed).toBe(1)
726
- expect(parsed._meta.batch_status).toBe('partial')
727
- expect(parsed.data.found).toBeGreaterThan(0)
728
- expect(result.isError).toBeUndefined()
729
- })
730
-
731
- it('every chunk fails → batch_status="failed" and isError:true even unconstrained', async () => {
732
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
733
- const u = url.toString()
734
- if (u.includes('/prospeo/bulk-enrich-person')) {
735
- // 502 → counted as a network-level failure
736
- return new Response(JSON.stringify({ error: 'upstream down' }), { status: 502 })
737
- }
738
- if (u.includes('/fullenrich/contact/enrich/bulk')) {
739
- return new Response(JSON.stringify({ error: 'down' }), { status: 502 })
740
- }
741
- if (u.includes('/findymail/search/name')) {
742
- return new Response(JSON.stringify({ error: 'down' }), { status: 502 })
743
- }
744
- if (u.includes('/icypeas/email-search')) {
745
- return new Response(JSON.stringify({ error: 'down' }), { status: 502 })
746
- }
747
- // Single-only fallbacks: all return 502 so executeWithFallback yields an error.
748
- return new Response(JSON.stringify({ error: 'down' }), { status: 502 })
749
- }) as typeof fetch
750
-
751
- const result = await findEmailsHandler({ people: makePeople(15) })
752
- const parsed = JSON.parse(result.content[0].text)
753
-
754
- expect(parsed.data.found).toBe(0)
755
- expect(parsed._meta.batch_status).toBe('failed')
756
- expect(parsed._meta.providers.prospeo.failed).toBeGreaterThan(0)
757
- expect(result.isError).toBe(true)
758
- })
759
-
760
- it('legitimate "no coverage" (every provider 200s with no email) stays batch_status="complete" and is NOT an error', async () => {
761
- // Distinguishing failure from no-coverage: providers respond cleanly but find no email.
762
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
763
- const u = url.toString()
764
- if (u.includes('/prospeo/bulk-enrich-person')) {
765
- return new Response(
766
- JSON.stringify({ error: false, results: [], total_cost: 0 }),
767
- { status: 200 },
768
- )
769
- }
770
- if (u.includes('/fullenrich/contact/enrich/bulk')) {
771
- return new Response(JSON.stringify({}), { status: 200 })
772
- }
773
- if (u.includes('/findymail/search/name')) {
774
- return new Response(JSON.stringify({ email: null }), { status: 200 })
775
- }
776
- if (u.includes('/icypeas/email-search')) {
777
- return new Response(JSON.stringify({ email: null }), { status: 200 })
778
- }
779
- return new Response(JSON.stringify({ email: null }), { status: 200 })
780
- }) as typeof fetch
781
-
782
- const result = await findEmailsHandler({ people: makePeople(12) })
783
- const parsed = JSON.parse(result.content[0].text)
784
-
785
- expect(parsed.data.found).toBe(0)
786
- expect(parsed._meta.batch_status).toBe('complete')
787
- expect(parsed._meta.providers.prospeo.failed).toBe(0)
788
- expect(parsed._meta.providers.findymail.failed).toBe(0)
789
- expect(result.isError).toBeUndefined()
790
- })
791
-
792
- it('small input (≤10) still works and emits _meta without chunking overhead', async () => {
793
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
794
- const u = url.toString()
795
- if (u.includes('/prospeo/bulk-enrich-person')) {
796
- return new Response(
797
- JSON.stringify({
798
- error: false,
799
- results: [{ identifier: 'p1', error: false, person: { email: { email: 'alice@example.com' } } }],
800
- total_cost: 1,
801
- }),
802
- { status: 200 },
803
- )
804
- }
805
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
806
- }) as typeof fetch
807
-
808
- const result = await findEmailsHandler({
809
- people: [{ id: 'p1', first_name: 'Alice', domain: 'example.com' }],
810
- })
811
- const parsed = JSON.parse(result.content[0].text)
812
-
813
- expect(parsed._meta.chunk_count).toBe(1)
814
- expect(parsed._meta.batch_status).toBe('complete')
815
- expect(parsed._meta.providers.prospeo).toEqual({ attempted: 1, failed: 0 })
816
- expect(result.isError).toBeUndefined()
817
- })
818
- })