@coldiq/mcp 0.1.6 → 0.1.8

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.
@@ -308,3 +308,172 @@ describe('executor postFilter integration', () => {
308
308
  }
309
309
  })
310
310
  })
311
+
312
+ // 402 / Insufficient credits propagation
313
+ // ---------------------------------------------------------------------------
314
+
315
+ describe('executor — 402 propagation', () => {
316
+ const originalFetch = globalThis.fetch
317
+
318
+ beforeEach(() => {
319
+ initClient('http://test-api.local', 'test-key-123')
320
+ })
321
+
322
+ afterEach(() => {
323
+ globalThis.fetch = originalFetch
324
+ vi.restoreAllMocks()
325
+ })
326
+
327
+ it('surfaces billingUrl + insufficientCredits when every provider returns 402', async () => {
328
+ stubProviders([
329
+ makeProvider({ id: 'p1', hasResult: () => false }),
330
+ makeProvider({ id: 'p2', hasResult: () => false }),
331
+ ])
332
+
333
+ const body = {
334
+ error: "You don't have enough credits to run this request — it costs 5 credits and your balance is 0. Top up your account at https://coldiq.com/marketplace/billing to continue.",
335
+ billingUrl: 'https://coldiq.com/marketplace/billing',
336
+ needed: 5,
337
+ balance: 0,
338
+ }
339
+ globalThis.fetch = vi.fn(async () =>
340
+ new Response(JSON.stringify(body), { status: 402 })
341
+ ) as typeof fetch
342
+
343
+ const result = await executeWithFallback('search_companies', { keywords: ['SaaS'] })
344
+
345
+ expect('error' in result).toBe(true)
346
+ if ('error' in result) {
347
+ expect(result.insufficientCredits).toBe(true)
348
+ expect(result.billingUrl).toBe('https://coldiq.com/marketplace/billing')
349
+ expect(result.error).toContain('https://coldiq.com/marketplace/billing')
350
+ expect(result.error).toMatch(/insufficient credits/i)
351
+ expect(result.providers_tried).toHaveLength(2)
352
+ expect(result.providers_tried[0].status).toBe(402)
353
+ }
354
+ })
355
+
356
+ it('does NOT set insufficientCredits when only some providers return 402', async () => {
357
+ stubProviders([
358
+ makeProvider({ id: 'p1', hasResult: () => false }),
359
+ makeProvider({ id: 'p2', hasResult: () => false }),
360
+ ])
361
+
362
+ let call = 0
363
+ globalThis.fetch = vi.fn(async () => {
364
+ call++
365
+ if (call === 1) {
366
+ return new Response(
367
+ JSON.stringify({ error: 'no credits', billingUrl: 'https://coldiq.com/marketplace/billing' }),
368
+ { status: 402 }
369
+ )
370
+ }
371
+ return new Response(JSON.stringify({ error: 'upstream timeout' }), { status: 502 })
372
+ }) as typeof fetch
373
+
374
+ const result = await executeWithFallback('search_companies', { keywords: ['SaaS'] })
375
+
376
+ expect('error' in result).toBe(true)
377
+ if ('error' in result) {
378
+ expect(result.insufficientCredits).toBeUndefined()
379
+ expect(result.billingUrl).toBe('https://coldiq.com/marketplace/billing')
380
+ expect(result.error).not.toMatch(/insufficient credits/i)
381
+ }
382
+ })
383
+
384
+ it('preserves the full 402 message in providers_tried (not truncated mid-URL)', async () => {
385
+ stubProviders([makeProvider({ id: 'p1', hasResult: () => false })])
386
+
387
+ const longMessage =
388
+ "You don't have enough credits to run this request — it costs 5 credits and your balance is 0. Top up your account at https://coldiq.com/marketplace/billing to continue."
389
+ globalThis.fetch = vi.fn(async () =>
390
+ new Response(
391
+ JSON.stringify({ error: longMessage, billingUrl: 'https://coldiq.com/marketplace/billing' }),
392
+ { status: 402 }
393
+ )
394
+ ) as typeof fetch
395
+
396
+ const result = await executeWithFallback('search_companies', { keywords: ['SaaS'] })
397
+
398
+ if ('error' in result) {
399
+ expect(result.providers_tried[0].error).toContain('https://coldiq.com/marketplace/billing')
400
+ }
401
+ })
402
+ })
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // Async polling — function-form pollIntervalMs (backoff schedule)
406
+ // ---------------------------------------------------------------------------
407
+
408
+ describe('executor async polling intervals', () => {
409
+ const originalFetch = globalThis.fetch
410
+ const originalSetTimeout = globalThis.setTimeout
411
+
412
+ beforeEach(() => {
413
+ initClient('http://test-api.local', 'test-key-123')
414
+ })
415
+
416
+ afterEach(() => {
417
+ globalThis.fetch = originalFetch
418
+ globalThis.setTimeout = originalSetTimeout
419
+ vi.restoreAllMocks()
420
+ })
421
+
422
+ it('function-form pollIntervalMs is invoked per attempt with the 1-indexed attempt number', async () => {
423
+ const intervalCalls: number[] = []
424
+ const requestedDelays: number[] = []
425
+
426
+ // Capture every sleep delay (executeAsync uses setTimeout via the local sleep helper)
427
+ // and resolve immediately so we don't actually wait.
428
+ globalThis.setTimeout = ((cb: () => void, ms?: number) => {
429
+ if (typeof ms === 'number' && ms > 0) requestedDelays.push(ms)
430
+ cb()
431
+ return 0 as unknown as ReturnType<typeof setTimeout>
432
+ }) as typeof setTimeout
433
+
434
+ // Provider that completes on poll #4 — drives 4 sleep calls before returning.
435
+ let pollCount = 0
436
+ stubProviders([
437
+ makeProvider({
438
+ id: 'backoff-provider',
439
+ hasResult: (data) => (data as Record<string, unknown>).done === true,
440
+ async: {
441
+ extractId: () => 'job-xyz',
442
+ pollEndpoint: (id) => `/poll/${id}`,
443
+ pollIntervalMs: (attempt) => {
444
+ intervalCalls.push(attempt)
445
+ // Use small numbers so total wait stays bounded if setTimeout were real
446
+ return attempt * 100
447
+ },
448
+ timeoutMs: 60_000,
449
+ isComplete: (data) => (data as Record<string, unknown>).done === true,
450
+ },
451
+ }),
452
+ ])
453
+
454
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
455
+ const u = url.toString()
456
+ if (u.includes('/poll/')) {
457
+ pollCount++
458
+ return new Response(
459
+ JSON.stringify(pollCount >= 4 ? { done: true } : { done: false }),
460
+ { status: 200 },
461
+ )
462
+ }
463
+ // create-job response
464
+ return new Response(JSON.stringify({ id: 'job-xyz' }), { status: 200 })
465
+ }) as typeof fetch
466
+
467
+ const result = await executeWithFallback('enrich_company', { domain: 'coldiq.com' })
468
+
469
+ expect('data' in result).toBe(true)
470
+ // Function called once per loop iteration with 1-indexed attempt numbers
471
+ expect(intervalCalls).toEqual([1, 2, 3, 4])
472
+ // Each requested delay matches `attempt * 100`
473
+ expect(requestedDelays).toEqual([100, 200, 300, 400])
474
+ })
475
+
476
+ })
477
+
478
+ // Note: the LeadsFactory backoff *schedule* itself is asserted against the live
479
+ // provider entry in tests/registry.test.ts to avoid drift between test and source.
@@ -509,12 +509,29 @@ describe('registry', () => {
509
509
  expect((result.body as Record<string, unknown>).company_linkedin_urls).toBeUndefined()
510
510
  })
