/ tests / cron / poll-free-scans.test.js
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  });