1
2import assert from 'node:assert/strict';
3import { after, afterEach, describe, it } from 'node:test';
4
5import type { MockHandler, MockRequest, MockServer } from './helpers.js';
6import { removeStorage, runActor, startMockTomba, startStandbyActor, totalCharges } from './helpers.js';
7
8const PAGE_SIZE = 10;
9
10function company(n: number) {
11 return {
12 name: `Company ${n}`,
13 description: 'Leading provider of innovative software solutions',
14 country: 'US',
15 state: 'California',
16 street_address: '123 Market Street',
17 postal_code: '94103',
18 industry: 'Software',
19 company_size: '51-200',
20 type: 'privately held',
21 founded: '2015',
22 city: 'San Francisco',
23 website_url: `company${n}.com`,
24 total_emails: 150,
25 revenue: '$10M-$50M',
26 phone_number: '+1-415-555-0100',
27 linkedin_url: `https://www.linkedin.com/company/company${n}`,
28 facebook_url: `https://www.facebook.com/company${n}`,
29 twitter_url: `https://twitter.com/company${n}`,
30 total_similar: 25,
31 };
32}
33
34
35const TOTALS: Record<string, number> = { big: 60, exact: 20, none: 0, nopages: 15 };
36
37interface SearchRequest {
38 query?: string;
39 filters?: Record<string, unknown>;
40 _source?: string[];
41 page?: number;
42}
43
44
45function searchPage(params: SearchRequest) {
46 const query = params.query ?? 'filters';
47 const total = TOTALS[query] ?? 25;
48 const page = params.page ?? 1;
49 const first = (page - 1) * PAGE_SIZE;
50 const companies = Array.from({ length: Math.max(0, Math.min(PAGE_SIZE, total - first)) }, (_, i) =>
51 company(first + i + 1),
52 );
53 const pages = Math.ceil(total / PAGE_SIZE);
54 return {
55 success: true,
56 data: { companies },
57 meta: {
58 total,
59 page,
60 limit: PAGE_SIZE,
61 ...(query === 'nopages' ? {} : { pages }),
62 ...(params.filters ? { filters: params.filters } : {}),
63 },
64 };
65}
66
67const body = (req: MockRequest) => req.body as SearchRequest;
68
69
70const tomba: MockHandler = (req) => {
71 assert.equal(req.method, 'POST');
72 assert.equal(req.path, '/reveal/search');
73 const params = body(req);
74 if (params.query === 'empty') return { body: { data: null } };
75 if (params.query === 'invalid') return { status: 422, body: { errors: { message: 'Invalid query' } } };
76 if (params.query === 'html') return { raw: '<html>Bad gateway</html>' };
77 return { body: searchPage(params) };
78};
79
80const servers: MockServer[] = [];
81const dirs: string[] = [];
82
83async function mock(handler: MockHandler = tomba): Promise<MockServer> {
84 const server = await startMockTomba(handler);
85 servers.push(server);
86 return server;
87}
88
89async function run(...args: Parameters<typeof runActor>) {
90 const result = await runActor(...args);
91 dirs.push(result.storageDir);
92 return result;
93}
94
95const pagesRequested = (server: MockServer) => server.requests.map((r) => body(r).page);
96
97afterEach(async () => {
98 await Promise.all(servers.splice(0).map(async (s) => s.close()));
99});
100
101after(async () => {
102 await Promise.all(dirs.map(removeStorage));
103});
104
105describe('company-search', () => {
106 it('returns one item per company and charges one event per billable page', async () => {
107 const server = await mock();
108 const result = await run({ input: { query: 'tech', maxResults: 10 }, endpoint: server.url });
109
110 assert.equal(result.code, 0, result.output);
111 assert.equal(result.items.length, 10);
112 assert.deepEqual(result.items[0], {
113 ...company(1),
114 source: 'tomba_company_search',
115 charged: true,
116 cached: false,
117 });
118 assert.deepEqual(server.requests[0].body, { query: 'tech', page: 1 });
119 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
120 });
121
122 it('sends the built-in credentials to Tomba', async () => {
123 const server = await mock();
124 await run({ input: { query: 'tech', maxResults: 1 }, endpoint: server.url });
125 assert.equal(server.requests[0].headers['x-tomba-key'], 'ta_test_key');
126 assert.equal(server.requests[0].headers['x-tomba-secret'], 'ts_test_secret');
127 });
128
129 it('trims the query and drops empty filters and fields', async () => {
130 const server = await mock();
131 await run({
132 input: { query: ' tech ', filters: {}, source: [], maxResults: 1 },
133 endpoint: server.url,
134 });
135 assert.deepEqual(server.requests[0].body, { query: 'tech', page: 1 });
136 });
137
138 it('passes filters, fields and the start page to Tomba', async () => {
139 const server = await mock();
140 const filters = { location_country: { include: ['US'] }, size: { exclude: ['1-10'] } };
141 const result = await run({
142 input: { filters, source: ['name', 'website_url'], page: 2, maxResults: 5 },
143 endpoint: server.url,
144 });
145 assert.deepEqual(server.requests[0].body, { filters, _source: ['name', 'website_url'], page: 2 });
146 assert.equal(result.items[0].name, 'Company 11');
147 });
148
149 it('pages until maxResults is reached, one request and one charge per page', async () => {
150 const server = await mock();
151 const result = await run({ input: { query: 'tech', maxResults: 15 }, endpoint: server.url });
152 assert.deepEqual(pagesRequested(server), [1, 2]);
153 assert.equal(result.items.length, 15);
154 assert.equal(result.items[14].name, 'Company 15');
155 assert.deepEqual(result.chargeCounts, { 'tomba-request': 2 });
156 });
157
158 it('stops paging at the last page reported by Tomba', async () => {
159 const server = await mock();
160 const result = await run({ input: { query: 'exact', maxResults: 1000 }, endpoint: server.url });
161
162 assert.deepEqual(pagesRequested(server), [1, 2]);
163 assert.equal(result.items.length, 20);
164 assert.equal(totalCharges(result), 2);
165 });
166
167 it('stops paging at a short page', async () => {
168 const server = await mock();
169 const result = await run({ input: { query: 'nopages', maxResults: 1000 }, endpoint: server.url });
170 assert.deepEqual(pagesRequested(server), [1, 2]);
171 assert.equal(result.items.length, 15);
172 assert.equal(totalCharges(result), 2);
173 });
174
175 it('keeps the companies it already paid for when a later page fails', async () => {
176 const server = await mock(async (req) =>
177 body(req).page === 2 ? { status: 422, body: { errors: { message: 'Bad page' } } } : tomba(req),
178 );
179 const result = await run({ input: { query: 'tech', maxResults: 100 }, endpoint: server.url });
180 assert.equal(result.code, 0, result.output);
181 assert.deepEqual(pagesRequested(server), [1, 2]);
182 assert.equal(result.items.length, 10);
183 assert.ok(result.items.every((i) => i.charged === true && i.error === undefined));
184 assert.equal(totalCharges(result), 1);
185 });
186
187 it('charges a search with no matching companies as a negative answer', async () => {
188 const server = await mock();
189 const result = await run({ input: { query: 'none' }, endpoint: server.url });
190 assert.equal(server.requests.length, 1);
191 assert.deepEqual(result.items, [
192 {
193 query: 'none',
194 page: 1,
195 source: 'tomba_company_search',
196 charged: true,
197 cached: false,
198 error: 'No companies found',
199 },
200 ]);
201 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
202 });
203
204 it('does not charge empty data', async () => {
205 const server = await mock();
206 const result = await run({ input: { query: 'empty' }, endpoint: server.url });
207 assert.equal(result.code, 0, result.output);
208 assert.equal(result.items.length, 1);
209 assert.equal(result.items[0].charged, false);
210 assert.equal(result.items[0].error, 'No companies found');
211 assert.equal(totalCharges(result), 0);
212 });
213
214 it('does not charge Tomba error statuses and does not retry them', async () => {
215 const server = await mock();
216 const result = await run({ input: { query: 'invalid' }, endpoint: server.url });
217 assert.equal(result.code, 0, result.output);
218 assert.equal(server.requests.length, 1);
219 assert.equal(result.items[0].charged, false);
220 assert.match(String(result.items[0].error), /422: Invalid query/);
221 assert.equal(totalCharges(result), 0);
222 });
223
224 it('does not charge a non-JSON body', async () => {
225 const server = await mock();
226 const result = await run({ input: { query: 'html' }, endpoint: server.url });
227 assert.equal(result.items[0].charged, false);
228 assert.match(String(result.items[0].error), /Invalid response/);
229 assert.equal(totalCharges(result), 0);
230 });
231
232 it('retries 429 and 5xx responses, then charges the success once', async () => {
233 let calls = 0;
234 const server = await mock(async (req) => {
235 calls++;
236 if (calls === 1)
237 return {
238 status: 429,
239 body: { errors: { message: 'Too many requests' } },
240 headers: { 'retry-after': '1' },
241 };
242 if (calls === 2) return { status: 503, body: {} };
243 return tomba(req);
244 });
245 const result = await run({ input: { query: 'tech', maxResults: 10, maxRetries: 3 }, endpoint: server.url });
246 assert.equal(server.requests.length, 3);
247 assert.equal(result.items.length, 10);
248 assert.equal(result.items[0].charged, true);
249 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
250 });
251
252 it('serves repeated runs from the cache for free', async () => {
253 const server = await mock();
254 const input = { query: 'tech', maxResults: 20 };
255 const first = await run({ input, endpoint: server.url });
256 assert.equal(totalCharges(first), 2);
257 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir });
258
259 assert.equal(server.requests.length, 2);
260 assert.equal(second.items.length, 20);
261 assert.ok(second.items.every((i) => i.cached === true && i.charged === false));
262 assert.equal(totalCharges(second), 0);
263 });
264
265 it('calls Tomba again when the cache is disabled', async () => {
266 const server = await mock();
267 const input = { query: 'tech', maxResults: 10, useCache: false };
268 const first = await run({ input, endpoint: server.url });
269 await run({ input, endpoint: server.url, storageDir: first.storageDir });
270 assert.equal(server.requests.length, 2);
271 });
272
273 it('stops at the max charge limit and resumes without reprocessing', async () => {
274 const server = await mock();
275 const input = { query: 'big', maxResults: 100, maxConcurrency: 1, useCache: false };
276
277
278 const first = await run({ input, endpoint: server.url, maxTotalChargeUsd: 2 });
279 assert.equal(first.code, 0, first.output);
280 assert.equal(totalCharges(first), 2);
281 assert.deepEqual(pagesRequested(server), [1, 2]);
282 assert.equal(first.items.length, 20);
283
284 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir, keepStorage: true });
285 assert.equal(second.code, 0, second.output);
286 assert.deepEqual(pagesRequested(server), [1, 2, 3, 4, 5, 6]);
287 assert.equal(second.items.length, 60);
288 assert.equal(new Set(second.items.map((i) => i.name)).size, 60);
289
290 assert.equal(totalCharges(second), 6);
291 });
292
293 it('keeps maxResults across a resumed run', async () => {
294 const server = await mock();
295 const input = { query: 'big', maxResults: 25, useCache: false };
296 const first = await run({ input, endpoint: server.url, maxTotalChargeUsd: 2 });
297 assert.equal(first.items.length, 20);
298 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir, keepStorage: true });
299 assert.deepEqual(pagesRequested(server), [1, 2, 3]);
300 assert.equal(second.items.length, 25);
301 });
302
303 it('fetches the pages of a search one after another', async () => {
304 let active = 0;
305 let peak = 0;
306 const server = await mock(async (req) => {
307 active++;
308 peak = Math.max(peak, active);
309 await new Promise((r) => {
310 setTimeout(r, 50);
311 });
312 active--;
313 return tomba(req);
314 });
315 await run({ input: { query: 'big', maxResults: 60, maxConcurrency: 10 }, endpoint: server.url });
316 assert.equal(server.requests.length, 6);
317 assert.equal(peak, 1);
318 });
319
320 it('fails without Tomba credentials and never calls the API', async () => {
321 const server = await mock();
322 const result = await run({ input: { query: 'tech' }, endpoint: server.url, withCredentials: false });
323 assert.notEqual(result.code, 0);
324 assert.match(result.output, /misconfigured/);
325 assert.doesNotMatch(result.output, /ta_test_key|ts_test_secret/);
326 assert.equal(server.requests.length, 0);
327 });
328
329 it('fails without a query or filters and never calls the API', async () => {
330 const server = await mock();
331 const result = await run({ input: { query: ' ', filters: {} }, endpoint: server.url });
332 assert.notEqual(result.code, 0);
333 assert.match(result.output, /query/);
334 assert.equal(server.requests.length, 0);
335 });
336});
337
338describe('company-search standby (real-time API)', () => {
339 it('answers the readiness probe and a bare GET with usage info', async () => {
340 const server = await mock();
341 const actor = await startStandbyActor({ endpoint: server.url });
342 try {
343 const probe = await actor.call('/', { headers: { 'x-apify-container-server-readiness-probe': '1' } });
344 assert.equal(probe.status, 200);
345 const usage = await actor.call('/');
346 assert.equal(usage.status, 200);
347 assert.match(String(usage.body.usage), /GET/);
348 assert.equal(server.requests.length, 0);
349 } finally {
350 await actor.stop();
351 }
352 });
353
354 it('searches from GET query parameters and charges one event per page', async () => {
355 const server = await mock();
356 const actor = await startStandbyActor({ endpoint: server.url });
357 let stopped;
358 try {
359 const filters = encodeURIComponent(JSON.stringify({ industry: { include: ['software'] } }));
360 const res = await actor.call(`/?query=tech&filters=${filters}&source=name,website_url&maxResults=15`);
361 assert.equal(res.status, 200);
362 const items = res.body.items as Record<string, unknown>[];
363 assert.equal(items.length, 15);
364 assert.equal(items[14].name, 'Company 15');
365 assert.deepEqual(server.requests[0].body, {
366 query: 'tech',
367 filters: { industry: { include: ['software'] } },
368 _source: ['name', 'website_url'],
369 page: 1,
370 });
371 assert.deepEqual(pagesRequested(server), [1, 2]);
372 } finally {
373 stopped = await actor.stop();
374 }
375 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 2 });
376 });
377
378 it('accepts a POST with the same JSON input as a normal run', async () => {
379 const server = await mock();
380 const actor = await startStandbyActor({ endpoint: server.url });
381 try {
382 const res = await actor.call('/', {
383 body: { filters: { size: { include: ['51-200'] } }, page: 2, maxResults: 5 },
384 });
385 assert.equal(res.status, 200);
386 const items = res.body.items as Record<string, unknown>[];
387 assert.equal(items.length, 5);
388 assert.equal(items[0].name, 'Company 11');
389 assert.deepEqual(pagesRequested(server), [2]);
390 } finally {
391 await actor.stop();
392 }
393 });
394
395 it('serves repeated requests from the cache for free', async () => {
396 const server = await mock();
397 const actor = await startStandbyActor({ endpoint: server.url });
398 let stopped;
399 try {
400 await actor.call('/?query=tech&maxResults=10');
401 const second = await actor.call('/?query=tech&maxResults=10');
402 assert.ok((second.body.items as Record<string, unknown>[]).every((i) => i.cached === true));
403 assert.equal(server.requests.length, 1);
404 } finally {
405 stopped = await actor.stop();
406 }
407 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 1 });
408 });
409
410 it('keeps serving after a request hits maxResults', async () => {
411 const server = await mock();
412 const actor = await startStandbyActor({ endpoint: server.url });
413 try {
414 const first = await actor.call('/?query=big&maxResults=5');
415 assert.equal((first.body.items as unknown[]).length, 5);
416 const second = await actor.call('/?query=big&maxResults=25');
417 assert.equal((second.body.items as unknown[]).length, 25);
418 assert.deepEqual(pagesRequested(server), [1, 2, 3]);
419 } finally {
420 await actor.stop();
421 }
422 });
423
424 it('rejects invalid input with 400 and unknown paths with 404', async () => {
425 const server = await mock();
426 const actor = await startStandbyActor({ endpoint: server.url });
427 try {
428 assert.equal((await actor.call('/', { body: {} })).status, 400);
429 assert.equal((await actor.call('/', { body: 'not json' })).status, 400);
430 assert.equal((await actor.call('/?query=tech&maxResults=abc')).status, 400);
431 const badFilters = await actor.call('/?filters=notjson');
432 assert.equal(badFilters.status, 400);
433 assert.match(String(badFilters.body.error), /filters/);
434 assert.equal((await actor.call(`/?filters=${encodeURIComponent('[1]')}`)).status, 400);
435 assert.equal((await actor.call('/?page=1')).status, 400);
436 assert.equal((await actor.call('/nope')).status, 404);
437 assert.equal((await actor.call('/', { method: 'DELETE' })).status, 405);
438 assert.equal(server.requests.length, 0);
439 } finally {
440 await actor.stop();
441 }
442 });
443
444 it('returns 402 once the max charge limit is reached', async () => {
445 const server = await mock();
446 const actor = await startStandbyActor({ endpoint: server.url, maxTotalChargeUsd: 1 });
447 try {
448 const first = await actor.call('/?query=tech&maxResults=30');
449 assert.equal(first.status, 200);
450 assert.equal((first.body.items as unknown[]).length, 10);
451 const second = await actor.call('/?query=other');
452 assert.equal(second.status, 402);
453 assert.equal(server.requests.length, 1);
454 } finally {
455 await actor.stop();
456 }
457 });
458});