@coldiq/mcp 0.1.19 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/client.d.ts +2 -0
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +7 -1
  4. package/dist/client.js.map +1 -1
  5. package/dist/executor.d.ts +11 -0
  6. package/dist/executor.d.ts.map +1 -1
  7. package/dist/executor.js +72 -11
  8. package/dist/executor.js.map +1 -1
  9. package/dist/index.js +2 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/registry.d.ts +1 -0
  12. package/dist/registry.d.ts.map +1 -1
  13. package/dist/registry.js +34 -8
  14. package/dist/registry.js.map +1 -1
  15. package/dist/tools/find-emails.d.ts +2 -7
  16. package/dist/tools/find-emails.d.ts.map +1 -1
  17. package/dist/tools/find-emails.js +193 -67
  18. package/dist/tools/find-emails.js.map +1 -1
  19. package/dist/tools/find-people.d.ts +3 -2
  20. package/dist/tools/find-people.d.ts.map +1 -1
  21. package/dist/tools/find-people.js +65 -7
  22. package/dist/tools/find-people.js.map +1 -1
  23. package/dist/tools/get-credit-balance.d.ts +17 -0
  24. package/dist/tools/get-credit-balance.d.ts.map +1 -0
  25. package/dist/tools/get-credit-balance.js +20 -0
  26. package/dist/tools/get-credit-balance.js.map +1 -0
  27. package/dist/utils/compact-people.d.ts +24 -0
  28. package/dist/utils/compact-people.d.ts.map +1 -0
  29. package/dist/utils/compact-people.js +306 -0
  30. package/dist/utils/compact-people.js.map +1 -0
  31. package/dist/utils/provider-resolver.d.ts.map +1 -1
  32. package/dist/utils/provider-resolver.js +12 -1
  33. package/dist/utils/provider-resolver.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/client.ts +9 -1
  36. package/src/executor.ts +89 -17
  37. package/src/index.ts +8 -0
  38. package/src/registry.ts +40 -8
  39. package/src/tools/find-emails.ts +251 -80
  40. package/src/tools/find-people.ts +70 -7
  41. package/src/tools/get-credit-balance.ts +24 -0
  42. package/src/utils/compact-people.ts +318 -0
  43. package/src/utils/provider-resolver.ts +12 -1
  44. package/tests/executor.test.ts +165 -0
  45. package/tests/registry-find-people.test.ts +31 -3
  46. package/tests/registry-search-companies.test.ts +46 -7
  47. package/tests/tools/find-emails.test.ts +267 -1
  48. package/tests/tools/find-people.test.ts +269 -5
  49. package/tests/tools/get-credit-balance.test.ts +56 -0
  50. package/tests/utils/compact-people.test.ts +462 -0
