report-delivery-supplement.test.js
1 /** 2 * Supplement tests for src/reports/report-delivery.js 3 * 4 * Covers: 5 * - getGradeColor logic for all grade letters (A, B, C, D, F, null/unknown) 6 * - deliverReport: missing RESEND_API_KEY throws 7 * - deliverReport: purchase not found throws 8 * - deliverReport: purchase with no report_path throws 9 * - deliverReport: Resend returns an error object → throws 10 * - deliverReport: happy path sends email, copies PDF to canonical path, updates DB 11 * 12 * All tests use in-memory SQLite via pg-mock — no real email is sent. 13 */ 14 15 import { test, describe, mock, before, after } from 'node:test'; 16 import assert from 'node:assert/strict'; 17 import Database from 'better-sqlite3'; 18 import { join } from 'path'; 19 import { tmpdir } from 'os'; 20 import { mkdirSync, rmSync, writeFileSync, existsSync } from 'fs'; 21 import { createPgMock } from '../helpers/pg-mock.js'; 22 23 // ── Set up temp dir for PDF files ───────────────────────────────────────────── 24 25 const tmpDir = join(tmpdir(), `delivery-test-${Date.now()}`); 26 mkdirSync(tmpDir, { recursive: true }); 27 const fakePdfPath = join(tmpDir, 'fake-report.pdf'); 28 29 process.env.RESEND_API_KEY = 'test-resend-key'; 30 process.env.AUDITANDFIX_SENDER_EMAIL = 'reports@auditandfix.com'; 31 32 // ── Create in-memory SQLite with required schema ────────────────────────────── 33 34 const testDb = new Database(':memory:'); 35 36 testDb.exec(` 37 CREATE TABLE IF NOT EXISTS sites ( 38 id INTEGER PRIMARY KEY, 39 domain TEXT, 40 landing_page_url TEXT, 41 status TEXT, 42 rescored_at DATETIME 43 ); 44 CREATE TABLE IF NOT EXISTS purchases ( 45 id INTEGER PRIMARY KEY AUTOINCREMENT, 46 email TEXT NOT NULL, 47 landing_page_url TEXT NOT NULL, 48 paypal_order_id TEXT UNIQUE, 49 amount INTEGER, 50 currency TEXT DEFAULT 'USD', 51 amount_usd INTEGER, 52 country_code TEXT, 53 report_path TEXT, 54 report_score REAL, 55 report_grade TEXT, 56 delivered_at TEXT, 57 updated_at TEXT DEFAULT CURRENT_TIMESTAMP, 58 created_at TEXT DEFAULT CURRENT_TIMESTAMP, 59 site_id INTEGER REFERENCES sites(id) 60 ); 61 `); 62 63 // ── Mock db.js BEFORE importing the module under test ───────────────────────── 64 65 mock.module('../../src/utils/db.js', { namedExports: createPgMock(testDb) }); 66 67 // ── Mock Resend so we never send real emails ────────────────────────────────── 68 69 const mockSendFn = mock.fn(async () => ({ id: 'test-email-id', error: null })); 70 71 mock.module('resend', { 72 namedExports: { 73 Resend: class MockResend { 74 constructor(_apiKey) { 75 this.emails = { send: mockSendFn }; 76 } 77 }, 78 }, 79 }); 80 81 mock.module('../../src/utils/load-env.js', { defaultExport: {} }); 82 83 // Import module under test AFTER mocks 84 const { deliverReport } = await import('../../src/reports/report-delivery.js'); 85 86 // ── Setup / Teardown ────────────────────────────────────────────────────────── 87 88 before(() => { 89 writeFileSync(fakePdfPath, '%PDF-1.4 fake pdf content for testing'); 90 }); 91 92 after(() => { 93 testDb.close(); 94 rmSync(tmpDir, { recursive: true, force: true }); 95 delete process.env.RESEND_API_KEY; 96 delete process.env.AUDITANDFIX_SENDER_EMAIL; 97 }); 98 99 // ── Tests ───────────────────────────────────────────────────────────────────── 100 101 describe('deliverReport — error cases', () => { 102 test('throws when RESEND_API_KEY is not set', async () => { 103 const savedKey = process.env.RESEND_API_KEY; 104 delete process.env.RESEND_API_KEY; 105 106 await assert.rejects(() => deliverReport(9999), /RESEND_API_KEY not configured/); 107 108 process.env.RESEND_API_KEY = savedKey; 109 }); 110 111 test('throws when purchase is not found', async () => { 112 await assert.rejects(() => deliverReport(99999), /Purchase 99999 not found/); 113 }); 114 115 test('throws when purchase has no report_path', async () => { 116 const { lastInsertRowid: purchaseId } = testDb 117 .prepare( 118 ` 119 INSERT INTO purchases (email, landing_page_url, report_path) 120 VALUES ('test@example.com', 'https://noreport.com', NULL) 121 ` 122 ) 123 .run(); 124 125 await assert.rejects(() => deliverReport(purchaseId), /has no report_path/); 126 }); 127 128 test('throws when Resend returns an error', async () => { 129 mockSendFn.mock.mockImplementation(async () => ({ 130 id: null, 131 error: { message: 'Invalid API key', name: 'validation_error' }, 132 })); 133 134 const { lastInsertRowid: purchaseId } = testDb 135 .prepare( 136 ` 137 INSERT INTO purchases (email, landing_page_url, report_path, report_score, report_grade) 138 VALUES ('fail@example.com', 'https://example.com', ?, 72, 'C') 139 ` 140 ) 141 .run(fakePdfPath); 142 143 await assert.rejects(() => deliverReport(purchaseId), /Resend error/); 144 145 // Reset mock 146 mockSendFn.mock.mockImplementation(async () => ({ id: 'test-email-id', error: null })); 147 }); 148 }); 149 150 describe('deliverReport — happy path', () => { 151 before(() => { 152 mockSendFn.mock.resetCalls(); 153 mockSendFn.mock.mockImplementation(async () => ({ id: 'test-email-id-123', error: null })); 154 }); 155 156 test('sends email, copies PDF to canonical path, updates purchase in DB', async () => { 157 const { lastInsertRowid: purchaseId } = testDb 158 .prepare( 159 ` 160 INSERT INTO purchases (email, landing_page_url, report_path, report_score, report_grade) 161 VALUES ('customer@example.com', 'https://acme-plumbing.com.au', ?, 68, 'D') 162 ` 163 ) 164 .run(fakePdfPath); 165 166 const result = await deliverReport(purchaseId); 167 168 assert.equal(result.success, true); 169 assert.equal(result.emailId, 'test-email-id-123'); 170 assert.ok(result.reportPath, 'reportPath should be set'); 171 assert.ok(existsSync(result.reportPath), 'canonical PDF should exist on disk'); 172 assert.ok(result.reportPath.endsWith(`${purchaseId}.pdf`), 'canonical path uses purchaseId'); 173 174 // DB should be updated 175 const updated = testDb.prepare('SELECT * FROM purchases WHERE id = ?').get(purchaseId); 176 assert.ok(updated.delivered_at, 'delivered_at should be set'); 177 assert.equal(updated.report_path, result.reportPath, 'report_path should be canonical path'); 178 179 // Resend was called with correct fields 180 assert.equal(mockSendFn.mock.callCount(), 1); 181 const sendArgs = mockSendFn.mock.calls[0].arguments[0]; 182 assert.ok(sendArgs.to === 'customer@example.com'); 183 assert.ok(sendArgs.subject.includes('acme-plumbing.com.au')); 184 assert.ok(Array.isArray(sendArgs.attachments) && sendArgs.attachments.length === 1); 185 }); 186 187 test('attaches PDF as base64 in the email', async () => { 188 mockSendFn.mock.resetCalls(); 189 190 const { lastInsertRowid: purchaseId } = testDb 191 .prepare( 192 ` 193 INSERT INTO purchases (email, landing_page_url, report_path, report_score, report_grade) 194 VALUES ('pdf-check@example.com', 'https://test-site.com', ?, 55, 'F') 195 ` 196 ) 197 .run(fakePdfPath); 198 199 await deliverReport(purchaseId); 200 201 const sendArgs = mockSendFn.mock.calls[0].arguments[0]; 202 const attachment = sendArgs.attachments[0]; 203 assert.ok(attachment.filename.includes('test-site.com')); 204 assert.ok(typeof attachment.content === 'string', 'content should be base64 string'); 205 // Base64 of '%PDF-1.4' starts with 'JVBER' 206 assert.ok(attachment.content.startsWith('JVBER'), 'content should be base64-encoded PDF'); 207 }); 208 209 test('handles A grade color correctly (sends without error)', async () => { 210 mockSendFn.mock.resetCalls(); 211 mockSendFn.mock.mockImplementation(async () => ({ id: 'grade-a-id', error: null })); 212 213 const { lastInsertRowid: purchaseId } = testDb 214 .prepare( 215 ` 216 INSERT INTO purchases (email, landing_page_url, report_path, report_score, report_grade) 217 VALUES ('agrade@example.com', 'https://highscore.com', ?, 92, 'A') 218 ` 219 ) 220 .run(fakePdfPath); 221 222 const result = await deliverReport(purchaseId); 223 assert.equal(result.success, true); 224 assert.equal(result.emailId, 'grade-a-id'); 225 }); 226 }); 227 228 describe('deliverReport — grade color logic', () => { 229 // The getGradeColor function is private but exercised via deliverReport HTML. 230 // We test it indirectly by confirming deliverReport doesn't throw for each grade. 231 232 const grades = ['A+', 'A', 'A-', 'B+', 'B', 'B-', 'C', 'D', 'F', null]; 233 234 for (const grade of grades) { 235 test(`handles grade ${grade ?? 'null'} without error`, async () => { 236 mockSendFn.mock.resetCalls(); 237 mockSendFn.mock.mockImplementation(async () => ({ id: 'ok', error: null })); 238 239 const { lastInsertRowid: purchaseId } = testDb 240 .prepare( 241 ` 242 INSERT INTO purchases (email, landing_page_url, report_path, report_score, report_grade) 243 VALUES ('grade@example.com', 'https://gradetest.com', ?, 70, ?) 244 ` 245 ) 246 .run(fakePdfPath, grade); 247 248 const result = await deliverReport(purchaseId); 249 assert.equal(result.success, true); 250 }); 251 } 252 });