1
2import assert from 'node:assert/strict';
3import { mkdtemp } from 'node:fs/promises';
4import { tmpdir } from 'node:os';
5import { join } from 'node:path';
6import { after, before, describe, it } from 'node:test';
7
8process.env.CRAWLEE_STORAGE_DIR = await mkdtemp(join(tmpdir(), 'tomba-unit-'));
9process.env.ACTOR_TEST_PAY_PER_EVENT = 'true';
10process.env.TOMBA_API_KEY = 'ta_unit';
11process.env.TOMBA_API_SECRET = 'ts_unit';
12
13const { Actor } = await import('apify');
14const { TombaException } = await import('tomba');
15const T = await import('../src/tomba.js');
16
17const ok = (body: unknown) => async () => ({ data: body, rateLimit: {} });
18const httpError = (status: number, body: unknown = {}, headers: Record<string, string> = {}) =>
19 new TombaException('error', { status, headers }, body);
20const charged = (event = T.EVENT_REQUEST) => Actor.getChargingManager().getChargedEventCount(event);
21
22let seq = 0;
23const params = () => ({ n: ++seq });
24
25before(async () => {
26 await Actor.init();
27 await T.setupTomba({ maxRetries: 1, useCache: true, cacheTtlHours: 1, maxConcurrency: 4 });
28});
29
30after(async () => {
31 await Actor.exit({ exit: false });
32});
33
34describe('isBillable', () => {
35 const cases: [string, unknown, boolean][] = [
36 ['data array', { data: [{ a: 1 }] }, true],
37 ['data object', { data: { email: 'a@b.co' } }, true],
38 ['negative answer (object with empty list)', { data: { organization: {}, emails: [] } }, true],
39 ['negative answer (undeliverable)', { data: { email: { status: 'invalid' } } }, true],
40 ['empty array', { data: [] }, false],
41 ['empty object', { data: {} }, false],
42 ['object with only null values', { data: { person: null, company: null } }, false],
43 ['negative answer (email null, other fields set)', { data: { email: null, first_name: 'John' } }, true],
44 ['null data', { data: null }, false],
45 ['missing data', { meta: {} }, false],
46 ['errors object', { data: { a: 1 }, errors: { message: 'x' } }, false],
47 ['string body', '<html>', false],
48 ['null body', null, false],
49 ['array body', [1, 2], false],
50 ];
51 for (const [name, body, expected] of cases) {
52 it(name, () => assert.equal(T.isBillable(body), expected));
53 }
54});
55
56describe('normalizers', () => {
57 it('normalizeDomain strips protocol, www, path and case', () => {
58 assert.equal(T.normalizeDomain(' https://www.Stripe.com/about?x=1 '), 'stripe.com');
59 assert.equal(T.normalizeDomain('http://tomba.io'), 'tomba.io');
60 assert.equal(T.normalizeDomain('sub.example.org'), 'sub.example.org');
61 });
62
63 it('normalizeEmail trims and lowercases', () => {
64 assert.equal(T.normalizeEmail(' John@Stripe.COM '), 'john@stripe.com');
65 });
66
67 it('unique keeps first occurrence and drops empty keys', () => {
68 assert.deepEqual(T.unique(['a', 'b', 'a', '', 'c']), ['a', 'b', 'c']);
69 assert.deepEqual(
70 T.unique([{ id: 1 }, { id: 2 }, { id: 1 }], (x) => String(x.id)),
71 [{ id: 1 }, { id: 2 }],
72 );
73 });
74});
75
76describe('phone data', () => {
77 it('counts phone numbers in phone_data', () => {
78 assert.equal(T.phoneDataCount({ phone_data: [{ number: '+1' }, { number: '+2' }] }), 2);
79 assert.equal(T.phoneDataCount({ phone_data: [] }), 0);
80 assert.equal(T.phoneDataCount({ phone_number: true }), 0);
81 assert.equal(T.phoneDataCount(null), 0);
82 assert.equal(T.phoneDataCount('x'), 0);
83 });
84
85 it('detects records with phone data', () => {
86 assert.equal(T.hasPhoneData({ phone_data: [{ number: '+1' }] }), true);
87 assert.equal(T.hasPhoneData({ phone_data: [] }), false);
88 assert.equal(T.hasPhoneData({}), false);
89 });
90
91 it('prices phone data at 5 credits', () => {
92 assert.equal(T.PHONE_CREDITS, 5);
93 });
94});
95
96describe('cacheStoreName', () => {
97 it("is unique per Actor so limited-permission runs never open another Actor's store", () => {
98 const previous = process.env.ACTOR_ID;
99 try {
100 process.env.ACTOR_ID = 'AbC123xyz';
101 assert.equal(T.cacheStoreName(), 'tomba-cache-AbC123xyz');
102 delete process.env.ACTOR_ID;
103 assert.equal(T.cacheStoreName(), 'tomba-cache');
104 } finally {
105 if (previous === undefined) delete process.env.ACTOR_ID;
106 else process.env.ACTOR_ID = previous;
107 }
108 });
109});
110
111describe('setupTomba without cache permissions', () => {
112 it('keeps working with an in-run cache when the cache store cannot be opened', async () => {
113 const original = Actor.openKeyValueStore.bind(Actor);
114 Actor.openKeyValueStore = async () => {
115 throw new Error('Insufficient permissions for the key-value store.');
116 };
117 try {
118 await T.setupTomba({ maxRetries: 1, useCache: true, cacheTtlHours: 1, maxConcurrency: 4 });
119 const p = { cachePermissionTest: true };
120 let calls = 0;
121 const fn = async () => {
122 calls++;
123 return { data: { data: [1] }, rateLimit: {} };
124 };
125 const first = await T.callTomba('perm', p, fn);
126 const second = await T.callTomba('perm', p, fn);
127 assert.equal(first.charged, true);
128 assert.equal(second.cached, true);
129 assert.equal(calls, 1);
130 } finally {
131 Actor.openKeyValueStore = original;
132 await T.setupTomba({ maxRetries: 1, useCache: true, cacheTtlHours: 1, maxConcurrency: 4 });
133 }
134 });
135});
136
137describe('runPool', () => {
138 it('processes every item with bounded concurrency', async () => {
139 let active = 0;
140 let peak = 0;
141 const seen: number[] = [];
142 await T.runPool(
143 Array.from({ length: 20 }, (_, i) => i),
144 async (item) => {
145 active++;
146 peak = Math.max(peak, active);
147 await new Promise((r) => {
148 setTimeout(r, 5);
149 });
150 seen.push(item);
151 active--;
152 },
153 3,
154 );
155 assert.equal(seen.length, 20);
156 assert.deepEqual(
157 [...seen].sort((a, b) => a - b),
158 Array.from({ length: 20 }, (_, i) => i),
159 );
160 assert.ok(peak <= 3, `peak concurrency ${peak}`);
161 });
162
163 it('handles an empty list', async () => {
164 await T.runPool([], async () => assert.fail('should not run'));
165 });
166});
167
168describe('callTomba', () => {
169 it('charges a response with data and unwraps the SDK { data, rateLimit } shape', async () => {
170 const start = charged();
171 const res = await T.callTomba('test', params(), ok({ data: [{ name: 'React' }] }));
172 assert.equal(res.status, 200);
173 assert.equal(res.charged, true);
174 assert.equal(res.cached, false);
175 assert.deepEqual(res.data, [{ name: 'React' }]);
176 assert.equal(charged(), start + 1);
177 });
178
179 it('accepts an SDK that returns the body directly', async () => {
180 const res = await T.callTomba('test', params(), async () => ({ data: { x: 1 } }));
181 assert.equal(res.charged, true);
182 assert.deepEqual(res.data, { x: 1 });
183 });
184
185 it('charges a negative answer', async () => {
186 const res = await T.callTomba('test', params(), ok({ data: { organization: {}, emails: [] } }));
187 assert.equal(res.charged, true);
188 });
189
190 it('serves a repeated request from cache without charging', async () => {
191 const p = params();
192 let calls = 0;
193 const fn = async () => {
194 calls++;
195 return { data: { data: { v: 1 } }, rateLimit: {} };
196 };
197 await T.callTomba('test', p, fn);
198 const start = charged();
199 const res = await T.callTomba('test', { ...p }, fn);
200 assert.equal(res.cached, true);
201 assert.equal(res.charged, false);
202 assert.deepEqual(res.data, { v: 1 });
203 assert.equal(calls, 1);
204 assert.equal(charged(), start);
205 });
206
207 it('uses a different cache entry for different params or endpoints', async () => {
208 const p = params();
209 await T.callTomba('a', p, ok({ data: [1] }));
210 const other = await T.callTomba('b', p, ok({ data: [2] }));
211 assert.equal(other.cached, false);
212 assert.deepEqual(other.data, [2]);
213 });
214
215 it('does not charge or cache empty data', async () => {
216 const p = params();
217 const start = charged();
218 const res = await T.callTomba('test', p, ok({ data: [] }));
219 assert.equal(res.charged, false);
220 assert.equal(res.status, 200);
221 const again = await T.callTomba('test', p, ok({ data: [] }));
222 assert.equal(again.cached, false);
223 assert.equal(charged(), start);
224 });
225
226 it('does not charge a success with an errors object', async () => {
227 const res = await T.callTomba('test', params(), ok({ data: { a: 1 }, errors: { message: 'Bad thing' } }));
228 assert.equal(res.charged, false);
229 assert.equal(res.error, 'Bad thing');
230 });
231
232 it('reports a non-JSON body as 502 without charging', async () => {
233 const res = await T.callTomba('test', params(), ok('<html>oops</html>'));
234 assert.equal(res.status, 502);
235 assert.equal(res.charged, false);
236 });
237
238 it('does not retry or charge a 422', async () => {
239 let calls = 0;
240 const res = await T.callTomba('test', params(), async () => {
241 calls++;
242 throw httpError(422, { errors: { message: 'Invalid domain' } });
243 });
244 assert.equal(calls, 1);
245 assert.equal(res.status, 422);
246 assert.equal(res.charged, false);
247 assert.match(res.error ?? '', /Invalid domain/);
248 });
249
250 it('does not retry SDK input validation errors', async () => {
251 let calls = 0;
252 const res = await T.callTomba('test', params(), async () => {
253 calls++;
254 throw new TombaException('Domain is required.');
255 });
256 assert.equal(calls, 1);
257 assert.equal(res.status, 400);
258 assert.equal(res.charged, false);
259 });
260
261 it('retries a 429 honoring Retry-After, then charges the success', async () => {
262 let calls = 0;
263 const res = await T.callTomba('test', params(), async () => {
264 calls++;
265 if (calls === 1) throw httpError(429, {}, { 'retry-after': '1' });
266 return { data: { data: { ok: true } }, rateLimit: {} };
267 });
268 assert.equal(calls, 2);
269 assert.equal(res.charged, true);
270 });
271
272 it('retries network errors', async () => {
273 let calls = 0;
274 const res = await T.callTomba('test', params(), async () => {
275 calls++;
276 if (calls === 1) throw new TombaException('connect ECONNREFUSED 127.0.0.1:9');
277 return { data: { data: [1] }, rateLimit: {} };
278 });
279 assert.equal(calls, 2);
280 assert.equal(res.charged, true);
281 });
282
283 it('gives up after maxRetries on 5xx without charging', async () => {
284 let calls = 0;
285 const res = await T.callTomba('test', params(), async () => {
286 calls++;
287 throw httpError(503);
288 });
289 assert.equal(calls, 2);
290 assert.equal(res.status, 503);
291 assert.equal(res.charged, false);
292 });
293
294 it('charges the given event name', async () => {
295 const start = charged(T.EVENT_PHONE_FINDER);
296 await T.callTomba('phone', params(), ok({ data: { phone: '+1' } }), T.EVENT_PHONE_FINDER);
297 assert.equal(charged(T.EVENT_PHONE_FINDER), start + 1);
298 });
299
300 it('charges a fixed count of events', async () => {
301 const start = charged();
302 const res = await T.callTomba('test', params(), ok({ data: [1] }), T.EVENT_REQUEST, 3);
303 assert.equal(res.chargedCount, 3);
304 assert.equal(charged(), start + 3);
305 });
306
307 it('charges a count computed from the response body', async () => {
308 const start = charged();
309 const res = await T.callTomba(
310 'test',
311 params(),
312 ok({ data: { items: [1, 2, 3, 4] } }),
313 T.EVENT_REQUEST,
314 (body) => (body.data as { items: unknown[] }).items.length,
315 );
316 assert.equal(res.chargedCount, 4);
317 assert.equal(charged(), start + 4);
318 });
319
320 it('does not charge a count for a non-billable response', async () => {
321 const start = charged();
322 const res = await T.callTomba('test', params(), ok({ data: [] }), T.EVENT_REQUEST, 5);
323 assert.equal(res.charged, false);
324 assert.equal(charged(), start);
325 });
326
327 it('tracks stats', () => {
328 assert.ok(T.stats.requests > 0);
329 assert.ok(T.stats.charged > 0);
330 assert.ok(T.stats.cached > 0);
331 assert.ok(T.stats.retries > 0);
332 assert.ok(T.stats.failed > 0);
333 });
334});