@@ -0,0 +1,318 @@
1
+ // Normalize find_people responses into a small, predictable shape.
2
+ //
3
+ // The find_people waterfall fans across 10 providers, each with a different
4
+ // payload shape (data.people[], data.data[], data.companies_personas[].personas[][],
5
+ // data.data.profiles[], data.content[], etc.). Verbose passthrough produces 30KB+
6
+ // per record from FullEnrich alone (full employment history, every company office,
7
+ // company description, specialties, skills, languages). Compact mode keeps only
8
+ // the fields agents actually need to act on a lead.
9
+
10
+ export interface CompactPerson {
11
+ full_name?: string
12
+ first_name?: string
13
+ last_name?: string
14
+ title?: string
15
+ seniority?: string
16
+ linkedin_url?: string
17
+ email?: string
18
+ company_name?: string
19
+ company_domain?: string
20
+ company_linkedin_url?: string
21
+ company_headcount?: number | string
22
+ location?: string
23
+ }
24
+
25
+ function getPath(obj: unknown, path: string): unknown {
26
+ if (obj == null) return undefined
27
+ let cur: unknown = obj
28
+ for (const part of path.split('.')) {
29
+ if (cur == null) return undefined
30
+ if (Array.isArray(cur)) {
31
+ const idx = Number(part)
32
+ if (!Number.isInteger(idx)) return undefined
33
+ cur = cur[idx]
34
+ } else if (typeof cur === 'object') {
35
+ cur = (cur as Record<string, unknown>)[part]
36
+ } else {
37
+ return undefined
38
+ }
39
+ }
40
+ return cur
41
+ }
42
+
43
+ function pick(obj: unknown, paths: string[]): unknown {
44
+ for (const p of paths) {
45
+ const v = getPath(obj, p)
46
+ if (v !== undefined && v !== null && v !== '') return v
47
+ }
48
+ return undefined
49
+ }
50
+
51
+ function asString(v: unknown): string | undefined {
52
+ if (typeof v === 'string') return v.length > 0 ? v : undefined
53
+ if (typeof v === 'number') return String(v)
54
+ return undefined
55
+ }
56
+
57
+ function flattenDeep(arr: unknown[]): unknown[] {
58
+ const out: unknown[] = []
59
+ for (const item of arr) {
60
+ if (Array.isArray(item)) out.push(...flattenDeep(item))
61
+ else out.push(item)
62
+ }
63
+ return out
64
+ }
65
+
66
+ // Wrapper-aware extraction. Providers that wrap people in atypical shapes get
67
+ // explicit handling; the rest fall through to common-key probing.
68
+ export function extractPeopleArray(data: unknown, providerId: string): unknown[] {
69
+ if (data == null) return []
70
+ if (Array.isArray(data)) return data
71
+ if (typeof data !== 'object') return []
72
+ const d = data as Record<string, unknown>
73
+
74
+ // LeadsFactory: companies_personas[i].personas is unknown[][] — nested arrays
75
+ // of personas per company. Flatten to a single contacts array.
76
+ if (providerId === 'leadsfactory' && Array.isArray(d.companies_personas)) {
77
+ const out: unknown[] = []
78
+ for (const group of d.companies_personas as Array<Record<string, unknown>>) {
79
+ const personas = group.personas
80
+ if (Array.isArray(personas)) out.push(...flattenDeep(personas))
81
+ }
82
+ return out
83
+ }
84
+
85
+ // LinkUp wraps results under data.profiles.
86
+ if (providerId === 'linkupapi-search-profiles') {
87
+ const inner = d.data
88
+ if (inner && typeof inner === 'object') {
89
+ const profiles = (inner as Record<string, unknown>).profiles
90
+ if (Array.isArray(profiles)) return profiles
91
+ }
92
+ return []
93
+ }
94
+
95
+ for (const key of ['people', 'data', 'results', 'contacts', 'content', 'profiles', 'items']) {
96
+ const v = d[key]
97
+ if (Array.isArray(v)) return v
98
+ }
99
+ return []
100
+ }
101
+
102
+ // Providers nest person fields at different depths:
103
+ // - LeadsFactory wraps in `{ contact: {...person}, company: {...}, persona_index, ... }`
104
+ // - AI-Ark wraps in `{ id, profile: {...person} }`
105
+ // - Apollo / PDL / FullEnrich keep person fields at the top level
106
+ // Merging the nested objects into the outer scope lets the candidate-path resolver
107
+ // find person fields regardless of provider. The sibling `company` on LeadsFactory
108
+ // stays at the merged top level so `company.name`, `company.domain` paths resolve.
109
+ function unwrapRecord(record: unknown): Record<string, unknown> | null {
110
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return null
111
+ const r = record as Record<string, unknown>
112
+ const merged: Record<string, unknown> = { ...r }
113
+ if (r.profile && typeof r.profile === 'object' && !Array.isArray(r.profile)) {
114
+ Object.assign(merged, r.profile as Record<string, unknown>)
115
+ }
116
+ if (r.contact && typeof r.contact === 'object' && !Array.isArray(r.contact)) {
117
+ Object.assign(merged, r.contact as Record<string, unknown>)
118
+ }
119
+ return merged
120
+ }
121
+
122
+ // Confirm a candidate value really points at LinkedIn before surfacing it as
123
+ // linkedin_url. Some providers (Sumble) keep their own profile URL under a bare
124
+ // `url` key — without this guard, agents pasting linkedin_url into LinkedIn
125
+ // lookups would silently fail. Accept linkedin domains and short canonical forms
126
+ // PDL stores (`linkedin.com/in/x`, no protocol).
127
+ function isLinkedInUrl(v: unknown): v is string {
128
+ if (typeof v !== 'string' || v.length === 0) return false
129
+ const lower = v.toLowerCase()
130
+ return lower.includes('linkedin.com/')
131
+ }
132
+
133
+ function pickLinkedIn(obj: unknown, paths: string[]): string | undefined {
134
+ for (const p of paths) {
135
+ const v = getPath(obj, p)
136
+ if (isLinkedInUrl(v)) return v
137
+ }
138
+ return undefined
139
+ }
140
+
141
+ export function normalizePerson(record: unknown): CompactPerson | null {
142
+ const r = unwrapRecord(record)
143
+ if (!r) return null
144
+
145
+ const out: CompactPerson = {}
146
+
147
+ const fullName = asString(pick(r, ['full_name', 'fullName', 'name', 'displayName']))
148
+ const firstName = asString(pick(r, ['first_name', 'firstName', 'given_name']))
149
+ const lastName = asString(pick(r, ['last_name', 'lastName', 'family_name', 'surname']))
150
+ if (fullName) out.full_name = fullName
151
+ if (firstName) out.first_name = firstName
152
+ if (lastName) out.last_name = lastName
153
+ if (!out.full_name && (firstName || lastName)) {
154
+ out.full_name = [firstName, lastName].filter(Boolean).join(' ')
155
+ }
156
+
157
+ const title = asString(pick(r, [
158
+ 'employment.current.title',
159
+ 'title',
160
+ 'job_title',
161
+ 'jobTitle',
162
+ 'position',
163
+ 'current_position.title',
164
+ 'current_position',
165
+ 'headline',
166
+ ]))
167
+ if (title) out.title = title
168
+
169
+ const seniorityRaw = pick(r, [
170
+ 'employment.current.seniority',
171
+ 'seniority',
172
+ 'job_title_levels.0',
173
+ 'job_level', // Sumble
174
+ 'level',
175
+ ])
176
+ const seniority = asString(seniorityRaw)
177
+ if (seniority) out.seniority = seniority
178
+
179
+ // LinkedIn URL is the one field worth strict-validating: a wrong value here
180
+ // silently breaks downstream LinkedIn-driven flows. `url`/`profile_url`/`linkedin`
181
+ // are too ambiguous to trust without the linkedin.com substring check.
182
+ const linkedin = pickLinkedIn(r, [
183
+ 'linkedin_url',
184
+ 'linkedinUrl',
185
+ 'social_profiles.professional_network.url',
186
+ 'social_profiles.linkedin.url',
187
+ 'profile_url',
188
+ 'linkedin',
189
+ 'url',
190
+ ])
191
+ if (linkedin) out.linkedin_url = linkedin
192
+
193
+ const email = asString(pick(r, [
194
+ 'email',
195
+ 'work_email',
196
+ 'personal_email',
197
+ 'best_email',
198
+ 'emails.0',
199
+ 'contact.email',
200
+ 'contact.emails.0',
201
+ ]))
202
+ if (email) out.email = email
203
+
204
+ const country = asString(pick(r, [
205
+ 'location.country',
206
+ 'country',
207
+ 'location_country',
208
+ 'job_company_location_country',
209
+ ]))
210
+ const city = asString(pick(r, [
211
+ 'location.city',
212
+ 'city',
213
+ 'location_locality',
214
+ 'job_company_location_locality',
215
+ ]))
216
+ const locationCombined = [city, country].filter(Boolean).join(', ')
217
+ if (locationCombined) out.location = locationCombined
218
+
219
+ const companyName = asString(pick(r, [
220
+ 'employment.current.company.name',
221
+ 'organization.name',
222
+ 'company.name',
223
+ 'job_company_name',
224
+ 'current_company.name',
225
+ 'companyName',
226
+ ]))
227
+ if (companyName) out.company_name = companyName
228
+
229
+ const companyDomain = asString(pick(r, [
230
+ 'employment.current.company.domain',
231
+ 'organization.primary_domain',
232
+ 'organization.website_url',
233
+ 'company.domain',
234
+ 'company.website',
235
+ 'job_company_website',
236
+ ]))
237
+ if (companyDomain) out.company_domain = companyDomain
238
+
239
+ const companyLinkedIn = asString(pick(r, [
240
+ 'employment.current.company.social_profiles.professional_network.url',
241
+ 'employment.current.company.social_profiles.linkedin.url',
242
+ 'employment.current.company.linkedin_url',
243
+ 'organization.linkedin_url',
244
+ 'company.linkedin_url',
245
+ 'job_company_linkedin_url',
246
+ ]))
247
+ if (companyLinkedIn) out.company_linkedin_url = companyLinkedIn
248
+
249
+ const headcountRaw = pick(r, [
250
+ 'employment.current.company.headcount',
251
+ 'employment.current.company.headcount_range',
252
+ 'organization.estimated_num_employees',
253
+ 'organization.num_employees',
254
+ 'company.headcount',
255
+ 'company.size',
256
+ 'job_company_size',
257
+ 'job_company_employee_count',
258
+ ])
259
+ if (typeof headcountRaw === 'number' || (typeof headcountRaw === 'string' && headcountRaw.length > 0)) {
260
+ out.company_headcount = headcountRaw
261
+ }
262
+
263
+ // Drop records that resolved to nothing meaningful.
264
+ if (Object.keys(out).length === 0) return null
265
+ return out
266
+ }
267
+
268
+ export interface CompactPayload {
269
+ people: CompactPerson[]
270
+ total?: number
271
+ gap_fill_provider?: string
272
+ revealed?: true
273
+ }
274
+
275
+ export function compactPayload(data: unknown, providerId: string): CompactPayload {
276
+ const mainArr = extractPeopleArray(data, providerId)
277
+ const main: CompactPerson[] = []
278
+ for (const r of mainArr) {
279
+ const p = normalizePerson(r)
280
+ if (p) main.push(p)
281
+ }
282
+
283
+ let gapFillProvider: string | undefined
284
+ let gapFillPeople: CompactPerson[] = []
285
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
286
+ const gf = (data as Record<string, unknown>).gap_fill
287
+ if (gf && typeof gf === 'object') {
288
+ gapFillProvider = asString((gf as Record<string, unknown>).provider)
289
+ const gfArr = extractPeopleArray(gf, gapFillProvider ?? '')
290
+ for (const r of gfArr) {
291
+ const p = normalizePerson(r)
292
+ if (p) gapFillPeople.push(p)
293
+ }
294
+ }
295
+ }
296
+
297
+ // Pull pagination total when present (FullEnrich exposes data.metadata.total).
298
+ let total: number | undefined
299
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
300
+ const meta = (data as Record<string, unknown>).metadata
301
+ if (meta && typeof meta === 'object') {
302
+ const t = (meta as Record<string, unknown>).total
303
+ if (typeof t === 'number') total = t
304
+ }
305
+ }
306
+
307
+ const out: CompactPayload = { people: [...main, ...gapFillPeople] }
308
+ if (total !== undefined) out.total = total
309
+ if (gapFillProvider && gapFillPeople.length > 0) out.gap_fill_provider = gapFillProvider
310
+ // Preserve the revealed flag the Apollo reveal flow sets — it tells the caller
311
+ // whether the emails/full names in `people` came from /apollo/people/bulk-match
312
+ // (paid +1 credit per person) vs the obfuscated /apollo/people/search response.
313
+ if (data && typeof data === 'object' && !Array.isArray(data) &&
314
+ (data as Record<string, unknown>).revealed === true) {
315
+ out.revealed = true
316
+ }
317
+ return out
318
+ }
@@ -4,7 +4,18 @@ import { fuzzyMatch } from './fuzzy.js'
4
4
 
