@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,487 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ extractPeopleArray,
4
+ normalizePerson,
5
+ compactPayload,
6
+ } from '../../src/utils/compact-people.js'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // extractPeopleArray — provider wrapper handling
10
+ // ---------------------------------------------------------------------------
11
+
12
+ describe('extractPeopleArray', () => {
13
+ it('leadsfactory: flattens companies_personas[i].personas (unknown[][]) into a flat list', () => {
14
+ const data = {
15
+ companies_personas: [
16
+ { company: 'ColdIQ', personas: [[{ contact: { name: 'Michel' } }], [{ contact: { name: 'Jane' } }]] },
17
+ { company: 'Folk', personas: [[{ contact: { name: 'Bob' } }]] },
18
+ ],
19
+ }
20
+ const arr = extractPeopleArray(data, 'leadsfactory')
21
+ expect(arr).toHaveLength(3)
22
+ })
23
+
24
+ it('leadsfactory: empty inner persona arrays produce empty result', () => {
25
+ expect(extractPeopleArray({ companies_personas: [{ company: 'X', personas: [[]] }] }, 'leadsfactory')).toEqual([])
26
+ })
27
+
28
+ it('linkupapi-search-profiles: reads data.profiles (nested wrapper)', () => {
29
+ const arr = extractPeopleArray({ data: { profiles: [{ name: 'A' }, { name: 'B' }] } }, 'linkupapi-search-profiles')
30
+ expect(arr).toHaveLength(2)
31
+ })
32
+
33
+ it('fullenrich / apollo / sumble / fullenrich: top-level people[]', () => {
34
+ expect(extractPeopleArray({ people: [{ name: 'A' }] }, 'apollo')).toHaveLength(1)
35
+ expect(extractPeopleArray({ people: [{ name: 'A' }] }, 'fullenrich-people-search')).toHaveLength(1)
36
+ })
37
+
38
+ it('pdl / companyenrich: top-level data[]', () => {
39
+ expect(extractPeopleArray({ data: [{ full_name: 'A' }] }, 'pdl')).toHaveLength(1)
40
+ expect(extractPeopleArray({ data: [{ name: 'C' }] }, 'companyenrich')).toHaveLength(1)
41
+ })
42
+
43
+ it('prospeo-search-person: top-level results[] (real upstream shape)', () => {
44
+ expect(extractPeopleArray({ results: [{ person: { full_name: 'B' } }] }, 'prospeo-search-person')).toHaveLength(1)
45
+ })
46
+
47
+ it('ai-ark-people: reads content[]', () => {
48
+ expect(extractPeopleArray({ content: [{ name: 'A' }] }, 'ai-ark-people')).toHaveLength(1)
49
+ })
50
+
51
+ it('findymail-search-employees: bare array passthrough', () => {
52
+ expect(extractPeopleArray([{ email: 'a@b.com' }], 'findymail-search-employees')).toHaveLength(1)
53
+ })
54
+
55
+ it('findymail-search-employees: contacts wrapper', () => {
56
+ expect(extractPeopleArray({ contacts: [{ email: 'a@b.com' }] }, 'findymail-search-employees')).toHaveLength(1)
57
+ })
58
+
59
+ it('companyenrich /people/search: items[] wrapper (real upstream shape)', () => {
60
+ // The CompanyEnrich /people/search endpoint returns { items: [...] } in prod;
61
+ // verified in src/providers/companyenrich/people-search.test.ts:45.
62
+ expect(extractPeopleArray({ items: [{ name: 'A' }, { name: 'B' }] }, 'companyenrich')).toHaveLength(2)
63
+ })
64
+
65
+ it('null/undefined/non-object returns []', () => {
66
+ expect(extractPeopleArray(null, 'apollo')).toEqual([])
67
+ expect(extractPeopleArray(undefined, 'apollo')).toEqual([])
68
+ expect(extractPeopleArray('whatever', 'apollo')).toEqual([])
69
+ })
70
+ })
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // normalizePerson — per-provider shape coverage
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe('normalizePerson — fullenrich shape', () => {
77
+ // Captured from a live find_people call on 2026-05-19. Trimmed to the fields
78
+ // we surface in compact mode + a sample of the verbose noise we drop.
79
+ const fullEnrichRecord = {
80
+ id: 'cd84e9f1-b8e7-4d7c-9b0a-1234567890ab',
81
+ full_name: 'Jane Smith',
82
+ first_name: 'Jane',
83
+ last_name: 'Smith',
84
+ location: { country: 'United States', country_code: 'US', city: 'Seattle', region: 'WA' },
85
+ social_profiles: {
86
+ professional_network: {
87
+ id: '12345',
88
+ url: 'https://www.linkedin.com/in/jane-smith',
89
+ handle: 'jane-smith',
90
+ connection_count: 500,
91
+ },
92
+ },
93
+ employment: {
94
+ current: {
95
+ title: 'VP of Sales',
96
+ seniority: 'VP',
97
+ description: 'Very long job description...',
98
+ company: {
99
+ id: 'co_microsoft',
100
+ name: 'Microsoft',
101
+ domain: 'microsoft.com',
102
+ headcount: 220000,
103
+ headcount_range: '10001+',
104
+ social_profiles: {
105
+ professional_network: { url: 'https://www.linkedin.com/company/microsoft' },
106
+ },
107
+ description: 'Every company has a mission...',
108
+ locations: { headquarters: { line1: '1 Microsoft Way' }, offices: [/* 50+ entries */] },
109
+ specialties: ['Business Software', 'Developer Tools', 'Quantum Computing'],
110
+ },
111
+ },
112
+ all: [/* heavy career history */],
113
+ },
114
+ educations: [/* ... */],
115
+ languages: [/* ... */],
116
+ skills: [/* ... */],
117
+ }
118
+
119
+ it('extracts compact fields and drops the heavy ones', () => {
120
+ const p = normalizePerson(fullEnrichRecord)!
121
+ expect(p).toEqual({
122
+ full_name: 'Jane Smith',
123
+ first_name: 'Jane',
124
+ last_name: 'Smith',
125
+ title: 'VP of Sales',
126
+ seniority: 'VP',
127
+ linkedin_url: 'https://www.linkedin.com/in/jane-smith',
128
+ company_name: 'Microsoft',
129
+ company_domain: 'microsoft.com',
130
+ company_linkedin_url: 'https://www.linkedin.com/company/microsoft',
131
+ company_headcount: 220000,
132
+ location: 'Seattle, United States',
133
+ })
134
+ })
135
+
136
+ it('stays under 500 chars per record on a trimmed FullEnrich shape', () => {
137
+ const compactSize = JSON.stringify(normalizePerson(fullEnrichRecord)).length
138
+ expect(compactSize).toBeLessThan(500)
139
+ // The realistic-size end-to-end check is the > 95% reduction assertion in the
140
+ // compactPayload suite below — this fixture is heavily trimmed and not representative.
141
+ })
142
+ })
143
+
144
+ describe('normalizePerson — apollo shape', () => {
145
+ it('reads organization.* for company info and top-level title/email', () => {
146
+ const p = normalizePerson({
147
+ id: 'apollo_123',
148
+ name: 'Bob Builder',
149
+ first_name: 'Bob',
150
+ last_name: 'Builder',
151
+ title: 'Chief Builder',
152
+ seniority: 'c_suite',
153
+ linkedin_url: 'https://linkedin.com/in/bob',
154
+ email: 'bob@buildco.com',
155
+ organization: {
156
+ name: 'BuildCo',
157
+ primary_domain: 'buildco.com',
158
+ linkedin_url: 'https://linkedin.com/company/buildco',
159
+ estimated_num_employees: 1000,
160
+ },
161
+ })!
162
+ expect(p).toEqual({
163
+ full_name: 'Bob Builder',
164
+ first_name: 'Bob',
165
+ last_name: 'Builder',
166
+ title: 'Chief Builder',
167
+ seniority: 'c_suite',
168
+ linkedin_url: 'https://linkedin.com/in/bob',
169
+ email: 'bob@buildco.com',
170
+ company_name: 'BuildCo',
171
+ company_domain: 'buildco.com',
172
+ company_linkedin_url: 'https://linkedin.com/company/buildco',
173
+ company_headcount: 1000,
174
+ })
175
+ })
176
+ })
177
+
178
+ describe('normalizePerson — pdl shape', () => {
179
+ it('reads job_company_* keys and job_title_levels[0]', () => {
180
+ const p = normalizePerson({
181
+ full_name: 'Charlie Chen',
182
+ first_name: 'Charlie',
183
+ last_name: 'Chen',
184
+ job_title: 'Director of Engineering',
185
+ job_title_levels: ['director'],
186
+ linkedin_url: 'linkedin.com/in/charlie',
187
+ work_email: 'charlie@biz.com',
188
+ job_company_name: 'BizCo',
189
+ job_company_website: 'biz.com',
190
+ job_company_linkedin_url: 'linkedin.com/company/biz',
191
+ job_company_size: '501-1000',
192
+ })!
193
+ expect(p).toEqual({
194
+ full_name: 'Charlie Chen',
195
+ first_name: 'Charlie',
196
+ last_name: 'Chen',
197
+ title: 'Director of Engineering',
198
+ seniority: 'director',
199
+ linkedin_url: 'linkedin.com/in/charlie',
200
+ email: 'charlie@biz.com',
201
+ company_name: 'BizCo',
202
+ company_domain: 'biz.com',
203
+ company_linkedin_url: 'linkedin.com/company/biz',
204
+ company_headcount: '501-1000',
205
+ })
206
+ })
207
+ })
208
+
209
+ describe('normalizePerson — leadsfactory shape', () => {
210
+ it('unwraps { contact: {...} } and pulls fields from inside', () => {
211
+ const p = normalizePerson({
212
+ contact: {
213
+ full_name: 'Dana Day',
214
+ first_name: 'Dana',
215
+ last_name: 'Day',
216
+ title: 'Head of Growth',
217
+ linkedin_url: 'https://linkedin.com/in/dana',
218
+ email: 'dana@growth.io',
219
+ company: {
220
+ name: 'Growth Inc',
221
+ domain: 'growth.io',
222
+ linkedin_url: 'https://linkedin.com/company/growth',
223
+ headcount: 50,
224
+ },
225
+ },
226
+ })!
227
+ expect(p.full_name).toBe('Dana Day')
228
+ expect(p.title).toBe('Head of Growth')
229
+ expect(p.company_name).toBe('Growth Inc')
230
+ expect(p.company_domain).toBe('growth.io')
231
+ expect(p.company_headcount).toBe(50)
232
+ expect(p.email).toBe('dana@growth.io')
233
+ })
234
+ })
235
+
236
+ describe('normalizePerson — sumble shape (real upstream fields)', () => {
237
+ // Verified against src/providers/sumble/route.test.ts:639-650 — Sumble /people/find
238
+ // returns { id, url, name, job_title, job_function, job_level, country } with NO
239
+ // linkedin_url field. The top-level `url` is a Sumble URL, NOT a LinkedIn URL.
240
+ const sumbleRecord = {
241
+ id: 100,
242
+ url: 'https://sumble.com/people/100',
243
+ name: 'Michel Lieben',
244
+ job_title: 'CEO',
245
+ job_function: 'sales',
246
+ job_level: 'executive',
247
+ country: 'Belgium',
248
+ }
249
+
250
+ it('extracts title from job_title and seniority from job_level', () => {
251
+ const p = normalizePerson(sumbleRecord)!
252
+ expect(p.title).toBe('CEO')
253
+ expect(p.seniority).toBe('executive')
254
+ })
255
+
256
+ it('does NOT surface the Sumble profile URL as linkedin_url (it is not a LinkedIn URL)', () => {
257
+ const p = normalizePerson(sumbleRecord)!
258
+ expect(p.linkedin_url).toBeUndefined()
259
+ })
260
+
261
+ it('accepts a real linkedin_url when the record provides one', () => {
262
+ const p = normalizePerson({ ...sumbleRecord, linkedin_url: 'https://www.linkedin.com/in/michel' })!
263
+ expect(p.linkedin_url).toBe('https://www.linkedin.com/in/michel')
264
+ })
265
+
266
+ it('picks up country as location', () => {
267
+ const p = normalizePerson(sumbleRecord)!
268
+ expect(p.location).toBe('Belgium')
269
+ })
270
+ })
271
+
272
+ describe('normalizePerson — ai-ark profile-nested shape', () => {
273
+ // AI-Ark /people returns { content: [{ id, profile: { full_name, ... } }] }.
274
+ // Verified against src/providers/ai-ark/route.test.ts:187.
275
+ it('unwraps record.profile so person fields are accessible to candidate paths', () => {
276
+ const p = normalizePerson({
277
+ id: 'aiark_1',
278
+ profile: {
279
+ full_name: 'Michel Lieben',
280
+ first_name: 'Michel',
281
+ last_name: 'Lieben',
282
+ title: 'CEO',
283
+ linkedin_url: 'https://www.linkedin.com/in/michel',
284
+ },
285
+ })!
286
+ expect(p.full_name).toBe('Michel Lieben')
287
+ expect(p.title).toBe('CEO')
288
+ expect(p.linkedin_url).toBe('https://www.linkedin.com/in/michel')
289
+ })
290
+ })
291
+
292
+ describe('normalizePerson — prospeo person-nested shape', () => {
293
+ // Prospeo /search-person returns { results: [{ person: { full_name, current_job_title, ... } }] }.
294
+ // Verified against the live api.prospeo.io/search-person response.
295
+ it('unwraps record.person and reads current_job_title as title', () => {
296
+ const p = normalizePerson({
297
+ person: {
298
+ person_id: 'aaaa07ad691a3a7f2ac039c6',
299
+ full_name: 'Catherine Powlett',
300
+ first_name: 'Catherine',
301
+ last_name: 'Powlett',
302
+ current_job_title: 'Marketing Manager',
303
+ linkedin_url: 'https://www.linkedin.com/in/catherinepowlett',
304
+ },
305
+ })!
306
+ expect(p.full_name).toBe('Catherine Powlett')
307
+ expect(p.first_name).toBe('Catherine')
308
+ expect(p.last_name).toBe('Powlett')
309
+ expect(p.title).toBe('Marketing Manager')
310
+ expect(p.linkedin_url).toBe('https://www.linkedin.com/in/catherinepowlett')
311
+ })
312
+ })
313
+
314
+ describe('normalizePerson — leadsfactory persona-level company sibling', () => {
315
+ // LF persona items have shape { contact, company, persona_index, persona_job_title }.
316
+ // Verified against src/providers/leadsfactory/schema.ts:134-141. The company sibling
317
+ // must remain accessible to company_* candidate paths after unwrapping contact.
318
+ it('reads company.* from the persona sibling, not just contact-nested fields', () => {
319
+ const p = normalizePerson({
320
+ contact_found: true,
321
+ persona_index: 0,
322
+ persona_job_title: 'CEO',
323
+ contact: {
324
+ full_name: 'Michel Lieben',
325
+ title: 'CEO',
326
+ linkedin_url: 'https://www.linkedin.com/in/michel',
327
+ },
328
+ company: {
329
+ name: 'ColdIQ',
330
+ domain: 'coldiq.com',
331
+ linkedin_url: 'https://www.linkedin.com/company/coldiq',
332
+ headcount: 12,
333
+ },
334
+ })!
335
+ expect(p.full_name).toBe('Michel Lieben')
336
+ expect(p.title).toBe('CEO')
337
+ expect(p.company_name).toBe('ColdIQ')
338
+ expect(p.company_domain).toBe('coldiq.com')
339
+ expect(p.company_linkedin_url).toBe('https://www.linkedin.com/company/coldiq')
340
+ expect(p.company_headcount).toBe(12)
341
+ })
342
+ })
343
+
344
+ describe('normalizePerson — linkedin URL strict validation', () => {
345
+ it('rejects non-LinkedIn URLs in url/profile_url/linkedin fallbacks', () => {
346
+ const p = normalizePerson({
347
+ name: 'X',
348
+ profile_url: 'https://example.com/profile/x',
349
+ url: 'https://sumble.com/people/100',
350
+ })!
351
+ expect(p.linkedin_url).toBeUndefined()
352
+ })
353
+
354
+ it('accepts LinkedIn URLs at fallback paths', () => {
355
+ const p = normalizePerson({
356
+ name: 'X',
357
+ profile_url: 'https://www.linkedin.com/in/x',
358
+ })!
359
+ expect(p.linkedin_url).toBe('https://www.linkedin.com/in/x')
360
+ })
361
+
362
+ it('accepts PDL canonical short form (no protocol, no www)', () => {
363
+ const p = normalizePerson({ full_name: 'X', linkedin_url: 'linkedin.com/in/x' })!
364
+ expect(p.linkedin_url).toBe('linkedin.com/in/x')
365
+ })
366
+ })
367
+
368
+ describe('normalizePerson — edge cases', () => {
369
+ it('composes full_name from first + last when full_name missing', () => {
370
+ const p = normalizePerson({ first_name: 'Anna', last_name: 'Apple' })!
371
+ expect(p.full_name).toBe('Anna Apple')
372
+ })
373
+
374
+ it('drops fully-empty records', () => {
375
+ expect(normalizePerson({})).toBeNull()
376
+ expect(normalizePerson(null)).toBeNull()
377
+ expect(normalizePerson('string')).toBeNull()
378
+ })
379
+
380
+ it('drops empty-string / null candidates and falls through to the next path', () => {
381
+ const p = normalizePerson({
382
+ title: '',
383
+ job_title: 'Fallback Title',
384
+ })!
385
+ expect(p.title).toBe('Fallback Title')
386
+ })
387
+
388
+ it('omits fields that resolved to nothing (no undefined leaks into output)', () => {
389
+ const p = normalizePerson({ full_name: 'Solo Person' })!
390
+ expect(Object.keys(p)).toEqual(['full_name'])
391
+ })
392
+
393
+ it('handles array index in path (job_title_levels.0)', () => {
394
+ const p = normalizePerson({ name: 'X', job_title_levels: ['vp', 'manager'] })!
395
+ expect(p.seniority).toBe('vp')
396
+ })
397
+ })
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // compactPayload — gap_fill folding + total passthrough
401
+ // ---------------------------------------------------------------------------
402
+
403
+ describe('compactPayload', () => {
404
+ it('folds gap_fill (apollo) results into the same people[] and reports gap_fill_provider', () => {
405
+ const data = {
406
+ companies_personas: [
407
+ { company: 'ColdIQ', personas: [[{ contact: { full_name: 'Michel L', title: 'CEO' } }]] },
408
+ ],
409
+ gap_fill: {
410
+ provider: 'apollo',
411
+ domains: ['nohit.com'],
412
+ people: [
413
+ {
414
+ name: 'Apollo Adam',
415
+ title: 'CTO',
416
+ organization: { name: 'NoHit', primary_domain: 'nohit.com' },
417
+ },
418
+ ],
419
+ },
420
+ }
421
+ const out = compactPayload(data, 'leadsfactory')
422
+ expect(out.people).toHaveLength(2)
423
+ expect(out.people[0].full_name).toBe('Michel L')
424
+ expect(out.people[1].full_name).toBe('Apollo Adam')
425
+ expect(out.people[1].company_domain).toBe('nohit.com')
426
+ expect(out.gap_fill_provider).toBe('apollo')
427
+ })
428
+
429
+ it('passes metadata.total through (fullenrich pagination)', () => {
430
+ const data = {
431
+ people: [{ full_name: 'A' }],
432
+ metadata: { total: 42, credits: 1, offset: 0 },
433
+ }
434
+ const out = compactPayload(data, 'fullenrich-people-search')
435
+ expect(out.total).toBe(42)
436
+ })
437
+
438
+ it('omits gap_fill_provider when there is no gap_fill block', () => {
439
+ const out = compactPayload({ people: [{ full_name: 'A' }] }, 'apollo')
440
+ expect(out.gap_fill_provider).toBeUndefined()
441
+ })
442
+
443
+ it('returns empty people[] when data is null or empty', () => {
444
+ expect(compactPayload(null, 'apollo').people).toEqual([])
445
+ expect(compactPayload({}, 'apollo').people).toEqual([])
446
+ })
447
+
448
+ it('end-to-end size check: a realistic fullenrich-like payload shrinks dramatically', () => {
449
+ const heavy = {
450
+ people: Array.from({ length: 5 }).map((_, i) => ({
451
+ id: `person_${i}`,
452
+ full_name: `Person ${i}`,
453
+ first_name: `Person`,
454
+ last_name: String(i),
455
+ social_profiles: { professional_network: { url: `https://linkedin.com/in/person${i}` } },
456
+ location: { country: 'United States', city: 'Seattle' },
457
+ employment: {
458
+ current: {
459
+ title: 'VP of Sales',
460
+ seniority: 'VP',
461
+ description: 'x'.repeat(1500),
462
+ company: {
463
+ name: 'Microsoft',
464
+ domain: 'microsoft.com',
465
+ headcount: 220000,
466
+ description: 'x'.repeat(800),
467
+ locations: { headquarters: { line1: '1 Microsoft Way' }, offices: Array.from({ length: 40 }).map(() => ({ line1: 'Office', line2: 'somewhere' })) },
468
+ specialties: Array.from({ length: 25 }).map((_, j) => `Specialty ${j}`),
469
+ social_profiles: { professional_network: { url: 'https://linkedin.com/company/microsoft' } },
470
+ },
471
+ },
472
+ all: Array.from({ length: 10 }).map(() => ({ title: 'Past role', description: 'y'.repeat(1000) })),
473
+ },
474
+ skills: Array.from({ length: 30 }).map((_, j) => `skill_${j}`),
475
+ languages: Array.from({ length: 5 }).map(() => ({ language: 'English', proficiency: 'Native' })),
476
+ educations: Array.from({ length: 4 }).map(() => ({ school_name: 'Some University' })),
477
+ })),
478
+ metadata: { total: 5 },
479
+ }
480
+ const rawSize = JSON.stringify(heavy).length
481
+ const compact = compactPayload(heavy, 'fullenrich-people-search')
482
+ const compactSize = JSON.stringify(compact).length
483
+ expect(compactSize).toBeLessThan(rawSize / 20) // > 95% reduction
484
+ expect(compact.people).toHaveLength(5)
485
+ expect(compact.total).toBe(5)
486
+ })
487
+ })