1
2import assert from 'node:assert/strict';
3import { after, afterEach, describe, it } from 'node:test';
4
5import type { MockHandler, MockServer } from './helpers.js';
6import {
7 inputSchemaErrors,
8 removeStorage,
9 runActor,
10 startMockTomba,
11 startStandbyActor,
12 totalCharges,
13} from './helpers.js';
14
15const MATT = 'https://www.linkedin.com/in/mattm';
16
17
18function profile(url: string): Record<string, unknown> {
19 return {
20 email: 'm@wordpress.org',
21 first_name: 'Matt',
22 last_name: 'Mullenweg',
23 full_name: 'Matt Mullenweg',
24 country: 'US',
25 gender: 'male',
26 phone_number: false,
27 position: 'CEO',
28 twitter: 'photomatt',
29 linkedin: url,
30 company: 'WordPress',
31 website_url: 'wordpress.org',
32 accept_all: false,
33 score: 99,
34 verification: { date: '2026-09-01', status: 'valid' },
35 sources: [
36 {
37 uri: 'https://wordpress.org/about/',
38 website_url: 'wordpress.org',
39 extracted_on: '2025-01-10T10:00:00+02:00',
40 last_seen_on: '2026-08-01T10:00:00+02:00',
41 still_on_page: true,
42 },
43 ],
44 };
45}
46
47const PHONES = [
48 { number: '+14155550123', type: 'mobile' },
49 { number: '+14155550124', type: 'work' },
50];
51
52
53function secondAddress(url: string): Record<string, unknown> {
54 return { ...profile(url), email: 'matt@automattic.com', company: 'Automattic', website_url: 'automattic.com' };
55}
56
57
58const tomba: MockHandler = (req) => {
59 assert.equal(req.method, 'GET');
60 assert.equal(req.path, '/linkedin');
61 const { url } = req.query;
62 const enrich = req.query.enrich_mobile === 'true';
63 if (url.endsWith('/empty')) return { body: { data: req.query.full === 'true' ? [] : null } };
64 if (url.endsWith('/noemail')) return { body: { data: { ...profile(url), email: null, score: 0, sources: [] } } };
65 if (url.endsWith('/invalid')) return { status: 422, body: { errors: { message: 'Invalid LinkedIn URL' } } };
66 if (url.endsWith('/html')) return { raw: '<html>Bad gateway</html>' };
67 const phones = enrich ? { phone_data: url.endsWith('/nophone') ? [] : PHONES } : {};
68 if (req.query.full === 'true') {
69 return {
70 body: {
71 data: [
72 { ...profile(url), ...phones },
73 { ...secondAddress(url), ...(enrich ? { phone_data: [] } : {}) },
74 ],
75 },
76 };
77 }
78 return { body: { data: { ...profile(url), ...phones } } };
79};
80
81const servers: MockServer[] = [];
82const dirs: string[] = [];
83
84async function mock(handler: MockHandler = tomba): Promise<MockServer> {
85 const server = await startMockTomba(handler);
86 servers.push(server);
87 return server;
88}
89
90async function run(...args: Parameters<typeof runActor>) {
91 const result = await runActor(...args);
92 dirs.push(result.storageDir);
93 return result;
94}
95
96afterEach(async () => {
97 await Promise.all(servers.splice(0).map(async (s) => s.close()));
98});
99
100after(async () => {
101 await Promise.all(dirs.map(removeStorage));
102});
103
104describe('linkedin-finder', () => {
105 it('returns the email and profile and charges one event per billable profile', async () => {
106 const server = await mock();
107 const empty = 'https://www.linkedin.com/in/empty';
108 const result = await run({ input: { linkedinUrls: [MATT, empty] }, endpoint: server.url });
109
110 assert.equal(result.code, 0, result.output);
111 assert.deepEqual(server.requests.map((r) => r.query.url).sort(), [empty, MATT]);
112
113 const found = result.items.find((i) => i.linkedin_url === MATT);
114 assert.deepEqual(found, {
115 ...profile(MATT),
116 phoneNumbers: 0,
117 linkedin_url: MATT,
118 source: 'tomba_linkedin_finder',
119 chargedCredits: 1,
120 charged: true,
121 cached: false,
122 });
123
124 const none = result.items.find((i) => i.linkedin_url === empty);
125 assert.deepEqual(none, {
126 email: '',
127 phoneNumbers: 0,
128 linkedin_url: empty,
129 source: 'tomba_linkedin_finder',
130 chargedCredits: 0,
131 charged: false,
132 cached: false,
133 error: 'No email found for this LinkedIn profile',
134 });
135
136 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
137 });
138
139 it('sends the built-in credentials to Tomba', async () => {
140 const server = await mock();
141 await run({ input: { linkedinUrls: [MATT] }, endpoint: server.url });
142 assert.equal(server.requests[0].headers['x-tomba-key'], 'ta_test_key');
143 assert.equal(server.requests[0].headers['x-tomba-secret'], 'ts_test_secret');
144 });
145
146 it('trims and deduplicates URLs and drops blank entries', async () => {
147 const server = await mock();
148 const result = await run({
149 input: { linkedinUrls: [` ${MATT} `, MATT, ' ', ''] },
150 endpoint: server.url,
151 });
152 assert.equal(result.code, 0, result.output);
153 assert.deepEqual(
154 server.requests.map((r) => r.query.url),
155 [MATT],
156 );
157 assert.equal(result.items.length, 1);
158 });
159
160 it('charges a negative answer (profile found but no email)', async () => {
161 const server = await mock();
162 const url = 'https://www.linkedin.com/in/noemail';
163 const result = await run({ input: { linkedinUrls: [url] }, endpoint: server.url });
164 assert.equal(result.code, 0, result.output);
165 assert.equal(result.items.length, 1);
166 assert.equal(result.items[0].email, null);
167 assert.equal(result.items[0].full_name, 'Matt Mullenweg');
168 assert.equal(result.items[0].charged, true);
169 assert.equal(result.items[0].error, undefined);
170 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
171 });
172
173 it('does not charge Tomba error statuses and does not retry them', async () => {
174 const server = await mock();
175 const result = await run({
176 input: { linkedinUrls: ['https://www.linkedin.com/in/invalid'] },
177 endpoint: server.url,
178 });
179 assert.equal(result.code, 0, result.output);
180 assert.equal(server.requests.length, 1);
181 assert.equal(result.items[0].charged, false);
182 assert.equal(result.items[0].email, '');
183 assert.match(String(result.items[0].error), /422: Invalid LinkedIn URL/);
184 assert.equal(totalCharges(result), 0);
185 });
186
187 it('does not charge a non-JSON body', async () => {
188 const server = await mock();
189 const result = await run({
190 input: { linkedinUrls: ['https://www.linkedin.com/in/html'] },
191 endpoint: server.url,
192 });
193 assert.equal(result.items[0].charged, false);
194 assert.match(String(result.items[0].error), /Invalid response/);
195 assert.equal(totalCharges(result), 0);
196 });
197
198 it('retries 429 and 5xx responses, then charges the success once', async () => {
199 let calls = 0;
200 const server = await mock(async (req) => {
201 calls++;
202 if (calls === 1)
203 return {
204 status: 429,
205 body: { errors: { message: 'Too many requests' } },
206 headers: { 'retry-after': '1' },
207 };
208 if (calls === 2) return { status: 503, body: {} };
209 return tomba(req);
210 });
211 const result = await run({ input: { linkedinUrls: [MATT], maxRetries: 3 }, endpoint: server.url });
212 assert.equal(server.requests.length, 3);
213 assert.equal(result.items.length, 1);
214 assert.equal(result.items[0].charged, true);
215 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
216 });
217
218 it('serves repeated runs from the cache for free', async () => {
219 const server = await mock();
220 const first = await run({ input: { linkedinUrls: [MATT] }, endpoint: server.url });
221 const second = await run({
222 input: { linkedinUrls: [MATT] },
223 endpoint: server.url,
224 storageDir: first.storageDir,
225 });
226
227 assert.equal(server.requests.length, 1);
228 assert.equal(second.items.length, 1);
229 assert.equal(second.items[0].email, 'm@wordpress.org');
230 assert.equal(second.items[0].cached, true);
231 assert.equal(second.items[0].charged, false);
232 assert.equal(second.items[0].chargedCredits, 0);
233 assert.equal(totalCharges(second), 0);
234 });
235
236 it('serves repeated runs with phone data and full=true from the cache for free', async () => {
237 const server = await mock();
238 const input = { linkedinUrls: [MATT], enrichMobile: true, full: true };
239 const first = await run({ input, endpoint: server.url });
240 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir });
241
242 assert.deepEqual(first.chargeCounts, { 'tomba-request': 12 });
243 assert.equal(server.requests.length, 1);
244 assert.equal(second.items.length, 2);
245 assert.ok(second.items.every((i) => i.cached === true && i.charged === false && i.chargedCredits === 0));
246 assert.deepEqual(second.items[0].phoneNumbers, 2);
247 assert.equal(totalCharges(second), 0);
248 });
249
250 it('calls Tomba again when the cache is disabled', async () => {
251 const server = await mock();
252 const first = await run({ input: { linkedinUrls: [MATT], useCache: false }, endpoint: server.url });
253 await run({
254 input: { linkedinUrls: [MATT], useCache: false },
255 endpoint: server.url,
256 storageDir: first.storageDir,
257 });
258 assert.equal(server.requests.length, 2);
259 });
260
261 it('stops at the max charge limit and resumes without reprocessing', async () => {
262 const server = await mock();
263 const linkedinUrls = ['a', 'b', 'c', 'd', 'e'].map((h) => `https://www.linkedin.com/in/${h}`);
264 const input = { linkedinUrls, maxConcurrency: 1, useCache: false, maxResults: 100 };
265
266
267 const first = await run({ input, endpoint: server.url, maxTotalChargeUsd: 2 });
268 assert.equal(first.code, 0, first.output);
269 assert.equal(totalCharges(first), 2);
270 assert.equal(server.requests.length, 2);
271 assert.equal(first.items.length, 2);
272
273 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir, keepStorage: true });
274 assert.equal(second.code, 0, second.output);
275 assert.deepEqual(
276 server.requests.map((r) => r.query.url),
277 linkedinUrls,
278 );
279
280 assert.equal(totalCharges(second), 5);
281 assert.equal(second.items.length, 5);
282 });
283
284 it('respects maxResults', async () => {
285 const server = await mock();
286 const result = await run({
287 input: {
288 linkedinUrls: ['https://www.linkedin.com/in/a', 'https://www.linkedin.com/in/b'],
289 maxResults: 1,
290 maxConcurrency: 1,
291 },
292 endpoint: server.url,
293 });
294 assert.equal(server.requests.length, 1);
295 assert.equal(result.items.length, 1);
296 assert.equal(totalCharges(result), 1);
297 });
298
299 it('runs requests in parallel', async () => {
300 let active = 0;
301 let peak = 0;
302 const server = await mock(async (req) => {
303 active++;
304 peak = Math.max(peak, active);
305 await new Promise((r) => {
306 setTimeout(r, 100);
307 });
308 active--;
309 return tomba(req);
310 });
311 const linkedinUrls = Array.from({ length: 8 }, (_, i) => `https://www.linkedin.com/in/person-${i}`);
312 await run({ input: { linkedinUrls, maxConcurrency: 4 }, endpoint: server.url });
313 assert.equal(server.requests.length, 8);
314 assert.ok(peak > 1 && peak <= 4, `peak concurrency ${peak}`);
315 });
316
317 it('sends enrich_mobile, full and webhook_url only when they are set', async () => {
318 const server = await mock();
319 await run({ input: { linkedinUrls: [MATT] }, endpoint: server.url });
320 await run({
321 input: { linkedinUrls: [MATT], enrichMobile: false, full: false, webhookUrl: ' ', useCache: false },
322 endpoint: server.url,
323 });
324 await run({
325 input: {
326 linkedinUrls: [MATT],
327 enrichMobile: true,
328 full: true,
329 webhookUrl: 'https://hooks.example.com/tomba',
330 },
331 endpoint: server.url,
332 });
333
334 assert.deepEqual(server.requests[0].query, { url: MATT });
335 assert.deepEqual(server.requests[1].query, { url: MATT });
336 assert.deepEqual(server.requests[2].query, {
337 url: MATT,
338 enrich_mobile: 'true',
339 full: 'true',
340 webhook_url: 'https://hooks.example.com/tomba',
341 });
342 });
343
344 it('charges 6 credits when the profile comes with phone data', async () => {
345 const server = await mock();
346 const result = await run({ input: { linkedinUrls: [MATT], enrichMobile: true }, endpoint: server.url });
347
348 assert.equal(result.code, 0, result.output);
349 assert.equal(result.items.length, 1);
350 const item = result.items[0];
351 assert.deepEqual(item.phone_data, PHONES);
352 assert.deepEqual(item.phoneNumbers, 2);
353 assert.equal(item.chargedCredits, 6);
354 assert.equal(item.charged, true);
355 assert.deepEqual(result.chargeCounts, { 'tomba-request': 6 });
356 });
357
358 it('charges 1 credit when phone enrichment finds no number', async () => {
359 const server = await mock();
360 const url = 'https://www.linkedin.com/in/nophone';
361 const result = await run({ input: { linkedinUrls: [url], enrichMobile: true }, endpoint: server.url });
362
363 assert.equal(result.items[0].email, 'm@wordpress.org');
364 assert.equal(result.items[0].phoneNumbers, 0);
365 assert.equal(result.items[0].chargedCredits, 1);
366 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
367 });
368
369 it('pushes one item per stored address with full=true and charges each address', async () => {
370 const server = await mock();
371 const result = await run({
372 input: { linkedinUrls: [MATT], enrichMobile: true, full: true },
373 endpoint: server.url,
374 });
375
376 assert.equal(result.code, 0, result.output);
377 assert.equal(result.items.length, 2);
378 const [first, second] = result.items;
379 assert.deepEqual(first, {
380 ...profile(MATT),
381 phone_data: PHONES,
382 phoneNumbers: 2,
383 linkedin_url: MATT,
384 source: 'tomba_linkedin_finder',
385 chargedCredits: 11,
386 charged: true,
387 cached: false,
388 });
389 assert.equal(second.email, 'matt@automattic.com');
390 assert.equal(second.linkedin_url, MATT);
391 assert.equal(second.source, 'tomba_linkedin_finder');
392 assert.equal(second.phoneNumbers, 0);
393 assert.equal(second.chargedCredits, 1);
394 assert.equal(second.charged, true);
395 assert.equal(second.cached, false);
396
397 assert.deepEqual(result.chargeCounts, { 'tomba-request': 12 });
398 });
399
400 it('charges 1 credit per address with full=true and no phone enrichment', async () => {
401 const server = await mock();
402 const result = await run({ input: { linkedinUrls: [MATT], full: true }, endpoint: server.url });
403
404 assert.equal(result.items.length, 2);
405 assert.deepEqual(
406 result.items.map((i) => i.chargedCredits),
407 [1, 1],
408 );
409 assert.deepEqual(result.chargeCounts, { 'tomba-request': 2 });
410 });
411
412 it('does not charge an empty address list with full=true', async () => {
413 const server = await mock();
414 const url = 'https://www.linkedin.com/in/empty';
415 const result = await run({ input: { linkedinUrls: [url], full: true }, endpoint: server.url });
416
417 assert.equal(result.items.length, 1);
418 assert.equal(result.items[0].charged, false);
419 assert.equal(result.items[0].chargedCredits, 0);
420 assert.equal(totalCharges(result), 0);
421 });
422
423 it('fails without Tomba credentials and never calls the API', async () => {
424 const server = await mock();
425 const result = await run({ input: { linkedinUrls: [MATT] }, endpoint: server.url, withCredentials: false });
426 assert.notEqual(result.code, 0);
427 assert.match(result.output, /misconfigured/);
428 assert.doesNotMatch(result.output, /ta_test_key|ts_test_secret/);
429 assert.equal(server.requests.length, 0);
430 });
431
432 it('fails on empty input', async () => {
433 const server = await mock();
434 const result = await run({ input: { linkedinUrls: [] }, endpoint: server.url });
435 assert.notEqual(result.code, 0);
436 assert.equal(server.requests.length, 0);
437 assert.equal(result.items.length, 0);
438 });
439});
440
441describe('linkedin-finder standby (real-time API)', () => {
442 it('answers the readiness probe and a bare GET with usage info', async () => {
443 const server = await mock();
444 const actor = await startStandbyActor({ endpoint: server.url });
445 try {
446 const probe = await actor.call('/', { headers: { 'x-apify-container-server-readiness-probe': '1' } });
447 assert.equal(probe.status, 200);
448 const usage = await actor.call('/');
449 assert.equal(usage.status, 200);
450 assert.match(String(usage.body.usage), /GET/);
451 assert.equal(server.requests.length, 0);
452 } finally {
453 await actor.stop();
454 }
455 });
456
457 it('looks up profiles from GET query parameters and charges per credit', async () => {
458 const server = await mock();
459 const actor = await startStandbyActor({ endpoint: server.url });
460 const empty = 'https://www.linkedin.com/in/empty';
461 let stopped;
462 try {
463 const res = await actor.call(
464 `/?url=${encodeURIComponent(MATT)}&linkedinUrl=${encodeURIComponent(empty)}&enrichMobile=true`,
465 );
466 assert.equal(res.status, 200);
467 const items = res.body.items as Record<string, unknown>[];
468 const found = items.find((i) => i.linkedin_url === MATT);
469 assert.equal(found?.email, 'm@wordpress.org');
470 assert.equal(found?.chargedCredits, 6);
471 assert.deepEqual(found?.phone_data, PHONES);
472 assert.equal(
473 items.find((i) => i.linkedin_url === empty)?.error,
474 'No email found for this LinkedIn profile',
475 );
476 assert.ok(server.requests.every((r) => r.query.enrich_mobile === 'true'));
477 } finally {
478 stopped = await actor.stop();
479 }
480 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 6 });
481 });
482
483 it('accepts a POST with the same JSON input as a normal run', async () => {
484 const server = await mock();
485 const actor = await startStandbyActor({ endpoint: server.url });
486 let stopped;
487 try {
488 const res = await actor.call('/', { body: { linkedinUrls: [MATT], full: true } });
489 assert.equal(res.status, 200);
490 const items = res.body.items as Record<string, unknown>[];
491 assert.deepEqual(
492 items.map((i) => i.email),
493 ['m@wordpress.org', 'matt@automattic.com'],
494 );
495 assert.deepEqual(server.requests[0].query, { url: MATT, full: 'true' });
496 } finally {
497 stopped = await actor.stop();
498 }
499 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 2 });
500 });
501
502 it('serves repeated requests from the cache for free', async () => {
503 const server = await mock();
504 const actor = await startStandbyActor({ endpoint: server.url });
505 let stopped;
506 try {
507 await actor.call(`/?url=${encodeURIComponent(MATT)}`);
508 const second = await actor.call(`/?url=${encodeURIComponent(MATT)}`);
509 assert.ok((second.body.items as Record<string, unknown>[]).every((i) => i.cached === true));
510 assert.equal(server.requests.length, 1);
511 } finally {
512 stopped = await actor.stop();
513 }
514 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 1 });
515 });
516
517 it('keeps serving after a request hits maxResults', async () => {
518 const server = await mock();
519 const actor = await startStandbyActor({ endpoint: server.url });
520 try {
521 const first = await actor.call(
522 '/?urls=https://www.linkedin.com/in/a,https://www.linkedin.com/in/b&maxResults=1',
523 );
524 assert.equal((first.body.items as unknown[]).length, 1);
525 const second = await actor.call('/?url=https://www.linkedin.com/in/c');
526 assert.equal((second.body.items as unknown[]).length, 1);
527 assert.equal(server.requests.length, 2);
528 } finally {
529 await actor.stop();
530 }
531 });
532
533 it('rejects invalid input with 400 and unknown paths with 404', async () => {
534 const server = await mock();
535 const actor = await startStandbyActor({ endpoint: server.url });
536 try {
537 assert.equal((await actor.call('/', { body: {} })).status, 400);
538 assert.equal((await actor.call('/', { body: 'not json' })).status, 400);
539 assert.equal((await actor.call(`/?url=${encodeURIComponent(MATT)}&maxResults=abc`)).status, 400);
540 assert.equal((await actor.call(`/?url=${encodeURIComponent(MATT)}&full=maybe`)).status, 400);
541 assert.equal((await actor.call('/?enrichMobile=true')).status, 400);
542 assert.equal((await actor.call('/nope')).status, 404);
543 assert.equal((await actor.call('/', { method: 'DELETE' })).status, 405);
544 assert.equal(server.requests.length, 0);
545 } finally {
546 await actor.stop();
547 }
548 });
549
550 it('returns 402 once the max charge limit is reached', async () => {
551 const server = await mock();
552 const actor = await startStandbyActor({ endpoint: server.url, maxTotalChargeUsd: 1 });
553 try {
554 const first = await actor.call('/?url=https://www.linkedin.com/in/a');
555 assert.equal(first.status, 200);
556 const second = await actor.call('/?url=https://www.linkedin.com/in/b');
557 assert.equal(second.status, 402);
558 assert.equal(server.requests.length, 1);
559 } finally {
560 await actor.stop();
561 }
562 });
563});
564
565describe('input schema', () => {
566 it('accepts common LinkedIn profile URL forms', () => {
567 assert.deepEqual(
568 inputSchemaErrors({
569 linkedinUrls: [
570 'https://www.linkedin.com/in/jane-doe',
571 'https://linkedin.com/in/jane-doe/',
572 'https://uk.linkedin.com/in/jane-doe?trk=public',
573 'http://www.linkedin.com/in/jos%C3%A9-doe',
574 ],
575 }),
576 [],
577 );
578 });
579
580 it('rejects URLs that are not LinkedIn profiles', () => {
581 assert.notDeepEqual(inputSchemaErrors({ linkedinUrls: ['https://www.linkedin.com/company/stripe'] }), []);
582 });
583});