free-score-api.test.js
1 /** 2 * Tests for the free website scanner inbound funnel. 3 * 4 * Unit tests for: 5 * - URL normalisation (logic mirrored in Worker scorer.js) 6 * - Factor summary building (logic mirrored in Worker index.js) 7 * - Free peek selection (logic mirrored in Worker index.js) 8 * - Issue counting (logic mirrored in Worker index.js) 9 * - Rate limit logic (logic mirrored in Worker index.js) 10 * - Poll daemon archiveScans() — SQLite write path 11 * 12 * Does NOT test the Cloudflare Worker directly (requires wrangler dev). 13 * Uses :memory: SQLite and a temporary test DB path — never writes to prod. 14 */ 15 16 import { test, describe, mock, before, after } from 'node:test'; 17 import assert from 'node:assert/strict'; 18 import { randomUUID } from 'crypto'; 19 import Database from 'better-sqlite3'; 20 import { createLazyPgMock } from './helpers/pg-mock.js'; 21 22 // archiveScans uses db.js (withTransaction) internally — mock it before dynamic import 23 let archiveScanDb = null; 24 mock.module('../src/utils/db.js', { 25 namedExports: createLazyPgMock(() => archiveScanDb), 26 }); 27 mock.module('../src/utils/logger.js', { 28 defaultExport: class { info() {} warn() {} error() {} debug() {} }, 29 }); 30 31 const { archiveScans } = await import('../src/api/free-score-api.js'); 32 33 // ─── normaliseUrl ───────────────────────────────────────────────────────────── 34 // Inline the logic since it mirrors the Worker (module-private in the Worker) 35 36 describe('normaliseUrl', () => { 37 function normaliseUrl(raw) { 38 let url = (raw || '').trim(); 39 if (!url.startsWith('http://') && !url.startsWith('https://')) { 40 url = `https://${url}`; 41 } 42 try { 43 const parsed = new URL(url); 44 if (parsed.hostname === 'auditandfix.com' || parsed.hostname === 'www.auditandfix.com') { 45 return null; 46 } 47 return parsed.href; 48 } catch { 49 return null; 50 } 51 } 52 53 test('prepends https:// when missing', () => { 54 assert.equal(normaliseUrl('example.com'), 'https://example.com/'); 55 }); 56 57 test('preserves https:// scheme', () => { 58 assert.equal(normaliseUrl('https://example.com'), 'https://example.com/'); 59 }); 60 61 test('preserves http:// scheme', () => { 62 assert.equal(normaliseUrl('http://example.com'), 'http://example.com/'); 63 }); 64 65 test('returns null for auditandfix.com (blocks self-scan)', () => { 66 assert.equal(normaliseUrl('https://auditandfix.com'), null); 67 assert.equal(normaliseUrl('www.auditandfix.com'), null); 68 }); 69 70 test('returns null for invalid URL', () => { 71 assert.equal(normaliseUrl('not a url at all!!'), null); 72 }); 73 74 test('trims whitespace', () => { 75 assert.match(normaliseUrl(' example.com '), /^https:\/\/example\.com/); 76 }); 77 }); 78 79 // ─── buildFactorSummary ─────────────────────────────────────────────────────── 80 81 describe('buildFactorSummary', () => { 82 function scoreToStatus(score) { 83 if (score >= 7) return 'good'; 84 if (score >= 4) return 'fair'; 85 return 'needs_work'; 86 } 87 88 function buildFactorSummary(factorScores) { 89 if (!factorScores) return {}; 90 return Object.fromEntries( 91 Object.entries(factorScores).map(([factor, data]) => [ 92 factor, 93 scoreToStatus(data?.score ?? 0), 94 ]) 95 ); 96 } 97 98 test('scores >= 7 are good', () => { 99 const summary = buildFactorSummary({ headline_quality: { score: 8 } }); 100 assert.equal(summary.headline_quality, 'good'); 101 }); 102 103 test('scores 4-6 are fair', () => { 104 const summary = buildFactorSummary({ call_to_action: { score: 5 } }); 105 assert.equal(summary.call_to_action, 'fair'); 106 }); 107 108 test('scores < 4 are needs_work', () => { 109 const summary = buildFactorSummary({ trust_signals: { score: 2 } }); 110 assert.equal(summary.trust_signals, 'needs_work'); 111 }); 112 113 test('handles null factor scores', () => { 114 const summary = buildFactorSummary(null); 115 assert.deepEqual(summary, {}); 116 }); 117 118 test('handles missing score field', () => { 119 const summary = buildFactorSummary({ headline_quality: {} }); 120 assert.equal(summary.headline_quality, 'needs_work'); 121 }); 122 }); 123 124 // ─── buildFreePeek ──────────────────────────────────────────────────────────── 125 126 describe('buildFreePeek', () => { 127 function buildFreePeek(factorScores) { 128 if (!factorScores) return null; 129 let weakest = null; 130 let weakestScore = Infinity; 131 for (const [factor, data] of Object.entries(factorScores)) { 132 if ((data?.score ?? 10) < weakestScore) { 133 weakestScore = data.score; 134 weakest = { factor, ...data }; 135 } 136 } 137 if (!weakest) return null; 138 return { 139 factor: weakest.factor, 140 score: weakest.score, 141 reasoning: weakest.reasoning || null, 142 }; 143 } 144 145 test('returns weakest factor', () => { 146 const factors = { 147 headline_quality: { score: 8, reasoning: 'Good headline' }, 148 call_to_action: { score: 2, reasoning: 'No visible CTA' }, 149 trust_signals: { score: 5, reasoning: 'Some trust signals' }, 150 }; 151 const peek = buildFreePeek(factors); 152 assert.equal(peek.factor, 'call_to_action'); 153 assert.equal(peek.score, 2); 154 assert.equal(peek.reasoning, 'No visible CTA'); 155 }); 156 157 test('includes reasoning', () => { 158 const factors = { headline_quality: { score: 3, reasoning: 'Weak headline' } }; 159 const peek = buildFreePeek(factors); 160 assert.equal(peek.reasoning, 'Weak headline'); 161 }); 162 163 test('returns null for empty factor scores', () => { 164 assert.equal(buildFreePeek(null), null); 165 assert.equal(buildFreePeek({}), null); 166 }); 167 }); 168 169 // ─── countIssues ───────────────────────────────────────────────────────────── 170 171 describe('countIssues', () => { 172 function countIssues(factorScores) { 173 if (!factorScores) return 0; 174 return Object.values(factorScores).filter(d => (d?.score ?? 10) < 5).length; 175 } 176 177 test('counts factors with score < 5', () => { 178 const factors = { 179 a: { score: 2 }, 180 b: { score: 4 }, 181 c: { score: 7 }, 182 }; 183 assert.equal(countIssues(factors), 2); 184 }); 185 186 test('returns 0 for null', () => { 187 assert.equal(countIssues(null), 0); 188 }); 189 190 test('returns 0 when all factors are healthy', () => { 191 assert.equal(countIssues({ a: { score: 8 }, b: { score: 9 } }), 0); 192 }); 193 }); 194 195 // ─── Rate limit logic ───────────────────────────────────────────────────────── 196 197 describe('rate limit', () => { 198 const rateLimitMap = new Map(); 199 200 function checkRateLimit(ip) { 201 const now = Date.now(); 202 const hourMs = 60 * 60 * 1000; 203 const dayMs = 24 * hourMs; 204 205 if (!rateLimitMap.has(ip)) { 206 rateLimitMap.set(ip, { hour: 0, day: 0, resetHour: now + hourMs, resetDay: now + dayMs }); 207 } 208 209 const entry = rateLimitMap.get(ip); 210 211 if (now > entry.resetHour) { 212 entry.hour = 0; 213 entry.resetHour = now + hourMs; 214 } 215 if (now > entry.resetDay) { 216 entry.day = 0; 217 entry.resetDay = now + dayMs; 218 } 219 220 if (entry.hour >= 10) 221 return { blocked: true, reason: 'Too many scans — try again in an hour.' }; 222 if (entry.day >= 50) return { blocked: true, reason: 'Daily scan limit reached.' }; 223 224 entry.hour++; 225 entry.day++; 226 return { blocked: false }; 227 } 228 229 test('allows first 10 scans per hour', () => { 230 const ip = `192.0.2.${randomUUID().slice(0, 3)}`; 231 for (let i = 0; i < 10; i++) { 232 assert.equal(checkRateLimit(ip).blocked, false); 233 } 234 }); 235 236 test('blocks 11th scan in same hour', () => { 237 const ip = `192.0.2.${randomUUID().slice(0, 3)}`; 238 for (let i = 0; i < 10; i++) checkRateLimit(ip); 239 assert.equal(checkRateLimit(ip).blocked, true); 240 }); 241 242 test('different IPs have independent limits', () => { 243 const ip1 = '10.0.0.1'; 244 const ip2 = '10.0.0.2'; 245 for (let i = 0; i < 10; i++) checkRateLimit(ip1); 246 assert.equal(checkRateLimit(ip1).blocked, true); 247 assert.equal(checkRateLimit(ip2).blocked, false); 248 }); 249 }); 250 251 // ─── archiveScans (poll daemon SQLite write) ────────────────────────────────── 252 253 describe('archiveScans', () => { 254 before(() => { 255 archiveScanDb = new Database(':memory:'); 256 archiveScanDb.pragma('journal_mode = WAL'); 257 // Minimal free_scans table matching the migration 258 archiveScanDb.exec(` 259 CREATE TABLE free_scans ( 260 id INTEGER PRIMARY KEY AUTOINCREMENT, 261 scan_id TEXT UNIQUE NOT NULL, 262 url TEXT NOT NULL, 263 domain TEXT NOT NULL, 264 email TEXT, 265 ip_address TEXT, 266 score REAL, 267 grade TEXT, 268 score_json TEXT, 269 industry TEXT, 270 country_code TEXT, 271 is_js_heavy INTEGER DEFAULT 0, 272 utm_source TEXT, 273 utm_medium TEXT, 274 utm_campaign TEXT, 275 ref TEXT, 276 converted_to TEXT, 277 converted_at TEXT, 278 created_at TEXT NOT NULL DEFAULT (datetime('now')), 279 email_captured_at TEXT, 280 marketing_optin INTEGER NOT NULL DEFAULT 0, 281 optin_timestamp TEXT, 282 expires_at TEXT NOT NULL DEFAULT (datetime('now', '+7 days')) 283 ) 284 `); 285 // sites and messages tables needed by feedScanEmailToNurture 286 archiveScanDb.exec(` 287 CREATE TABLE IF NOT EXISTS sites ( 288 id INTEGER PRIMARY KEY AUTOINCREMENT, 289 domain TEXT UNIQUE NOT NULL, 290 landing_page_url TEXT, 291 status TEXT DEFAULT 'scan_optin', 292 keyword TEXT, 293 score REAL, 294 grade TEXT, 295 country_code TEXT, 296 created_at TEXT DEFAULT (datetime('now')), 297 updated_at TEXT DEFAULT (datetime('now')) 298 ); 299 CREATE TABLE IF NOT EXISTS messages ( 300 id INTEGER PRIMARY KEY AUTOINCREMENT, 301 site_id INTEGER, 302 contact_uri TEXT, 303 message_type TEXT, 304 body TEXT, 305 status TEXT DEFAULT 'pending', 306 created_at TEXT DEFAULT (datetime('now')), 307 UNIQUE(site_id, contact_uri, message_type) 308 ); 309 `); 310 }); 311 312 after(() => { 313 archiveScanDb.close(); 314 archiveScanDb = null; 315 }); 316 317 function makeScan(overrides = {}) { 318 return { 319 scan_id: randomUUID(), 320 url: 'https://example.com/', 321 domain: 'example.com', 322 ip_address: '1.2.3.4', 323 score: 65, 324 grade: 'D', 325 factor_scores: { headline_quality: { score: 5, reasoning: 'OK' } }, 326 industry: 'general_business', 327 country_code: 'AU', 328 is_js_heavy: 0, 329 utm_source: null, 330 utm_medium: null, 331 utm_campaign: null, 332 ref: null, 333 email: null, 334 email_captured_at: null, 335 created_at: new Date().toISOString(), 336 kv_key: `scan_${randomUUID()}`, 337 ...overrides, 338 }; 339 } 340 341 test('inserts a new scan record', async () => { 342 const scan = makeScan(); 343 const inserted = await archiveScans([scan]); 344 assert.equal(inserted, 1); 345 const row = archiveScanDb.prepare('SELECT * FROM free_scans WHERE scan_id = ?').get(scan.scan_id); 346 assert.ok(row); 347 assert.equal(row.domain, 'example.com'); 348 assert.equal(row.score, 65); 349 assert.equal(row.grade, 'D'); 350 assert.ok(row.score_json.includes('headline_quality')); 351 }); 352 353 test('ignores duplicate scan_id (INSERT OR IGNORE)', async () => { 354 const scan = makeScan(); 355 const first = await archiveScans([scan]); 356 const second = await archiveScans([scan]); 357 assert.equal(first, 1); 358 assert.equal(second, 0); 359 const count = archiveScanDb 360 .prepare('SELECT COUNT(*) as cnt FROM free_scans WHERE scan_id = ?') 361 .get(scan.scan_id).cnt; 362 assert.equal(count, 1); 363 }); 364 365 test('inserts multiple scans in one call', async () => { 366 const scans = [makeScan(), makeScan(), makeScan()]; 367 const inserted = await archiveScans(scans); 368 assert.equal(inserted, 3); 369 }); 370 371 test('handles null factor_scores gracefully', async () => { 372 const scan = makeScan({ factor_scores: null }); 373 const inserted = await archiveScans([scan]); 374 assert.equal(inserted, 1); 375 const row = archiveScanDb.prepare('SELECT score_json FROM free_scans WHERE scan_id = ?').get(scan.scan_id); 376 assert.equal(row.score_json, null); 377 }); 378 379 test('stores email when provided', async () => { 380 const scan = makeScan({ 381 email: 'test@example.com', 382 email_captured_at: new Date().toISOString(), 383 }); 384 await archiveScans([scan]); 385 const row = archiveScanDb.prepare('SELECT email FROM free_scans WHERE scan_id = ?').get(scan.scan_id); 386 assert.equal(row.email, 'test@example.com'); 387 }); 388 389 test('returns 0 for empty array', async () => { 390 const inserted = await archiveScans([]); 391 assert.equal(inserted, 0); 392 }); 393 });