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