@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.
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +7 -1
- package/dist/client.js.map +1 -1
- package/dist/executor.d.ts +11 -0
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +72 -11
- package/dist/executor.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +34 -8
- package/dist/registry.js.map +1 -1
- package/dist/tools/find-emails.d.ts +2 -7
- package/dist/tools/find-emails.d.ts.map +1 -1
- package/dist/tools/find-emails.js +193 -67
- package/dist/tools/find-emails.js.map +1 -1
- package/dist/tools/find-people.d.ts +3 -2
- package/dist/tools/find-people.d.ts.map +1 -1
- package/dist/tools/find-people.js +65 -7
- package/dist/tools/find-people.js.map +1 -1
- package/dist/tools/get-credit-balance.d.ts +17 -0
- package/dist/tools/get-credit-balance.d.ts.map +1 -0
- package/dist/tools/get-credit-balance.js +20 -0
- package/dist/tools/get-credit-balance.js.map +1 -0
- package/dist/utils/compact-people.d.ts +24 -0
- package/dist/utils/compact-people.d.ts.map +1 -0
- package/dist/utils/compact-people.js +306 -0
- package/dist/utils/compact-people.js.map +1 -0
- package/dist/utils/provider-resolver.d.ts.map +1 -1
- package/dist/utils/provider-resolver.js +12 -1
- package/dist/utils/provider-resolver.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +9 -1
- package/src/executor.ts +89 -17
- package/src/index.ts +8 -0
- package/src/registry.ts +40 -8
- package/src/tools/find-emails.ts +251 -80
- package/src/tools/find-people.ts +70 -7
- package/src/tools/get-credit-balance.ts +24 -0
- package/src/utils/compact-people.ts +318 -0
- package/src/utils/provider-resolver.ts +12 -1
- package/tests/executor.test.ts +165 -0
- package/tests/registry-find-people.test.ts +31 -3
- package/tests/registry-search-companies.test.ts +46 -7
- package/tests/tools/find-emails.test.ts +267 -1
- package/tests/tools/find-people.test.ts +269 -5
- package/tests/tools/get-credit-balance.test.ts +56 -0
- 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
|
-
|
|
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
|
package/tests/executor.test.ts
CHANGED
|
@@ -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
|
-
|
|
118
|
+
person_job_title: { include: ['CEO', 'CTO'] },
|
|
119
119
|
person_seniority: { include: ['C-Level'] },
|
|
120
120
|
company: { websites: { include: ['coldiq.com'] } },
|
|
121
|
-
|
|
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.
|
|
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
|
-
|
|
47
|
-
|
|
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.
|
|
58
|
-
expect(filters.
|
|
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.
|
|
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
|
// ---------------------------------------------------------------------------
|