cro-report-generator.test.js
1 /** 2 * Tests for src/reports/cro-report-generator.js 3 * 4 * Covers generateReport() — real SQLite DB + PDFKit PDF generation. 5 * Also covers pure helper functions via the generateReport() call path. 6 */ 7 8 import { test, describe, mock, before, after } from 'node:test'; 9 import assert from 'node:assert/strict'; 10 import Database from 'better-sqlite3'; 11 import { tmpdir } from 'os'; 12 import { join } from 'path'; 13 import { existsSync, rmSync, writeFileSync } from 'fs'; 14 import { join as joinPath } from 'path'; 15 import { createPgMock } from '../helpers/pg-mock.js'; 16 17 const SCORES_DIR = joinPath(process.cwd(), 'data', 'scores'); 18 const insertedScoreIds = []; 19 20 const generatedFiles = []; 21 22 // ── Create in-memory SQLite with required schema ────────────────────────────── 23 24 const testDb = new Database(':memory:'); 25 26 testDb.exec(` 27 CREATE TABLE IF NOT EXISTS sites ( 28 id INTEGER PRIMARY KEY AUTOINCREMENT, 29 domain TEXT NOT NULL, 30 landing_page_url TEXT NOT NULL, 31 keyword TEXT NOT NULL, 32 screenshot_path TEXT, 33 score_json TEXT, 34 score REAL, 35 grade TEXT, 36 scored_at DATETIME DEFAULT CURRENT_TIMESTAMP, 37 status TEXT DEFAULT 'found', 38 rescored_at DATETIME 39 ); 40 CREATE TABLE IF NOT EXISTS messages ( 41 id INTEGER PRIMARY KEY AUTOINCREMENT, 42 site_id INTEGER, 43 report_url TEXT, 44 status TEXT, 45 direction TEXT, 46 contact_method TEXT, 47 contact_uri TEXT, 48 created_at TEXT DEFAULT (datetime('now')), 49 updated_at TEXT DEFAULT (datetime('now')), 50 message_type TEXT DEFAULT 'outreach', 51 raw_payload TEXT, 52 read_at TEXT 53 ); 54 `); 55 56 // ── Mock db.js BEFORE importing module under test ───────────────────────────── 57 58 mock.module('../../src/utils/db.js', { namedExports: createPgMock(testDb) }); 59 mock.module('../../src/utils/load-env.js', { defaultExport: {} }); 60 61 // Import module under test AFTER mocks 62 const { generateReport } = await import('../../src/reports/cro-report-generator.js'); 63 64 // ── Teardown ────────────────────────────────────────────────────────────────── 65 66 after(() => { 67 testDb.close(); 68 // Clean up generated PDF files 69 for (const f of generatedFiles) { 70 if (existsSync(f)) { 71 try { 72 rmSync(f); 73 } catch { 74 /* ignore */ 75 } 76 } 77 } 78 // Clean up score files written by insertSite 79 for (const id of insertedScoreIds) { 80 const p = joinPath(SCORES_DIR, `${id}.json`); 81 if (existsSync(p)) { 82 try { rmSync(p); } catch { /* ignore */ } 83 } 84 } 85 }); 86 87 // ── Helper: insert a site with full score_json ──────────────────────────────── 88 89 function insertSite(overrides = {}) { 90 const scoreJson = JSON.stringify({ 91 category_scores: { 92 above_fold_score: 60, 93 cta_score: 45, 94 trust_score: 70, 95 mobile_score: 55, 96 ux_score: 65, 97 }, 98 major_issues: ['No clear CTA', 'Missing trust badges'], 99 strengths: ['Fast load time', 'Clear headline'], 100 quick_wins: ['Add phone number to header'], 101 recommendations: ['Redesign CTA button', 'Add testimonials'], 102 ...overrides.scoreJson, 103 }); 104 105 const result = testDb 106 .prepare( 107 `INSERT INTO sites (domain, landing_page_url, keyword, score, grade, score_json, scored_at) 108 VALUES (?, ?, ?, ?, ?, ?, datetime('now'))` 109 ) 110 .run( 111 overrides.domain || 'example.com', 112 overrides.landing_page_url || 'https://example.com', 113 overrides.keyword || 'web design', 114 overrides.score ?? 65, 115 overrides.grade || 'D+', 116 scoreJson 117 ); 118 const siteId = result.lastInsertRowid; 119 // Write score file to filesystem (prod code reads from filesystem first) 120 writeFileSync(joinPath(SCORES_DIR, `${siteId}.json`), scoreJson, 'utf8'); 121 insertedScoreIds.push(siteId); 122 return siteId; 123 } 124 125 function insertMessage(siteId) { 126 const result = testDb 127 .prepare( 128 `INSERT INTO messages (site_id, direction, contact_method, contact_uri) 129 VALUES (?, 'outbound', 'email', 'test@example.com')` 130 ) 131 .run(siteId); 132 return result.lastInsertRowid; 133 } 134 135 describe('generateReport', () => { 136 test('throws if site not found', async () => { 137 await assert.rejects(generateReport(999999, 1), /Site 999999 not found/); 138 }); 139 140 test('throws if site has no score_json', async () => { 141 const result = testDb 142 .prepare( 143 `INSERT INTO sites (domain, landing_page_url, keyword, score, grade) 144 VALUES ('noscore.com', 'https://noscore.com', 'test', 50, 'F')` 145 ) 146 .run(); 147 const siteId = result.lastInsertRowid; 148 149 // Ensure no score file exists on filesystem for this siteId 150 const scorePath = joinPath(SCORES_DIR, `${siteId}.json`); 151 if (existsSync(scorePath)) rmSync(scorePath); 152 153 await assert.rejects(generateReport(siteId, 1), /has no scoring data/); 154 }); 155 156 test('generates a PDF file and returns its path', async () => { 157 const siteId = insertSite({ grade: 'D+', score: 65 }); 158 const msgId = insertMessage(siteId); 159 160 const filepath = await generateReport(siteId, msgId); 161 generatedFiles.push(filepath); 162 163 assert.ok(typeof filepath === 'string', 'should return a string path'); 164 assert.ok(filepath.endsWith('.pdf'), 'should return a PDF path'); 165 assert.ok(existsSync(filepath), 'PDF file should exist on disk'); 166 }); 167 168 test('updates messages.report_url after generation', async () => { 169 const siteId = insertSite({ domain: 'test2.com', score: 75, grade: 'C+' }); 170 const msgId = insertMessage(siteId); 171 172 const filepath = await generateReport(siteId, msgId); 173 generatedFiles.push(filepath); 174 175 const row = testDb.prepare('SELECT report_url, status FROM messages WHERE id = ?').get(msgId); 176 177 assert.ok(row.report_url, 'report_url should be set'); 178 assert.ok(row.report_url.endsWith('.pdf'), 'report_url should reference PDF'); 179 assert.equal(row.status, 'report_delivered'); 180 }); 181 182 test('handles A-grade site (green color path)', async () => { 183 const siteId = insertSite({ domain: 'excellent.com', score: 95, grade: 'A+' }); 184 const msgId = insertMessage(siteId); 185 186 const filepath = await generateReport(siteId, msgId); 187 generatedFiles.push(filepath); 188 189 assert.ok(existsSync(filepath), 'should generate PDF for A-grade site'); 190 }); 191 192 test('handles B-grade site (light green color path)', async () => { 193 const siteId = insertSite({ domain: 'good.com', score: 85, grade: 'B+' }); 194 const msgId = insertMessage(siteId); 195 196 const filepath = await generateReport(siteId, msgId); 197 generatedFiles.push(filepath); 198 199 assert.ok(existsSync(filepath), 'should generate PDF for B-grade site'); 200 }); 201 202 test('handles C-grade site (amber color path)', async () => { 203 const siteId = insertSite({ domain: 'fair.com', score: 73, grade: 'C' }); 204 const msgId = insertMessage(siteId); 205 206 const filepath = await generateReport(siteId, msgId); 207 generatedFiles.push(filepath); 208 209 assert.ok(existsSync(filepath), 'should generate PDF for C-grade site'); 210 }); 211 212 test('handles F-grade site (red color path)', async () => { 213 const siteId = insertSite({ domain: 'critical.com', score: 30, grade: 'F' }); 214 const msgId = insertMessage(siteId); 215 216 const filepath = await generateReport(siteId, msgId); 217 generatedFiles.push(filepath); 218 219 assert.ok(existsSync(filepath), 'should generate PDF for F-grade site'); 220 }); 221 222 test('handles score_json without optional fields (no category_scores)', async () => { 223 const siteId = insertSite({ 224 domain: 'minimal.com', 225 score: 55, 226 grade: 'F', 227 scoreJson: { 228 category_scores: null, 229 major_issues: null, 230 strengths: null, 231 quick_wins: null, 232 recommendations: null, 233 }, 234 }); 235 // Override the score_json with minimal data 236 testDb.prepare('UPDATE sites SET score_json = ? WHERE id = ?').run( 237 JSON.stringify({ summary: 'Minimal data' }), 238 siteId 239 ); 240 // Also update the score file on disk 241 writeFileSync(joinPath(SCORES_DIR, `${siteId}.json`), JSON.stringify({ summary: 'Minimal data' }), 'utf8'); 242 243 const msgId = insertMessage(siteId); 244 const filepath = await generateReport(siteId, msgId); 245 generatedFiles.push(filepath); 246 247 assert.ok(existsSync(filepath), 'should generate PDF with minimal score_json'); 248 }); 249 250 test('handles score_json with empty arrays (no issues/strengths rendered)', async () => { 251 const siteId = insertSite({ 252 domain: 'empty-arrays.com', 253 score: 58, 254 grade: 'F', 255 scoreJson: { 256 category_scores: { 257 above_fold_score: 40, 258 cta_score: 30, 259 trust_score: 50, 260 mobile_score: 60, 261 ux_score: 45, 262 }, 263 major_issues: [], 264 strengths: [], 265 quick_wins: [], 266 recommendations: [], 267 }, 268 }); 269 const msgId = insertMessage(siteId); 270 const filepath = await generateReport(siteId, msgId); 271 generatedFiles.push(filepath); 272 273 assert.ok(existsSync(filepath), 'should generate PDF with empty arrays'); 274 }); 275 276 test('covers score color thresholds (low score < 30)', async () => { 277 // score 25 → red (#f44336) in addCategoryScore 278 const siteId = insertSite({ 279 domain: 'lowelow.com', 280 score: 20, 281 grade: 'F', 282 scoreJson: { 283 category_scores: { 284 above_fold_score: 25, // triggers red 285 cta_score: 35, // triggers orange 286 trust_score: 55, // triggers amber 287 mobile_score: 72, // triggers light green 288 ux_score: 88, // triggers green 289 }, 290 }, 291 }); 292 const msgId = insertMessage(siteId); 293 const filepath = await generateReport(siteId, msgId); 294 generatedFiles.push(filepath); 295 296 assert.ok(existsSync(filepath), 'should generate PDF covering all score color paths'); 297 }); 298 });