511
511
 
512
- it('LeadsFactory has async config', () => {
512
+ it('LeadsFactory has async config with continuous backoff schedule', () => {
513
513
  const providers = getProviders('find_people')
514
514
  const lf = providers.find((p) => p.id === 'leadsfactory')!
515
515
  expect(lf.async).toBeDefined()
516
- expect(lf.async!.pollIntervalMs).toBe(15000)
517
516
  expect(lf.async!.timeoutMs).toBe(300_000)
517
+ // Function-form pollIntervalMs: fast first probe, then continuously growing backoff.
518
+ const sched = lf.async!.pollIntervalMs
519
+ expect(typeof sched).toBe('function')
520
+ const fn = sched as (attempt: number) => number
521
+ expect(fn(1)).toBe(3000)
522
+ expect(fn(2)).toBe(7000)
523
+ expect(fn(3)).toBe(15000)
524
+ expect(fn(4)).toBe(25000)
525
+ expect(fn(8)).toBe(65000)
526
+ // At least 7 polls must fit inside the 5-minute timeout — guards against
527
+ // future tuning that would make the ramp so steep we only get 1–2 polls.
528
+ let cumulative = 0
529
+ let attempts = 0
530
+ while (cumulative < lf.async!.timeoutMs) {
531
+ attempts++
532
+ cumulative += fn(attempts)
533
+ }
534
+ expect(attempts).toBeGreaterThanOrEqual(7)
518
535
  })
519
536
 
520
537
  it('Apollo maps correctly', () => {
@@ -244,6 +244,49 @@ describe('find_emails handler (bulk)', () => {
244
244
  expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'fullenrich' })
245
245
  })
246
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' })
288
+ })
289
+
247
290
  it('gracefully handles prospeo bulk failure and still tries fallbacks', async () => {
248
291
  globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
249
292
  const u = url.toString()