/ tests / reports / cro-report-generator.test.js
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  });