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 ARTICLE = 'https://www.shopify.com/blog/self-publish-a-book';
9
10const AUTHOR = {
11 email: 'jane.doe@shopify.com',
12 first_name: 'Jane',
13 last_name: 'Doe',
14 full_name: 'Jane Doe',
15 website_url: 'shopify.com',
16 company: 'Shopify',
17 position: 'Content Writer',
18 country: 'CA',
19 gender: 'female',
20 twitter: 'https://twitter.com/janedoe',
21 linkedin: 'https://www.linkedin.com/in/janedoe',
22 score: 96,
23 accept_all: false,
24 phone_number: false,
25 verification: { date: '2025-10-17T00:00:00+02:00', status: 'valid' },
26 sources: [
27 {
28 uri: ARTICLE,
29 website_url: 'shopify.com',
30 extracted_on: '2024-09-17T11:26:56+02:00',
31 last_seen_on: '2025-09-06T04:51:06+02:00',
32 still_on_page: true,
33 },
34 ],
35};
36
37
38const tomba: MockHandler = (req) => {
39 assert.equal(req.method, 'GET');
40 assert.equal(req.path, '/author-finder');
41 const { url } = req.query;
42 if (url.includes('emptyobj')) return { body: { data: {} } };
43 if (url.includes('empty')) return { body: { data: null } };
44 if (url.includes('noemail'))
45 return {
46 body: {
47 data: { email: null, first_name: 'John', last_name: 'Smith', full_name: 'John Smith', score: 0 },
48 },
49 };
50 if (url.includes('invalid')) return { status: 422, body: { errors: { message: 'Invalid url' } } };
51 if (url.includes('html')) return { raw: '<html>Bad gateway</html>' };
52 return { body: { data: AUTHOR } };
53};
54
55const servers: MockServer[] = [];
56const dirs: string[] = [];
57
58async function mock(handler: MockHandler = tomba): Promise<MockServer> {
59 const server = await startMockTomba(handler);
60 servers.push(server);
61 return server;
62}
63
64async function run(...args: Parameters<typeof runActor>) {
65 const result = await runActor(...args);
66 dirs.push(result.storageDir);
67 return result;
68}
69
70afterEach(async () => {
71 await Promise.all(servers.splice(0).map(async (s) => s.close()));
72});
73
74after(async () => {
75 await Promise.all(dirs.map(removeStorage));
76});
77
78describe('author-finder', () => {
79 it('returns the author and charges one event per billable URL', async () => {
80 const server = await mock();
81 const result = await run({ input: { urls: [ARTICLE, 'https://example.com/empty'] }, endpoint: server.url });
82
83 assert.equal(result.code, 0, result.output);
84 assert.equal(server.requests.length, 2);
85 const found = result.items.find((i) => i.input_url === ARTICLE);
86 assert.deepEqual(found, {
87 ...AUTHOR,
88 input_url: ARTICLE,
89 source: 'tomba_author_finder',
90 chargedCredits: 1,
91 charged: true,
92 cached: false,
93 });
94
95 const empty = result.items.find((i) => i.input_url === 'https://example.com/empty');
96 assert.deepEqual(empty, {
97 input_url: 'https://example.com/empty',
98 email: null,
99 source: 'tomba_author_finder',
100 chargedCredits: 0,
101 charged: false,
102 cached: false,
103 error: 'No author found',
104 });
105
106 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
107 });
108
109 it('sends the built-in credentials and the URL to Tomba', async () => {
110 const server = await mock();
111 await run({ input: { urls: [ARTICLE] }, endpoint: server.url });
112 assert.equal(server.requests.length, 1);
113 assert.equal(server.requests[0].query.url, ARTICLE);
114 assert.equal(server.requests[0].headers['x-tomba-key'], 'ta_test_key');
115 assert.equal(server.requests[0].headers['x-tomba-secret'], 'ts_test_secret');
116 });
117
118 it('sends webhook_url only when it is set', async () => {
119 const server = await mock();
120 await run({ input: { urls: [ARTICLE] }, endpoint: server.url });
121 await run({ input: { urls: [ARTICLE], webhookUrl: ' ' }, endpoint: server.url });
122 const result = await run({
123 input: { urls: [ARTICLE], webhookUrl: ' https://hooks.example.com/tomba ' },
124 endpoint: server.url,
125 });
126
127 assert.deepEqual(
128 server.requests.map((r) => r.query),
129 [{ url: ARTICLE }, { url: ARTICLE }, { url: ARTICLE, webhook_url: 'https://hooks.example.com/tomba' }],
130 );
131
132 assert.equal(result.items[0].chargedCredits, 1);
133 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
134 });
135
136 it('trims and deduplicates URLs and ignores blank entries', async () => {
137 const server = await mock();
138 const result = await run({
139 input: { urls: [` ${ARTICLE} `, ARTICLE, '', ' '] },
140 endpoint: server.url,
141 });
142 assert.equal(result.code, 0, result.output);
143 assert.deepEqual(
144 server.requests.map((r) => r.query.url),
145 [ARTICLE],
146 );
147 assert.equal(result.items.length, 1);
148 });
149
150 it('does not charge an empty data object', async () => {
151 const server = await mock();
152 const result = await run({ input: { urls: ['https://example.com/emptyobj'] }, endpoint: server.url });
153 assert.equal(result.items[0].charged, false);
154 assert.equal(result.items[0].email, null);
155 assert.equal(result.items[0].error, 'No author found');
156 assert.equal(totalCharges(result), 0);
157 });
158
159 it('charges a negative answer (author record without an email)', async () => {
160 const server = await mock();
161 const result = await run({ input: { urls: ['https://example.com/noemail'] }, endpoint: server.url });
162 assert.equal(result.items.length, 1);
163 assert.equal(result.items[0].email, null);
164 assert.equal(result.items[0].full_name, 'John Smith');
165 assert.equal(result.items[0].charged, true);
166 assert.equal(result.items[0].error, undefined);
167 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
168 });
169
170 it('does not charge Tomba error statuses and does not retry them', async () => {
171 const server = await mock();
172 const result = await run({ input: { urls: ['https://example.com/invalid'] }, endpoint: server.url });
173 assert.equal(result.code, 0, result.output);
174 assert.equal(server.requests.length, 1);
175 assert.equal(result.items[0].charged, false);
176 assert.equal(result.items[0].email, null);
177 assert.match(String(result.items[0].error), /422: Invalid url/);
178 assert.equal(totalCharges(result), 0);
179 });
180
181 it('does not charge a non-JSON body', async () => {
182 const server = await mock();
183 const result = await run({ input: { urls: ['https://example.com/html'] }, endpoint: server.url });
184 assert.equal(result.items[0].charged, false);
185 assert.match(String(result.items[0].error), /Invalid response/);
186 assert.equal(totalCharges(result), 0);
187 });
188
189 it('retries 429 and 5xx responses, then charges the success once', async () => {
190 let calls = 0;
191 const server = await mock(async (req) => {
192 calls++;
193 if (calls === 1)
194 return {
195 status: 429,
196 body: { errors: { message: 'Too many requests' } },
197 headers: { 'retry-after': '1' },
198 };
199 if (calls === 2) return { status: 503, body: {} };
200 return tomba(req);
201 });
202 const result = await run({ input: { urls: [ARTICLE], maxRetries: 3 }, endpoint: server.url });
203 assert.equal(server.requests.length, 3);
204 assert.equal(result.items[0].charged, true);
205 assert.equal(result.items[0].email, AUTHOR.email);
206 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });
207 });
208
209 it('serves repeated runs from the cache for free', async () => {
210 const server = await mock();
211 const first = await run({ input: { urls: [ARTICLE] }, endpoint: server.url });
212 const second = await run({ input: { urls: [ARTICLE] }, endpoint: server.url, storageDir: first.storageDir });
213
214 assert.equal(server.requests.length, 1);
215 assert.equal(second.items.length, 1);
216 assert.equal(second.items[0].email, AUTHOR.email);
217 assert.equal(second.items[0].cached, true);
218 assert.equal(second.items[0].charged, false);
219 assert.equal(second.items[0].chargedCredits, 0);
220 assert.equal(totalCharges(second), 0);
221 });
222
223 it('calls Tomba again when the cache is disabled', async () => {
224 const server = await mock();
225 const first = await run({ input: { urls: [ARTICLE], useCache: false }, endpoint: server.url });
226 await run({ input: { urls: [ARTICLE], useCache: false }, endpoint: server.url, storageDir: first.storageDir });
227 assert.equal(server.requests.length, 2);
228 });
229
230 it('stops at the max charge limit and resumes without reprocessing', async () => {
231 const server = await mock();
232 const urls = ['a', 'b', 'c', 'd', 'e'].map((p) => `https://example.com/${p}`);
233 const input = { urls, maxConcurrency: 1, useCache: false, maxResults: 100 };
234
235
236 const first = await run({ input, endpoint: server.url, maxTotalChargeUsd: 2 });
237 assert.equal(first.code, 0, first.output);
238 assert.equal(totalCharges(first), 2);
239 assert.equal(server.requests.length, 2);
240 assert.equal(first.items.length, 2);
241
242 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir, keepStorage: true });
243 assert.equal(second.code, 0, second.output);
244 assert.deepEqual(
245 server.requests.map((r) => r.query.url),
246 urls,
247 );
248 assert.equal(totalCharges(second), 5);
249 assert.equal(second.items.length, 5);
250 });
251
252 it('respects maxResults', async () => {
253 const server = await mock();
254 const result = await run({
255 input: { urls: ['https://a.com/1', 'https://b.com/2', 'https://c.com/3'], maxResults: 2 },
256 endpoint: server.url,
257 });
258 assert.equal(server.requests.length, 2);
259 assert.equal(result.items.length, 2);
260 assert.equal(totalCharges(result), 2);
261 });
262
263 it('runs requests in parallel', async () => {
264 let active = 0;
265 let peak = 0;
266 const server = await mock(async (req) => {
267 active++;
268 peak = Math.max(peak, active);
269 await new Promise((r) => {
270 setTimeout(r, 100);
271 });
272 active--;
273 return tomba(req);
274 });
275 const urls = Array.from({ length: 8 }, (_, i) => `https://site${i}.com/post`);
276 await run({ input: { urls, maxConcurrency: 4 }, endpoint: server.url });
277 assert.equal(server.requests.length, 8);
278 assert.ok(peak > 1 && peak <= 4, `peak concurrency ${peak}`);
279 });
280
281 it('fails without Tomba credentials and never calls the API', async () => {
282 const server = await mock();
283 const result = await run({ input: { urls: [ARTICLE] }, endpoint: server.url, withCredentials: false });
284 assert.notEqual(result.code, 0);
285 assert.match(result.output, /misconfigured/);
286 assert.doesNotMatch(result.output, /ta_test_key|ts_test_secret/);
287 assert.equal(server.requests.length, 0);
288 });
289
290 it('fails on empty input', async () => {
291 const server = await mock();
292 const result = await run({ input: { urls: [] }, endpoint: server.url });
293 assert.notEqual(result.code, 0);
294 assert.equal(server.requests.length, 0);
295 });
296});
297
298describe('author-finder standby (real-time API)', () => {
299 it('answers the readiness probe and a bare GET with usage info', async () => {
300 const server = await mock();
301 const actor = await startStandbyActor({ endpoint: server.url });
302 try {
303 const probe = await actor.call('/', { headers: { 'x-apify-container-server-readiness-probe': '1' } });
304 assert.equal(probe.status, 200);
305 const usage = await actor.call('/');
306 assert.equal(usage.status, 200);
307 assert.match(String(usage.body.usage), /GET/);
308 assert.equal(server.requests.length, 0);
309 } finally {
310 await actor.stop();
311 }
312 });
313
314 it('looks up URLs from GET query parameters and charges per billable URL', async () => {
315 const server = await mock();
316 const actor = await startStandbyActor({ endpoint: server.url });
317 let stopped;
318 try {
319 const query = new URLSearchParams([
320 ['url', ARTICLE],
321 ['url', 'https://example.com/empty'],
322 ['webhookUrl', 'https://hooks.example.com/tomba'],
323 ]);
324 const res = await actor.call(`/?${query}`);
325 assert.equal(res.status, 200);
326 const items = res.body.items as Record<string, unknown>[];
327 assert.deepEqual(
328 items.find((i) => i.input_url === ARTICLE),
329 {
330 ...AUTHOR,
331 input_url: ARTICLE,
332 source: 'tomba_author_finder',
333 chargedCredits: 1,
334 charged: true,
335 cached: false,
336 },
337 );
338 assert.equal(items.find((i) => i.input_url === 'https://example.com/empty')?.error, 'No author found');
339 assert.ok(server.requests.every((r) => r.query.webhook_url === 'https://hooks.example.com/tomba'));
340 } finally {
341 stopped = await actor.stop();
342 }
343 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 1 });
344 });
345
346 it('accepts a POST with the same JSON input as a normal run', async () => {
347 const server = await mock();
348 const actor = await startStandbyActor({ endpoint: server.url });
349 try {
350 const res = await actor.call('/', {
351 body: { urls: ['https://a.com/post', 'https://b.com/post', 'https://c.com/post'], maxResults: 2 },
352 });
353 assert.equal(res.status, 200);
354 assert.equal((res.body.items as unknown[]).length, 2);
355 assert.equal(server.requests.length, 2);
356 } finally {
357 await actor.stop();
358 }
359 });
360
361 it('serves repeated requests from the cache for free', async () => {
362 const server = await mock();
363 const actor = await startStandbyActor({ endpoint: server.url });
364 let stopped;
365 try {
366 await actor.call(`/?url=${encodeURIComponent(ARTICLE)}`);
367 const second = await actor.call(`/?url=${encodeURIComponent(ARTICLE)}`);
368 assert.ok((second.body.items as Record<string, unknown>[]).every((i) => i.cached === true));
369 assert.equal(server.requests.length, 1);
370 } finally {
371 stopped = await actor.stop();
372 }
373 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 1 });
374 });
375
376 it('keeps serving after a request hits maxResults', async () => {
377 const server = await mock();
378 const actor = await startStandbyActor({ endpoint: server.url });
379 try {
380 const first = await actor.call('/?url=https://a.com/post&url=https://b.com/post&maxResults=1');
381 assert.equal((first.body.items as unknown[]).length, 1);
382 const second = await actor.call('/?urls=https://c.com/post,https://d.com/post');
383 assert.equal((second.body.items as unknown[]).length, 2);
384 } finally {
385 await actor.stop();
386 }
387 });
388
389 it('rejects invalid input with 400 and unknown paths with 404', async () => {
390 const server = await mock();
391 const actor = await startStandbyActor({ endpoint: server.url });
392 try {
393 assert.equal((await actor.call('/', { body: {} })).status, 400);
394 assert.equal((await actor.call('/', { body: { urls: ARTICLE } })).status, 400);
395 assert.equal((await actor.call('/', { body: 'not json' })).status, 400);
396 assert.equal((await actor.call('/?maxResults=abc&url=https://a.com/post')).status, 400);
397 assert.equal((await actor.call('/nope')).status, 404);
398 assert.equal((await actor.call('/', { method: 'DELETE' })).status, 405);
399 assert.equal(server.requests.length, 0);
400 } finally {
401 await actor.stop();
402 }
403 });
404
405 it('returns 402 once the max charge limit is reached', async () => {
406 const server = await mock();
407 const actor = await startStandbyActor({ endpoint: server.url, maxTotalChargeUsd: 1 });
408 try {
409 const first = await actor.call('/?url=https://a.com/post');
410 assert.equal(first.status, 200);
411 const second = await actor.call('/?url=https://b.com/post');
412 assert.equal(second.status, 402);
413 assert.equal(server.requests.length, 1);
414 } finally {
415 await actor.stop();
416 }
417 });
418});