process-purchases.test.js
1 /** 2 * Process Purchases Unit Tests 3 * 4 * Tests processPendingPurchases() with mocked report generation and delivery, 5 * and a real in-memory SQLite database via pg-mock. 6 * 7 * Key behaviors tested: 8 * - Status transitions: paid → processing → delivered 9 * - Retry logic: increments retry_count on failure, resets status to 'paid' 10 * - Max retries: after 3 failures, status becomes 'failed' + human_review_queue entry 11 * - Mixed batch: multiple purchases with mixed outcomes 12 */ 13 14 import { describe, test, mock, before, after, beforeEach } from 'node:test'; 15 import assert from 'node:assert/strict'; 16 import Database from 'better-sqlite3'; 17 import { createPgMock } from '../helpers/pg-mock.js'; 18 19 // Mock report modules before importing process-purchases.js 20 const generateReportMock = mock.fn(); 21 const deliverReportMock = mock.fn(); 22 23 mock.module('../../src/reports/report-orchestrator.js', { 24 namedExports: { 25 generateAuditReportForPurchase: generateReportMock, 26 }, 27 }); 28 29 mock.module('../../src/reports/report-delivery.js', { 30 namedExports: { 31 deliverReport: deliverReportMock, 32 }, 33 }); 34 35 // Mock dotenv 36 mock.module('dotenv', { 37 defaultExport: { config: () => {} }, 38 namedExports: { config: () => {} }, 39 }); 40 41 // Create in-memory SQLite and mock db.js BEFORE importing process-purchases 42 const testDb = new Database(':memory:'); 43 44 testDb.exec(` 45 CREATE TABLE IF NOT EXISTS purchases ( 46 id INTEGER PRIMARY KEY AUTOINCREMENT, 47 email TEXT, 48 landing_page_url TEXT, 49 paypal_order_id TEXT UNIQUE, 50 amount INTEGER, 51 currency TEXT, 52 amount_usd INTEGER, 53 status TEXT DEFAULT 'paid', 54 retry_count INTEGER DEFAULT 0, 55 error_message TEXT, 56 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 57 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 58 delivered_at DATETIME, 59 site_id INTEGER, 60 report_path TEXT, 61 report_score REAL, 62 report_grade TEXT 63 ); 64 CREATE TABLE IF NOT EXISTS human_review_queue ( 65 id INTEGER PRIMARY KEY AUTOINCREMENT, 66 file TEXT NOT NULL, 67 type TEXT NOT NULL, 68 priority TEXT NOT NULL DEFAULT 'medium', 69 reason TEXT, 70 status TEXT NOT NULL DEFAULT 'pending', 71 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 72 resolved_at DATETIME 73 ); 74 `); 75 76 mock.module('../../src/utils/db.js', { namedExports: createPgMock(testDb) }); 77 78 const { processPendingPurchases } = await import('../../src/cron/process-purchases.js'); 79 80 /** 81 * Insert a purchase into the test DB 82 */ 83 function insertPurchase(overrides = {}) { 84 const defaults = { 85 email: 'test@example.com', 86 landing_page_url: 'https://test-biz.com', 87 paypal_order_id: `ORDER_${Date.now()}_${Math.random().toString(36).slice(2)}`, 88 amount: 29700, 89 currency: 'USD', 90 amount_usd: 29700, 91 status: 'paid', 92 retry_count: 0, 93 }; 94 const data = { ...defaults, ...overrides }; 95 96 const result = testDb 97 .prepare( 98 `INSERT INTO purchases (email, landing_page_url, paypal_order_id, amount, currency, amount_usd, status, retry_count) 99 VALUES (?, ?, ?, ?, ?, ?, ?, ?)` 100 ) 101 .run( 102 data.email, 103 data.landing_page_url, 104 data.paypal_order_id, 105 data.amount, 106 data.currency, 107 data.amount_usd, 108 data.status, 109 data.retry_count 110 ); 111 112 return result.lastInsertRowid; 113 } 114 115 /** 116 * Age a purchase to be older than 6 hours so it passes any age gate 117 */ 118 function ageRecord(id) { 119 testDb 120 .prepare("UPDATE purchases SET created_at = datetime('now', '-7 hours') WHERE id = ?") 121 .run(id); 122 } 123 124 describe('processPendingPurchases', () => { 125 beforeEach(() => { 126 // Clear purchases between tests 127 testDb.prepare('DELETE FROM purchases').run(); 128 testDb.prepare('DELETE FROM human_review_queue').run(); 129 130 generateReportMock.mock.resetCalls(); 131 deliverReportMock.mock.resetCalls(); 132 133 // Default: both succeed 134 generateReportMock.mock.mockImplementation(async () => ({ success: true })); 135 deliverReportMock.mock.mockImplementation(async () => ({ 136 success: true, 137 emailId: 'email_123', 138 })); 139 }); 140 141 test('returns zero counts with empty queue', async () => { 142 const result = await processPendingPurchases(); 143 144 assert.deepEqual(result, { processed: 0, delivered: 0, failed: 0 }); 145 assert.equal(generateReportMock.mock.calls.length, 0); 146 }); 147 148 test('processes fresh purchases immediately (no age gate)', async () => { 149 insertPurchase({ email: 'fresh@test.com', paypal_order_id: 'FRESH_ORDER' }); 150 151 const result = await processPendingPurchases(); 152 153 assert.equal(result.processed, 1, 'Fresh purchase should be processed immediately'); 154 assert.equal(result.delivered, 1); 155 assert.equal(generateReportMock.mock.calls.length, 1); 156 }); 157 158 test('processes purchase older than 6 hours', async () => { 159 const id = insertPurchase({ email: 'old@test.com', paypal_order_id: 'OLD_ORDER' }); 160 ageRecord(id); 161 162 const result = await processPendingPurchases(); 163 164 assert.equal(result.processed, 1); 165 assert.equal(result.delivered, 1); 166 assert.equal(result.failed, 0); 167 168 const purchase = testDb.prepare('SELECT * FROM purchases WHERE id = ?').get(id); 169 assert.equal(purchase.status, 'delivered'); 170 assert.ok(purchase.delivered_at, 'delivered_at should be set'); 171 }); 172 173 test('sets status to processing before generating report', async () => { 174 const id = insertPurchase({ paypal_order_id: 'ORDER_STATUS_CHECK' }); 175 ageRecord(id); 176 177 let statusDuringGeneration; 178 generateReportMock.mock.mockImplementation(async () => { 179 statusDuringGeneration = testDb 180 .prepare('SELECT status FROM purchases WHERE id = ?') 181 .get(id).status; 182 return { success: true }; 183 }); 184 185 await processPendingPurchases(); 186 187 assert.equal( 188 statusDuringGeneration, 189 'processing', 190 'Status should be processing during report generation' 191 ); 192 }); 193 194 test('increments retry_count and resets to paid on first failure', async () => { 195 const id = insertPurchase({ paypal_order_id: 'ORDER_FAIL_1', retry_count: 0 }); 196 ageRecord(id); 197 198 generateReportMock.mock.mockImplementation(async () => { 199 throw new Error('Browser timeout'); 200 }); 201 202 const result = await processPendingPurchases(); 203 204 assert.equal(result.processed, 1); 205 assert.equal(result.failed, 1); 206 assert.equal(result.delivered, 0); 207 208 const purchase = testDb.prepare('SELECT * FROM purchases WHERE id = ?').get(id); 209 assert.equal(purchase.status, 'paid', 'Should reset to paid for retry'); 210 assert.equal(purchase.retry_count, 1); 211 assert.equal(purchase.error_message, 'Browser timeout'); 212 }); 213 214 test('escalates to failed status after MAX_RETRIES (3) failures', async () => { 215 const id = insertPurchase({ paypal_order_id: 'ORDER_MAX_RETRY', retry_count: 2 }); 216 ageRecord(id); 217 218 generateReportMock.mock.mockImplementation(async () => { 219 throw new Error('Max retries exceeded'); 220 }); 221 222 await processPendingPurchases(); 223 224 const purchase = testDb.prepare('SELECT * FROM purchases WHERE id = ?').get(id); 225 assert.equal(purchase.status, 'failed'); 226 assert.equal(purchase.retry_count, 3); 227 assert.equal(purchase.error_message, 'Max retries exceeded'); 228 }); 229 230 test('adds failed purchase to human_review_queue', async () => { 231 const id = insertPurchase({ 232 email: 'maxretry@test.com', 233 paypal_order_id: 'ORDER_HUMAN_REVIEW', 234 retry_count: 2, 235 }); 236 ageRecord(id); 237 238 generateReportMock.mock.mockImplementation(async () => { 239 throw new Error('Report generation crashed'); 240 }); 241 242 await processPendingPurchases(); 243 244 const queueEntry = testDb 245 .prepare('SELECT * FROM human_review_queue WHERE file = ?') 246 .get(`purchase_${id}`); 247 248 assert.ok(queueEntry, 'Should have human review queue entry'); 249 assert.equal(queueEntry.type, 'purchase_failure'); 250 assert.equal(queueEntry.priority, 'high'); 251 assert.ok(queueEntry.reason.includes('maxretry@test.com')); 252 assert.ok(queueEntry.reason.includes('Report generation crashed')); 253 }); 254 255 test('delivery failure also counts as failed', async () => { 256 const id = insertPurchase({ paypal_order_id: 'ORDER_DELIVERY_FAIL', retry_count: 0 }); 257 ageRecord(id); 258 259 generateReportMock.mock.mockImplementation(async () => ({ success: true })); 260 deliverReportMock.mock.mockImplementation(async () => { 261 throw new Error('Email delivery failed: invalid address'); 262 }); 263 264 const result = await processPendingPurchases(); 265 266 assert.equal(result.failed, 1); 267 268 const purchase = testDb.prepare('SELECT * FROM purchases WHERE id = ?').get(id); 269 assert.equal(purchase.status, 'paid', 'Should reset to paid for retry'); 270 assert.equal(purchase.retry_count, 1); 271 }); 272 273 test('processes mixed batch: one success + one failure', async () => { 274 const successId = insertPurchase({ 275 email: 'success@test.com', 276 paypal_order_id: 'ORDER_SUCCESS', 277 }); 278 const failId = insertPurchase({ email: 'fail@test.com', paypal_order_id: 'ORDER_FAIL' }); 279 ageRecord(successId); 280 ageRecord(failId); 281 282 let callCount = 0; 283 generateReportMock.mock.mockImplementation(async () => { 284 callCount++; 285 if (callCount === 2) throw new Error('Second purchase failed'); 286 return { success: true }; 287 }); 288 289 const result = await processPendingPurchases(); 290 291 assert.equal(result.processed, 2); 292 assert.equal(result.delivered, 1); 293 assert.equal(result.failed, 1); 294 }); 295 296 test('processes multiple aged purchases in order (oldest first)', async () => { 297 const ids = []; 298 for (let i = 0; i < 3; i++) { 299 const id = insertPurchase({ paypal_order_id: `ORDER_MULTI_${i}` }); 300 ageRecord(id); 301 ids.push(id); 302 } 303 304 const processedIds = []; 305 generateReportMock.mock.mockImplementation(async purchaseId => { 306 processedIds.push(purchaseId); 307 return { success: true }; 308 }); 309 310 const result = await processPendingPurchases(); 311 312 assert.equal(result.processed, 3); 313 assert.equal(result.delivered, 3); 314 assert.equal(processedIds.length, 3); 315 }); 316 317 test('skips non-paid statuses (processing, delivered, failed, refunded)', async () => { 318 const statuses = ['processing', 'delivered', 'failed', 'refunded']; 319 for (const status of statuses) { 320 const id = insertPurchase({ paypal_order_id: `ORDER_SKIP_${status}`, status }); 321 ageRecord(id); 322 } 323 324 const result = await processPendingPurchases(); 325 assert.equal(result.processed, 0, 'Should only pick up paid purchases'); 326 }); 327 });