5
5
  // find_emails uses a custom waterfall — its providers are not in the registry.
6
6
  // Exported so find-emails.ts stays in sync without a second hardcoded list.
7
- export const FIND_EMAILS_PROVIDERS = ['prospeo', 'fullenrich', 'findymail', 'icypeas']
7
+ // Order = auto-route execution order: bulk providers first (Steps 1-3), then
8
+ // the single-find_email fallback providers used for stragglers (Step 4).
9
+ export const FIND_EMAILS_PROVIDERS = [
10
+ 'prospeo',
11
+ 'fullenrich',
12
+ 'findymail',
13
+ 'icypeas',
14
+ 'limadata-work-email',
15
+ 'blitzapi',
16
+ 'limadata-work-email-linkedin',
17
+ 'linkupapi',
18
+ ]
8
19
 
9
20
  export function getProvidersForCapability(capability: Capability | 'find_emails'): string[] {
10
21
  if (capability === 'find_emails') return FIND_EMAILS_PROVIDERS
@@ -734,6 +734,56 @@ describe('executeWithFallback with options.providers', () => {
734
734
  expect(result._meta.matchedFrom).toEqual({ prospec: 'prospeo' })
735
735
  }
736
736
  })
737
+
738
+ it('surfaces credits_charged + credits_remaining in success _meta when API emits credit headers', async () => {
739
+ stubProviders([
740
+ makeProvider({ id: 'prospeo', priority: 1, hasResult: () => true }),
741
+ ])
742
+
743
+ globalThis.fetch = vi.fn(async () =>
744
+ new Response(JSON.stringify({ ok: true }), {
745
+ status: 200,
746
+ headers: {
747
+ 'X-ColdIQ-Credits-Charged': '3',
748
+ 'X-ColdIQ-Credits-Remaining': '197',
749
+ },
750
+ })
751
+ ) as typeof fetch
752
+
753
+ const result = await executeWithFallback(
754
+ 'enrich_company',
755
+ { domain: 'coldiq.com' },
756
+ { providers: ['prospeo'] },
757
+ )
758
+
759
+ expect('data' in result).toBe(true)
760
+ if ('data' in result) {
761
+ expect(result._meta.credits_charged).toBe(3)
762
+ expect(result._meta.credits_remaining).toBe(197)
763
+ }
764
+ })
765
+
766
+ it('omits credit fields from _meta when API does not emit credit headers', async () => {
767
+ stubProviders([
768
+ makeProvider({ id: 'prospeo', priority: 1, hasResult: () => true }),
769
+ ])
770
+
771
+ globalThis.fetch = vi.fn(async () =>
772
+ new Response(JSON.stringify({ ok: true }), { status: 200 })
773
+ ) as typeof fetch
774
+
775
+ const result = await executeWithFallback(
776
+ 'enrich_company',
777
+ { domain: 'coldiq.com' },
778
+ { providers: ['prospeo'] },
779
+ )
780
+
781
+ expect('data' in result).toBe(true)
782
+ if ('data' in result) {
783
+ expect(result._meta.credits_charged).toBeUndefined()
784
+ expect(result._meta.credits_remaining).toBeUndefined()
785
+ }
786
+ })
737
787
  })
