@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,192 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
- import { initClient } from '../../src/client.js'
3
- import { searchCompaniesHandler, searchCompaniesSchema } 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 (compact by default)', 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
- // Compact-by-default: the raw provider array is normalized into companies[].
27
- expect(parsed.data.companies).toHaveLength(1)
28
- expect(parsed.data.companies[0]).toMatchObject({ name: 'ColdIQ', domain: 'coldiq.com' })
29
- })
30
-
31
- it('compact (default) keeps identity + firmographic fields and drops heavy ones', async () => {
32
- // Mirror the real CompanyEnrich shape: companies under data.items[], each a
33
- // heavy object with funding/technologies/keywords/socials/seo.
34
- globalThis.fetch = vi.fn(async () =>
35
- new Response(JSON.stringify({
36
- items: [{
37
- id: '0192562e-7008-702b-97a2-0b2c489d339b',
38
- name: 'Hugging Face',
39
- domain: 'huggingface.co',
40
- website: 'https://huggingface.co',
41
- industry: 'Business Services',
42
- categories: ['b2b', 'b2c', 'saas'],
43
- employees: '201-500',
44
- revenue: '10m-50m',
45
- founded_year: 2016,
46
- description: 'A very long company description that should be dropped in compact mode...',
47
- keywords: Array.from({ length: 28 }, (_, i) => `kw${i}`),
48
- technologies: ['Stripe', 'Nginx', 'Node-Js'],
49
- naics_codes: ['511210', '518210'],
50
- socials: { linkedin_url: 'https://www.linkedin.com/company/huggingface', twitter_url: 'https://twitter.com/huggingface' },
51
- financial: { total_funding: 395200000, funding: [{ date: '2024-08-01', type: 'Venture Round' }] },
52
- location: { country: { code: 'FR', name: 'France' }, city: { name: 'Paris' } },
53
- }],
54
- page: 1,
55
- totalItems: 10000,
56
- }), { status: 200 })
57
- ) as typeof fetch
58
-
59
- const result = await searchCompaniesHandler({ keywords: ['SaaS'], countries: ['FR'], limit: 5 })
60
-
61
- expect(result.isError).toBeUndefined()
62
- const parsed = JSON.parse(result.content[0].text)
63
- expect(parsed.data.total).toBe(10000)
64
- expect(parsed.data.companies).toHaveLength(1)
65
- const c = parsed.data.companies[0]
66
- // Kept: identity + firmographics + downstream-critical linkedin_url.
67
- expect(c).toEqual({
68
- name: 'Hugging Face',
69
- domain: 'huggingface.co',
70
- website: 'https://huggingface.co',
71
- linkedin_url: 'https://www.linkedin.com/company/huggingface',
72
- employees: '201-500',
73
- revenue: '10m-50m',
74
- industry: 'Business Services',
75
- country: 'France',
76
- city: 'Paris',
77
- founded_year: 2016,
78
- categories: ['b2b', 'b2c', 'saas'],
79
- })
80
- // Dropped heavy fields.
81
- expect(c.description).toBeUndefined()
82
- expect(c.keywords).toBeUndefined()
83
- expect(c.technologies).toBeUndefined()
84
- expect(c.naics_codes).toBeUndefined()
85
- expect(c.financial).toBeUndefined()
86
- expect(c.id).toBeUndefined()
87
- })
88
-
89
- it('fields=verbose returns the raw provider passthrough unchanged', async () => {
90
- globalThis.fetch = vi.fn(async () =>
91
- new Response(JSON.stringify({ items: [{ name: 'Hugging Face', keywords: ['ai'], technologies: ['Stripe'] }], totalItems: 10000 }), { status: 200 })
92
- ) as typeof fetch
93
-
94
- const result = await searchCompaniesHandler({ keywords: ['SaaS'], limit: 5, fields: 'verbose' })
95
-
96
- expect(result.isError).toBeUndefined()
97
- const parsed = JSON.parse(result.content[0].text)
98
- // Raw shape preserved: items[] with heavy fields, no companies[] normalization.
99
- expect(parsed.data.items).toHaveLength(1)
100
- expect(parsed.data.items[0].keywords).toEqual(['ai'])
101
- expect(parsed.data.items[0].technologies).toEqual(['Stripe'])
102
- expect(parsed.data.companies).toBeUndefined()
103
- })
104
-
105
- it('falls back to FullEnrich (priority 2) on CompanyEnrich failure', async () => {
106
- let callCount = 0
107
- globalThis.fetch = vi.fn(async () => {
108
- callCount++
109
- if (callCount === 1) {
110
- return new Response(JSON.stringify({ error: 'Provider error' }), { status: 502 })
111
- }
112
- return new Response(JSON.stringify({ companies: [{ name: 'ColdIQ' }] }), { status: 200 })
113
- }) as typeof fetch
114
-
115
- const result = await searchCompaniesHandler({ countries: ['FR'], limit: 5 })
116
-
117
- expect(result.isError).toBeUndefined()
118
- const parsed = JSON.parse(result.content[0].text)
119
- expect(parsed._meta.provider).toBe('fullenrich')
120
- })
121
-
122
- it('returns error when all providers fail', async () => {
123
- globalThis.fetch = vi.fn(async () =>
124
- new Response(JSON.stringify({ error: 'Service down' }), { status: 500 })
125
- ) as typeof fetch
126
-
127
- const result = await searchCompaniesHandler({ countries: ['FR'], limit: 5 })
128
-
129
- expect(result.isError).toBe(true)
130
- const parsed = JSON.parse(result.content[0].text)
131
- expect(parsed.error).toContain('All')
132
- expect(parsed.providers_tried.length).toBeGreaterThan(0)
133
- })
134
-
135
- it('surfaces the upstream match count as _meta.total_entries (TAM signal)', async () => {
136
- globalThis.fetch = vi.fn(async () =>
137
- new Response(JSON.stringify({ organizations: [{ name: 'ColdIQ' }], pagination: { total_entries: 8371 } }), { status: 200 })
138
- ) as typeof fetch
139
-
140
- const result = await searchCompaniesHandler({ keywords: ['SaaS'], limit: 250, use_providers: ['apollo'] })
141
-
142
- expect(result.isError).toBeUndefined()
143
- const parsed = JSON.parse(result.content[0].text)
144
- expect(parsed._meta.provider).toBe('apollo')
145
- expect(parsed._meta.total_entries).toBe(8371)
146
- })
147
-
148
- it('limit accepts up to 500 and rejects above', () => {
149
- expect(searchCompaniesSchema.limit.safeParse(500).success).toBe(true)
150
- expect(searchCompaniesSchema.limit.safeParse(600).success).toBe(false)
151
- })
152
-
153
- describe('use_providers', () => {
154
- it('Scenario A — pinned provider succeeds, skips others', async () => {
155
- let callCount = 0
156
- globalThis.fetch = vi.fn(async () => {
157
- callCount++
158
- return new Response(JSON.stringify({ data: [{ name: 'ColdIQ' }] }), { status: 200 })
159
- }) as typeof fetch
160
-
161
- const result = await searchCompaniesHandler({ countries: ['FR'], limit: 5, use_providers: ['companyenrich'] })
162
-
163
- expect(result.isError).toBeUndefined()
164
- expect(callCount).toBe(1)
165
- const parsed = JSON.parse(result.content[0].text)
166
- expect(parsed._meta.provider).toBe('companyenrich')
167
- })
168
-
169
- it('Scenario E — unrecognized provider returns isError with helpful message', async () => {
170
- const result = await searchCompaniesHandler({ countries: ['FR'], limit: 5, use_providers: ['salesforce'] })
171
-
172
- expect(result.isError).toBe(true)
173
- const parsed = JSON.parse(result.content[0].text)
174
- expect(parsed.error).toContain("'salesforce' is not a recognized provider")
175
- expect(parsed.error).toContain('ColdIQ will automatically pick the best tool')
176
- expect(parsed.available_providers).toBeInstanceOf(Array)
177
- })
178
-
179
- it('Scenario D — fuzzy match resolves typo and surfaces matchedFrom', async () => {
180
- globalThis.fetch = vi.fn(async () =>
181
- new Response(JSON.stringify({ data: [{ name: 'ColdIQ' }] }), { status: 200 })
182
- ) as typeof fetch
183
-
184
- const result = await searchCompaniesHandler({ countries: ['FR'], limit: 5, use_providers: ['fullenrch'] })
185
-
186
- expect(result.isError).toBeUndefined()
187
- const parsed = JSON.parse(result.content[0].text)
188
- expect(parsed._meta.provider).toBe('fullenrich')
189
- expect(parsed._meta.matchedFrom).toEqual({ fullenrch: 'fullenrich' })
190
- })
191
- })
192
- })
@@ -1,210 +0,0 @@
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
- })
@@ -1,210 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
- import { initClient } from '../../src/client.js'
3
- import { searchPlacesHandler, applyPlaceFilters } from '../../src/tools/search-places.js'
4
-
5
- describe('applyPlaceFilters', () => {
6
- const sample = {
7
- places: [
8
- { title: 'Low rating, many reviews', totalScore: 2.9, reviewsCount: 81 },
9
- { title: 'Mid rating, mid reviews', totalScore: 3.9, reviewsCount: 322 },
10
- { title: 'High rating, many reviews', totalScore: 4.6, reviewsCount: 1101 },
11
- { title: 'High rating, few reviews', totalScore: 4.7, reviewsCount: 12 },
12
- { title: 'Missing fields', /* no totalScore or reviewsCount */ },
13
- ],
14
- }
15
-
16
- it('passes through unchanged when no filters supplied', () => {
17
- const out = applyPlaceFilters(sample, {}) as { places: unknown[] }
18
- expect(out.places).toHaveLength(5)
19
- })
20
-
21
- it('does not mutate its input', () => {
22
- const before = JSON.stringify(sample)
23
- applyPlaceFilters(sample, { maxRating: 4 })
24
- expect(JSON.stringify(sample)).toBe(before)
25
- })
26
-
27
- it('max_overall_rating drops places above the cap', () => {
28
- const out = applyPlaceFilters(sample, { maxRating: 4 }) as { places: Array<{ totalScore?: number }> }
29
- expect(out.places.map((p) => p.totalScore)).toEqual([2.9, 3.9])
30
- })
31
-
32
- it('min_total_reviews drops places below the floor', () => {
33
- const out = applyPlaceFilters(sample, { minReviews: 20 }) as { places: Array<{ reviewsCount?: number }> }
34
- expect(out.places.map((p) => p.reviewsCount)).toEqual([81, 322, 1101])
35
- })
36
-
37
- it('combined filters apply as AND', () => {
38
- const out = applyPlaceFilters(sample, { maxRating: 4, minReviews: 20 }) as { places: Array<{ title?: string }> }
39
- expect(out.places.map((p) => p.title)).toEqual(['Low rating, many reviews', 'Mid rating, mid reviews'])
40
- })
41
-
42
- it('places with missing totalScore/reviewsCount are dropped when corresponding filter is active', () => {
43
- const out = applyPlaceFilters(sample, { minRating: 0 }) as { places: Array<{ title?: string }> }
44
- expect(out.places.map((p) => p.title)).not.toContain('Missing fields')
45
- })
46
-
47
- it('non-object / non-array shapes are passed through untouched', () => {
48
- expect(applyPlaceFilters(null, { maxRating: 4 })).toBe(null)
49
- expect(applyPlaceFilters({ places: 'not an array' }, { maxRating: 4 })).toEqual({ places: 'not an array' })
50
- })
51
- })
52
-
53
- describe('search_places handler', () => {
54
- const originalFetch = globalThis.fetch
55
-
56
- beforeEach(() => {
57
- initClient('http://test-api.local', 'test-key')
58
- })
59
-
60
- afterEach(() => {
61
- globalThis.fetch = originalFetch
62
- })
63
-
64
- it('US query — openmart wins (sync, highest priority for supported countries)', async () => {
65
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
66
- const u = url.toString()
67
- if (u.includes('/openmart/search')) {
68
- return new Response(JSON.stringify({ data: [{ name: 'Blue Bottle Coffee', city: 'San Francisco' }] }), { status: 200 })
69
- }
70
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
71
- }) as typeof fetch
72
-
73
- const result = await searchPlacesHandler({ query: 'coffee shop', country: 'US', limit: 5 })
74
-
75
- expect(result.isError).toBeFalsy()
76
- const parsed = JSON.parse(result.content[0].text)
77
- expect(parsed._meta.provider).toBe('openmart')
78
- expect(parsed.data.data).toHaveLength(1)
79
- })
80
-
81
- it('non-openmart country — routes to google_maps (openmart skipped)', async () => {
82
- let openmartCalled = false
83
-
84
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
85
- const u = url.toString()
86
- if (u.includes('/openmart/search')) { openmartCalled = true }
87
- if (u.includes('/google-maps/scraper') && !u.includes('/scraper/')) {
88
- return new Response(JSON.stringify({ jobId: 'gm-1' }), { status: 200 })
89
- }
90
- if (u.includes('/google-maps/scraper/gm-1')) {
91
- return new Response(JSON.stringify({ jobId: 'gm-1', status: 'done', places: [{ title: 'Café de Flore', address: 'Paris' }] }), { 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_places')
98
- const gm = providers.find((p) => p.id === 'google_maps')!
99
- const orig = { t: gm.async!.timeoutMs, i: gm.async!.pollIntervalMs }
100
-
101
- try {
102
- gm.async!.timeoutMs = 500
103
- gm.async!.pollIntervalMs = 20
104
- const result = await searchPlacesHandler({ query: 'café', country: 'FR', limit: 5 })
105
-
106
- expect(result.isError).toBeFalsy()
107
- const parsed = JSON.parse(result.content[0].text)
108
- expect(parsed._meta.provider).toBe('google_maps')
109
- expect(openmartCalled).toBe(false)
110
- } finally {
111
- gm.async!.timeoutMs = orig.t
112
- gm.async!.pollIntervalMs = orig.i
113
- }
114
- })
115
-
116
- it('provider=google_maps pinned — openmart skipped even for US', async () => {
117
- let openmartCalled = false
118
-
119
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
120
- const u = url.toString()
121
- if (u.includes('/openmart/search')) { openmartCalled = true }
122
- if (u.includes('/google-maps/scraper') && !u.includes('/scraper/')) {
123
- return new Response(JSON.stringify({ jobId: 'gm-2' }), { status: 200 })
124
- }
125
- if (u.includes('/google-maps/scraper/gm-2')) {
126
- return new Response(JSON.stringify({ jobId: 'gm-2', status: 'done', places: [{ title: 'Starbucks' }] }), { status: 200 })
127
- }
128
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
129
- }) as typeof fetch
130
-
131
- const { getProviders } = await import('../../src/registry.js')
132
- const providers = getProviders('search_places')
133
- const gm = providers.find((p) => p.id === 'google_maps')!
134
- const orig = { t: gm.async!.timeoutMs, i: gm.async!.pollIntervalMs }
135
-
136
- try {
137
- gm.async!.timeoutMs = 500
138
- gm.async!.pollIntervalMs = 20
139
- const result = await searchPlacesHandler({ query: 'coffee', country: 'US', provider: 'google_maps', limit: 5 })
140
-
141
- expect(result.isError).toBeFalsy()
142
- const parsed = JSON.parse(result.content[0].text)
143
- expect(parsed._meta.provider).toBe('google_maps')
144
- expect(openmartCalled).toBe(false)
145
- } finally {
146
- gm.async!.timeoutMs = orig.t
147
- gm.async!.pollIntervalMs = orig.i
148
- }
149
- })
150
-
151
- it('google_maps response is post-filtered by max_overall_rating + min_total_reviews', async () => {
152
- // Regression guard: if the handler stops wiring applyPlaceFilters, this test fails.
153
- globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
154
- const u = url.toString()
155
- if (u.includes('/google-maps/scraper') && !u.includes('/scraper/')) {
156
- return new Response(JSON.stringify({ jobId: 'gm-filter' }), { status: 200 })
157
- }
158
- if (u.includes('/google-maps/scraper/gm-filter')) {
159
- return new Response(JSON.stringify({
160
- jobId: 'gm-filter',
161
- status: 'done',
162
- places: [
163
- { title: 'Strict match — keep', totalScore: 3.5, reviewsCount: 120 },
164
- { title: 'Rating too high — drop', totalScore: 4.7, reviewsCount: 800 },
165
- { title: 'Too few reviews — drop', totalScore: 2.9, reviewsCount: 10 },
166
- ],
167
- }), { status: 200 })
168
- }
169
- return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
170
- }) as typeof fetch
171
-
172
- const { getProviders } = await import('../../src/registry.js')
173
- const providers = getProviders('search_places')
174
- const gm = providers.find((p) => p.id === 'google_maps')!
175
- const orig = { t: gm.async!.timeoutMs, i: gm.async!.pollIntervalMs }
176
-
177
- try {
178
- gm.async!.timeoutMs = 500
179
- gm.async!.pollIntervalMs = 20
180
- const result = await searchPlacesHandler({
181
- query: 'dentist',
182
- country: 'FR',
183
- provider: 'google_maps',
184
- max_overall_rating: 4,
185
- min_total_reviews: 20,
186
- limit: 10,
187
- })
188
-
189
- expect(result.isError).toBeFalsy()
190
- const parsed = JSON.parse(result.content[0].text)
191
- expect(parsed._meta.provider).toBe('google_maps')
192
- expect(parsed.data.places.map((p: { title: string }) => p.title)).toEqual(['Strict match — keep'])
193
- } finally {
194
- gm.async!.timeoutMs = orig.t
195
- gm.async!.pollIntervalMs = orig.i
196
- }
197
- })
198
-
199
- it('all providers fail — returns isError', async () => {
200
- globalThis.fetch = vi.fn(async () => {
201
- return new Response(JSON.stringify({ error: 'down' }), { status: 503 })
202
- }) as typeof fetch
203
-
204
- const result = await searchPlacesHandler({ query: 'coffee', country: 'US' })
205
-
206
- expect(result.isError).toBe(true)
207
- const parsed = JSON.parse(result.content[0].text)
208
- expect(parsed.error).toBeTruthy()
209
- })
210
- })