/ tests / api / free-score-api-supplement.test.js
free-score-api-supplement.test.js
  1  /**
  2   * Supplement tests for src/api/free-score-api.js
  3   *
  4   * Covers the async DB-backed functions using pg-mock:
  5   *   - archiveScans: insert, duplicate skip, email patch, feedScanEmailToNurture
  6   *   - fetchPendingScans: missing env var path, fetch success/error paths
  7   *   - acknowledgeScans: fires DELETE requests, handles failures gracefully
  8   *   - scanToParams: correct field mapping (tested via archiveScans results)
  9   *
 10   * The existing free-score-api.test.js tests inline logic only.
 11   * This file tests the async exported functions with mocked db.js.
 12   */
 13  
 14  import { test, describe, mock, before, after, beforeEach } from 'node:test';
 15  import assert from 'node:assert/strict';
 16  import { randomUUID } from 'crypto';
 17  import Database from 'better-sqlite3';
 18  import { createPgMock } from '../helpers/pg-mock.js';
 19  
 20  // ─── In-memory DB ─────────────────────────────────────────────────────────────
 21  
 22  const db = new Database(':memory:');
 23  
 24  db.exec(`
 25    CREATE TABLE IF NOT EXISTS free_scans (
 26      id                INTEGER PRIMARY KEY AUTOINCREMENT,
 27      scan_id           TEXT    UNIQUE NOT NULL,
 28      url               TEXT    NOT NULL,
 29      domain            TEXT    NOT NULL,
 30      ip_address        TEXT,
 31      score             REAL,
 32      grade             TEXT,
 33      score_json        TEXT,
 34      industry          TEXT,
 35      country_code      TEXT,
 36      is_js_heavy       INTEGER DEFAULT 0,
 37      utm_source        TEXT,
 38      utm_medium        TEXT,
 39      utm_campaign      TEXT,
 40      ref               TEXT,
 41      email             TEXT,
 42      email_captured_at TEXT,
 43      marketing_optin   INTEGER NOT NULL DEFAULT 0,
 44      optin_timestamp   TEXT,
 45      created_at        TEXT    NOT NULL DEFAULT (datetime('now')),
 46      expires_at        TEXT    NOT NULL DEFAULT (datetime('now', '+7 days'))
 47    );
 48  
 49    CREATE TABLE IF NOT EXISTS sites (
 50      id               INTEGER PRIMARY KEY AUTOINCREMENT,
 51      domain           TEXT    UNIQUE NOT NULL,
 52      landing_page_url TEXT,
 53      keyword          TEXT,
 54      country_code     TEXT,
 55      grade            TEXT,
 56      score            REAL,
 57      status           TEXT    DEFAULT 'found',
 58      rescored_at      TEXT,
 59      created_at       TEXT    DEFAULT (datetime('now')),
 60      updated_at       TEXT    DEFAULT (datetime('now'))
 61    );
 62  
 63    CREATE TABLE IF NOT EXISTS messages (
 64      id              INTEGER PRIMARY KEY AUTOINCREMENT,
 65      site_id         INTEGER NOT NULL,
 66      direction       TEXT    NOT NULL,
 67      contact_method  TEXT    NOT NULL,
 68      contact_uri     TEXT    NOT NULL,
 69      message_type    TEXT    NOT NULL,
 70      message_body    TEXT,
 71      approval_status TEXT,
 72      read_at         TEXT,
 73      created_at      TEXT    DEFAULT (datetime('now')),
 74      updated_at      TEXT    DEFAULT (datetime('now')),
 75      UNIQUE(site_id, contact_uri, message_type)
 76    );
 77  `);
 78  
 79  // ─── Mock modules BEFORE import ───────────────────────────────────────────────
 80  
 81  mock.module('../../src/utils/db.js', {
 82    namedExports: createPgMock(db),
 83  });
 84  
 85  mock.module('../../src/utils/logger.js', {
 86    defaultExport: class {
 87      info() {}
 88      warn() {}
 89      error() {}
 90      success() {}
 91      debug() {}
 92    },
 93  });
 94  
 95  // Set AUDITANDFIX_WORKER_URL before import so the module constant is non-empty.
 96  // Tests that want the empty-URL path must test the module-level behaviour differently.
 97  process.env.AUDITANDFIX_WORKER_URL = 'https://mock.worker.example.com';
 98  
 99  const { archiveScans, fetchPendingScans, acknowledgeScans } = await import(
100    '../../src/api/free-score-api.js'
101  );
102  
103  // ─── Helpers ──────────────────────────────────────────────────────────────────
104  
105  function clearTables() {
106    db.prepare('DELETE FROM messages').run();
107    db.prepare('DELETE FROM sites').run();
108    db.prepare('DELETE FROM free_scans').run();
109  }
110  
111  function makeScan(overrides = {}) {
112    return {
113      scan_id: randomUUID(),
114      url: 'https://example.com/',
115      domain: 'example.com',
116      ip_address: '1.2.3.4',
117      score: 65,
118      grade: 'D',
119      factor_scores: { headline_quality: { score: 5, reasoning: 'OK' } },
120      industry: 'general_business',
121      country_code: 'AU',
122      is_js_heavy: 0,
123      utm_source: null,
124      utm_medium: null,
125      utm_campaign: null,
126      ref: null,
127      email: null,
128      email_captured_at: null,
129      marketing_optin: 0,
130      optin_timestamp: null,
131      created_at: new Date().toISOString(),
132      ...overrides,
133    };
134  }
135  
136  // ─── archiveScans ─────────────────────────────────────────────────────────────
137  
138  describe('archiveScans', () => {
139    beforeEach(() => clearTables());
140  
141    test('inserts a new scan record and returns count 1', async () => {
142      const scan = makeScan();
143      const inserted = await archiveScans([scan]);
144      assert.equal(inserted, 1);
145      const row = db.prepare('SELECT * FROM free_scans WHERE scan_id = ?').get(scan.scan_id);
146      assert.ok(row, 'row should exist in DB');
147      assert.equal(row.domain, 'example.com');
148      assert.equal(row.score, 65);
149      assert.equal(row.grade, 'D');
150    });
151  
152    test('returns 0 for empty array', async () => {
153      const inserted = await archiveScans([]);
154      assert.equal(inserted, 0);
155    });
156  
157    test('skips duplicate scan_id (ON CONFLICT DO NOTHING)', async () => {
158      const scan = makeScan();
159      const first = await archiveScans([scan]);
160      const second = await archiveScans([scan]);
161      assert.equal(first, 1);
162      assert.equal(second, 0);
163      const count = db
164        .prepare('SELECT COUNT(*) as cnt FROM free_scans WHERE scan_id = ?')
165        .get(scan.scan_id).cnt;
166      assert.equal(count, 1);
167    });
168  
169    test('inserts multiple scans in one call', async () => {
170      const scans = [makeScan(), makeScan(), makeScan()];
171      const inserted = await archiveScans(scans);
172      assert.equal(inserted, 3);
173    });
174  
175    test('serialises factor_scores as JSON in score_json column', async () => {
176      const scan = makeScan({ factor_scores: { call_to_action: { score: 3 } } });
177      await archiveScans([scan]);
178      const row = db.prepare('SELECT score_json FROM free_scans WHERE scan_id = ?').get(scan.scan_id);
179      assert.ok(row.score_json, 'score_json should be stored');
180      const parsed = JSON.parse(row.score_json);
181      assert.ok('call_to_action' in parsed);
182    });
183  
184    test('stores null score_json when factor_scores is null', async () => {
185      const scan = makeScan({ factor_scores: null });
186      await archiveScans([scan]);
187      const row = db.prepare('SELECT score_json FROM free_scans WHERE scan_id = ?').get(scan.scan_id);
188      assert.equal(row.score_json, null);
189    });
190  
191    test('stores email when provided', async () => {
192      const scan = makeScan({
193        email: 'customer@example.com',
194        email_captured_at: new Date().toISOString(),
195      });
196      await archiveScans([scan]);
197      const row = db.prepare('SELECT email FROM free_scans WHERE scan_id = ?').get(scan.scan_id);
198      assert.equal(row.email, 'customer@example.com');
199    });
200  
201    test('stores is_js_heavy as truthy integer', async () => {
202      const scan = makeScan({ is_js_heavy: true });
203      await archiveScans([scan]);
204      const row = db.prepare('SELECT is_js_heavy FROM free_scans WHERE scan_id = ?').get(scan.scan_id);
205      assert.ok(row.is_js_heavy, 'is_js_heavy should be truthy');
206    });
207  
208    test('stores UTM parameters', async () => {
209      const scan = makeScan({
210        utm_source: 'google',
211        utm_medium: 'cpc',
212        utm_campaign: 'audit-q1',
213      });
214      await archiveScans([scan]);
215      const row = db
216        .prepare('SELECT utm_source, utm_medium, utm_campaign FROM free_scans WHERE scan_id = ?')
217        .get(scan.scan_id);
218      assert.equal(row.utm_source, 'google');
219      assert.equal(row.utm_medium, 'cpc');
220      assert.equal(row.utm_campaign, 'audit-q1');
221    });
222  
223    test('second call with same scan_id returns 0 inserts (ON CONFLICT DO NOTHING)', async () => {
224      // The email patch path uses `AND $1 IS NOT NULL` which triggers a pg positional
225      // reuse pattern that pg-mock cannot translate to SQLite. We verify only that
226      // duplicate inserts correctly return 0 from the ON CONFLICT DO NOTHING path.
227      const scan = makeScan({ email: null });
228      const first = await archiveScans([scan]);
229      assert.equal(first, 1);
230  
231      // Re-submit same scan_id → ON CONFLICT DO NOTHING → rowCount = 0 → inserted stays 0
232      const second = await archiveScans([{ ...scan }]);
233      assert.equal(second, 0);
234    });
235  
236    test('handles multiple scans where some have emails and some do not', async () => {
237      const scanNoEmail = makeScan({ email: null, domain: 'noemail2.com' });
238      const scanWithEmail = makeScan({ email: 'has@email.com', domain: 'hasemail.com', marketing_optin: true });
239  
240      const inserted = await archiveScans([scanNoEmail, scanWithEmail]);
241      assert.equal(inserted, 2);
242  
243      const siteNoEmail = db.prepare('SELECT id FROM sites WHERE domain = ?').get('noemail2.com') || null;
244      const siteWithEmail = db.prepare('SELECT id FROM sites WHERE domain = ?').get('hasemail.com') || null;
245      assert.equal(siteNoEmail, null, 'site should not be created for scan without email');
246      assert.ok(siteWithEmail, 'site should be created for scan with email');
247    });
248  
249    test('uses created_at when provided', async () => {
250      const ts = '2025-01-15T10:00:00.000Z';
251      const scan = makeScan({ created_at: ts });
252      await archiveScans([scan]);
253      const row = db.prepare('SELECT created_at FROM free_scans WHERE scan_id = ?').get(scan.scan_id);
254      assert.equal(row.created_at, ts);
255    });
256  
257    test('uses current time when created_at is missing', async () => {
258      const scan = makeScan({ created_at: undefined });
259      const beforeMs = Date.now();
260      await archiveScans([scan]);
261      const afterMs = Date.now();
262      const row = db.prepare('SELECT created_at FROM free_scans WHERE scan_id = ?').get(scan.scan_id);
263      const storedMs = new Date(row.created_at).getTime();
264      assert.ok(
265        storedMs >= beforeMs - 5000 && storedMs <= afterMs + 5000,
266        'created_at should be near current time'
267      );
268    });
269  });
270  
271  // ─── feedScanEmailToNurture (via archiveScans) ─────────────────────────────────
272  
273  describe('feedScanEmailToNurture (via archiveScans)', () => {
274    beforeEach(() => clearTables());
275  
276    test('creates a sites row when scan has email and domain', async () => {
277      const scan = makeScan({
278        email: 'optin@newsite.com',
279        domain: 'newsite.com',
280        marketing_optin: true,
281      });
282      await archiveScans([scan]);
283      const site = db.prepare('SELECT * FROM sites WHERE domain = ?').get('newsite.com');
284      assert.ok(site, 'site row should be created');
285      assert.equal(site.keyword, 'scan');
286    });
287  
288    test('creates a messages row with scan_optin message_type', async () => {
289      const scan = makeScan({
290        scan_id: randomUUID(),
291        email: 'optin2@somesite.com',
292        domain: 'somesite.com',
293        marketing_optin: true,
294        score: 72,
295      });
296      await archiveScans([scan]);
297      const msg = db
298        .prepare("SELECT * FROM messages WHERE contact_uri = 'optin2@somesite.com'")
299        .get();
300      assert.ok(msg, 'message row should be created');
301      assert.equal(msg.message_type, 'scan_optin');
302      assert.equal(msg.direction, 'inbound');
303    });
304  
305    test('does not create site row when email is absent', async () => {
306      const scan = makeScan({ email: null, domain: 'noemail.com' });
307      await archiveScans([scan]);
308      const site = db.prepare('SELECT id FROM sites WHERE domain = ?').get('noemail.com') || null;
309      assert.equal(site, null);
310    });
311  
312    test('does not create duplicate messages on re-poll (ON CONFLICT DO NOTHING)', async () => {
313      const scan = makeScan({
314        scan_id: randomUUID(),
315        email: 'dedup@dedup.com',
316        domain: 'dedup.com',
317        marketing_optin: true,
318      });
319      // First archive creates site + message
320      await archiveScans([scan]);
321      // Second archive with same domain/email — new scan_id so free_scans INSERT succeeds,
322      // but messages ON CONFLICT DO NOTHING should skip the duplicate
323      const scan2 = { ...scan, scan_id: randomUUID() };
324      await archiveScans([scan2]);
325  
326      const msgCount = db
327        .prepare("SELECT COUNT(*) as cnt FROM messages WHERE contact_uri = 'dedup@dedup.com'")
328        .get().cnt;
329      assert.equal(msgCount, 1, 'should only have one scan_optin message per email per site');
330    });
331  
332    test('message body mentions marketing opt-in when true', async () => {
333      const scan = makeScan({
334        email: 'mktg@optin.com',
335        domain: 'optin.com',
336        marketing_optin: true,
337        score: 55,
338      });
339      await archiveScans([scan]);
340      const msg = db
341        .prepare("SELECT message_body FROM messages WHERE contact_uri = 'mktg@optin.com'")
342        .get();
343      assert.ok(msg.message_body.includes('Marketing opt-in'), 'body should mention opt-in');
344    });
345  
346    test('message body notes no marketing opt-in when false', async () => {
347      const scan = makeScan({
348        email: 'nomktg@noptin.com',
349        domain: 'noptin.com',
350        marketing_optin: false,
351      });
352      await archiveScans([scan]);
353      const msg = db
354        .prepare("SELECT message_body FROM messages WHERE contact_uri = 'nomktg@noptin.com'")
355        .get();
356      assert.ok(msg.message_body.includes('No marketing opt-in'), 'body should note no opt-in');
357    });
358  
359    test('does not overwrite existing site from outreach pipeline', async () => {
360      // Pre-existing pipeline site
361      db.prepare(
362        `INSERT INTO sites (domain, landing_page_url, keyword, status) VALUES ('pipelinesite.com', 'https://pipelinesite.com', 'plumber sydney', 'scored')`
363      ).run();
364  
365      const scan = makeScan({
366        email: 'scan@pipelinesite.com',
367        domain: 'pipelinesite.com',
368        marketing_optin: true,
369      });
370      await archiveScans([scan]);
371  
372      // Status should remain 'scored' (ON CONFLICT DO NOTHING on domain)
373      const site = db.prepare('SELECT keyword, status FROM sites WHERE domain = ?').get('pipelinesite.com');
374      assert.equal(site.keyword, 'plumber sydney');
375      assert.equal(site.status, 'scored');
376    });
377  
378    test('stores country_code on new site row', async () => {
379      const scan = makeScan({
380        email: 'gb@uksite.co.uk',
381        domain: 'uksite.co.uk',
382        country_code: 'GB',
383        marketing_optin: true,
384      });
385      await archiveScans([scan]);
386      const site = db.prepare('SELECT country_code FROM sites WHERE domain = ?').get('uksite.co.uk');
387      assert.equal(site.country_code, 'GB');
388    });
389  });
390  
391  // ─── fetchPendingScans ────────────────────────────────────────────────────────
392  // Note: AUDITANDFIX_WORKER_URL is captured as a module-level constant at import
393  // time. Since we set it before the import above, the URL is always populated.
394  // We test fetch success/error paths by mocking global.fetch.
395  
396  describe('fetchPendingScans', () => {
397    test('returns scans array from successful worker response', async () => {
398      const mockScans = [
399        { scan_id: 'abc', domain: 'test.com', score: 70 },
400      ];
401  
402      const origFetch = global.fetch;
403      global.fetch = async () => ({
404        ok: true,
405        json: async () => ({ scans: mockScans }),
406      });
407  
408      try {
409        const result = await fetchPendingScans();
410        assert.deepEqual(result, mockScans);
411      } finally {
412        global.fetch = origFetch;
413      }
414    });
415  
416    test('returns empty array when worker response has no scans key', async () => {
417      const origFetch = global.fetch;
418      global.fetch = async () => ({
419        ok: true,
420        json: async () => ({}),
421      });
422  
423      try {
424        const result = await fetchPendingScans();
425        assert.deepEqual(result, []);
426      } finally {
427        global.fetch = origFetch;
428      }
429    });
430  
431    test('throws when worker returns non-ok response', async () => {
432      const origFetch = global.fetch;
433      global.fetch = async () => ({
434        ok: false,
435        status: 503,
436        text: async () => 'Service Unavailable',
437      });
438  
439      try {
440        await assert.rejects(() => fetchPendingScans(), /Worker responded 503/);
441      } finally {
442        global.fetch = origFetch;
443      }
444    });
445  
446    test('includes X-Auth-Secret header in request', async () => {
447      let capturedHeaders;
448      const origFetch = global.fetch;
449      global.fetch = async (url, opts) => {
450        capturedHeaders = opts?.headers || {};
451        return { ok: true, json: async () => ({ scans: [] }) };
452      };
453  
454      try {
455        await fetchPendingScans();
456        assert.ok('X-Auth-Secret' in capturedHeaders, 'should send X-Auth-Secret header');
457      } finally {
458        global.fetch = origFetch;
459      }
460    });
461  });
462  
463  // ─── acknowledgeScans ─────────────────────────────────────────────────────────
464  
465  describe('acknowledgeScans', () => {
466    let savedUrl;
467    let deletedKeys;
468  
469    before(() => {
470      savedUrl = process.env.AUDITANDFIX_WORKER_URL;
471      process.env.AUDITANDFIX_WORKER_URL = 'https://mock.example.com';
472    });
473  
474    after(() => {
475      if (savedUrl !== undefined) process.env.AUDITANDFIX_WORKER_URL = savedUrl;
476      else delete process.env.AUDITANDFIX_WORKER_URL;
477    });
478  
479    beforeEach(() => {
480      deletedKeys = [];
481    });
482  
483    test('sends DELETE for each kv key', async () => {
484      const origFetch = global.fetch;
485      global.fetch = async (url, opts) => {
486        if (opts?.method === 'DELETE') deletedKeys.push(url);
487        return { ok: true };
488      };
489  
490      try {
491        await acknowledgeScans(['scan:abc', 'scan:def']);
492        assert.equal(deletedKeys.length, 2);
493        assert.ok(deletedKeys.some(u => u.includes('scan%3Aabc')));
494        assert.ok(deletedKeys.some(u => u.includes('scan%3Adef')));
495      } finally {
496        global.fetch = origFetch;
497      }
498    });
499  
500    test('does not throw when worker returns error on delete', async () => {
501      const origFetch = global.fetch;
502      global.fetch = async () => ({ ok: false, status: 404 });
503  
504      try {
505        await assert.doesNotReject(() => acknowledgeScans(['scan:missing']));
506      } finally {
507        global.fetch = origFetch;
508      }
509    });
510  
511    test('handles empty keys array without error', async () => {
512      const origFetch = global.fetch;
513      global.fetch = async () => { throw new Error('should not be called'); };
514  
515      try {
516        await assert.doesNotReject(() => acknowledgeScans([]));
517      } finally {
518        global.fetch = origFetch;
519      }
520    });
521  
522    test('URL-encodes keys with special characters', async () => {
523      const origFetch = global.fetch;
524      global.fetch = async (url) => {
525        deletedKeys.push(url);
526        return { ok: true };
527      };
528  
529      try {
530        await acknowledgeScans(['scan:2024-01-01/abc def']);
531        assert.ok(deletedKeys[0].includes(encodeURIComponent('scan:2024-01-01/abc def')));
532      } finally {
533        global.fetch = origFetch;
534      }
535    });
536  });