738
788
 
739
789
  // Note: the LeadsFactory backoff *schedule* itself is asserted against the live
@@ -789,3 +839,118 @@ describe('per-provider sync timeout cap', () => {
789
839
  expect(elapsed).toBeLessThan(2000)
790
840
  })
791
841
  })
842
+
843
+ // ---------------------------------------------------------------------------
844
+ // upstream_error — structured upstream body survives the short-string flatten
845
+ // ---------------------------------------------------------------------------
846
+
847
+ describe('executor upstream_error propagation', () => {
848
+ const originalFetch = globalThis.fetch
849
+
850
+ beforeEach(() => {
851
+ initClient('http://test-api.local', 'test-key-123')
852
+ })
853
+
854
+ afterEach(() => {
855
+ globalThis.fetch = originalFetch
856
+ vi.restoreAllMocks()
857
+ })
858
+
859
+ it('preserves API `details` passthrough verbatim under providers_tried[i].upstream_error', async () => {
860
+ stubProviders([makeProvider({ id: 'prospeo', hasResult: () => false })])
861
+
862
+ globalThis.fetch = vi.fn(async () =>
863
+ new Response(
864
+ JSON.stringify({
865
+ error: "INVALID_REQUEST: Invalid value '[CMO]' for filter 'job_titles'",
866
+ details: {
867
+ error_code: 'INVALID_REQUEST',
868
+ filter_error: "Invalid value '[CMO]' for filter 'job_titles'",
869
+ },
870
+ }),
871
+ { status: 400 },
872
+ ),
873
+ ) as typeof fetch
874
+
875
+ const result = await executeWithFallback('find_people', { company_domains: ['coldiq.com'] })
876
+
877
+ expect('error' in result).toBe(true)
878
+ if ('error' in result) {
879
+ expect(result.providers_tried).toHaveLength(1)
880
+ const tried = result.providers_tried[0]
881
+ expect(tried.status).toBe(400)
882
+ expect(tried.error).toContain('INVALID_REQUEST')
883
+ expect(tried.upstream_error).toEqual({
884
+ error_code: 'INVALID_REQUEST',
885
+ filter_error: "Invalid value '[CMO]' for filter 'job_titles'",
886
+ })
887
+ }
888
+ })
889
+
890
+ it('falls back to the full body when API has no `details` field', async () => {
891
+ stubProviders([makeProvider({ id: 'unmigrated', hasResult: () => false })])
892
+
893
+ globalThis.fetch = vi.fn(async () =>
894
+ new Response(
895
+ JSON.stringify({ error: true, error_code: 'X', filter_error: 'whatever' }),
896
+ { status: 400 },
897
+ ),
898
+ ) as typeof fetch
899
+
900
+ const result = await executeWithFallback('find_people', { company_domains: ['coldiq.com'] })
901
+
902
+ expect('error' in result).toBe(true)
903
+ if ('error' in result) {
904
+ const tried = result.providers_tried[0]
905
+ // The short `error` string is the JSON-stringified boolean (the old behavior),
906
+ // but the structured body now rides along under `upstream_error` so the
907
+ // caller can still recover the detail.
908
+ expect(tried.error).toBe('true')
909
+ expect(tried.upstream_error).toMatchObject({
910
+ error_code: 'X',
911
+ filter_error: 'whatever',
912
+ })
913
+ }
914
+ })
915
+
916
+ it('defensively caps oversized upstream_error payloads to a 2KB string', async () => {
917
+ stubProviders([makeProvider({ id: 'noisy', hasResult: () => false })])
918
+
919
+ const huge = 'x'.repeat(5000)
920
+ globalThis.fetch = vi.fn(async () =>
921
+ new Response(
922
+ JSON.stringify({ error: 'bad', details: { blob: huge } }),
923
+ { status: 400 },
924
+ ),
925
+ ) as typeof fetch
926
+
927
+ const result = await executeWithFallback('find_people', { company_domains: ['coldiq.com'] })
928
+
929
+ expect('error' in result).toBe(true)
930
+ if ('error' in result) {
931
+ const upstream = result.providers_tried[0].upstream_error
932
+ expect(typeof upstream).toBe('string')
933
+ expect((upstream as string).length).toBeLessThanOrEqual(2048)
934
+ }
935
+ })
936
+
937
+ it('surfaces the synthetic non-JSON envelope as upstream_error', async () => {
938
+ stubProviders([makeProvider({ id: 'empty', hasResult: () => false })])
939
+
940
+ // No JSON body — client.ts synthesizes { error: 'Non-JSON response from API' },
941
+ // which has no `details` field so the executor falls back to the full envelope.
942
+ // Surfacing it (rather than dropping to undefined) keeps "upstream returned
943
+ // garbage" debuggable end-to-end.
944
+ globalThis.fetch = vi.fn(async () =>
945
+ new Response('not json', { status: 500 }),
946
+ ) as typeof fetch
947
+
948
+ const result = await executeWithFallback('find_people', { company_domains: ['coldiq.com'] })
949
+
950
+ expect('error' in result).toBe(true)
951
+ if ('error' in result) {
952
+ const tried = result.providers_tried[0]
953
+ expect(tried.upstream_error).toEqual({ error: 'Non-JSON response from API' })
954
+ }
955
+ })
956
+ })
@@ -115,10 +115,10 @@ describe('prospeo-search-person', () => {
115
115
  })).toEqual({
116
116
  body: {
117
117
  filters: {
118
- job_title: { include: ['CEO', 'CTO'] },
118
+ person_job_title: { include: ['CEO', 'CTO'] },
119
119
  person_seniority: { include: ['C-Level'] },
120
120
  company: { websites: { include: ['coldiq.com'] } },
121
- person_location: { include: ['Belgium'] },
121
+ person_location_search: { include: ['Belgium'] },
122
122
  },
123
123
  },
124
124
  })
