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