/ tests / cron / send-scan-email-sequence.test.js
send-scan-email-sequence.test.js
  1  /**
  2   * Tests for src/cron/send-scan-email-sequence.js
  3   *
  4   * Tests the pure/testable functions:
  5   *   - enrollScanEmailSequence: DB enrolment logic
  6   *   - sendScanEmailSequence: main runner (missing API key path)
  7   *
  8   * Since the module doesn't export most internal functions, we test them
  9   * indirectly through enrollScanEmailSequence and sendScanEmailSequence,
 10   * and also by re-implementing the pure logic to verify contracts.
 11   */
 12  
 13  import { test, describe, mock, before, after } from 'node:test';
 14  import assert from 'node:assert/strict';
 15  import Database from 'better-sqlite3';
 16  import { createPgMock } from '../helpers/pg-mock.js';
 17  
 18  // ─── Create in-memory test DB ─────────────────────────────────────────────────
 19  
 20  const db = new Database(':memory:');
 21  
 22  db.exec(`
 23    CREATE TABLE IF NOT EXISTS free_scans (
 24      id INTEGER PRIMARY KEY AUTOINCREMENT,
 25      scan_id TEXT UNIQUE NOT NULL,
 26      url TEXT NOT NULL DEFAULT '',
 27      domain TEXT NOT NULL DEFAULT '',
 28      email TEXT,
 29      ip_address TEXT,
 30      score REAL,
 31      grade TEXT,
 32      score_json TEXT,
 33      industry TEXT,
 34      country_code TEXT,
 35      is_js_heavy INTEGER DEFAULT 0,
 36      created_at TEXT NOT NULL DEFAULT (datetime('now')),
 37      email_captured_at TEXT,
 38      marketing_optin INTEGER DEFAULT 0,
 39      optin_timestamp TEXT,
 40      expires_at TEXT NOT NULL DEFAULT (datetime('now', '+7 days'))
 41    );
 42  
 43    CREATE TABLE IF NOT EXISTS scan_email_sequence (
 44      id INTEGER PRIMARY KEY AUTOINCREMENT,
 45      scan_id TEXT UNIQUE NOT NULL,
 46      email TEXT NOT NULL,
 47      segment TEXT NOT NULL,
 48      country_code TEXT DEFAULT 'US',
 49      score REAL,
 50      grade TEXT,
 51      domain TEXT,
 52      score_json TEXT,
 53      next_email_num INTEGER DEFAULT 1,
 54      next_send_at TEXT,
 55      last_sent_at TEXT,
 56      status TEXT DEFAULT 'active',
 57      unsubscribe_token TEXT,
 58      purchase_detected_at TEXT,
 59      created_at TEXT DEFAULT (datetime('now')),
 60      updated_at TEXT DEFAULT (datetime('now'))
 61    );
 62  
 63    CREATE TABLE IF NOT EXISTS purchases (
 64      id INTEGER PRIMARY KEY AUTOINCREMENT,
 65      email TEXT NOT NULL,
 66      product TEXT,
 67      amount REAL,
 68      status TEXT DEFAULT 'completed',
 69      created_at TEXT DEFAULT (datetime('now'))
 70    );
 71  `);
 72  
 73  // ─── Mock db.js BEFORE importing module under test ────────────────────────────
 74  
 75  mock.module('../../src/utils/db.js', {
 76    namedExports: createPgMock(db),
 77  });
 78  
 79  mock.module('../../src/utils/logger.js', {
 80    defaultExport: class {
 81      info() {}
 82      warn() {}
 83      error() {}
 84      success() {}
 85      debug() {}
 86    },
 87  });
 88  
 89  mock.module('../../src/utils/load-env.js', {
 90    namedExports: {},
 91  });
 92  
 93  mock.module('resend', {
 94    namedExports: {
 95      Resend: class {
 96        emails = { send: async () => ({ data: { id: 'mock-id' }, error: null }) };
 97      },
 98    },
 99  });
