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