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 });