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
15
16const VALID = {
17 valid: true,
18 local_format: '(415) 555-0132',
19 intl_format: '+1 415-555-0132',
20 e164_format: '+14155550132',
21 rfc3966_format: 'tel:+1-415-555-0132',
22 country_code: 'US',
23 line_type: 'MOBILE',
24 carrier: 'AT&T',
25 region: { name: 'California', code: 'CA' },
26 timezones: ['America/Los_Angeles'],
27};
28
29const FRENCH = {
30 valid: true,
31 local_format: '06 12 34 56 78',
32 intl_format: '+33 6 12 34 56 78',
33 e164_format: '+33612345678',
34 rfc3966_format: 'tel:+33-6-12-34-56-78',
35 country_code: 'FR',
36 line_type: 'MOBILE',
37 carrier: 'Orange',
38 region: { name: 'France', code: 'FR' },
39 timezones: ['Europe/Paris'],
40};
41
42
43const tomba: MockHandler = (req) => {
44 assert.equal(req.method, 'GET');
45 assert.equal(req.path, '/phone-validator');
46 const { phone } = req.query;
47 if (phone === '+10000000000') return { body: { data: { valid: false, country_code: 'US' } } };
48 if (phone === '+19999999999') return { body: { data: null } };
49 if (phone === '+18888888888') return { body: { data: {} } };
50 if (phone === '12') return { status: 422, body: { errors: { message: 'The phone must be a valid number' } } };
51 if (phone === '+17777777777') return { raw: '<html>Bad gateway</html>' };
52 if (req.query.country_code === 'FR') return { body: { data: FRENCH } };
53 return { body: { data: VALID } };
54};
55
56const servers: MockServer[] = [];
57const dirs: string[] = [];
58
59async function mock(handler: MockHandler = tomba): Promise<MockServer> {
60 const server = await startMockTomba(handler);
61 servers.push(server);
62 return server;
63}
64
65async function run(...args: Parameters<typeof runActor>) {
66 const result = await runActor(...args);
67 dirs.push(result.storageDir);
68 return result;
69}
70
71afterEach(async () => {
72 await Promise.all(servers.splice(0).map(async (s) => s.close()));
73});
74
75after(async () => {
76 await Promise.all(dirs.map(removeStorage));
77});
78
79describe('phone-validator', () => {
80 it('validates a phone number and charges one event', async () => {
81 const server = await mock();
82 const result = await run({ input: { phoneNumbers: [{ phoneNumber: '+14155550132' }] }, endpoint: server.url });
83
84 assert.equal(result.code, 0, result.output);
85 assert.deepEqual(server.requests[0].query, { phone: '+14155550132' });
86 assert.deepEqual(result.items, [
87 {
88 phone_number: '+14155550132',
89 valid: true,
90 country_code: 'US',
91 location: 'California',
92 carrier: 'AT&T',
93 line_type: 'MOBILE',
94 national_format: '(415) 555-0132',
95 international_format: '+1 415-555-0132',
96 e164_format: '+14155550132',
97 rfc3966_format: 'tel:+1-415-555-0132',
98 timezone: ['America/Los_Angeles'],
99 source: 'tomba_phone_validator',
100 charged: true,
101 cached: false,
102 },
103 ]);
104 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
105 });
106
107 it('sends the country code for local numbers', async () => {
108 const server = await mock();
109 const result = await run({
110 input: { phoneNumbers: [{ phoneNumber: '06 12 34 56 78', countryCode: 'fr' }] },
111 endpoint: server.url,
112 });
113
114 assert.deepEqual(server.requests[0].query, { phone: '06 12 34 56 78', country_code: 'FR' });
115 assert.equal(result.items[0].input_country_code, 'FR');
116 assert.equal(result.items[0].international_format, '+33 6 12 34 56 78');
117 assert.equal(result.items[0].carrier, 'Orange');
118 });
119
120 it('charges an invalid number, because Tomba answered', async () => {
121 const server = await mock();
122 const result = await run({ input: { phoneNumbers: [{ phoneNumber: '+10000000000' }] }, endpoint: server.url });
123
124 assert.equal(result.items[0].valid, false);
125 assert.equal(result.items[0].country_code, 'US');
126 assert.equal(result.items[0].charged, true);
127 assert.equal(result.items[0].error, undefined);
128 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
129 });
130
131 it('does not charge empty or null data', async () => {
132 const server = await mock();
133 const result = await run({
134 input: { phoneNumbers: [{ phoneNumber: '+19999999999' }, { phoneNumber: '+18888888888' }] },
135 endpoint: server.url,
136 });
137
138 assert.equal(result.code, 0, result.output);
139 assert.equal(result.items.length, 2);
140 for (const item of result.items) {
141 assert.deepEqual(item, {
142 phone_number: item.phone_number,
143 valid: false,
144 source: 'tomba_phone_validator',
145 charged: false,
146 cached: false,
147 error: 'No validation data returned',
148 });
149 }
150 assert.equal(totalCharges(result), 0);
151 });
152
153 it('sends the built-in credentials to Tomba', async () => {
154 const server = await mock();
155 await run({ input: { phoneNumbers: [{ phoneNumber: '+14155550132' }] }, endpoint: server.url });
156 assert.equal(server.requests[0].headers['x-tomba-key'], 'ta_test_key');
157 assert.equal(server.requests[0].headers['x-tomba-secret'], 'ts_test_secret');
158 });
159
160 it('trims and deduplicates phone numbers', async () => {
161 const server = await mock();
162 const result = await run({
163 input: {
164 phoneNumbers: [
165 { phoneNumber: ' +1 (415) 555-0132 ' },
166 { phoneNumber: '+14155550132' },
167 { phoneNumber: '+1-415-555-0132' },
168
169 { phoneNumber: '+14155550132', countryCode: 'US' },
170 { phoneNumber: ' ' },
171 ],
172 },
173 endpoint: server.url,
174 });
175
176 assert.equal(result.code, 0, result.output);
177 assert.deepEqual(
178 server.requests.map((r) => r.query),
179 [{ phone: '+1 (415) 555-0132' }, { phone: '+14155550132', country_code: 'US' }],
180 );
181 assert.equal(result.items.length, 2);
182 });
183
184 it('does not charge Tomba error statuses and does not retry them', async () => {
185 const server = await mock();
186 const result = await run({ input: { phoneNumbers: [{ phoneNumber: '12' }] }, endpoint: server.url });
187 assert.equal(result.code, 0, result.output);
188 assert.equal(server.requests.length, 1);
189 assert.equal(result.items[0].charged, false);
190 assert.equal(result.items[0].valid, false);
191 assert.match(String(result.items[0].error), /422: The phone must be a valid number/);
192 assert.equal(totalCharges(result), 0);
193 });
194
195 it('does not charge a non-JSON body', async () => {
196 const server = await mock();
197 const result = await run({ input: { phoneNumbers: [{ phoneNumber: '+17777777777' }] }, endpoint: server.url });
198 assert.equal(result.items[0].charged, false);
199 assert.match(String(result.items[0].error), /Invalid response/);
200 assert.equal(totalCharges(result), 0);
201 });
202
203 it('retries 429 and 5xx responses, then charges the success once', async () => {
204 let calls = 0;
205 const server = await mock(async (req) => {
206 calls++;
207 if (calls === 1)
208 return {
209 status: 429,
210 body: { errors: { message: 'Too many requests' } },
211 headers: { 'retry-after': '1' },
212 };
213 if (calls === 2) return { status: 500, body: {} };
214 return tomba(req);
215 });
216 const result = await run({
217 input: { phoneNumbers: [{ phoneNumber: '+14155550132' }], maxRetries: 3 },
218 endpoint: server.url,
219 });
220 assert.equal(server.requests.length, 3);
221 assert.equal(result.items.length, 1);
222 assert.equal(result.items[0].charged, true);
223 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
224 });
225
226 it('serves repeated runs from the cache for free', async () => {
227 const server = await mock();
228 const input = { phoneNumbers: [{ phoneNumber: '+14155550132' }] };
229 const first = await run({ input, endpoint: server.url });
230 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir });
231
232 assert.equal(server.requests.length, 1);
233 assert.equal(second.items.length, 1);
234 assert.equal(second.items[0].cached, true);
235 assert.equal(second.items[0].charged, false);
236 assert.equal(second.items[0].international_format, '+1 415-555-0132');
237 assert.equal(totalCharges(second), 0);
238 });
239
240 it('calls Tomba again when the cache is disabled', async () => {
241 const server = await mock();
242 const input = { phoneNumbers: [{ phoneNumber: '+14155550132' }], useCache: false };
243 const first = await run({ input, endpoint: server.url });
244 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir });
245 assert.equal(server.requests.length, 2);
246 assert.equal(second.items[0].cached, false);
247 assert.deepEqual(second.chargeCounts, { 'tomba-request': 1 });
248 });
249
250 it('stops at the max charge limit and resumes without reprocessing', async () => {
251 const server = await mock();
252 const numbers = ['+14155550101', '+14155550102', '+14155550103', '+14155550104', '+14155550105'];
253 const input = {
254 phoneNumbers: numbers.map((phoneNumber) => ({ phoneNumber })),
255 maxConcurrency: 1,
256 useCache: false,
257 maxResults: 100,
258 };
259
260
261 const first = await run({ input, endpoint: server.url, maxTotalChargeUsd: 2 });
262 assert.equal(first.code, 0, first.output);
263 assert.equal(totalCharges(first), 2);
264 assert.equal(server.requests.length, 2);
265
266 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir, keepStorage: true });
267 assert.equal(second.code, 0, second.output);
268 assert.deepEqual(
269 server.requests.map((r) => r.query.phone),
270 numbers,
271 );
272 assert.equal(second.items.length, 5);
273 });
274
275 it('respects maxResults', async () => {
276 const server = await mock();
277 const result = await run({
278 input: {
279 phoneNumbers: [{ phoneNumber: '+14155550101' }, { phoneNumber: '+14155550102' }],
280 maxResults: 1,
281 maxConcurrency: 1,
282 },
283 endpoint: server.url,
284 });
285 assert.equal(result.items.length, 1);
286 assert.equal(server.requests.length, 1);
287 });
288
289 it('runs requests in parallel', async () => {
290 let active = 0;
291 let peak = 0;
292 const server = await mock(async (req) => {
293 active++;
294 peak = Math.max(peak, active);
295 await new Promise((r) => {
296 setTimeout(r, 100);
297 });
298 active--;
299 return tomba(req);
300 });
301 const phoneNumbers = Array.from({ length: 8 }, (_, i) => ({ phoneNumber: `+1415555020${i}` }));
302 await run({ input: { phoneNumbers, maxConcurrency: 4 }, endpoint: server.url });
303 assert.equal(server.requests.length, 8);
304 assert.ok(peak > 1 && peak <= 4, `peak concurrency ${peak}`);
305 });
306
307 it('fails without Tomba credentials and never calls the API', async () => {
308 const server = await mock();
309 const result = await run({
310 input: { phoneNumbers: [{ phoneNumber: '+14155550132' }] },
311 endpoint: server.url,
312 withCredentials: false,
313 });
314 assert.notEqual(result.code, 0);
315 assert.match(result.output, /misconfigured/);
316 assert.doesNotMatch(result.output, /ta_test_key|ts_test_secret/);
317 assert.equal(server.requests.length, 0);
318 });
319
320 it('fails on empty input', async () => {
321 const server = await mock();
322 const result = await run({ input: { phoneNumbers: [] }, endpoint: server.url });
323 assert.notEqual(result.code, 0);
324 assert.equal(server.requests.length, 0);
325 });
326
327 it('fails when phoneNumbers is missing', async () => {
328 const server = await mock();
329 const result = await run({ input: {}, endpoint: server.url });
330 assert.notEqual(result.code, 0);
331 assert.equal(server.requests.length, 0);
332 });
333});
334
335describe('phone-validator standby (real-time API)', () => {
336 it('answers the readiness probe and a bare GET with usage info', async () => {
337 const server = await mock();
338 const actor = await startStandbyActor({ endpoint: server.url });
339 try {
340 const probe = await actor.call('/', { headers: { 'x-apify-container-server-readiness-probe': '1' } });
341 assert.equal(probe.status, 200);
342 const usage = await actor.call('/');
343 assert.equal(usage.status, 200);
344 assert.match(String(usage.body.usage), /GET/);
345 assert.equal(server.requests.length, 0);
346 } finally {
347 await actor.stop();
348 }
349 });
350
351 it('validates numbers from GET query parameters and charges per billable number', async () => {
352 const server = await mock();
353 const actor = await startStandbyActor({ endpoint: server.url });
354 let stopped;
355 try {
356
357 const res = await actor.call('/?phone=+14155550132&phone=%2B19999999999');
358 assert.equal(res.status, 200);
359 const items = res.body.items as Record<string, unknown>[];
360 assert.equal(items.find((i) => i.phone_number === '+14155550132')?.valid, true);
361 assert.equal(items.find((i) => i.phone_number === '+19999999999')?.error, 'No validation data returned');
362 assert.deepEqual(server.requests.map((r) => r.query.phone).sort(), ['+14155550132', '+19999999999']);
363 } finally {
364 stopped = await actor.stop();
365 }
366 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 1 });
367 });
368
369 it('applies countryCode (or country_code) to every number in a GET request', async () => {
370 const server = await mock();
371 const actor = await startStandbyActor({ endpoint: server.url });
372 try {
373 const res = await actor.call('/?phone=0612345678,0698765432&country_code=fr');
374 assert.equal(res.status, 200);
375 const items = res.body.items as Record<string, unknown>[];
376 assert.equal(items.length, 2);
377 assert.ok(items.every((i) => i.input_country_code === 'FR' && i.country_code === 'FR'));
378 assert.ok(server.requests.every((r) => r.query.country_code === 'FR'));
379 const camel = await actor.call('/?phone=0611111111&countryCode=FR');
380 assert.equal((camel.body.items as Record<string, unknown>[])[0].input_country_code, 'FR');
381 } finally {
382 await actor.stop();
383 }
384 });
385
386 it('accepts a POST with the same JSON input as a normal run', async () => {
387 const server = await mock();
388 const actor = await startStandbyActor({ endpoint: server.url });
389 try {
390 const res = await actor.call('/', {
391 body: {
392 phoneNumbers: [{ phoneNumber: '+14155550132' }, { phoneNumber: '0612345678', countryCode: 'FR' }],
393 },
394 });
395 assert.equal(res.status, 200);
396 assert.equal((res.body.items as unknown[]).length, 2);
397 } finally {
398 await actor.stop();
399 }
400 });
401
402 it('serves repeated requests from the cache for free', async () => {
403 const server = await mock();
404 const actor = await startStandbyActor({ endpoint: server.url });
405 let stopped;
406 try {
407 await actor.call('/?phone=%2B14155550132');
408 const second = await actor.call('/?phone=%2B14155550132');
409 assert.ok((second.body.items as Record<string, unknown>[]).every((i) => i.cached === true));
410 assert.equal(server.requests.length, 1);
411 } finally {
412 stopped = await actor.stop();
413 }
414 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 1 });
415 });
416
417 it('keeps serving after a request hits maxResults', async () => {
418 const server = await mock();
419 const actor = await startStandbyActor({ endpoint: server.url });
420 try {
421 const first = await actor.call('/?phone=%2B14155550001&maxResults=1');
422 assert.equal((first.body.items as unknown[]).length, 1);
423 const second = await actor.call('/?phone=%2B14155550002,%2B14155550003');
424 assert.equal(second.status, 200);
425 assert.equal((second.body.items as unknown[]).length, 2);
426 } finally {
427 await actor.stop();
428 }
429 });
430
431 it('rejects invalid input with 400 and unknown paths with 404', async () => {
432 const server = await mock();
433 const actor = await startStandbyActor({ endpoint: server.url });
434 try {
435 assert.equal((await actor.call('/', { body: {} })).status, 400);
436 assert.equal((await actor.call('/', { body: 'not json' })).status, 400);
437 assert.equal((await actor.call('/?countryCode=US')).status, 400);
438 assert.equal((await actor.call('/?phone=%2B14155550132&maxResults=abc')).status, 400);
439 assert.equal((await actor.call('/nope')).status, 404);
440 assert.equal((await actor.call('/', { method: 'DELETE' })).status, 405);
441 assert.equal(server.requests.length, 0);
442 } finally {
443 await actor.stop();
444 }
445 });
446
447 it('returns 402 once the max charge limit is reached', async () => {
448 const server = await mock();
449 const actor = await startStandbyActor({ endpoint: server.url, maxTotalChargeUsd: 1 });
450 try {
451 const first = await actor.call('/?phone=%2B14155550001');
452 assert.equal(first.status, 200);
453 const second = await actor.call('/?phone=%2B14155550002');
454 assert.equal(second.status, 402);
455 assert.equal(server.requests.length, 1);
456 } finally {
457 await actor.stop();
458 }
459 });
460});
461
462describe('input schema', () => {
463 it('accepts lowercase country codes', () => {
464 assert.deepEqual(
465 inputSchemaErrors({ phoneNumbers: [{ phoneNumber: '+44 20 7946 0958', countryCode: 'gb' }] }),
466 [],
467 );
468 });
469});