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