zerobounce.test.js
1 /** 2 * Tests for src/utils/zerobounce.js 3 * 4 * All external API calls are mocked via globalThis.fetch. 5 * DB operations use the pg-mock pattern. 6 */ 7 import { test, describe, beforeEach, mock } from 'node:test'; 8 import assert from 'node:assert/strict'; 9 import Database from 'better-sqlite3'; 10 import { createPgMock } from '../helpers/pg-mock.js'; 11 12 // Initialize the email_validations table 13 const db = new Database(':memory:'); 14 db.exec(` 15 CREATE TABLE IF NOT EXISTS email_validations ( 16 id INTEGER PRIMARY KEY AUTOINCREMENT, 17 email TEXT UNIQUE NOT NULL, 18 status TEXT NOT NULL, 19 sub_status TEXT, 20 free_email INTEGER, 21 mx_found INTEGER, 22 validated_at TEXT DEFAULT (CURRENT_TIMESTAMP), 23 expires_at TEXT NOT NULL 24 ) 25 `); 26 27 process.env.ZEROBOUNCE_API_KEY = 'test-api-key'; 28 process.env.ZEROBOUNCE_ENABLED = 'true'; 29 30 mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) }); 31 32 const { 33 validateEmail, 34 validateEmailWithApi, 35 validateEmailBatchWithApi, 36 checkCredits, 37 BLOCKED_STATUSES, 38 } = await import('../../src/utils/zerobounce.js'); 39 40 function clearValidations() { 41 db.exec('DELETE FROM email_validations'); 42 } 43 44 describe('BLOCKED_STATUSES', () => { 45 test('blocks invalid', () => assert.ok(BLOCKED_STATUSES.has('invalid'))); 46 test('blocks spamtrap', () => assert.ok(BLOCKED_STATUSES.has('spamtrap'))); 47 test('blocks abuse', () => assert.ok(BLOCKED_STATUSES.has('abuse'))); 48 test('blocks do_not_mail', () => assert.ok(BLOCKED_STATUSES.has('do_not_mail'))); 49 test('does not block valid', () => assert.ok(!BLOCKED_STATUSES.has('valid'))); 50 test('does not block catch-all', () => assert.ok(!BLOCKED_STATUSES.has('catch-all'))); 51 test('does not block unknown', () => assert.ok(!BLOCKED_STATUSES.has('unknown'))); 52 }); 53 54 describe('validateEmailWithApi', () => { 55 test('returns parsed result for a valid email', async () => { 56 const mockResponse = { 57 status: 'valid', 58 sub_status: '', 59 free_email: false, 60 mx_found: true, 61 }; 62 63 const origFetch = globalThis.fetch; 64 globalThis.fetch = async () => ({ 65 ok: true, 66 json: async () => mockResponse, 67 }); 68 69 try { 70 const result = await validateEmailWithApi('test@example.com'); 71 assert.equal(result.status, 'valid'); 72 assert.equal(result.sub_status, null); // empty string → null 73 assert.equal(result.free_email, false); 74 assert.equal(result.mx_found, true); 75 } finally { 76 globalThis.fetch = origFetch; 77 } 78 }); 79 80 test('handles string "true"/"false" booleans from API', async () => { 81 const origFetch = globalThis.fetch; 82 globalThis.fetch = async () => ({ 83 ok: true, 84 json: async () => ({ 85 status: 'valid', 86 sub_status: null, 87 free_email: 'true', 88 mx_found: 'false', 89 }), 90 }); 91 92 try { 93 const result = await validateEmailWithApi('test@example.com'); 94 assert.equal(result.free_email, true); 95 assert.equal(result.mx_found, false); 96 } finally { 97 globalThis.fetch = origFetch; 98 } 99 }); 100 101 test('handles null free_email and mx_found', async () => { 102 const origFetch = globalThis.fetch; 103 globalThis.fetch = async () => ({ 104 ok: true, 105 json: async () => ({ status: 'unknown', sub_status: null, free_email: null, mx_found: null }), 106 }); 107 108 try { 109 const result = await validateEmailWithApi('test@example.com'); 110 assert.equal(result.free_email, null); 111 assert.equal(result.mx_found, null); 112 } finally { 113 globalThis.fetch = origFetch; 114 } 115 }); 116 117 test('throws on non-OK response', async () => { 118 const origFetch = globalThis.fetch; 119 globalThis.fetch = async () => ({ 120 ok: false, 121 status: 429, 122 text: async () => 'Rate limited', 123 }); 124 125 try { 126 await assert.rejects( 127 () => validateEmailWithApi('test@example.com'), 128 /ZeroBounce API error 429/ 129 ); 130 } finally { 131 globalThis.fetch = origFetch; 132 } 133 }); 134 135 test('throws on data.error field', async () => { 136 const origFetch = globalThis.fetch; 137 globalThis.fetch = async () => ({ 138 ok: true, 139 json: async () => ({ error: 'Invalid API key' }), 140 }); 141 142 try { 143 await assert.rejects( 144 () => validateEmailWithApi('test@example.com'), 145 /ZeroBounce: Invalid API key/ 146 ); 147 } finally { 148 globalThis.fetch = origFetch; 149 } 150 }); 151 152 test('throws when API key missing', async () => { 153 const origKey = process.env.ZEROBOUNCE_API_KEY; 154 delete process.env.ZEROBOUNCE_API_KEY; 155 156 try { 157 await assert.rejects( 158 () => validateEmailWithApi('test@example.com'), 159 /ZEROBOUNCE_API_KEY is not configured/ 160 ); 161 } finally { 162 process.env.ZEROBOUNCE_API_KEY = origKey; 163 } 164 }); 165 }); 166 167 describe('validateEmailBatchWithApi', () => { 168 test('returns empty map for empty input', async () => { 169 const result = await validateEmailBatchWithApi([]); 170 assert.equal(result.size, 0); 171 }); 172 173 test('throws for batch > 200', async () => { 174 const emails = Array.from({ length: 201 }, (_, i) => `user${i}@example.com`); 175 await assert.rejects(() => validateEmailBatchWithApi(emails), /Batch size must not exceed 200/); 176 }); 177 178 test('throws when API key missing', async () => { 179 const origKey = process.env.ZEROBOUNCE_API_KEY; 180 delete process.env.ZEROBOUNCE_API_KEY; 181 182 try { 183 await assert.rejects( 184 () => validateEmailBatchWithApi(['a@b.com']), 185 /ZEROBOUNCE_API_KEY is not configured/ 186 ); 187 } finally { 188 process.env.ZEROBOUNCE_API_KEY = origKey; 189 } 190 }); 191 192 test('returns parsed results from API', async () => { 193 const origFetch = globalThis.fetch; 194 globalThis.fetch = async () => ({ 195 ok: true, 196 json: async () => ({ 197 email_batch: [ 198 { 199 address: 'valid@example.com', 200 status: 'valid', 201 sub_status: null, 202 free_email: false, 203 mx_found: true, 204 }, 205 { 206 address: 'bad@example.com', 207 status: 'invalid', 208 sub_status: 'mailbox_not_found', 209 free_email: null, 210 mx_found: null, 211 }, 212 ], 213 }), 214 }); 215 216 try { 217 const result = await validateEmailBatchWithApi(['valid@example.com', 'bad@example.com']); 218 assert.equal(result.size, 2); 219 assert.equal(result.get('valid@example.com').status, 'valid'); 220 assert.equal(result.get('bad@example.com').status, 'invalid'); 221 assert.equal(result.get('bad@example.com').sub_status, 'mailbox_not_found'); 222 } finally { 223 globalThis.fetch = origFetch; 224 } 225 }); 226 227 test('skips items with no address', async () => { 228 const origFetch = globalThis.fetch; 229 globalThis.fetch = async () => ({ 230 ok: true, 231 json: async () => ({ 232 email_batch: [ 233 { address: null, status: 'unknown' }, 234 { 235 address: 'good@example.com', 236 status: 'valid', 237 sub_status: null, 238 free_email: null, 239 mx_found: null, 240 }, 241 ], 242 }), 243 }); 244 245 try { 246 const result = await validateEmailBatchWithApi(['good@example.com']); 247 assert.equal(result.size, 1); 248 } finally { 249 globalThis.fetch = origFetch; 250 } 251 }); 252 253 test('throws on non-OK response', async () => { 254 const origFetch = globalThis.fetch; 255 globalThis.fetch = async () => ({ 256 ok: false, 257 status: 500, 258 text: async () => 'Server error', 259 }); 260 261 try { 262 await assert.rejects( 263 () => validateEmailBatchWithApi(['a@b.com']), 264 /ZeroBounce batch API error 500/ 265 ); 266 } finally { 267 globalThis.fetch = origFetch; 268 } 269 }); 270 }); 271 272 describe('checkCredits', () => { 273 test('returns credits from API (Credits key)', async () => { 274 const origFetch = globalThis.fetch; 275 globalThis.fetch = async () => ({ 276 ok: true, 277 json: async () => ({ Credits: '500' }), 278 }); 279 280 try { 281 const credits = await checkCredits(); 282 assert.equal(credits, 500); 283 } finally { 284 globalThis.fetch = origFetch; 285 } 286 }); 287 288 test('returns credits from API (lowercase credits key)', async () => { 289 const origFetch = globalThis.fetch; 290 globalThis.fetch = async () => ({ 291 ok: true, 292 json: async () => ({ credits: '250.5' }), 293 }); 294 295 try { 296 const credits = await checkCredits(); 297 assert.equal(credits, 250.5); 298 } finally { 299 globalThis.fetch = origFetch; 300 } 301 }); 302 303 test('returns 0 for NaN response', async () => { 304 const origFetch = globalThis.fetch; 305 globalThis.fetch = async () => ({ 306 ok: true, 307 json: async () => ({}), 308 }); 309 310 try { 311 const credits = await checkCredits(); 312 assert.equal(credits, 0); 313 } finally { 314 globalThis.fetch = origFetch; 315 } 316 }); 317 318 test('throws on non-OK response', async () => { 319 const origFetch = globalThis.fetch; 320 globalThis.fetch = async () => ({ ok: false, status: 403 }); 321 322 try { 323 await assert.rejects(() => checkCredits(), /ZeroBounce credits check failed: HTTP 403/); 324 } finally { 325 globalThis.fetch = origFetch; 326 } 327 }); 328 329 test('throws when API key missing', async () => { 330 const origKey = process.env.ZEROBOUNCE_API_KEY; 331 delete process.env.ZEROBOUNCE_API_KEY; 332 333 try { 334 await assert.rejects(() => checkCredits(), /ZEROBOUNCE_API_KEY is not configured/); 335 } finally { 336 process.env.ZEROBOUNCE_API_KEY = origKey; 337 } 338 }); 339 }); 340 341 describe('validateEmail', () => { 342 beforeEach(() => clearValidations()); 343 344 test('returns skipped when disabled', async () => { 345 process.env.ZEROBOUNCE_ENABLED = 'false'; 346 try { 347 const result = await validateEmail('test@example.com'); 348 assert.equal(result.status, 'skipped'); 349 assert.equal(result.blocked, false); 350 } finally { 351 process.env.ZEROBOUNCE_ENABLED = 'true'; 352 } 353 }); 354 355 test('returns skipped when no API key', async () => { 356 const origKey = process.env.ZEROBOUNCE_API_KEY; 357 delete process.env.ZEROBOUNCE_API_KEY; 358 359 try { 360 const result = await validateEmail('test@example.com'); 361 assert.equal(result.status, 'skipped'); 362 assert.equal(result.blocked, false); 363 } finally { 364 process.env.ZEROBOUNCE_API_KEY = origKey; 365 } 366 }); 367 368 test('hits API when cache is empty', async () => { 369 const origFetch = globalThis.fetch; 370 let called = false; 371 globalThis.fetch = async () => { 372 called = true; 373 return { 374 ok: true, 375 json: async () => ({ 376 status: 'valid', 377 sub_status: null, 378 free_email: false, 379 mx_found: true, 380 }), 381 }; 382 }; 383 384 try { 385 const result = await validateEmail('fresh@example.com'); 386 assert.ok(called, 'fetch should have been called'); 387 assert.equal(result.status, 'valid'); 388 assert.equal(result.cached, false); 389 assert.equal(result.blocked, false); 390 } finally { 391 globalThis.fetch = origFetch; 392 } 393 }); 394 395 test('returns cached result on second call', async () => { 396 const origFetch = globalThis.fetch; 397 let callCount = 0; 398 globalThis.fetch = async () => { 399 callCount++; 400 return { 401 ok: true, 402 json: async () => ({ 403 status: 'valid', 404 sub_status: null, 405 free_email: false, 406 mx_found: true, 407 }), 408 }; 409 }; 410 411 try { 412 await validateEmail('cached@example.com'); 413 const result = await validateEmail('cached@example.com'); 414 assert.equal(callCount, 1, 'fetch should only be called once'); 415 assert.equal(result.cached, true); 416 } finally { 417 globalThis.fetch = origFetch; 418 } 419 }); 420 421 test('bypasses cache when useCache=false', async () => { 422 const origFetch = globalThis.fetch; 423 let callCount = 0; 424 globalThis.fetch = async () => { 425 callCount++; 426 return { 427 ok: true, 428 json: async () => ({ status: 'valid', sub_status: null, free_email: null, mx_found: null }), 429 }; 430 }; 431 432 try { 433 await validateEmail('nocache@example.com'); 434 await validateEmail('nocache@example.com', { useCache: false }); 435 assert.equal(callCount, 2); 436 } finally { 437 globalThis.fetch = origFetch; 438 } 439 }); 440 441 test('returns blocked=true for invalid status', async () => { 442 const origFetch = globalThis.fetch; 443 globalThis.fetch = async () => ({ 444 ok: true, 445 json: async () => ({ 446 status: 'invalid', 447 sub_status: 'mailbox_not_found', 448 free_email: null, 449 mx_found: null, 450 }), 451 }); 452 453 try { 454 const result = await validateEmail('invalid@example.com'); 455 assert.equal(result.blocked, true); 456 assert.equal(result.status, 'invalid'); 457 } finally { 458 globalThis.fetch = origFetch; 459 } 460 }); 461 462 test('fails open on API error', async () => { 463 const origFetch = globalThis.fetch; 464 globalThis.fetch = async () => { 465 throw new Error('Network error'); 466 }; 467 468 try { 469 const result = await validateEmail('error@example.com'); 470 assert.equal(result.status, 'unknown'); 471 assert.equal(result.blocked, false); 472 assert.ok(result.error); 473 } finally { 474 globalThis.fetch = origFetch; 475 } 476 }); 477 });