@@ -129,7 +129,7 @@ describe('prospeo-search-person', () => {
129
129
  const filters = (result.body as Record<string, unknown>).filters as Record<string, unknown>
130
130
  expect(filters.person_seniority).toBeUndefined()
131
131
  expect(filters.company).toBeUndefined()
132
- expect(filters.person_location).toBeUndefined()
132
+ expect(filters.person_location_search).toBeUndefined()
133
133
  })
134
134
 
135
135
  it('hasResult: true when data array non-empty', () => {
@@ -364,6 +364,18 @@ describe('apollo (find_people)', () => {
364
364
  expect(body.q_keywords).toBe('growth AI')
365
365
  })
366
366
 
367
+ it('mapParams clamps per_page to 100 when limit exceeds Apollo upstream cap', () => {
368
+ const result = p().mapParams({ job_titles: ['CEO'], limit: 150 })
369
+ const body = result.body as Record<string, unknown>
370
+ expect(body.per_page).toBe(100)
371
+ })
372
+
373
+ it('mapParams passes through per_page when limit is at or below 100', () => {
374
+ const result = p().mapParams({ job_titles: ['CEO'], limit: 50 })
375
+ const body = result.body as Record<string, unknown>
376
+ expect(body.per_page).toBe(50)
377
+ })
378
+
367
379
  it('mapParams omits q_keywords when keywords absent', () => {
368
380
  const result = p().mapParams({ job_titles: ['CEO'], limit: 10 })
369
381
  const body = result.body as Record<string, unknown>
@@ -466,6 +478,22 @@ describe('leadsfactory (find_people)', () => {
466
478
  const personas = (result: ReturnType<ReturnType<typeof p>['mapParams']>) =>
467
479
  ((result.body as Record<string, unknown>).search as Record<string, unknown>).personas as { job_title: string }[]
468
480
 
481
+ it('isApplicable: true with company_domains', () => {
482
+ expect(p().isApplicable!({ company_domains: ['coldiq.com'], job_titles: ['CEO'] })).toBe(true)
483
+ })
484
+
485
+ it('isApplicable: true with company_linkedin_urls', () => {
486
+ expect(p().isApplicable!({ company_linkedin_urls: ['https://www.linkedin.com/company/coldiq'], job_titles: ['CEO'] })).toBe(true)
487
+ })
488
+
489
+ it('isApplicable: false without any company identifier (LF upstream 500s on pure prospecting)', () => {
490
+ expect(p().isApplicable!({ job_titles: ['CEO'], seniorities: ['C-Level'], locations: ['FR'] })).toBe(false)
491
+ })
492
+
493
+ it('isApplicable: false on empty input', () => {
494
+ expect(p().isApplicable!({})).toBe(false)
495
+ })
496
+
469
497
  it('same-domain variants collapse to 1 persona with comma-joined titles', () => {
470
498
  const result = p().mapParams({ company_domains: ['coldiq.com'], job_titles: ['VP Sales', 'Head of Sales'], limit: 5 })
471
499
  expect(personas(result)).toEqual([{ job_title: 'VP Sales, Head of Sales' }])
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { getProviders } from '../src/registry.js'
2
+ import { getProviders, prospeoHeadcountBuckets } from '../src/registry.js'
3
3
 
4
4
  const providers = () => getProviders('search_companies')
5
5
  const get = (id: string) => {
@@ -43,8 +43,8 @@ describe('prospeo-search-company', () => {
43
43
  filters: {
44
44
  company_keywords: { include: ['sales engagement'] },
45
45
  company_industry: { include: ['SaaS'] },
46
- company_country: { include: ['Belgium'] },
47
- company_headcount_custom: { min: 10, max: 200 },
46
+ company_location_search: { include: ['Belgium'] },
47
+ company_headcount_range: ['1-10', '11-20', '21-50', '51-100', '101-200'],
48
48
  },
49
49
  },
50
50
  })
@@ -54,14 +54,17 @@ describe('prospeo-search-company', () => {
54
54
  const result = p().mapParams({ keywords: ['outbound'] })
55
55
  const filters = (result.body as Record<string, unknown>).filters as Record<string, unknown>
56
56
  expect(filters.company_industry).toBeUndefined()
57
- expect(filters.company_country).toBeUndefined()
58
- expect(filters.company_headcount_custom).toBeUndefined()
57
+ expect(filters.company_location_search).toBeUndefined()
58
+ expect(filters.company_headcount_range).toBeUndefined()
59
59
  })
60
60
 
61
- it('mapParams includes headcount when only min is given', () => {
61
+ it('mapParams includes headcount buckets when only min is given', () => {
62
62
  const result = p().mapParams({ keywords: ['crm'], min_employees: 50 })
63
63
  const filters = (result.body as Record<string, unknown>).filters as Record<string, unknown>
64
- expect(filters.company_headcount_custom).toEqual({ min: 50, max: undefined })
64
+ expect(filters.company_headcount_range).toEqual([
65
+ '21-50', '51-100', '101-200', '201-500', '501-1000',
66
+ '1001-2000', '2001-5000', '5001-10000', '10000+',
67
+ ])
65
68
  })
66
69
 
67
70
  it('hasResult: true when data array non-empty', () => {
@@ -250,6 +253,42 @@ describe('theirstack search_companies', () => {
250
253
  })
251
254
  })
252
255
 
256
+ // ---------------------------------------------------------------------------
257
+ // prospeoHeadcountBuckets — maps numeric range to Prospeo's fixed buckets
258
+ // ---------------------------------------------------------------------------
259
+
260
+ describe('prospeoHeadcountBuckets', () => {
261
+ it('returns all overlapping buckets for a finite range', () => {
262
+ expect(prospeoHeadcountBuckets(10, 500)).toEqual([
263
+ '1-10', '11-20', '21-50', '51-100', '101-200', '201-500',
264
+ ])
265
+ })
266
+
267
+ it('treats undefined min as 0 (open lower bound)', () => {
268
+ expect(prospeoHeadcountBuckets(undefined, 50)).toEqual(['1-10', '11-20', '21-50'])
269
+ })
270
+
271
+ it('treats undefined max as +Infinity (open upper bound)', () => {
272
+ expect(prospeoHeadcountBuckets(2001, undefined)).toEqual([
273
+ '2001-5000', '5001-10000', '10000+',
274
+ ])
275
+ })
276
+
277
+ it('returns single bucket when min === max within a bucket', () => {
278
+ expect(prospeoHeadcountBuckets(75, 75)).toEqual(['51-100'])
279
+ })
280
+
281
+ it('includes 10000+ when min is above 10000', () => {
282
+ expect(prospeoHeadcountBuckets(15000, undefined)).toEqual(['10000+'])
283
+ })
284
+
285
+ it('excludes 10000+ when max is exactly 10000', () => {
286
+ const buckets = prospeoHeadcountBuckets(5001, 10000)
287
+ expect(buckets).toEqual(['5001-10000'])
288
+ expect(buckets).not.toContain('10000+')
289
+ })
290
+ })
291
+
253
292
  // ---------------------------------------------------------------------------
254
293
  // Ordering: search_companies providers sorted correctly
255
294
  // ---------------------------------------------------------------------------