/ tests / utils / zerobounce.test.js
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  });