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