@coldiq/mcp 0.1.19 → 0.2.5

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 +35 -9
  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 +311 -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 +41 -9
  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 +323 -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 +39 -7
  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 +487 -0
@@ -0,0 +1,323 @@
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
+ // - Prospeo wraps in `{ person: {...person fields including current_job_title}, ... }`
106
+ // - Apollo / PDL / FullEnrich keep person fields at the top level
107
+ // Merging the nested objects into the outer scope lets the candidate-path resolver
108
+ // find person fields regardless of provider. The sibling `company` on LeadsFactory
109
+ // stays at the merged top level so `company.name`, `company.domain` paths resolve.
110
+ function unwrapRecord(record: unknown): Record<string, unknown> | null {
111
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return null
112
+ const r = record as Record<string, unknown>
113
+ const merged: Record<string, unknown> = { ...r }
114
+ if (r.profile && typeof r.profile === 'object' && !Array.isArray(r.profile)) {
115
+ Object.assign(merged, r.profile as Record<string, unknown>)
116
+ }
117
+ if (r.contact && typeof r.contact === 'object' && !Array.isArray(r.contact)) {
118
+ Object.assign(merged, r.contact as Record<string, unknown>)
119
+ }
120
+ if (r.person && typeof r.person === 'object' && !Array.isArray(r.person)) {
121
+ Object.assign(merged, r.person as Record<string, unknown>)
122
+ }
123
+ return merged
124
+ }
125
+
126
+ // Confirm a candidate value really points at LinkedIn before surfacing it as
127
+ // linkedin_url. Some providers (Sumble) keep their own profile URL under a bare
128
+ // `url` key — without this guard, agents pasting linkedin_url into LinkedIn
129
+ // lookups would silently fail. Accept linkedin domains and short canonical forms
130
+ // PDL stores (`linkedin.com/in/x`, no protocol).
131
+ function isLinkedInUrl(v: unknown): v is string {
132
+ if (typeof v !== 'string' || v.length === 0) return false
133
+ const lower = v.toLowerCase()
134
+ return lower.includes('linkedin.com/')
135
+ }
136
+
137
+ function pickLinkedIn(obj: unknown, paths: string[]): string | undefined {
138
+ for (const p of paths) {
139
+ const v = getPath(obj, p)
140
+ if (isLinkedInUrl(v)) return v
141
+ }
142
+ return undefined
143
+ }
144
+
145
+ export function normalizePerson(record: unknown): CompactPerson | null {
146
+ const r = unwrapRecord(record)
147
+ if (!r) return null
148
+
149
+ const out: CompactPerson = {}
150
+
151
+ const fullName = asString(pick(r, ['full_name', 'fullName', 'name', 'displayName']))
152
+ const firstName = asString(pick(r, ['first_name', 'firstName', 'given_name']))
153
+ const lastName = asString(pick(r, ['last_name', 'lastName', 'family_name', 'surname']))
154
+ if (fullName) out.full_name = fullName
155
+ if (firstName) out.first_name = firstName
156
+ if (lastName) out.last_name = lastName
157
+ if (!out.full_name && (firstName || lastName)) {
158
+ out.full_name = [firstName, lastName].filter(Boolean).join(' ')
159
+ }
160
+
161
+ const title = asString(pick(r, [
162
+ 'employment.current.title',
163
+ 'title',
164
+ 'job_title',
165
+ 'jobTitle',
166
+ 'current_job_title',
167
+ 'position',
168
+ 'current_position.title',
169
+ 'current_position',
170
+ 'headline',
171
+ ]))
172
+ if (title) out.title = title
173
+
174
+ const seniorityRaw = pick(r, [
175
+ 'employment.current.seniority',
176
+ 'seniority',
177
+ 'job_title_levels.0',
178
+ 'job_level', // Sumble
179
+ 'level',
180
+ ])
181
+ const seniority = asString(seniorityRaw)
182
+ if (seniority) out.seniority = seniority
183
+
184
+ // LinkedIn URL is the one field worth strict-validating: a wrong value here
185
+ // silently breaks downstream LinkedIn-driven flows. `url`/`profile_url`/`linkedin`
186
+ // are too ambiguous to trust without the linkedin.com substring check.
187
+ const linkedin = pickLinkedIn(r, [
188
+ 'linkedin_url',
189
+ 'linkedinUrl',
190
+ 'social_profiles.professional_network.url',
191
+ 'social_profiles.linkedin.url',
192
+ 'profile_url',
193
+ 'linkedin',
194
+ 'url',
195
+ ])
196
+ if (linkedin) out.linkedin_url = linkedin
197
+
198
+ const email = asString(pick(r, [
199
+ 'email',
200
+ 'work_email',
201
+ 'personal_email',
202
+ 'best_email',
203
+ 'emails.0',
204
+ 'contact.email',
205
+ 'contact.emails.0',
206
+ ]))
207
+ if (email) out.email = email
208
+
209
+ const country = asString(pick(r, [
210
+ 'location.country',
211
+ 'country',
212
+ 'location_country',
213
+ 'job_company_location_country',
214
+ ]))
215
+ const city = asString(pick(r, [
216
+ 'location.city',
217
+ 'city',
218
+ 'location_locality',
219
+ 'job_company_location_locality',
220
+ ]))
221
+ const locationCombined = [city, country].filter(Boolean).join(', ')
222
+ if (locationCombined) out.location = locationCombined
223
+
224
+ const companyName = asString(pick(r, [
225
+ 'employment.current.company.name',
226
+ 'organization.name',
227
+ 'company.name',
228
+ 'job_company_name',
229
+ 'current_company.name',
230
+ 'companyName',
231
+ ]))
232
+ if (companyName) out.company_name = companyName
233
+
234
+ const companyDomain = asString(pick(r, [
235
+ 'employment.current.company.domain',
236
+ 'organization.primary_domain',
237
+ 'organization.website_url',
238
+ 'company.domain',
239
+ 'company.website',
240
+ 'job_company_website',
241
+ ]))
242
+ if (companyDomain) out.company_domain = companyDomain
243
+
244
+ const companyLinkedIn = asString(pick(r, [
245
+ 'employment.current.company.social_profiles.professional_network.url',
246
+ 'employment.current.company.social_profiles.linkedin.url',
247
+ 'employment.current.company.linkedin_url',
248
+ 'organization.linkedin_url',
249
+ 'company.linkedin_url',
250
+ 'job_company_linkedin_url',
251
+ ]))
252
+ if (companyLinkedIn) out.company_linkedin_url = companyLinkedIn
253
+
254
+ const headcountRaw = pick(r, [
255
+ 'employment.current.company.headcount',
256
+ 'employment.current.company.headcount_range',
257
+ 'organization.estimated_num_employees',
258
+ 'organization.num_employees',
259
+ 'company.headcount',
260
+ 'company.size',
261
+ 'job_company_size',
262
+ 'job_company_employee_count',
263
+ ])
264
+ if (typeof headcountRaw === 'number' || (typeof headcountRaw === 'string' && headcountRaw.length > 0)) {
265
+ out.company_headcount = headcountRaw
266
+ }
267
+
268
+ // Drop records that resolved to nothing meaningful.
269
+ if (Object.keys(out).length === 0) return null
270
+ return out
271
+ }
272
+
273
+ export interface CompactPayload {
274
+ people: CompactPerson[]
275
+ total?: number
276
+ gap_fill_provider?: string
277
+ revealed?: true
278
+ }
279
+
280
+ export function compactPayload(data: unknown, providerId: string): CompactPayload {
281
+ const mainArr = extractPeopleArray(data, providerId)
282
+ const main: CompactPerson[] = []
283
+ for (const r of mainArr) {
284
+ const p = normalizePerson(r)
285
+ if (p) main.push(p)
286
+ }
287
+
288
+ let gapFillProvider: string | undefined
289
+ let gapFillPeople: CompactPerson[] = []
290
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
291
+ const gf = (data as Record<string, unknown>).gap_fill
292
+ if (gf && typeof gf === 'object') {
293
+ gapFillProvider = asString((gf as Record<string, unknown>).provider)
294
+ const gfArr = extractPeopleArray(gf, gapFillProvider ?? '')
295
+ for (const r of gfArr) {
296
+ const p = normalizePerson(r)
297
+ if (p) gapFillPeople.push(p)
298
+ }
299
+ }
300
+ }
301
+
302
+ // Pull pagination total when present (FullEnrich exposes data.metadata.total).
303
+ let total: number | undefined
304
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
305
+ const meta = (data as Record<string, unknown>).metadata
306
+ if (meta && typeof meta === 'object') {
307
+ const t = (meta as Record<string, unknown>).total
308
+ if (typeof t === 'number') total = t
309
+ }
310
+ }
311
+
312
+ const out: CompactPayload = { people: [...main, ...gapFillPeople] }
313
+ if (total !== undefined) out.total = total
314
+ if (gapFillProvider && gapFillPeople.length > 0) out.gap_fill_provider = gapFillProvider
315
+ // Preserve the revealed flag the Apollo reveal flow sets — it tells the caller
316
+ // whether the emails/full names in `people` came from /apollo/people/bulk-match
317
+ // (paid +1 credit per person) vs the obfuscated /apollo/people/search response.
318
+ if (data && typeof data === 'object' && !Array.isArray(data) &&
319
+ (data as Record<string, unknown>).revealed === true) {
320
+ out.revealed = true
321
+ }
322
+ return out
323
+ }
@@ -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,21 +129,25 @@ 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
- it('hasResult: true when data array non-empty', () => {
136
- expect(p().hasResult({ data: [{ name: 'Michel Lieben' }] })).toBe(true)
135
+ it('hasResult: true when results array non-empty', () => {
136
+ expect(p().hasResult({ results: [{ person: { full_name: 'Michel Lieben' } }] })).toBe(true)
137
137
  })
138
138
 
139
- it('hasResult: false when data is empty', () => {
140
- expect(p().hasResult({ data: [] })).toBe(false)
139
+ it('hasResult: false when results is empty', () => {
140
+ expect(p().hasResult({ results: [] })).toBe(false)
141
141
  })
142
142
 
143
143
  it('hasResult: false on empty object', () => {
144
144
  expect(p().hasResult({})).toBe(false)
145
145
  })
146
146
 
147
+ it('hasResult: false when only legacy data field is present (real upstream uses results)', () => {
148
+ expect(p().hasResult({ data: [{ name: 'Stale' }] })).toBe(false)
149
+ })
150
+
147
151
  it('priority is 7', () => {
148
152
  expect(p().priority).toBe(7)
149
153
  })
@@ -364,6 +368,18 @@ describe('apollo (find_people)', () => {
364
368
  expect(body.q_keywords).toBe('growth AI')
365
369
  })
366
370
 
371
+ it('mapParams clamps per_page to 100 when limit exceeds Apollo upstream cap', () => {
372
+ const result = p().mapParams({ job_titles: ['CEO'], limit: 150 })
373
+ const body = result.body as Record<string, unknown>
374
+ expect(body.per_page).toBe(100)
375
+ })
376
+
377
+ it('mapParams passes through per_page when limit is at or below 100', () => {
378
+ const result = p().mapParams({ job_titles: ['CEO'], limit: 50 })
379
+ const body = result.body as Record<string, unknown>
380
+ expect(body.per_page).toBe(50)
381
+ })
382
+
367
383
  it('mapParams omits q_keywords when keywords absent', () => {
368
384
  const result = p().mapParams({ job_titles: ['CEO'], limit: 10 })
369
385
  const body = result.body as Record<string, unknown>
@@ -466,6 +482,22 @@ describe('leadsfactory (find_people)', () => {
466
482
  const personas = (result: ReturnType<ReturnType<typeof p>['mapParams']>) =>
467
483
  ((result.body as Record<string, unknown>).search as Record<string, unknown>).personas as { job_title: string }[]
468
484
 
485
+ it('isApplicable: true with company_domains', () => {
486
+ expect(p().isApplicable!({ company_domains: ['coldiq.com'], job_titles: ['CEO'] })).toBe(true)
487
+ })
488
+
489
+ it('isApplicable: true with company_linkedin_urls', () => {
490
+ expect(p().isApplicable!({ company_linkedin_urls: ['https://www.linkedin.com/company/coldiq'], job_titles: ['CEO'] })).toBe(true)
491
+ })
492
+
493
+ it('isApplicable: false without any company identifier (LF upstream 500s on pure prospecting)', () => {
494
+ expect(p().isApplicable!({ job_titles: ['CEO'], seniorities: ['C-Level'], locations: ['FR'] })).toBe(false)
495
+ })
496
+
497
+ it('isApplicable: false on empty input', () => {
498
+ expect(p().isApplicable!({})).toBe(false)
499
+ })
500
+
469
501
  it('same-domain variants collapse to 1 persona with comma-joined titles', () => {
470
502
  const result = p().mapParams({ company_domains: ['coldiq.com'], job_titles: ['VP Sales', 'Head of Sales'], limit: 5 })
471
503
  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
  // ---------------------------------------------------------------------------