@coldiq/mcp 0.1.0

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 (176) hide show
  1. package/dist/client.d.ts +8 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +47 -0
  4. package/dist/client.js.map +1 -0
  5. package/dist/executor.d.ts +21 -0
  6. package/dist/executor.d.ts.map +1 -0
  7. package/dist/executor.js +130 -0
  8. package/dist/executor.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +49 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/registry.d.ts +49 -0
  14. package/dist/registry.d.ts.map +1 -0
  15. package/dist/registry.js +3104 -0
  16. package/dist/registry.js.map +1 -0
  17. package/dist/tools/enrich-company.d.ts +22 -0
  18. package/dist/tools/enrich-company.d.ts.map +1 -0
  19. package/dist/tools/enrich-company.js +21 -0
  20. package/dist/tools/enrich-company.js.map +1 -0
  21. package/dist/tools/enrich-email.d.ts +24 -0
  22. package/dist/tools/enrich-email.d.ts.map +1 -0
  23. package/dist/tools/enrich-email.js +19 -0
  24. package/dist/tools/enrich-email.js.map +1 -0
  25. package/dist/tools/enrich-emails.d.ts +31 -0
  26. package/dist/tools/enrich-emails.d.ts.map +1 -0
  27. package/dist/tools/enrich-emails.js +146 -0
  28. package/dist/tools/enrich-emails.js.map +1 -0
  29. package/dist/tools/enrich-person.d.ts +26 -0
  30. package/dist/tools/enrich-person.d.ts.map +1 -0
  31. package/dist/tools/enrich-person.js +23 -0
  32. package/dist/tools/enrich-person.js.map +1 -0
  33. package/dist/tools/fetch-page-content.d.ts +22 -0
  34. package/dist/tools/fetch-page-content.d.ts.map +1 -0
  35. package/dist/tools/fetch-page-content.js +32 -0
  36. package/dist/tools/fetch-page-content.js.map +1 -0
  37. package/dist/tools/find-email.d.ts +24 -0
  38. package/dist/tools/find-email.d.ts.map +1 -0
  39. package/dist/tools/find-email.js +19 -0
  40. package/dist/tools/find-email.js.map +1 -0
  41. package/dist/tools/find-emails.d.ts +31 -0
  42. package/dist/tools/find-emails.d.ts.map +1 -0
  43. package/dist/tools/find-emails.js +146 -0
  44. package/dist/tools/find-emails.js.map +1 -0
  45. package/dist/tools/find-influencers.d.ts +29 -0
  46. package/dist/tools/find-influencers.d.ts.map +1 -0
  47. package/dist/tools/find-influencers.js +30 -0
  48. package/dist/tools/find-influencers.js.map +1 -0
  49. package/dist/tools/find-people.d.ts +26 -0
  50. package/dist/tools/find-people.d.ts.map +1 -0
  51. package/dist/tools/find-people.js +61 -0
  52. package/dist/tools/find-people.js.map +1 -0
  53. package/dist/tools/find-phone.d.ts +24 -0
  54. package/dist/tools/find-phone.d.ts.map +1 -0
  55. package/dist/tools/find-phone.js +48 -0
  56. package/dist/tools/find-phone.js.map +1 -0
  57. package/dist/tools/find-signals.d.ts +26 -0
  58. package/dist/tools/find-signals.d.ts.map +1 -0
  59. package/dist/tools/find-signals.js +82 -0
  60. package/dist/tools/find-signals.js.map +1 -0
  61. package/dist/tools/search-ads.d.ts +33 -0
  62. package/dist/tools/search-ads.d.ts.map +1 -0
  63. package/dist/tools/search-ads.js +33 -0
  64. package/dist/tools/search-ads.js.map +1 -0
  65. package/dist/tools/search-companies.d.ts +42 -0
  66. package/dist/tools/search-companies.d.ts.map +1 -0
  67. package/dist/tools/search-companies.js +37 -0
  68. package/dist/tools/search-companies.js.map +1 -0
  69. package/dist/tools/search-jobs.d.ts +51 -0
  70. package/dist/tools/search-jobs.d.ts.map +1 -0
  71. package/dist/tools/search-jobs.js +64 -0
  72. package/dist/tools/search-jobs.js.map +1 -0
  73. package/dist/tools/search-places.d.ts +47 -0
  74. package/dist/tools/search-places.d.ts.map +1 -0
  75. package/dist/tools/search-places.js +42 -0
  76. package/dist/tools/search-places.js.map +1 -0
  77. package/dist/tools/search-reddit.d.ts +27 -0
  78. package/dist/tools/search-reddit.d.ts.map +1 -0
  79. package/dist/tools/search-reddit.js +30 -0
  80. package/dist/tools/search-reddit.js.map +1 -0
  81. package/dist/tools/search-seo.d.ts +37 -0
  82. package/dist/tools/search-seo.d.ts.map +1 -0
  83. package/dist/tools/search-seo.js +49 -0
  84. package/dist/tools/search-seo.js.map +1 -0
  85. package/dist/tools/search-web.d.ts +23 -0
  86. package/dist/tools/search-web.d.ts.map +1 -0
  87. package/dist/tools/search-web.js +20 -0
  88. package/dist/tools/search-web.js.map +1 -0
  89. package/dist/tools/verify-email.d.ts +20 -0
  90. package/dist/tools/verify-email.d.ts.map +1 -0
  91. package/dist/tools/verify-email.js +15 -0
  92. package/dist/tools/verify-email.js.map +1 -0
  93. package/package.json +28 -0
  94. package/src/client.ts +60 -0
  95. package/src/executor.ts +182 -0
  96. package/src/index.ts +155 -0
  97. package/src/registry.ts +3159 -0
  98. package/src/tools/enrich-company.ts +25 -0
  99. package/src/tools/enrich-person.ts +27 -0
  100. package/src/tools/fetch-page-content.ts +36 -0
  101. package/src/tools/find-email.ts +23 -0
  102. package/src/tools/find-emails.ts +190 -0
  103. package/src/tools/find-influencers.ts +34 -0
  104. package/src/tools/find-people.ts +69 -0
  105. package/src/tools/find-phone.ts +53 -0
  106. package/src/tools/find-signals.ts +93 -0
  107. package/src/tools/search-ads.ts +44 -0
  108. package/src/tools/search-companies.ts +41 -0
  109. package/src/tools/search-jobs.ts +73 -0
  110. package/src/tools/search-places.ts +52 -0
  111. package/src/tools/search-reddit.ts +34 -0
  112. package/src/tools/search-seo.ts +59 -0
  113. package/src/tools/search-web.ts +24 -0
  114. package/src/tools/verify-email.ts +19 -0
  115. package/test-ads-live.ts +77 -0
  116. package/test-company-live.ts +91 -0
  117. package/test-email-live.ts +171 -0
  118. package/test-influencers-live.ts +66 -0
  119. package/test-jobs-live.ts +69 -0
  120. package/test-linkupapi-live.ts +137 -0
  121. package/test-phone-live.ts +41 -0
  122. package/test-places-live.ts +89 -0
  123. package/test-reddit-live.ts +66 -0
  124. package/test-search-live.ts +79 -0
  125. package/test-seo-live.ts +68 -0
  126. package/test-web-live.ts +67 -0
  127. package/tests/client.test.ts +90 -0
  128. package/tests/executor.test.ts +83 -0
  129. package/tests/gtm/01-icp-to-emails.test.ts +43 -0
  130. package/tests/gtm/02-icp-bulk-emails.test.ts +38 -0
  131. package/tests/gtm/03-icp-to-phones.test.ts +39 -0
  132. package/tests/gtm/04-funding-signal-outreach.test.ts +42 -0
  133. package/tests/gtm/05-hiring-signal-decisionmakers.test.ts +41 -0
  134. package/tests/gtm/06-intent-signal-outreach.test.ts +44 -0
  135. package/tests/gtm/07-places-to-content.test.ts +50 -0
  136. package/tests/gtm/08-domain-to-account.test.ts +44 -0
  137. package/tests/gtm/09-linkedin-to-everything.test.ts +41 -0
  138. package/tests/gtm/10-jobs-vs-signals-routing.test.ts +38 -0
  139. package/tests/gtm/11-find-vs-enrich-routing.test.ts +39 -0
  140. package/tests/gtm/12-bogus-domain-graceful.test.ts +42 -0
  141. package/tests/gtm/13-private-linkedin-graceful.test.ts +44 -0
  142. package/tests/gtm/14-empty-handoff.test.ts +43 -0
  143. package/tests/gtm/15-seo-reddit-research.test.ts +38 -0
  144. package/tests/gtm/README.md +59 -0
  145. package/tests/gtm/harness.ts +217 -0
  146. package/tests/gtm/tools-bridge.ts +232 -0
  147. package/tests/gtm-scenarios.md +32 -0
  148. package/tests/live/smoke-report.ts +255 -0
  149. package/tests/live/smoke.test.ts +134 -0
  150. package/tests/registry-enrich-person.test.ts +447 -0
  151. package/tests/registry-fetch-page-content.test.ts +90 -0
  152. package/tests/registry-find-people.test.ts +467 -0
  153. package/tests/registry-find-signals.test.ts +470 -0
  154. package/tests/registry-linkupapi.test.ts +331 -0
  155. package/tests/registry-search-companies.test.ts +188 -0
  156. package/tests/registry-search-jobs.test.ts +116 -0
  157. package/tests/registry.test.ts +2210 -0
  158. package/tests/tools/enrich-company.test.ts +92 -0
  159. package/tests/tools/enrich-email.test.ts +94 -0
  160. package/tests/tools/enrich-emails.test.ts +271 -0
  161. package/tests/tools/enrich-person.test.ts +140 -0
  162. package/tests/tools/fetch-page-content.test.ts +108 -0
  163. package/tests/tools/find-influencers.test.ts +91 -0
  164. package/tests/tools/find-people.test.ts +344 -0
  165. package/tests/tools/find-phone.test.ts +100 -0
  166. package/tests/tools/find-signals.test.ts +110 -0
  167. package/tests/tools/search-ads.test.ts +182 -0
  168. package/tests/tools/search-companies.test.ts +58 -0
  169. package/tests/tools/search-jobs.test.ts +210 -0
  170. package/tests/tools/search-places.test.ts +114 -0
  171. package/tests/tools/search-reddit.test.ts +125 -0
  172. package/tests/tools/search-seo.test.ts +183 -0
  173. package/tests/tools/search-web.test.ts +79 -0
  174. package/tests/tools/verify-email.test.ts +68 -0
  175. package/tsconfig.json +17 -0
  176. package/vitest.config.ts +7 -0
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ // Mock executeWithFallback so handler tests never hit real providers
4
+ vi.mock('../../src/executor.js', () => ({
5
+ executeWithFallback: vi.fn().mockResolvedValue({ data: [], _meta: { provider: 'mock' } }),
6
+ isExecutionError: vi.fn().mockReturnValue(false),
7
+ }))
8
+
9
+ import { findSignalsHandler } from '../../src/tools/find-signals.js'
10
+
11
+ beforeEach(() => {
12
+ vi.clearAllMocks()
13
+ })
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // intent pre-check: requires companies or domains
17
+ // ---------------------------------------------------------------------------
18
+
19
+ describe('find_signals handler — intent guard', () => {
20
+ it('rejects intent with no companies or domains', async () => {
21
+ const result = await findSignalsHandler({ signal_type: 'intent', limit: 10 })
22
+ expect(result.isError).toBe(true)
23
+ const body = JSON.parse(result.content[0].text)
24
+ expect(body.error).toMatch(/intent signal_type requires/)
25
+ })
26
+
27
+ it('rejects intent with empty companies array', async () => {
28
+ const result = await findSignalsHandler({ signal_type: 'intent', companies: [], limit: 10 })
29
+ expect(result.isError).toBe(true)
30
+ })
31
+
32
+ it('rejects intent with empty domains array', async () => {
33
+ const result = await findSignalsHandler({ signal_type: 'intent', domains: [], limit: 10 })
34
+ expect(result.isError).toBe(true)
35
+ })
36
+
37
+ it('allows intent with companies present', async () => {
38
+ const result = await findSignalsHandler({ signal_type: 'intent', companies: ['ColdIQ'], limit: 10 })
39
+ expect(result.isError).toBeUndefined()
40
+ })
41
+
42
+ it('allows intent with domains present', async () => {
43
+ const result = await findSignalsHandler({ signal_type: 'intent', domains: ['coldiq.com'], limit: 10 })
44
+ expect(result.isError).toBeUndefined()
45
+ })
46
+ })
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // feed-style pre-check: news and startup_post reject company filters
50
+ // ---------------------------------------------------------------------------
51
+
52
+ describe('find_signals handler — feed-style guard (news)', () => {
53
+ it('rejects news with companies filter', async () => {
54
+ const result = await findSignalsHandler({ signal_type: 'news', companies: ['ColdIQ'] })
55
+ expect(result.isError).toBe(true)
56
+ const body = JSON.parse(result.content[0].text)
57
+ expect(body.error).toMatch(/news and startup_post/)
58
+ })
59
+
60
+ it('rejects news with domains filter', async () => {
61
+ const result = await findSignalsHandler({ signal_type: 'news', domains: ['coldiq.com'] })
62
+ expect(result.isError).toBe(true)
63
+ })
64
+
65
+ it('allows news with countries only', async () => {
66
+ const result = await findSignalsHandler({ signal_type: 'news', countries: ['US'], limit: 10 })
67
+ expect(result.isError).toBeUndefined()
68
+ })
69
+
70
+ it('allows news with since and limit only', async () => {
71
+ const result = await findSignalsHandler({ signal_type: 'news', since: '2026-01-01', limit: 5 })
72
+ expect(result.isError).toBeUndefined()
73
+ })
74
+ })
75
+
76
+ describe('find_signals handler — feed-style guard (startup_post)', () => {
77
+ it('rejects startup_post with companies filter', async () => {
78
+ const result = await findSignalsHandler({ signal_type: 'startup_post', companies: ['ColdIQ'] })
79
+ expect(result.isError).toBe(true)
80
+ const body = JSON.parse(result.content[0].text)
81
+ expect(body.error).toMatch(/news and startup_post/)
82
+ })
83
+
84
+ it('rejects startup_post with domains filter', async () => {
85
+ const result = await findSignalsHandler({ signal_type: 'startup_post', domains: ['coldiq.com'] })
86
+ expect(result.isError).toBe(true)
87
+ })
88
+
89
+ it('allows startup_post with since filter only', async () => {
90
+ const result = await findSignalsHandler({ signal_type: 'startup_post', since: '2026-01-01', limit: 25 })
91
+ expect(result.isError).toBeUndefined()
92
+ })
93
+ })
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // company-targeted types: no pre-check rejection
97
+ // ---------------------------------------------------------------------------
98
+
99
+ describe('find_signals handler — company-targeted types pass through', () => {
100
+ const types = ['funding', 'acquisition', 'hiring', 'job_change'] as const
101
+
102
+ for (const signal_type of types) {
103
+ it(`allows ${signal_type} with or without companies`, async () => {
104
+ const withCompanies = await findSignalsHandler({ signal_type, companies: ['ColdIQ'] })
105
+ const withoutCompanies = await findSignalsHandler({ signal_type, limit: 10 })
106
+ expect(withCompanies.isError).toBeUndefined()
107
+ expect(withoutCompanies.isError).toBeUndefined()
108
+ })
109
+ }
110
+ })
@@ -0,0 +1,182 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { searchAdsHandler } from '../../src/tools/search-ads.js'
4
+
5
+ describe('search_ads 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('domains filter — routes to google_ads only', async () => {
17
+ let metaCalled = false
18
+ let linkedinCalled = false
19
+
20
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
21
+ const u = url.toString()
22
+ if (u.includes('/meta-ads/')) metaCalled = true
23
+ if (u.includes('/linkedin-ad-library/')) linkedinCalled = true
24
+ if (u.includes('/google-ads/search') && !u.includes('/search/')) {
25
+ return new Response(JSON.stringify({ jobId: 'ads-1' }), { status: 200 })
26
+ }
27
+ if (u.includes('/google-ads/search/ads-1')) {
28
+ return new Response(JSON.stringify({ jobId: 'ads-1', status: 'done', ads: [{ advertiser: 'Salesforce', headline: 'CRM Leader' }] }), { status: 200 })
29
+ }
30
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
31
+ }) as typeof fetch
32
+
33
+ const { getProviders } = await import('../../src/registry.js')
34
+ const providers = getProviders('search_ads')
35
+ const ga = providers.find((p) => p.id === 'google_ads')!
36
+ const orig = { t: ga.async!.timeoutMs, i: ga.async!.pollIntervalMs }
37
+ ga.async!.timeoutMs = 500
38
+ ga.async!.pollIntervalMs = 20
39
+
40
+ try {
41
+ const result = await searchAdsHandler({ domains: ['salesforce.com'] })
42
+
43
+ expect(result.isError).toBeFalsy()
44
+ const parsed = JSON.parse(result.content[0].text)
45
+ expect(parsed._meta.provider).toBe('google_ads')
46
+ expect(metaCalled).toBe(false)
47
+ expect(linkedinCalled).toBe(false)
48
+ } finally {
49
+ ga.async!.timeoutMs = orig.t
50
+ ga.async!.pollIntervalMs = orig.i
51
+ }
52
+ })
53
+
54
+ it('search_urls filter — routes to linkedin_ad_library only', async () => {
55
+ let googleCalled = false
56
+
57
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
58
+ const u = url.toString()
59
+ if (u.includes('/google-ads/')) googleCalled = true
60
+ if (u.includes('/linkedin-ad-library/search') && !u.includes('/search/')) {
61
+ return new Response(JSON.stringify({ jobId: 'li-1' }), { status: 200 })
62
+ }
63
+ if (u.includes('/linkedin-ad-library/search/li-1')) {
64
+ return new Response(JSON.stringify({ jobId: 'li-1', status: 'done', ads: [{ company: 'ColdIQ', creative: 'B2B ad' }] }), { status: 200 })
65
+ }
66
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
67
+ }) as typeof fetch
68
+
69
+ const { getProviders } = await import('../../src/registry.js')
70
+ const providers = getProviders('search_ads')
71
+ const li = providers.find((p) => p.id === 'linkedin_ad_library')!
72
+ const orig = { t: li.async!.timeoutMs, i: li.async!.pollIntervalMs }
73
+ li.async!.timeoutMs = 500
74
+ li.async!.pollIntervalMs = 20
75
+
76
+ try {
77
+ const result = await searchAdsHandler({
78
+ search_urls: ['https://linkedin.com/ad-library/search?companyIds=123'],
79
+ })
80
+
81
+ expect(result.isError).toBeFalsy()
82
+ const parsed = JSON.parse(result.content[0].text)
83
+ expect(parsed._meta.provider).toBe('linkedin_ad_library')
84
+ expect(googleCalled).toBe(false)
85
+ } finally {
86
+ li.async!.timeoutMs = orig.t
87
+ li.async!.pollIntervalMs = orig.i
88
+ }
89
+ })
90
+
91
+ it('query only — falls through google_ads (no results) → meta_ads wins', async () => {
92
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
93
+ const u = url.toString()
94
+ // google_ads: create returns jobId, poll returns no ads
95
+ if (u.includes('/google-ads/search') && !u.includes('/search/')) {
96
+ return new Response(JSON.stringify({ jobId: 'ga-2' }), { status: 200 })
97
+ }
98
+ if (u.includes('/google-ads/search/ga-2')) {
99
+ return new Response(JSON.stringify({ jobId: 'ga-2', status: 'done', ads: [] }), { status: 200 })
100
+ }
101
+ // meta_ads: returns results
102
+ if (u.includes('/meta-ads/search') && !u.includes('/search/')) {
103
+ return new Response(JSON.stringify({ jobId: 'meta-1' }), { status: 200 })
104
+ }
105
+ if (u.includes('/meta-ads/search/meta-1')) {
106
+ return new Response(JSON.stringify({ jobId: 'meta-1', status: 'done', ads: [{ page_name: 'ColdIQ', body: 'Outbound faster' }] }), { status: 200 })
107
+ }
108
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
109
+ }) as typeof fetch
110
+
111
+ const { getProviders } = await import('../../src/registry.js')
112
+ const providers = getProviders('search_ads')
113
+ const ga = providers.find((p) => p.id === 'google_ads')!
114
+ const ma = providers.find((p) => p.id === 'meta_ads')!
115
+ const origGa = { t: ga.async!.timeoutMs, i: ga.async!.pollIntervalMs }
116
+ const origMa = { t: ma.async!.timeoutMs, i: ma.async!.pollIntervalMs }
117
+ ga.async!.timeoutMs = 500
118
+ ga.async!.pollIntervalMs = 20
119
+ ma.async!.timeoutMs = 500
120
+ ma.async!.pollIntervalMs = 20
121
+
122
+ try {
123
+ const result = await searchAdsHandler({ query: 'ColdIQ' })
124
+
125
+ expect(result.isError).toBeFalsy()
126
+ const parsed = JSON.parse(result.content[0].text)
127
+ expect(parsed._meta.provider).toBe('meta_ads')
128
+ } finally {
129
+ ga.async!.timeoutMs = origGa.t
130
+ ga.async!.pollIntervalMs = origGa.i
131
+ ma.async!.timeoutMs = origMa.t
132
+ ma.async!.pollIntervalMs = origMa.i
133
+ }
134
+ })
135
+
136
+ it('platform=meta pinned — only meta_ads fires', async () => {
137
+ let googleCalled = false
138
+
139
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
140
+ const u = url.toString()
141
+ if (u.includes('/google-ads/')) googleCalled = true
142
+ if (u.includes('/meta-ads/search') && !u.includes('/search/')) {
143
+ return new Response(JSON.stringify({ jobId: 'meta-2' }), { status: 200 })
144
+ }
145
+ if (u.includes('/meta-ads/search/meta-2')) {
146
+ return new Response(JSON.stringify({ jobId: 'meta-2', status: 'done', ads: [{ page_name: 'ColdIQ' }] }), { status: 200 })
147
+ }
148
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
149
+ }) as typeof fetch
150
+
151
+ const { getProviders } = await import('../../src/registry.js')
152
+ const providers = getProviders('search_ads')
153
+ const ma = providers.find((p) => p.id === 'meta_ads')!
154
+ const orig = { t: ma.async!.timeoutMs, i: ma.async!.pollIntervalMs }
155
+ ma.async!.timeoutMs = 500
156
+ ma.async!.pollIntervalMs = 20
157
+
158
+ try {
159
+ const result = await searchAdsHandler({ platform: 'meta', query: 'ColdIQ' })
160
+
161
+ expect(result.isError).toBeFalsy()
162
+ const parsed = JSON.parse(result.content[0].text)
163
+ expect(parsed._meta.provider).toBe('meta_ads')
164
+ expect(googleCalled).toBe(false)
165
+ } finally {
166
+ ma.async!.timeoutMs = orig.t
167
+ ma.async!.pollIntervalMs = orig.i
168
+ }
169
+ })
170
+
171
+ it('all providers fail — returns isError', async () => {
172
+ globalThis.fetch = vi.fn(async () => {
173
+ return new Response(JSON.stringify({ error: 'down' }), { status: 503 })
174
+ }) as typeof fetch
175
+
176
+ const result = await searchAdsHandler({ query: 'ColdIQ' })
177
+
178
+ expect(result.isError).toBe(true)
179
+ const parsed = JSON.parse(result.content[0].text)
180
+ expect(parsed.error).toBeTruthy()
181
+ })
182
+ })
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { searchCompaniesHandler } from '../../src/tools/search-companies.js'
4
+
5
+ describe('search_companies 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 results from first successful provider', async () => {
17
+ globalThis.fetch = vi.fn(async () =>
18
+ new Response(JSON.stringify({ data: [{ name: 'ColdIQ', domain: 'coldiq.com' }] }), { status: 200 })
19
+ ) as typeof fetch
20
+
21
+ const result = await searchCompaniesHandler({ countries: ['FR'], limit: 5 })
22
+
23
+ expect(result.isError).toBeUndefined()
24
+ const parsed = JSON.parse(result.content[0].text)
25
+ expect(parsed._meta.provider).toBe('companyenrich')
26
+ expect(parsed.data.data).toHaveLength(1)
27
+ })
28
+
29
+ it('falls back to FullEnrich (priority 2) on CompanyEnrich failure', async () => {
30
+ let callCount = 0
31
+ globalThis.fetch = vi.fn(async () => {
32
+ callCount++
33
+ if (callCount === 1) {
34
+ return new Response(JSON.stringify({ error: 'Provider error' }), { status: 502 })
35
+ }
36
+ return new Response(JSON.stringify({ companies: [{ name: 'ColdIQ' }] }), { status: 200 })
37
+ }) as typeof fetch
38
+
39
+ const result = await searchCompaniesHandler({ countries: ['FR'], limit: 5 })
40
+
41
+ expect(result.isError).toBeUndefined()
42
+ const parsed = JSON.parse(result.content[0].text)
43
+ expect(parsed._meta.provider).toBe('fullenrich')
44
+ })
45
+
46
+ it('returns error when all providers fail', async () => {
47
+ globalThis.fetch = vi.fn(async () =>
48
+ new Response(JSON.stringify({ error: 'Service down' }), { status: 500 })
49
+ ) as typeof fetch
50
+
51
+ const result = await searchCompaniesHandler({ countries: ['FR'], limit: 5 })
52
+
53
+ expect(result.isError).toBe(true)
54
+ const parsed = JSON.parse(result.content[0].text)
55
+ expect(parsed.error).toContain('All')
56
+ expect(parsed.providers_tried.length).toBeGreaterThan(0)
57
+ })
58
+ })
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { searchJobsHandler } from '../../src/tools/search-jobs.js'
4
+
5
+ describe('search_jobs 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('contradictory filters — rejected at handler level before any provider is called', async () => {
17
+ let fetchCalled = false
18
+ globalThis.fetch = vi.fn(async () => {
19
+ fetchCalled = true
20
+ return new Response(JSON.stringify({}), { status: 200 })
21
+ }) as typeof fetch
22
+
23
+ const result = await searchJobsHandler({
24
+ ats_slugs: ['greenhouse'],
25
+ seniority_levels: ['Director'],
26
+ })
27
+
28
+ expect(result.isError).toBe(true)
29
+ const parsed = JSON.parse(result.content[0].text)
30
+ expect(parsed.error).toMatch(/contradictory/i)
31
+ expect(fetchCalled).toBe(false)
32
+ })
33
+
34
+ it('ats_slugs filter — routes to career_site_jobs only (LinkedIn skipped)', async () => {
35
+ let linkedinCalled = false
36
+
37
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
38
+ const u = url.toString()
39
+ if (u.includes('/linkedin-jobs-api/search')) {
40
+ linkedinCalled = true
41
+ throw new Error('linkedin_jobs_api should not fire when ats_slugs is set')
42
+ }
43
+ if (u.includes('/career-site-jobs/search') && !u.includes('/search/')) {
44
+ // Async create — returns a jobId
45
+ return new Response(JSON.stringify({ jobId: 'job-1' }), { status: 200 })
46
+ }
47
+ if (u.includes('/career-site-jobs/search/job-1')) {
48
+ // Poll — done with results
49
+ return new Response(JSON.stringify({ jobId: 'job-1', status: 'done', jobs: [{ title: 'Software Engineer', company: 'Stripe' }] }), { status: 200 })
50
+ }
51
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
52
+ }) as typeof fetch
53
+
54
+ const { getProviders } = await import('../../src/registry.js')
55
+ const providers = getProviders('search_jobs')
56
+ const cs = providers.find((p) => p.id === 'career_site_jobs')!
57
+ const orig = { t: cs.async!.timeoutMs, i: cs.async!.pollIntervalMs }
58
+ cs.async!.timeoutMs = 500
59
+ cs.async!.pollIntervalMs = 20
60
+
61
+ try {
62
+ const result = await searchJobsHandler({
63
+ ats_slugs: ['greenhouse'],
64
+ title_keywords: ['Engineer'],
65
+ limit: 10,
66
+ })
67
+
68
+ expect(result.isError).toBeFalsy()
69
+ const parsed = JSON.parse(result.content[0].text)
70
+ expect(parsed._meta.provider).toBe('career_site_jobs')
71
+ expect(linkedinCalled).toBe(false)
72
+ } finally {
73
+ cs.async!.timeoutMs = orig.t
74
+ cs.async!.pollIntervalMs = orig.i
75
+ }
76
+ })
77
+
78
+ it('seniority_levels filter — routes to linkedin_jobs_api only (career_site_jobs skipped)', async () => {
79
+ let careerSiteCalled = false
80
+
81
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
82
+ const u = url.toString()
83
+ if (u.includes('/career-site-jobs/search') && !u.includes('/search/')) {
84
+ careerSiteCalled = true
85
+ throw new Error('career_site_jobs should not fire when seniority_levels is set')
86
+ }
87
+ if (u.includes('/linkedin-jobs-api/search') && !u.includes('/search/')) {
88
+ return new Response(JSON.stringify({ jobId: 'job-2' }), { status: 200 })
89
+ }
90
+ if (u.includes('/linkedin-jobs-api/search/job-2')) {
91
+ return new Response(JSON.stringify({ jobId: 'job-2', status: 'done', jobs: [{ title: 'VP of Sales', company: 'HubSpot' }] }), { status: 200 })
92
+ }
93
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
94
+ }) as typeof fetch
95
+
96
+ const { getProviders } = await import('../../src/registry.js')
97
+ const providers = getProviders('search_jobs')
98
+ const li = providers.find((p) => p.id === 'linkedin_jobs_api')!
99
+ const orig = { t: li.async!.timeoutMs, i: li.async!.pollIntervalMs }
100
+ li.async!.timeoutMs = 500
101
+ li.async!.pollIntervalMs = 20
102
+
103
+ try {
104
+ const result = await searchJobsHandler({
105
+ seniority_levels: ['Director'],
106
+ title_keywords: ['VP'],
107
+ limit: 10,
108
+ })
109
+
110
+ expect(result.isError).toBeFalsy()
111
+ const parsed = JSON.parse(result.content[0].text)
112
+ expect(parsed._meta.provider).toBe('linkedin_jobs_api')
113
+ expect(careerSiteCalled).toBe(false)
114
+ } finally {
115
+ li.async!.timeoutMs = orig.t
116
+ li.async!.pollIntervalMs = orig.i
117
+ }
118
+ })
119
+
120
+ it('async timeout — career_site_jobs times out, falls back to theirstack-jobs', async () => {
121
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
122
+ const u = url.toString()
123
+ if (u.includes('/career-site-jobs/search') && !u.includes('/search/')) {
124
+ return new Response(JSON.stringify({ jobId: 'job-3' }), { status: 200 })
125
+ }
126
+ if (u.includes('/career-site-jobs/search/job-3')) {
127
+ // Always running — will cause timeout
128
+ return new Response(JSON.stringify({ jobId: 'job-3', status: 'running' }), { status: 200 })
129
+ }
130
+ if (u.includes('/linkedin-jobs-api/search') && !u.includes('/search/')) {
131
+ return new Response(JSON.stringify({ jobId: 'job-4' }), { status: 200 })
132
+ }
133
+ if (u.includes('/linkedin-jobs-api/search/job-4')) {
134
+ return new Response(JSON.stringify({ jobId: 'job-4', status: 'running' }), { status: 200 })
135
+ }
136
+ if (u.includes('/theirstack/jobs/search')) {
137
+ return new Response(JSON.stringify({ data: [{ title: 'SDR', company_name: 'ColdIQ' }] }), { status: 200 })
138
+ }
139
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
140
+ }) as typeof fetch
141
+
142
+ const { getProviders } = await import('../../src/registry.js')
143
+ const providers = getProviders('search_jobs')
144
+ const cs = providers.find((p) => p.id === 'career_site_jobs')!
145
+ const li = providers.find((p) => p.id === 'linkedin_jobs_api')!
146
+ const origCs = { t: cs.async!.timeoutMs, i: cs.async!.pollIntervalMs }
147
+ const origLi = { t: li.async!.timeoutMs, i: li.async!.pollIntervalMs }
148
+ cs.async!.timeoutMs = 100
149
+ cs.async!.pollIntervalMs = 20
150
+ li.async!.timeoutMs = 100
151
+ li.async!.pollIntervalMs = 20
152
+
153
+ try {
154
+ const result = await searchJobsHandler({
155
+ title_keywords: ['SDR'],
156
+ limit: 10,
157
+ })
158
+
159
+ expect(result.isError).toBeFalsy()
160
+ const parsed = JSON.parse(result.content[0].text)
161
+ expect(parsed._meta.provider).toBe('theirstack-jobs')
162
+ expect(parsed.data.data).toHaveLength(1)
163
+ } finally {
164
+ cs.async!.timeoutMs = origCs.t
165
+ cs.async!.pollIntervalMs = origCs.i
166
+ li.async!.timeoutMs = origLi.t
167
+ li.async!.pollIntervalMs = origLi.i
168
+ }
169
+ })
170
+
171
+ it('shared-only filters — both career_site_jobs and linkedin_jobs_api are applicable', async () => {
172
+ // With only title_keywords and locations (shared filters), career_site_jobs fires first
173
+ let careerSiteCreate = 0
174
+
175
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
176
+ const u = url.toString()
177
+ if (u.includes('/career-site-jobs/search') && !u.includes('/search/')) {
178
+ careerSiteCreate++
179
+ return new Response(JSON.stringify({ jobId: 'job-5' }), { status: 200 })
180
+ }
181
+ if (u.includes('/career-site-jobs/search/job-5')) {
182
+ return new Response(JSON.stringify({ jobId: 'job-5', status: 'done', jobs: [{ title: 'SDR', company: 'ColdIQ' }] }), { status: 200 })
183
+ }
184
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
185
+ }) as typeof fetch
186
+
187
+ const { getProviders } = await import('../../src/registry.js')
188
+ const providers = getProviders('search_jobs')
189
+ const cs = providers.find((p) => p.id === 'career_site_jobs')!
190
+ const orig = { t: cs.async!.timeoutMs, i: cs.async!.pollIntervalMs }
191
+ cs.async!.timeoutMs = 500
192
+ cs.async!.pollIntervalMs = 20
193
+
194
+ try {
195
+ const result = await searchJobsHandler({
196
+ title_keywords: ['SDR'],
197
+ locations: ['Paris, France'],
198
+ limit: 10,
199
+ })
200
+
201
+ expect(result.isError).toBeFalsy()
202
+ const parsed = JSON.parse(result.content[0].text)
203
+ expect(parsed._meta.provider).toBe('career_site_jobs')
204
+ expect(careerSiteCreate).toBe(1)
205
+ } finally {
206
+ cs.async!.timeoutMs = orig.t
207
+ cs.async!.pollIntervalMs = orig.i
208
+ }
209
+ })
210
+ })
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { searchPlacesHandler } from '../../src/tools/search-places.js'
4
+
5
+ describe('search_places 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('US query — openmart wins (sync, highest priority for supported countries)', async () => {
17
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
18
+ const u = url.toString()
19
+ if (u.includes('/openmart/search')) {
20
+ return new Response(JSON.stringify({ data: [{ name: 'Blue Bottle Coffee', city: 'San Francisco' }] }), { status: 200 })
21
+ }
22
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
23
+ }) as typeof fetch
24
+
25
+ const result = await searchPlacesHandler({ query: 'coffee shop', country: 'US', limit: 5 })
26
+
27
+ expect(result.isError).toBeFalsy()
28
+ const parsed = JSON.parse(result.content[0].text)
29
+ expect(parsed._meta.provider).toBe('openmart')
30
+ expect(parsed.data.data).toHaveLength(1)
31
+ })
32
+
33
+ it('non-openmart country — routes to google_maps (openmart skipped)', async () => {
34
+ let openmartCalled = false
35
+
36
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
37
+ const u = url.toString()
38
+ if (u.includes('/openmart/search')) { openmartCalled = true }
39
+ if (u.includes('/google-maps/scraper') && !u.includes('/scraper/')) {
40
+ return new Response(JSON.stringify({ jobId: 'gm-1' }), { status: 200 })
41
+ }
42
+ if (u.includes('/google-maps/scraper/gm-1')) {
43
+ return new Response(JSON.stringify({ jobId: 'gm-1', status: 'done', places: [{ title: 'Café de Flore', address: 'Paris' }] }), { status: 200 })
44
+ }
45
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
46
+ }) as typeof fetch
47
+
48
+ const { getProviders } = await import('../../src/registry.js')
49
+ const providers = getProviders('search_places')
50
+ const gm = providers.find((p) => p.id === 'google_maps')!
51
+ const orig = { t: gm.async!.timeoutMs, i: gm.async!.pollIntervalMs }
52
+ gm.async!.timeoutMs = 500
53
+ gm.async!.pollIntervalMs = 20
54
+
55
+ try {
56
+ const result = await searchPlacesHandler({ query: 'café', country: 'FR', limit: 5 })
57
+
58
+ expect(result.isError).toBeFalsy()
59
+ const parsed = JSON.parse(result.content[0].text)
60
+ expect(parsed._meta.provider).toBe('google_maps')
61
+ expect(openmartCalled).toBe(false)
62
+ } finally {
63
+ gm.async!.timeoutMs = orig.t
64
+ gm.async!.pollIntervalMs = orig.i
65
+ }
66
+ })
67
+
68
+ it('provider=google_maps pinned — openmart skipped even for US', async () => {
69
+ let openmartCalled = false
70
+
71
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
72
+ const u = url.toString()
73
+ if (u.includes('/openmart/search')) { openmartCalled = true }
74
+ if (u.includes('/google-maps/scraper') && !u.includes('/scraper/')) {
75
+ return new Response(JSON.stringify({ jobId: 'gm-2' }), { status: 200 })
76
+ }
77
+ if (u.includes('/google-maps/scraper/gm-2')) {
78
+ return new Response(JSON.stringify({ jobId: 'gm-2', status: 'done', places: [{ title: 'Starbucks' }] }), { status: 200 })
79
+ }
80
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
81
+ }) as typeof fetch
82
+
83
+ const { getProviders } = await import('../../src/registry.js')
84
+ const providers = getProviders('search_places')
85
+ const gm = providers.find((p) => p.id === 'google_maps')!
86
+ const orig = { t: gm.async!.timeoutMs, i: gm.async!.pollIntervalMs }
87
+ gm.async!.timeoutMs = 500
88
+ gm.async!.pollIntervalMs = 20
89
+
90
+ try {
91
+ const result = await searchPlacesHandler({ query: 'coffee', country: 'US', provider: 'google_maps', limit: 5 })
92
+
93
+ expect(result.isError).toBeFalsy()
94
+ const parsed = JSON.parse(result.content[0].text)
95
+ expect(parsed._meta.provider).toBe('google_maps')
96
+ expect(openmartCalled).toBe(false)
97
+ } finally {
98
+ gm.async!.timeoutMs = orig.t
99
+ gm.async!.pollIntervalMs = orig.i
100
+ }
101
+ })
102
+
103
+ it('all providers fail — returns isError', async () => {
104
+ globalThis.fetch = vi.fn(async () => {
105
+ return new Response(JSON.stringify({ error: 'down' }), { status: 503 })
106
+ }) as typeof fetch
107
+
108
+ const result = await searchPlacesHandler({ query: 'coffee', country: 'US' })
109
+
110
+ expect(result.isError).toBe(true)
111
+ const parsed = JSON.parse(result.content[0].text)
112
+ expect(parsed.error).toBeTruthy()
113
+ })
114
+ })