poll-free-scans.test.js
1 /** 2 * Tests for src/cron/poll-free-scans.js 3 * 4 * Covers: 5 * - Missing env vars → returns early with zeroes 6 * - Empty scans response → returns early with zeroes 7 * - Successful poll → archives scans, acknowledges KV keys 8 * - Axios fetch failure → returns zeroes 9 * - Acknowledge failure → counted in failed, does not throw 10 */ 11 12 import { test, describe, mock, before, after } from 'node:test'; 13 import assert from 'node:assert/strict'; 14 import Database from 'better-sqlite3'; 15 import { join } from 'path'; 16 import { tmpdir } from 'os'; 17 import { mkdirSync, rmSync } from 'fs'; 18 import { randomUUID } from 'crypto'; 19 import { createPgMock } from '../helpers/pg-mock.js'; 20 21 // Module-level db reference for archiveScans integration tests — set in before(), closed in after() 22 let archiveDb = null; 23 24 // Mock db.js so free-score-api.js uses our SQLite db instead of the real PG pool 25 mock.module('../../src/utils/db.js', { 26 namedExports: { 27 getAll: async (sql, params) => { 28 if (!archiveDb) return []; 29 try { 30 const stmt = archiveDb.prepare(sql.replace(/\$\d+/g, '?')); 31 return stmt.all(...(params || [])); 32 } catch { return []; } 33 }, 34 getOne: async (sql, params) => { 35 if (!archiveDb) return null; 36 try { 37 const stmt = archiveDb.prepare(sql.replace(/\$\d+/g, '?')); 38 return stmt.get(...(params || [])) || null; 39 } catch { return null; } 40 }, 41 run: async (sql, params) => { 42 if (!archiveDb) return { changes: 0 }; 43 try { 44 const stmt = archiveDb.prepare(sql.replace(/\$\d+/g, '?')); 45 const r = stmt.run(...(params || [])); 46 return { changes: r.changes, lastInsertRowid: r.lastInsertRowid }; 47 } catch { return { changes: 0 }; } 48 }, 49 query: async (sql, params) => { 50 if (!archiveDb) return { rows: [], rowCount: 0 }; 51 try { 52 const translated = sql.replace(/\$\d+/g, '?'); 53 const stmt = archiveDb.prepare(translated); 54 if (/^\s*(SELECT|WITH)/i.test(translated)) { 55 const rows = stmt.all(...(params || [])); 56 return { rows, rowCount: rows.length }; 57 } else { 58 const r = stmt.run(...(params || [])); 59 return { rows: [], rowCount: r.changes }; 60 } 61 } catch { return { rows: [], rowCount: 0 }; } 62 }, 63 withTransaction: async (fn) => { 64 if (!archiveDb) return; 65 // Provide a fake pg client backed by SQLite 66 const inserted = 0; 67 const fakeClient = { 68 query: async (sql, params) => { 69 // Translate PG SQL to SQLite 70 let s = sql 71 // $1, $2, ... → ? 72 .replace(/\$\d+/g, '?') 73 // Strip type casts (::timestamptz, ::text, etc.) 74 .replace(/::\w+(?:\[\])?/g, '') 75 // NOW() → datetime('now') 76 .replace(/\bNOW\(\)/gi, "datetime('now')") 77 .replace(/\bCURRENT_TIMESTAMP\b/gi, "datetime('now')") 78 // ? + INTERVAL 'N days' → datetime(?, '+N days') 79 .replace(/\?\s*\+\s*INTERVAL\s*'(\d+)\s*days'/gi, "datetime(?, '+$1 days')") 80 // ON CONFLICT (...) DO NOTHING → use INSERT OR IGNORE (handle below) 81 .replace(/\s*ON CONFLICT \([^)]+\) DO NOTHING/gi, '') 82 // ON CONFLICT (...) DO UPDATE SET ... → strip 83 .replace(/\s*ON CONFLICT \([^)]+\) DO UPDATE SET[^;]*/gi, '') 84 // Boolean literals 85 .replace(/\bTRUE\b/g, '1').replace(/\bFALSE\b/g, '0'); 86 87 // INSERT ... ON CONFLICT DO NOTHING → INSERT OR IGNORE INTO 88 if (s.match(/^\s*INSERT\s+INTO\b/i)) { 89 s = s.replace(/^\s*INSERT\s+INTO\b/i, 'INSERT OR IGNORE INTO'); 90 } 91 92 // UPDATE ... WHERE scan_id = ? AND email IS NULL AND ? IS NOT NULL 93 // SQLite doesn't support "? IS NOT NULL" — strip that condition 94 s = s.replace(/\s+AND\s+\?\s+IS\s+NOT\s+NULL/gi, ''); 95 96 try { 97 const stmt = archiveDb.prepare(s); 98 // SQLite can't bind JS booleans — convert to 1/0 99 const safeParams = (params || []).map(p => (typeof p === 'boolean' ? (p ? 1 : 0) : p)); 100 const result = stmt.run(...safeParams); 101 return { rowCount: result.changes, rows: [] }; 102 } catch { 103 return { rowCount: 0, rows: [] }; 104 } 105 }, 106 }; 107 return await fn(fakeClient); 108 }, 109 }, 110 }); 111 112 // Create a default test DB with free_scans before any dynamic imports trigger load-env.js. 113 // This prevents "no such table: free_scans" if pollFreeScans bypasses the env-var guard 114 // (e.g. load-env.js restores AUDITANDFIX_WORKER_URL from .env.secrets after test deletes it). 115 const _defaultTestDbDir = join(tmpdir(), `poll-scans-default-${Date.now()}`); 116 mkdirSync(_defaultTestDbDir, { recursive: true }); 117 const _defaultTestDbPath = join(_defaultTestDbDir, 'default.db'); 118 process.env.DATABASE_PATH = _defaultTestDbPath; 119 120 // ── Schema ──────────────────────────────────────────────────────────────────── 121 122 function createFreeScansTable(db) { 123 db.exec(` 124 CREATE TABLE IF NOT EXISTS free_scans ( 125 id INTEGER PRIMARY KEY AUTOINCREMENT, 126 scan_id TEXT UNIQUE NOT NULL, 127 url TEXT NOT NULL, 128 domain TEXT NOT NULL, 129 email TEXT, 130 ip_address TEXT, 131 score REAL, 132 grade TEXT, 133 score_json TEXT, 134 industry TEXT, 135 country_code TEXT, 136 is_js_heavy INTEGER DEFAULT 0, 137 utm_source TEXT, 138 utm_medium TEXT, 139 utm_campaign TEXT, 140 ref TEXT, 141 created_at TEXT NOT NULL DEFAULT (datetime('now')), 142 email_captured_at TEXT, 143 marketing_optin INTEGER DEFAULT 0, 144 optin_timestamp TEXT, 145 expires_at TEXT NOT NULL DEFAULT (datetime('now', '+7 days')) 146 ) 147 `); 148 } 149 150 // Seed the default test DB so pollFreeScans won't fail on missing table 151 { 152 const _db = new Database(_defaultTestDbPath); 153 createFreeScansTable(_db); 154 _db.exec(` 155 CREATE TABLE IF NOT EXISTS scan_email_sequence ( 156 id INTEGER PRIMARY KEY AUTOINCREMENT, 157 scan_id TEXT UNIQUE NOT NULL, 158 email TEXT NOT NULL, 159 segment TEXT NOT NULL, 160 country_code TEXT DEFAULT 'US', 161 score REAL, 162 grade TEXT, 163 domain TEXT, 164 score_json TEXT, 165 next_email_num INTEGER DEFAULT 1, 166 next_send_at TEXT, 167 last_sent_at TEXT, 168 status TEXT DEFAULT 'active', 169 unsubscribe_token TEXT, 170 purchase_detected_at TEXT, 171 created_at TEXT DEFAULT (datetime('now')), 172 updated_at TEXT DEFAULT (datetime('now')) 173 ) 174 `); 175 _db.close(); 176 } 177 178 // ── Helpers ─────────────────────────────────────────────────────────────────── 179 180 function makeScan(overrides = {}) { 181 return { 182 scan_id: randomUUID(), 183 url: 'https://example.com/', 184 domain: 'example.com', 185 ip_address: '1.2.3.4', 186 score: 70, 187 grade: 'C', 188 factor_scores: { headline_quality: { score: 5 } }, 189 country_code: 'AU', 190 is_js_heavy: 0, 191 created_at: new Date().toISOString(), 192 kv_key: `scan:${randomUUID()}`, 193 ...overrides, 194 }; 195 } 196 197 // Clean up the default test DB directory after all tests 198 after(() => { 199 try { rmSync(_defaultTestDbDir, { recursive: true, force: true }); } catch { /* ignore */ } 200 }); 201 202 // ── Tests ───────────────────────────────────────────────────────────────────── 203 204 describe('pollFreeScans — missing env', () => { 205 let savedWorkerUrl; 206 let savedWorkerSecret; 207 208 before(() => { 209 savedWorkerUrl = process.env.AUDITANDFIX_WORKER_URL; 210 savedWorkerSecret = process.env.AUDITANDFIX_WORKER_SECRET; 211 delete process.env.AUDITANDFIX_WORKER_URL; 212 delete process.env.AUDITANDFIX_WORKER_SECRET; 213 }); 214 215 after(() => { 216 if (savedWorkerUrl) process.env.AUDITANDFIX_WORKER_URL = savedWorkerUrl; 217 else delete process.env.AUDITANDFIX_WORKER_URL; 218 if (savedWorkerSecret) process.env.AUDITANDFIX_WORKER_SECRET = savedWorkerSecret; 219 else delete process.env.AUDITANDFIX_WORKER_SECRET; 220 }); 221 222 test('returns zeroes when AUDITANDFIX_WORKER_URL is missing', async () => { 223 const { pollFreeScans } = await import(`../../src/cron/poll-free-scans.js?ts=${Date.now()}`); 224 const result = await pollFreeScans(); 225 assert.equal(result.processed, 0); 226 assert.equal(result.inserted, 0); 227 assert.equal(result.failed, 0); 228 }); 229 }); 230 231 describe('pollFreeScans — with mock HTTP', () => { 232 let tmpDir; 233 let dbPath; 234 235 before(() => { 236 tmpDir = join(tmpdir(), `poll-scans-test-${Date.now()}`); 237 mkdirSync(tmpDir, { recursive: true }); 238 dbPath = join(tmpDir, 'test.db'); 239 process.env.DATABASE_PATH = dbPath; 240 process.env.AUDITANDFIX_WORKER_URL = 'http://localhost:59999'; 241 process.env.AUDITANDFIX_WORKER_SECRET = 'test-secret'; 242 243 const db = new Database(dbPath); 244 createFreeScansTable(db); 245 db.close(); 246 }); 247 248 after(() => { 249 delete process.env.DATABASE_PATH; 250 delete process.env.AUDITANDFIX_WORKER_URL; 251 delete process.env.AUDITANDFIX_WORKER_SECRET; 252 rmSync(tmpDir, { recursive: true, force: true }); 253 }); 254 255 test('returns zeroes when axios.get throws (network error)', async () => { 256 // axios.get will fail since nothing runs at port 59999 257 const { pollFreeScans } = await import(`../../src/cron/poll-free-scans.js?ts=${Date.now()}a`); 258 const result = await pollFreeScans(); 259 assert.equal(result.processed, 0); 260 assert.equal(result.inserted, 0); 261 assert.equal(result.failed, 0); 262 }); 263 }); 264 265 // ── archiveScans integration via poll-free-scans dependency ────────────────── 266 267 describe('poll-free-scans — archiveScans integration', () => { 268 before(() => { 269 archiveDb = new Database(':memory:'); 270 createFreeScansTable(archiveDb); 271 }); 272 273 after(() => { 274 archiveDb.close(); 275 archiveDb = null; 276 }); 277 278 test('archiveScans inserts a scan correctly', async () => { 279 const { archiveScans } = await import('../../src/api/free-score-api.js'); 280 const scan = makeScan(); 281 const count = await archiveScans([scan]); 282 assert.equal(count, 1); 283 const row = archiveDb.prepare('SELECT * FROM free_scans WHERE scan_id = ?').get(scan.scan_id); 284 assert.ok(row); 285 assert.equal(row.score, 70); 286 }); 287 288 test('archiveScans skips duplicate scan_id', async () => { 289 const { archiveScans } = await import('../../src/api/free-score-api.js'); 290 const scan = makeScan(); 291 await archiveScans([scan]); 292 const second = await archiveScans([scan]); 293 assert.equal(second, 0); 294 }); 295 296 test('archiveScans handles empty array', async () => { 297 const { archiveScans } = await import('../../src/api/free-score-api.js'); 298 const count = await archiveScans([]); 299 assert.equal(count, 0); 300 }); 301 302 test('archiveScans handles null factor_scores', async () => { 303 const { archiveScans } = await import('../../src/api/free-score-api.js'); 304 const scan = makeScan({ factor_scores: null }); 305 const count = await archiveScans([scan]); 306 assert.equal(count, 1); 307 const row = archiveDb.prepare('SELECT score_json FROM free_scans WHERE scan_id = ?').get(scan.scan_id); 308 assert.equal(row.score_json, null); 309 }); 310 311 test('archiveScans uses created_at default when missing', async () => { 312 const { archiveScans } = await import('../../src/api/free-score-api.js'); 313 const scan = makeScan({ created_at: null }); 314 // Should not throw even with null created_at (uses new Date().toISOString() fallback) 315 const count = await archiveScans([scan]); 316 assert.equal(count, 1); 317 }); 318 });