100  
101  mock.module('../../src/reports/scan-email-templates.js', {
102    namedExports: {
103      getEmailTemplate: () => ({
104        subject: 'Test Subject',
105        html: '<p>Test</p>',
106        text: 'Test',
107      }),
108    },
109  });
110  
111  // ─── Import AFTER mock.module ─────────────────────────────────────────────────
112  
113  const { enrollScanEmailSequence, sendScanEmailSequence } = await import(
114    '../../src/cron/send-scan-email-sequence.js'
115  );
116  
117  // ─── Helpers ──────────────────────────────────────────────────────────────────
118  
119  function clearTables() {
120    db.prepare('DELETE FROM scan_email_sequence').run();
121    db.prepare('DELETE FROM purchases').run();
122    db.prepare('DELETE FROM free_scans').run();
123  }
124  
125  // ── enrollScanEmailSequence ─────────────────────────────────────────────────
126  
127  describe('enrollScanEmailSequence', () => {
128    before(() => clearTables());
129  
130    test('enrolls a valid scan with email and marketing_optin', async () => {
131      const scan = {
132        scan_id: 'scan-001',
133        email: 'test@example.com',
134        marketing_optin: 1,
135        score: 45,
136        grade: 'D',
137        domain: 'example.com',
138        country_code: 'AU',
139        score_json: JSON.stringify({ headline_quality: 3.5, call_to_action: 6.2 }),
140      };
141      const result = await enrollScanEmailSequence(scan);
142      assert.equal(result.enrolled, true);
143      assert.ok(result.seqId, 'should return seqId');
144      assert.equal(result.segment, 'A'); // score 45 <= 59 → segment A
145    });
146  
147    test('assigns segment A for score <= 59', async () => {
148      clearTables();
149      const scan = {
150        scan_id: 'scan-seg-a',
151        email: 'a@example.com',
152        marketing_optin: 1,
153        score: 59,
154        domain: 'a.com',
155      };
156      const result = await enrollScanEmailSequence(scan);
157      assert.equal(result.segment, 'A');
158    });
159  
160    test('assigns segment B for score 60-76', async () => {
161      clearTables();
162      const scan = {
163        scan_id: 'scan-seg-b',
164        email: 'b@example.com',
165        marketing_optin: 1,
166        score: 70,
167        domain: 'b.com',
168      };
169      const result = await enrollScanEmailSequence(scan);
170      assert.equal(result.segment, 'B');
171    });
172  
173    test('assigns segment C for score >= 77', async () => {
174      clearTables();
175      const scan = {
176        scan_id: 'scan-seg-c',
177        email: 'c@example.com',
178        marketing_optin: 1,
179        score: 82,
180        domain: 'c.com',
181      };
182      const result = await enrollScanEmailSequence(scan);
183      assert.equal(result.segment, 'C');
184    });
185  
186    test('returns not enrolled when email is missing', async () => {
187      clearTables();
188      const scan = {
189        scan_id: 'scan-no-email',
190        email: null,
191        marketing_optin: 1,
192        score: 50,
193        domain: 'noemail.com',
194      };
195      const result = await enrollScanEmailSequence(scan);
196      assert.equal(result.enrolled, false);
197      assert.equal(result.reason, 'no_email_or_no_optin');
198    });
199  
200    test('returns not enrolled when marketing_optin is 0', async () => {
201      clearTables();
202      const scan = {
203        scan_id: 'scan-no-optin',
204        email: 'nooptin@example.com',
205        marketing_optin: 0,
206        score: 50,
207        domain: 'nooptin.com',
208      };
209      const result = await enrollScanEmailSequence(scan);
210      assert.equal(result.enrolled, false);
211      assert.equal(result.reason, 'no_email_or_no_optin');
212    });
213  
214    test('returns not enrolled when already enrolled', async () => {
215      clearTables();
216      const scan = {
217        scan_id: 'scan-dup',
218        email: 'dup@example.com',
219        marketing_optin: 1,
220        score: 50,
221        domain: 'dup.com',
222      };
223      await enrollScanEmailSequence(scan);
224      const result = await enrollScanEmailSequence(scan);
225      assert.equal(result.enrolled, false);
226      assert.equal(result.reason, 'already_enrolled');
227    });
228  
229    test('returns not enrolled when email has already purchased', async () => {
230      clearTables();
231      // Insert a purchase first
232      db.prepare("INSERT INTO purchases (email, product, amount, status) VALUES (?, 'full_audit', 297, 'completed')").run('buyer@example.com');
233  
234      const scan = {
235        scan_id: 'scan-buyer',
236        email: 'buyer@example.com',
237        marketing_optin: 1,
238        score: 50,
239        domain: 'buyer.com',
240      };
241      const result = await enrollScanEmailSequence(scan);
242      assert.equal(result.enrolled, false);
243      assert.equal(result.reason, 'already_purchased');
244    });
245  
246    test('does NOT count failed/refunded purchases as purchased', async () => {
247      clearTables();
248      db.prepare("INSERT INTO purchases (email, product, amount, status) VALUES (?, 'full_audit', 297, 'failed')").run('failed@example.com');
249      db.prepare("INSERT INTO purchases (email, product, amount, status) VALUES (?, 'full_audit', 297, 'refunded')").run('refunded@example.com');
250  
251      const scan1 = {
252        scan_id: 'scan-failed',
253        email: 'failed@example.com',
254        marketing_optin: 1,
255        score: 50,
256        domain: 'failed.com',
257      };
258      const result1 = await enrollScanEmailSequence(scan1);
259      assert.equal(result1.enrolled, true, 'failed purchase should not block enrolment');
260  
261      const scan2 = {
262        scan_id: 'scan-refunded',
263        email: 'refunded@example.com',
264        marketing_optin: 1,
265        score: 50,
266        domain: 'refunded.com',
267      };
268      const result2 = await enrollScanEmailSequence(scan2);
269      assert.equal(result2.enrolled, true, 'refunded purchase should not block enrolment');
270    });
271  
272    test('sets unsubscribe_token after enrolment', async () => {
273      clearTables();
274      const scan = {
275        scan_id: 'scan-token',
276        email: 'token@example.com',
277        marketing_optin: 1,
278        score: 50,
279        domain: 'token.com',
280      };
281      const result = await enrollScanEmailSequence(scan);
282      assert.equal(result.enrolled, true);
283  
284      const row = db.prepare('SELECT unsubscribe_token FROM scan_email_sequence WHERE id = ?').get(result.seqId);
285      assert.ok(row.unsubscribe_token, 'should have unsubscribe token');
286      assert.ok(row.unsubscribe_token.length > 10, 'token should be reasonably long');
287    });
288  
289    test('sets next_email_num to 1 and next_send_at to approximately now', async () => {
290      clearTables();
291      const scan = {
292        scan_id: 'scan-timing',
293        email: 'timing@example.com',
294        marketing_optin: 1,
295        score: 50,
296        domain: 'timing.com',
297      };
298      const beforeMs = Date.now();
299      const result = await enrollScanEmailSequence(scan);
300      const afterMs = Date.now();
301  
302      const row = db.prepare('SELECT next_email_num, next_send_at FROM scan_email_sequence WHERE id = ?').get(result.seqId);
303      assert.equal(row.next_email_num, 1);
304  
305      const sendAt = new Date(row.next_send_at).getTime();
306      // Should be within a few seconds of now
307      assert.ok(sendAt >= beforeMs - 5000 && sendAt <= afterMs + 5000, 'next_send_at should be near current time');
308    });
309  
310    test('defaults country_code to US when not provided', async () => {
311      clearTables();
312      const scan = {
313        scan_id: 'scan-no-cc',
314        email: 'nocc@example.com',
315        marketing_optin: 1,
316        score: 50,
317        domain: 'nocc.com',
318        country_code: null,
319      };
320      await enrollScanEmailSequence(scan);
321      const row = db.prepare("SELECT country_code FROM scan_email_sequence WHERE scan_id = 'scan-no-cc'").get();
322      assert.equal(row.country_code, 'US');
323    });
324  
325    test('handles zero score gracefully (segment A)', async () => {
326      clearTables();
327      const scan = {
328        scan_id: 'scan-zero',
329        email: 'zero@example.com',
330        marketing_optin: 1,
331        score: 0,
332        domain: 'zero.com',
333      };
334      const result = await enrollScanEmailSequence(scan);
335      assert.equal(result.enrolled, true);
336      assert.equal(result.segment, 'A');
337    });
338  
339    test('handles null score gracefully (parseFloat → 0 → segment A)', async () => {
340      clearTables();
341      const scan = {
342        scan_id: 'scan-null-score',
343        email: 'nullscore@example.com',
344        marketing_optin: 1,
345        score: null,
346        domain: 'nullscore.com',
347      };
348      const result = await enrollScanEmailSequence(scan);
349      assert.equal(result.enrolled, true);
350      assert.equal(result.segment, 'A');
351    });
352  });
353  
354  // ── sendScanEmailSequence — missing API key ─────────────────────────────────
355  
356  describe('sendScanEmailSequence — missing API key', () => {
357    let savedKey;
358  
359    before(() => {
360      savedKey = process.env.RESEND_API_KEY;
361      delete process.env.RESEND_API_KEY;
362    });
363  
364    after(() => {
365      if (savedKey) process.env.RESEND_API_KEY = savedKey;
366      else delete process.env.RESEND_API_KEY;
367    });
368  
369    test('returns zeroes when RESEND_API_KEY is not configured', async () => {
370      const result = await sendScanEmailSequence();
371      assert.deepEqual(result, { checked: 0, sent: 0, skipped: 0, failed: 0 });
372    });
373  });
374  
375  // ── sendScanEmailSequence — no emails due ───────────────────────────────────
376  
377  describe('sendScanEmailSequence — no emails due', () => {
378    let savedKey;
379  
380    before(() => {
381      savedKey = process.env.RESEND_API_KEY;
382      process.env.RESEND_API_KEY = 'test_key_for_testing';
383      // Clear all sequences so nothing is due
384      clearTables();
385    });
386  
387    after(() => {
388      if (savedKey) process.env.RESEND_API_KEY = savedKey;
389      else delete process.env.RESEND_API_KEY;
390    });
391  
392    test('returns zeroes when no emails are due', async () => {
393      const result = await sendScanEmailSequence();
394      assert.deepEqual(result, { checked: 0, sent: 0, skipped: 0, failed: 0 });
395    });
396  });
397  
398  // ── Segment boundary tests ──────────────────────────────────────────────────
399  
400  describe('scoreToSegment — boundary values (tested via enrol)', () => {
401    before(() => clearTables());
402  
403    test('score 59 → segment A', async () => {
404      clearTables();
405      const scan = { scan_id: 's-59', email: 'e59@x.com', marketing_optin: 1, score: 59, domain: 'x.com' };
406      const result = await enrollScanEmailSequence(scan);
407      assert.equal(result.segment, 'A');
408    });
409  
410    test('score 60 → segment B', async () => {
411      clearTables();
412      const scan = { scan_id: 's-60', email: 'e60@x.com', marketing_optin: 1, score: 60, domain: 'x.com' };
413      const result = await enrollScanEmailSequence(scan);
414      assert.equal(result.segment, 'B');
415    });
416  
417    test('score 76 → segment B', async () => {
418      clearTables();
419      const scan = { scan_id: 's-76', email: 'e76@x.com', marketing_optin: 1, score: 76, domain: 'x.com' };
420      const result = await enrollScanEmailSequence(scan);
421      assert.equal(result.segment, 'B');
422    });
423  
424    test('score 77 → segment C', async () => {
425      clearTables();
426      const scan = { scan_id: 's-77', email: 'e77@x.com', marketing_optin: 1, score: 77, domain: 'x.com' };
427      const result = await enrollScanEmailSequence(scan);
428      assert.equal(result.segment, 'C');
429    });
430  
431    test('score 100 → segment C', async () => {
432      clearTables();
433      const scan = { scan_id: 's-100', email: 'e100@x.com', marketing_optin: 1, score: 100, domain: 'x.com' };
434      const result = await enrollScanEmailSequence(scan);
435      assert.equal(result.segment, 'C');
436    });
437  });