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
15function organization(domain: string) {
16 return {
17 website_url: domain,
18 organization: 'Stripe',
19 location: {
20 country: 'US',
21 city: 'San Francisco',
22 state: 'California',
23 street_address: '354 Oyster Point Blvd',
24 postal_code: '94080',
25 },
26 social_links: {
27 twitter_url: 'https://twitter.com/stripe',
28 facebook_url: null,
29 linkedin_url: 'https://www.linkedin.com/company/stripe',
30 },
31 phone_number: '+1 415 298 5539',
32 industries: 'Information Technology and Services',
33 founded: '2010',
34 company_size: '5K-10K',
35 company_type: 'privately held',
36 revenue: '$100M-$250M',
37 accept_all: false,
38 description: 'Stripe is a payment processing platform.',
39 pattern: '{first}',
40 disposable: false,
41 webmail: false,
42 };
43}
44
45const PHONE = { number: '+14155550123', type: 'mobile' };
46
47function email(domain: string, n: number, enrichMobile = false) {
48 return {
49 email: `person${n}@${domain}`,
50 first_name: `First${n}`,
51 last_name: `Last${n}`,
52 full_name: `First${n} Last${n}`,
53 gender: 'female',
54
55 phone_number: domain === 'phones.com' && n % 3 === 0,
56
57 ...(enrichMobile ? { phone_data: n % 3 === 0 ? [PHONE] : [] } : {}),
58 type: 'personal',
59 country: 'US',
60 position: 'Financial Crimes Analyst',
61 department: 'finance',
62 seniority: 'senior',
63 twitter: null,
64 linkedin: `https://www.linkedin.com/in/person${n}`,
65 accept_all: false,
66 pattern: '{first}',
67 score: 99,
68 verification: { date: '2025-09-13T00:00:00+02:00', status: 'valid' },
69 last_updated_at: '2025-09-13T00:00:00+02:00',
70 sources: [
71 {
72 uri: `https://${domain}/about`,
73 website_url: domain,
74 extracted_on: '2024-01-01T00:00:00+02:00',
75 last_seen_on: '2025-09-01T00:00:00+02:00',
76 still_on_page: true,
77 },
78 ],
79 };
80}
81
82
83const EMAIL_COUNTS: Record<string, number> = { 'exact.com': 20, 'nometa.com': 15, 'nomail.com': 0 };
84
85
86function searchPage(query: Record<string, string>, withMeta = true) {
87
88 const domain =
89 query.domain ??
90 `${String(query.company)
91 .toLowerCase()
92 .replace(/[^a-z0-9]/g, '')}.com`;
93 const enrichMobile = query.enrich_mobile === 'true';
94 const total = EMAIL_COUNTS[domain] ?? 25;
95 const pageSize = Number(query.limit ?? 10);
96 const page = Number(query.page ?? 1);
97 const first = (page - 1) * pageSize;
98 const emails = Array.from({ length: Math.max(0, Math.min(pageSize, total - first)) }, (_, i) =>
99 email(domain, first + i + 1, enrichMobile),
100 );
101 return {
102 data: { organization: organization(domain), emails },
103 ...(withMeta
104 ? {
105 meta: {
106 total,
107 pageSize,
108 current: page,
109 total_pages: Math.ceil(total / pageSize),
110 params: { domain, page, limit: pageSize },
111 },
112 }
113 : {}),
114 };
115}
116
117
118const tomba: MockHandler = (req) => {
119 assert.equal(req.method, 'GET');
120 assert.equal(req.path, '/domain-search');
121 const { domain, page } = req.query;
122 if (domain === 'empty.com') return { body: { data: null } };
123 if (domain === 'invalid.com') return { status: 422, body: { errors: { message: 'Invalid domain' } } };
124 if (domain === 'html.com') return { raw: '<html>Bad gateway</html>' };
125 if (domain === 'brokenpage.com' && page === '2') return { status: 422, body: { errors: { message: 'Bad page' } } };
126 return { body: searchPage(req.query, domain !== 'nometa.com') };
127};
128
129const servers: MockServer[] = [];
130const dirs: string[] = [];
131
132async function mock(handler: MockHandler = tomba): Promise<MockServer> {
133 const server = await startMockTomba(handler);
134 servers.push(server);
135 return server;
136}
137
138async function run(...args: Parameters<typeof runActor>) {
139 const result = await runActor(...args);
140 dirs.push(result.storageDir);
141 return result;
142}
143
144const pagesRequested = (server: MockServer) => server.requests.map((r) => `${r.query.domain}#${r.query.page}`);
145
146afterEach(async () => {
147 await Promise.all(servers.splice(0).map(async (s) => s.close()));
148});
149
150after(async () => {
151 await Promise.all(dirs.map(removeStorage));
152});
153
154describe('domain-search', () => {
155 it('returns one item per domain and charges one event per billable page', async () => {
156 const server = await mock();
157 const result = await run({ input: { domains: ['stripe.com'] }, endpoint: server.url });
158
159 assert.equal(result.code, 0, result.output);
160 assert.equal(result.items.length, 1);
161 const expected = searchPage({ domain: 'stripe.com', page: '1', limit: '10' });
162 assert.deepEqual(result.items[0], {
163 domain: 'stripe.com',
164 organization: expected.data.organization,
165 emails: expected.data.emails,
166 phoneNumbers: 0,
167 meta: expected.meta,
168 pages: 1,
169 chargedRequests: 1,
170 chargedCredits: 1,
171 charged: true,
172 cached: false,
173 });
174 assert.deepEqual(server.requests[0].query, { domain: 'stripe.com', page: '1', limit: '10' });
175 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
176 });
177
178 describe('pricing: 1 credit per 10 results requested, +5 per address with phone data', () => {
179 for (const [limit, credits] of [
180 ['10', 1],
181 ['20', 2],
182 ['50', 5],
183 ] as const) {
184 it(`limit ${limit} costs ${credits} credit(s) per page`, async () => {
185 const server = await mock();
186 const result = await run({
187 input: { domains: ['stripe.com'], limit, maxEmailsPerDomain: Number(limit) },
188 endpoint: server.url,
189 });
190 assert.equal(server.requests.length, 1);
191 assert.equal(result.items[0].chargedCredits, credits);
192 assert.deepEqual(result.chargeCounts, { 'tomba-request': credits });
193 });
194 }
195
196 it('does not charge extra for the phone_number flag alone', async () => {
197 const server = await mock();
198 const result = await run({
199 input: { domains: ['phones.com'], limit: '10', maxEmailsPerDomain: 10 },
200 endpoint: server.url,
201 });
202 const emails = result.items[0].emails as { phone_number: boolean; phone_data?: unknown }[];
203 assert.equal(emails.filter((e) => e.phone_number).length, 3);
204 assert.ok(emails.every((e) => e.phone_data === undefined));
205 assert.equal(result.items[0].phoneNumbers, 0);
206 assert.equal(result.items[0].chargedCredits, 1);
207 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
208 });
209
210 it('with enrichMobile adds 5 credits per returned address with phone data', async () => {
211 const server = await mock();
212 const result = await run({
213 input: { domains: ['phones.com'], limit: '10', maxEmailsPerDomain: 10, enrichMobile: true },
214 endpoint: server.url,
215 });
216
217 const emails = result.items[0].emails as { phone_data: unknown[] }[];
218 assert.equal(emails.filter((e) => e.phone_data.length > 0).length, 3);
219 assert.equal(result.items[0].chargedCredits, 16);
220 assert.deepEqual(result.chargeCounts, { 'tomba-request': 16 });
221 });
222
223 it('charges the phone data of the whole returned page, even when fewer addresses are kept', async () => {
224 const server = await mock();
225 const result = await run({
226 input: { domains: ['phones.com'], limit: '20', maxEmailsPerDomain: 2, enrichMobile: true },
227 endpoint: server.url,
228 });
229
230 assert.equal(result.items[0].chargedCredits, 32);
231 assert.equal(result.items[0].phoneNumbers, 0);
232 });
233
234 it('charges every page by its own results', async () => {
235 const server = await mock();
236 const result = await run({
237 input: { domains: ['phones.com'], limit: '10', maxEmailsPerDomain: 25, enrichMobile: true },
238 endpoint: server.url,
239 });
240
241 assert.equal(server.requests.length, 3);
242 assert.equal(result.items[0].chargedCredits, 43);
243 assert.deepEqual(result.chargeCounts, { 'tomba-request': 43 });
244 });
245
246 it('charges the base credits for a company with no addresses', async () => {
247 const server = await mock();
248 const result = await run({ input: { domains: ['nomail.com'], limit: '50' }, endpoint: server.url });
249 assert.equal(result.items[0].chargedCredits, 5);
250 assert.deepEqual(result.chargeCounts, { 'tomba-request': 5 });
251 });
252
253 it('charges nothing for cached pages', async () => {
254 const server = await mock();
255 const input = { domains: ['phones.com'], limit: '10', maxEmailsPerDomain: 10, enrichMobile: true };
256 const first = await run({ input, endpoint: server.url });
257 assert.equal(first.items[0].chargedCredits, 16);
258 const second = await run({
259 input,
260 endpoint: server.url,
261 storageDir: first.storageDir,
262 });
263 assert.equal(second.items[0].chargedCredits, 0);
264 assert.equal(second.items[0].cached, true);
265 assert.deepEqual(second.chargeCounts, {});
266 });
267 });
268
269 it('sends the built-in credentials to Tomba', async () => {
270 const server = await mock();
271 await run({ input: { domains: ['stripe.com'] }, endpoint: server.url });
272 assert.equal(server.requests[0].headers['x-tomba-key'], 'ta_test_key');
273 assert.equal(server.requests[0].headers['x-tomba-secret'], 'ts_test_secret');
274 });
275
276 it('normalizes and deduplicates domains', async () => {
277 const server = await mock();
278 await run({
279 input: { domains: ['https://www.Stripe.com/jobs', 'stripe.com', 'STRIPE.COM '] },
280 endpoint: server.url,
281 });
282 assert.deepEqual(
283 server.requests.map((r) => r.query.domain),
284 ['stripe.com'],
285 );
286 });
287
288 it('passes the filters and the start page to Tomba', async () => {
289 const server = await mock();
290 await run({
291 input: { domains: ['stripe.com'], page: 2, limit: '20', department: 'finance', country: 'US' },
292 endpoint: server.url,
293 });
294 assert.deepEqual(server.requests[0].query, {
295 domain: 'stripe.com',
296 page: '2',
297 limit: '20',
298 department: 'finance',
299 country: 'US',
300 });
301 });
302
303 it('sends enrich_mobile and webhook_url as query parameters', async () => {
304 const server = await mock();
305 await run({
306 input: { domains: ['stripe.com'], enrichMobile: true, webhookUrl: 'https://example.com/hook' },
307 endpoint: server.url,
308 });
309 assert.deepEqual(server.requests[0].query, {
310 domain: 'stripe.com',
311 page: '1',
312 limit: '10',
313 enrich_mobile: 'true',
314 webhook_url: 'https://example.com/hook',
315 });
316 });
317
318 it('does not send enrich_mobile when enrichMobile is off', async () => {
319 const server = await mock();
320 await run({ input: { domains: ['stripe.com'], enrichMobile: false }, endpoint: server.url });
321 assert.equal('enrich_mobile' in server.requests[0].query, false);
322 assert.equal('webhook_url' in server.requests[0].query, false);
323 });
324
325 it('fails on a webhook URL that is not http(s) without calling Tomba', async () => {
326 const server = await mock();
327 const result = await run({
328 input: { domains: ['stripe.com'], webhookUrl: 'ftp://example.com' },
329 endpoint: server.url,
330 });
331 assert.notEqual(result.code, 0);
332 assert.equal(server.requests.length, 0);
333 });
334
335 it('reports the phone numbers returned with enrichMobile', async () => {
336 const server = await mock();
337 const result = await run({
338 input: { domains: ['phones.com'], maxEmailsPerDomain: 10, enrichMobile: true },
339 endpoint: server.url,
340 });
341 const emails = result.items[0].emails as { email: string; phone_data: unknown[] }[];
342 assert.deepEqual(emails[2].phone_data, [{ number: '+14155550123', type: 'mobile' }]);
343 assert.deepEqual(emails[0].phone_data, []);
344 assert.equal(result.items[0].phoneNumbers, 3);
345 });
346
347 it('searches company names with company instead of domain and deduplicates them', async () => {
348 const server = await mock();
349 const result = await run({
350 input: { companies: ['Stripe', ' stripe ', 'Acme Corp', ''] },
351 endpoint: server.url,
352 });
353 assert.equal(result.code, 0, result.output);
354 assert.deepEqual(
355 server.requests.map((r) => r.query),
356 [
357 { company: 'Stripe', page: '1', limit: '10' },
358 { company: 'Acme Corp', page: '1', limit: '10' },
359 ],
360 );
361 const byCompany = Object.fromEntries(result.items.map((i) => [i.company, i]));
362 assert.equal(byCompany.Stripe.domain, 'stripe.com');
363 assert.equal((byCompany.Stripe.emails as unknown[]).length, 10);
364 assert.equal(byCompany.Stripe.chargedCredits, 1);
365 assert.equal(byCompany['Acme Corp'].domain, 'acmecorp.com');
366 assert.deepEqual(result.chargeCounts, { 'tomba-request': 2 });
367 });
368
369 it('omits domain for a company search when Tomba does not report one', async () => {
370 const server = await mock(() => ({ body: { data: { organization: { organization: 'X' }, emails: [] } } }));
371 const result = await run({ input: { companies: ['Nowhere'] }, endpoint: server.url });
372 assert.equal(result.items[0].company, 'Nowhere');
373 assert.equal('domain' in result.items[0], false);
374 });
375
376 it('saves a company search without results with the company name', async () => {
377 const server = await mock(() => ({ body: { data: null } }));
378 const result = await run({ input: { companies: ['Unknown Co'] }, endpoint: server.url });
379 assert.deepEqual(result.items, [
380 { company: 'Unknown Co', charged: false, cached: false, error: 'No results found' },
381 ]);
382 assert.equal(totalCharges(result), 0);
383 });
384
385 it('searches domains and companies together and resumes them separately', async () => {
386 const server = await mock();
387 const input = { domains: ['stripe.com'], companies: ['Stripe'], useCache: false };
388 const first = await run({ input, endpoint: server.url });
389 assert.equal(first.code, 0, first.output);
390 assert.deepEqual(
391 server.requests.map((r) => r.query.domain ?? `company:${r.query.company}`),
392 ['stripe.com', 'company:Stripe'],
393 );
394 assert.equal(first.items.length, 2);
395 assert.deepEqual(first.chargeCounts, { 'tomba-request': 2 });
396
397
398 await run({ input, endpoint: server.url, storageDir: first.storageDir, keepStorage: true });
399 assert.equal(server.requests.length, 2);
400 });
401
402 it('caches domain and company searches separately', async () => {
403 const server = await mock();
404 const first = await run({ input: { domains: ['stripe.com'] }, endpoint: server.url });
405 const second = await run({
406 input: { companies: ['Stripe'] },
407 endpoint: server.url,
408 storageDir: first.storageDir,
409 });
410 assert.equal(server.requests.length, 2);
411 assert.equal(second.items[0].cached, false);
412 assert.equal(second.items[0].charged, true);
413 });
414
415 it('fails without domains or companies and never calls the API', async () => {
416 const server = await mock();
417 const result = await run({ input: { domains: [], companies: [' '] }, endpoint: server.url });
418 assert.notEqual(result.code, 0);
419 assert.match(result.output, /domains.*companies/);
420 assert.equal(server.requests.length, 0);
421 });
422
423 it('pages until maxEmailsPerDomain is reached, one request and one charge per page', async () => {
424 const server = await mock();
425 const result = await run({
426 input: { domains: ['stripe.com'], maxEmailsPerDomain: 15, limit: '10' },
427 endpoint: server.url,
428 });
429 assert.deepEqual(pagesRequested(server), ['stripe.com#1', 'stripe.com#2']);
430 const item = result.items[0];
431 assert.equal((item.emails as unknown[]).length, 15);
432 assert.equal((item.emails as { email: string }[])[14].email, 'person15@stripe.com');
433 assert.equal(item.pages, 2);
434 assert.equal(item.chargedRequests, 2);
435 assert.equal(item.charged, true);
436 assert.deepEqual(result.chargeCounts, { 'tomba-request': 2 });
437 });
438
439 it('stops paging at the last page reported by Tomba', async () => {
440 const server = await mock();
441 const result = await run({
442 input: { domains: ['exact.com'], maxEmailsPerDomain: 100, limit: '10' },
443 endpoint: server.url,
444 });
445
446 assert.deepEqual(pagesRequested(server), ['exact.com#1', 'exact.com#2']);
447 assert.equal((result.items[0].emails as unknown[]).length, 20);
448 assert.equal(totalCharges(result), 2);
449 });
450
451 it('stops paging at a short page', async () => {
452 const server = await mock();
453 const result = await run({
454 input: { domains: ['nometa.com'], maxEmailsPerDomain: 100, limit: '10' },
455 endpoint: server.url,
456 });
457 assert.deepEqual(pagesRequested(server), ['nometa.com#1', 'nometa.com#2']);
458 assert.equal((result.items[0].emails as unknown[]).length, 15);
459 assert.equal(result.items[0].pages, 2);
460 assert.equal(totalCharges(result), 2);
461 });
462
463 it('keeps the pages it already paid for when a later page fails', async () => {
464 const server = await mock();
465 const result = await run({
466 input: { domains: ['brokenpage.com'], maxEmailsPerDomain: 30, limit: '10' },
467 endpoint: server.url,
468 });
469 assert.deepEqual(pagesRequested(server), ['brokenpage.com#1', 'brokenpage.com#2']);
470 assert.equal((result.items[0].emails as unknown[]).length, 10);
471 assert.equal(result.items[0].pages, 1);
472 assert.equal(result.items[0].charged, true);
473 assert.equal(totalCharges(result), 1);
474 });
475
476 it('charges an organization with zero emails as a negative answer', async () => {
477 const server = await mock();
478 const result = await run({ input: { domains: ['nomail.com'], maxEmailsPerDomain: 50 }, endpoint: server.url });
479 assert.equal(server.requests.length, 1);
480 const item = result.items[0];
481 assert.deepEqual(item.emails, []);
482 assert.equal((item.organization as { organization: string }).organization, 'Stripe');
483 assert.equal(item.charged, true);
484 assert.equal(item.error, undefined);
485 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
486 });
487
488 it('omits the organization when includeCompanyInfo is false', async () => {
489 const server = await mock();
490 const result = await run({
491 input: { domains: ['stripe.com'], includeCompanyInfo: false },
492 endpoint: server.url,
493 });
494 assert.equal('organization' in result.items[0], false);
495 assert.equal((result.items[0].emails as unknown[]).length, 10);
496 });
497
498 it('does not charge empty data', async () => {
499 const server = await mock();
500 const result = await run({ input: { domains: ['empty.com'] }, endpoint: server.url });
501 assert.equal(result.code, 0, result.output);
502 assert.deepEqual(result.items, [
503 { domain: 'empty.com', charged: false, cached: false, error: 'No results found' },
504 ]);
505 assert.equal(totalCharges(result), 0);
506 });
507
508 it('does not charge Tomba error statuses and does not retry them', async () => {
509 const server = await mock();
510 const result = await run({ input: { domains: ['invalid.com'] }, endpoint: server.url });
511 assert.equal(result.code, 0, result.output);
512 assert.equal(server.requests.length, 1);
513 assert.equal(result.items[0].charged, false);
514 assert.match(String(result.items[0].error), /422: Invalid domain/);
515 assert.equal(totalCharges(result), 0);
516 });
517
518 it('does not charge a non-JSON body', async () => {
519 const server = await mock();
520 const result = await run({ input: { domains: ['html.com'] }, endpoint: server.url });
521 assert.equal(result.items[0].charged, false);
522 assert.match(String(result.items[0].error), /Invalid response/);
523 assert.equal(totalCharges(result), 0);
524 });
525
526 it('retries 429 and 5xx responses, then charges the success once', async () => {
527 let calls = 0;
528 const server = await mock(async (req) => {
529 calls++;
530 if (calls === 1)
531 return {
532 status: 429,
533 body: { errors: { message: 'Too many requests' } },
534 headers: { 'retry-after': '1' },
535 };
536 if (calls === 2) return { status: 503, body: {} };
537 return tomba(req);
538 });
539 const result = await run({ input: { domains: ['stripe.com'], maxRetries: 3 }, endpoint: server.url });
540 assert.equal(server.requests.length, 3);
541 assert.equal(result.items[0].charged, true);
542 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
543 });
544
545 it('serves repeated runs from the cache for free', async () => {
546 const server = await mock();
547 const input = { domains: ['stripe.com'], maxEmailsPerDomain: 20 };
548 const first = await run({ input, endpoint: server.url });
549 assert.equal(totalCharges(first), 2);
550 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir });
551
552 assert.equal(server.requests.length, 2);
553 assert.equal(second.items.length, 1);
554 assert.equal((second.items[0].emails as unknown[]).length, 20);
555 assert.equal(second.items[0].cached, true);
556 assert.equal(second.items[0].charged, false);
557 assert.equal(second.items[0].chargedRequests, 0);
558 assert.equal(totalCharges(second), 0);
559 });
560
561 it('calls Tomba again when the cache is disabled', async () => {
562 const server = await mock();
563 const first = await run({ input: { domains: ['stripe.com'], useCache: false }, endpoint: server.url });
564 await run({
565 input: { domains: ['stripe.com'], useCache: false },
566 endpoint: server.url,
567 storageDir: first.storageDir,
568 });
569 assert.equal(server.requests.length, 2);
570 });
571
572 it('stops at the max charge limit and resumes without reprocessing', async () => {
573 const server = await mock();
574 const domains = ['a.com', 'b.com', 'c.com', 'd.com', 'e.com'];
575 const input = { domains, maxConcurrency: 1, useCache: false };
576
577
578 const first = await run({ input, endpoint: server.url, maxTotalChargeUsd: 2 });
579 assert.equal(first.code, 0, first.output);
580 assert.equal(totalCharges(first), 2);
581 assert.equal(server.requests.length, 2);
582 assert.deepEqual(
583 first.items.map((i) => i.domain),
584 ['a.com', 'b.com'],
585 );
586
587 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir, keepStorage: true });
588 assert.equal(second.code, 0, second.output);
589
590 assert.equal(totalCharges(second), 5);
591 assert.deepEqual(
592 server.requests.map((r) => r.query.domain),
593 domains,
594 );
595 });
596
597 it('stops paging a domain when the charge limit is reached mid-domain', async () => {
598 const server = await mock();
599 const result = await run({
600 input: { domains: ['stripe.com'], maxEmailsPerDomain: 30, useCache: false },
601 endpoint: server.url,
602 maxTotalChargeUsd: 2,
603 });
604 assert.equal(result.code, 0, result.output);
605 assert.deepEqual(pagesRequested(server), ['stripe.com#1', 'stripe.com#2']);
606 assert.equal((result.items[0].emails as unknown[]).length, 20);
607 assert.equal(totalCharges(result), 2);
608 });
609
610 it('runs domains in parallel', async () => {
611 let active = 0;
612 let peak = 0;
613 const server = await mock(async (req) => {
614 active++;
615 peak = Math.max(peak, active);
616 await new Promise((r) => {
617 setTimeout(r, 100);
618 });
619 active--;
620 return tomba(req);
621 });
622 const domains = Array.from({ length: 8 }, (_, i) => `site${i}.com`);
623 await run({ input: { domains, maxConcurrency: 4 }, endpoint: server.url });
624 assert.equal(server.requests.length, 8);
625 assert.ok(peak > 1 && peak <= 4, `peak concurrency ${peak}`);
626 });
627
628 it('fails without Tomba credentials and never calls the API', async () => {
629 const server = await mock();
630 const result = await run({ input: { domains: ['stripe.com'] }, endpoint: server.url, withCredentials: false });
631 assert.notEqual(result.code, 0);
632 assert.match(result.output, /misconfigured/);
633 assert.doesNotMatch(result.output, /ta_test_key|ts_test_secret/);
634 assert.equal(server.requests.length, 0);
635 });
636
637 it('fails on empty input', async () => {
638 const server = await mock();
639 const result = await run({ input: { domains: [] }, endpoint: server.url });
640 assert.notEqual(result.code, 0);
641 assert.equal(server.requests.length, 0);
642 });
643});
644
645describe('domain-search standby (real-time API)', () => {
646 it('answers the readiness probe and a bare GET with usage info', async () => {
647 const server = await mock();
648 const actor = await startStandbyActor({ endpoint: server.url });
649 try {
650 const probe = await actor.call('/', { headers: { 'x-apify-container-server-readiness-probe': '1' } });
651 assert.equal(probe.status, 200);
652 const usage = await actor.call('/');
653 assert.equal(usage.status, 200);
654 assert.match(String(usage.body.usage), /GET/);
655 assert.equal(server.requests.length, 0);
656 } finally {
657 await actor.stop();
658 }
659 });
660
661 it('searches domains and companies from GET query parameters and charges the credits per page', async () => {
662 const server = await mock();
663 const actor = await startStandbyActor({ endpoint: server.url });
664 let stopped;
665 try {
666 const res = await actor.call(
667 '/?domain=https://www.Stripe.com/pricing&domain=empty.com&company=Acme&limit=20&maxEmailsPerDomain=30&department=finance&country=US&includeCompanyInfo=false',
668 );
669 assert.equal(res.status, 200);
670 const items = res.body.items as Record<string, unknown>[];
671 const stripe = items.find((i) => i.domain === 'stripe.com');
672
673 assert.equal((stripe?.emails as unknown[]).length, 25);
674 assert.equal(stripe?.pages, 2);
675 assert.equal(stripe?.chargedCredits, 4);
676 assert.equal(stripe?.organization, undefined);
677 const acme = items.find((i) => i.company === 'Acme');
678 assert.equal(acme?.domain, 'acme.com');
679 assert.equal(acme?.chargedCredits, 4);
680 assert.equal(items.find((i) => i.domain === 'empty.com')?.error, 'No results found');
681 const first = server.requests.find((r) => r.query.domain === 'stripe.com');
682 assert.equal(first?.query.department, 'finance');
683 assert.equal(first?.query.country, 'US');
684 assert.equal(first?.query.limit, '20');
685 } finally {
686 stopped = await actor.stop();
687 }
688 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 8 });
689 });
690
691 it('accepts a POST with the same JSON input as a normal run', async () => {
692 const server = await mock();
693 const actor = await startStandbyActor({ endpoint: server.url });
694 let stopped;
695 try {
696 const res = await actor.call('/', { body: { domains: ['phones.com'], enrichMobile: true } });
697 assert.equal(res.status, 200);
698 const [item] = res.body.items as Record<string, unknown>[];
699 assert.equal((item.emails as unknown[]).length, 10);
700
701 assert.equal(item.phoneNumbers, 3);
702 assert.equal(item.chargedCredits, 16);
703 assert.equal(server.requests[0].query.enrich_mobile, 'true');
704 } finally {
705 stopped = await actor.stop();
706 }
707 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 16 });
708 });
709
710 it('serves repeated requests from the cache for free', async () => {
711 const server = await mock();
712 const actor = await startStandbyActor({ endpoint: server.url });
713 let stopped;
714 try {
715 await actor.call('/?domain=stripe.com');
716 const second = await actor.call('/?domain=stripe.com');
717 assert.ok((second.body.items as Record<string, unknown>[]).every((i) => i.cached === true));
718 assert.equal(server.requests.length, 1);
719 } finally {
720 stopped = await actor.stop();
721 }
722 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 1 });
723 });
724
725 it('keeps serving after a request stops at maxEmailsPerDomain', async () => {
726 const server = await mock();
727 const actor = await startStandbyActor({ endpoint: server.url });
728 try {
729 const first = await actor.call('/?domain=a.com&maxEmailsPerDomain=5');
730 const [a] = first.body.items as Record<string, unknown>[];
731 assert.equal((a.emails as unknown[]).length, 5);
732 const second = await actor.call('/?domains=b.com,c.com&maxEmailsPerDomain=15');
733 const items = second.body.items as Record<string, unknown>[];
734 assert.deepEqual(
735 items.map((i) => (i.emails as unknown[]).length),
736 [15, 15],
737 );
738 } finally {
739 await actor.stop();
740 }
741 });
742
743 it('rejects invalid input with 400 and unknown paths with 404', async () => {
744 const server = await mock();
745 const actor = await startStandbyActor({ endpoint: server.url });
746 try {
747 assert.equal((await actor.call('/', { body: {} })).status, 400);
748 assert.equal((await actor.call('/', { body: 'not json' })).status, 400);
749 assert.equal((await actor.call('/?page=abc&domain=a.com')).status, 400);
750 assert.equal((await actor.call('/?enrichMobile=maybe&domain=a.com')).status, 400);
751 assert.equal((await actor.call('/?webhookUrl=ftp://x&domain=a.com')).status, 400);
752 assert.equal((await actor.call('/nope')).status, 404);
753 assert.equal((await actor.call('/', { method: 'DELETE' })).status, 405);
754 assert.equal(server.requests.length, 0);
755 } finally {
756 await actor.stop();
757 }
758 });
759
760 it('returns 402 once the max charge limit is reached', async () => {
761 const server = await mock();
762 const actor = await startStandbyActor({ endpoint: server.url, maxTotalChargeUsd: 1 });
763 try {
764 const first = await actor.call('/?domain=a.com');
765 assert.equal(first.status, 200);
766 const second = await actor.call('/?domain=b.com');
767 assert.equal(second.status, 402);
768 assert.equal(server.requests.length, 1);
769 } finally {
770 await actor.stop();
771 }
772 });
773});
774
775describe('input schema', () => {
776 it('accepts multi-part domains and lowercase country codes', () => {
777 assert.deepEqual(inputSchemaErrors({ domains: ['bbc.co.uk', 'https://blog.stripe.com/'], country: 'gb' }), []);
778 });
779});