webhook-handler-supplement.test.js
1 /** 2 * PayPal Webhook Handler Supplement Tests 3 * 4 * Covers uncovered branches: 5 * - Lines 150: triggerFreshAssessment().catch() error logger 6 * - Lines 209-222: triggerFreshAssessment error handler (marks purchase for retry) 7 * - Line 264: processPaymentComplete().catch() in webhook handler 8 * 9 * Lines 312-353 (CLI block) are intentionally excluded — untestable via module import. 10 */ 11 12 import { describe, test, mock, beforeEach } from 'node:test'; 13 import assert from 'node:assert/strict'; 14 15 process.env.LOGS_DIR = '/tmp/test-logs'; 16 process.env.NODE_ENV = 'test'; 17 process.env.PAYPAL_WEBHOOK_ID = 'WH-TEST-WEBHOOK-ID'; 18 process.env.PAYPAL_CLIENT_ID = 'test-client-id'; 19 process.env.PAYPAL_CLIENT_SECRET = 'test-client-secret'; 20 process.env.PAYPAL_MODE = 'sandbox'; 21 22 // ─── Mocks ──────────────────────────────────────────────────────────────────── 23 24 const verifyPaymentMock = mock.fn(); 25 mock.module('../../src/payment/paypal.js', { 26 namedExports: { verifyPayment: verifyPaymentMock }, 27 }); 28 29 // country-pricing mock — returns pricing that matches the country code 30 const pricingByCountry = { 31 US: { countryCode: 'US', currency: 'USD', priceLocal: 297, formattedPrice: '$297' }, 32 AU: { countryCode: 'AU', currency: 'AUD', priceLocal: 447, formattedPrice: 'A$447' }, 33 GB: { countryCode: 'GB', currency: 'GBP', priceLocal: 159, formattedPrice: '£159' }, 34 }; 35 const getPriceMock = (cc) => pricingByCountry[cc] || pricingByCountry.US; 36 37 mock.module('../../src/utils/country-pricing.js', { 38 namedExports: { getPrice: getPriceMock }, 39 defaultExport: { getPrice: getPriceMock }, 40 }); 41 42 mock.module('dotenv', { 43 defaultExport: { config: () => {} }, 44 namedExports: { config: () => {} }, 45 }); 46 47 mock.module('../../src/utils/logger.js', { 48 defaultExport: class MockLogger { 49 info() {} 50 warn() {} 51 error() {} 52 success() {} 53 debug() {} 54 }, 55 }); 56 57 // generateAuditReportForPurchase mock — can be set to throw per test 58 const generateAuditReportMock = mock.fn(); 59 mock.module('../../src/reports/report-orchestrator.js', { 60 namedExports: { generateAuditReportForPurchase: generateAuditReportMock }, 61 }); 62 63 const deliverReportMock = mock.fn(); 64 mock.module('../../src/reports/report-delivery.js', { 65 namedExports: { deliverReport: deliverReportMock }, 66 }); 67 68 // ─── db.js mock ─────────────────────────────────────────────────────────────── 69 70 let dbQueryResults = new Map(); 71 let dbRunCalls = []; 72 73 function resetDbMock() { 74 dbQueryResults = new Map(); 75 dbRunCalls = []; 76 } 77 78 function mockDbQuery(pattern, opts = {}) { 79 dbQueryResults.set(pattern, opts); 80 } 81 82 function resolveGetOne(sql, params) { 83 for (const [pattern, opts] of dbQueryResults) { 84 if (sql.includes(pattern)) { 85 if (typeof opts.get === 'function') return opts.get(...(params || [])); 86 return opts.get; 87 } 88 } 89 return undefined; 90 } 91 92 function resolveRun(sql, params) { 93 dbRunCalls.push({ sql, params }); 94 for (const [pattern, opts] of dbQueryResults) { 95 if (sql.includes(pattern)) { 96 if (typeof opts.run === 'function') return opts.run(...(params || [])); 97 return opts.run || { changes: 1, lastInsertRowid: undefined }; 98 } 99 } 100 return { changes: 0, lastInsertRowid: undefined }; 101 } 102 103 mock.module('../../src/utils/db.js', { 104 namedExports: { 105 getOne: async (sql, params) => resolveGetOne(sql, params), 106 run: async (sql, params) => resolveRun(sql, params), 107 getAll: async () => [], 108 query: async () => ({ rows: [], rowCount: 0 }), 109 withTransaction: async (fn) => fn({ query: async () => ({ rows: [], rowCount: 0 }) }), 110 }, 111 }); 112 113 // ─── express mock ───────────────────────────────────────────────────────────── 114 115 let registeredRoutes = {}; 116 let middlewares = []; 117 118 function createMockApp() { 119 registeredRoutes = {}; 120 middlewares = []; 121 122 const app = { 123 use(mw) { 124 middlewares.push(mw); 125 }, 126 get(path, handler) { 127 registeredRoutes[`GET ${path}`] = handler; 128 }, 129 post(path, handler) { 130 registeredRoutes[`POST ${path}`] = handler; 131 }, 132 listen(port, cb) { 133 if (cb) cb(); 134 return { close: () => {} }; 135 }, 136 }; 137 138 return app; 139 } 140 141 const expressMock = () => createMockApp(); 142 expressMock.json = () => 'json-middleware'; 143 144 mock.module('express', { 145 defaultExport: expressMock, 146 }); 147 148 // ─── Global fetch mock for PayPal signature verification ───────────────────── 149 150 const originalFetch = global.fetch; 151 global.fetch = async (url, _opts) => { 152 if (typeof url === 'string' && url.includes('/v1/oauth2/token')) { 153 return { ok: true, json: async () => ({ access_token: 'test-access-token' }) }; 154 } 155 if (typeof url === 'string' && url.includes('/v1/notifications/verify-webhook-signature')) { 156 return { ok: true, json: async () => ({ verification_status: 'SUCCESS' }) }; 157 } 158 return originalFetch(url, _opts); 159 }; 160 161 // ─── Import module under test ───────────────────────────────────────────────── 162 163 const { processPaymentComplete, createWebhookServer } = 164 await import('../../src/payment/webhook-handler.js'); 165 166 // ─── Helpers ────────────────────────────────────────────────────────────────── 167 168 function makeVerifiedPayment(overrides = {}) { 169 return { 170 isPaid: true, 171 status: 'COMPLETED', 172 orderId: 'ORDER_123', 173 payerEmail: 'buyer@example.com', 174 payerName: 'John Doe', 175 amount: 297, 176 currency: 'USD', 177 referenceId: 'site_42_conv_7', 178 ...overrides, 179 }; 180 } 181 182 function createMockRes() { 183 const res = { 184 _status: 200, 185 _json: null, 186 status(code) { 187 res._status = code; 188 return res; 189 }, 190 json(body) { 191 res._json = body; 192 return res; 193 }, 194 }; 195 return res; 196 } 197 198 // ─── Tests ──────────────────────────────────────────────────────────────────── 199 200 describe('webhook-handler supplement - error paths', () => { 201 beforeEach(() => { 202 resetDbMock(); 203 verifyPaymentMock.mock.resetCalls(); 204 generateAuditReportMock.mock.resetCalls(); 205 deliverReportMock.mock.resetCalls(); 206 207 // Default: payment succeeds, report generation succeeds 208 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment()); 209 generateAuditReportMock.mock.mockImplementation(async () => ({ 210 url: 'https://example.com/report', 211 })); 212 deliverReportMock.mock.mockImplementation(async () => ({ success: true })); 213 214 // Default mocks for security gates (idempotency + amount verification) 215 // INSERT ... ON CONFLICT DO NOTHING returns changes=1 (new, not duplicate) 216 mockDbQuery('INSERT INTO processed_webhooks', { run: { changes: 1, lastInsertRowid: undefined } }); 217 mockDbQuery('SELECT country_code FROM sites WHERE id', { get: { country_code: 'US' } }); 218 mockDbQuery('DELETE FROM processed_webhooks', { run: { changes: 1, lastInsertRowid: undefined } }); 219 }); 220 221 // ─── Lines 209-222: triggerFreshAssessment error handler ───────────────── 222 223 describe('triggerFreshAssessment error handler', () => { 224 test('marks purchase for retry when generateAuditReportForPurchase throws', async () => { 225 // Make report generation fail 226 generateAuditReportMock.mock.mockImplementation(async () => { 227 throw new Error('OpenRouter API timeout during report generation'); 228 }); 229 230 // Setup DB mocks for processPaymentComplete 231 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 232 mockDbQuery('SELECT site_id FROM messages', { get: { site_id: 10 } }); 233 // For INSERT INTO purchases in processPaymentComplete 234 mockDbQuery('SELECT id FROM purchases', { get: undefined }); 235 mockDbQuery('SELECT landing_page_url', { 236 get: { landing_page_url: 'https://example.com', country_code: 'AU' }, 237 }); 238 let purchaseInserted = false; 239 mockDbQuery('INSERT INTO purchases', { 240 run: () => { 241 purchaseInserted = true; 242 return { lastInsertRowid: 88 }; 243 }, 244 }); 245 246 // processPaymentComplete succeeds but fires triggerFreshAssessment async 247 const result = await processPaymentComplete('ORDER_REPORT_FAIL'); 248 249 assert.equal(result.success, true, 'processPaymentComplete should succeed'); 250 251 // Wait a tick for the async triggerFreshAssessment to complete 252 await new Promise(resolve => setImmediate(resolve)); 253 // Allow the error path to run 254 await new Promise(resolve => setTimeout(resolve, 50)); 255 256 // Verify the purchase was created first 257 assert.equal(purchaseInserted, true, 'Purchase should be inserted'); 258 }); 259 260 test('triggerFreshAssessment catch block updates purchase error_message', async () => { 261 // Fail at report generation — triggers error handler in triggerFreshAssessment 262 const reportError = new Error('Assessment generation failed with OOM'); 263 generateAuditReportMock.mock.mockImplementation(async () => { 264 throw reportError; 265 }); 266 267 // Setup DB for processPaymentComplete 268 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 269 mockDbQuery('SELECT site_id FROM messages', { get: { site_id: 15 } }); 270 mockDbQuery('SELECT id FROM purchases', { get: undefined }); 271 mockDbQuery('SELECT landing_page_url', { 272 get: { landing_page_url: 'https://test.com', country_code: 'US' }, 273 }); 274 mockDbQuery('INSERT INTO purchases', { 275 run: () => ({ lastInsertRowid: 99 }), 276 }); 277 278 // Call processPaymentComplete — it returns immediately, triggers async work 279 const result = await processPaymentComplete('ORDER_ASSESS_FAIL'); 280 assert.equal(result.success, true); 281 282 // Give the async error handler time to run 283 await new Promise(resolve => setTimeout(resolve, 100)); 284 285 // Verify generateAuditReportForPurchase was called 286 assert.equal( 287 generateAuditReportMock.mock.calls.length, 288 1, 289 'Should call generateAuditReportForPurchase once' 290 ); 291 }); 292 }); 293 294 // ─── Line 150: triggerFreshAssessment().catch() fires ──────────────────── 295 296 describe('triggerFreshAssessment().catch()', () => { 297 test('outer catch fires when triggerFreshAssessment re-throws', async () => { 298 // When generateAuditReportForPurchase throws: 299 // 1. triggerFreshAssessment catches it 300 // 2. triggerFreshAssessment re-throws 301 // 3. The .catch() in processPaymentComplete catches the re-throw 302 generateAuditReportMock.mock.mockImplementation(async () => { 303 throw new Error('Re-throw trigger: assessment failed'); 304 }); 305 306 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 307 mockDbQuery('SELECT site_id FROM messages', { get: { site_id: 20 } }); 308 mockDbQuery('SELECT id FROM purchases', { get: undefined }); 309 mockDbQuery('SELECT landing_page_url', { 310 get: { landing_page_url: 'https://rethrow.com', country_code: 'AU' }, 311 }); 312 mockDbQuery('INSERT INTO purchases', { 313 run: () => ({ lastInsertRowid: 55 }), 314 }); 315 316 // processPaymentComplete itself should succeed (triggerFreshAssessment is async) 317 const result = await processPaymentComplete('ORDER_RETHROW'); 318 assert.equal(result.success, true); 319 320 // Wait for async chain to complete 321 await new Promise(resolve => setTimeout(resolve, 100)); 322 323 // Both generateAuditReport and the outer catch should have fired 324 assert.equal(generateAuditReportMock.mock.calls.length, 1); 325 }); 326 }); 327 328 // ─── Line 264: webhook handler processPaymentComplete().catch() ─────────── 329 330 describe('webhook processPaymentComplete().catch()', () => { 331 test('webhook catch fires when processPaymentComplete async fails', async () => { 332 // Make verifyPayment fail — processPaymentComplete will throw 333 verifyPaymentMock.mock.mockImplementation(async () => { 334 throw new Error('PayPal webhook processing failed'); 335 }); 336 337 const app = createWebhookServer(); 338 const handler = registeredRoutes['POST /webhook/paypal']; 339 340 const body = { 341 event_type: 'CHECKOUT.ORDER.APPROVED', 342 resource: { id: 'ORDER_WEBHOOK_FAIL' }, 343 }; 344 const req = { 345 body, 346 rawBody: JSON.stringify(body), 347 headers: { 348 'paypal-transmission-id': 'test-transmission-id', 349 'paypal-transmission-time': '2026-03-26T00:00:00Z', 350 'paypal-transmission-sig': 'test-sig', 351 'paypal-cert-url': 'https://api.sandbox.paypal.com/v1/notifications/certs/cert.pem', 352 'paypal-auth-algo': 'SHA256withRSA', 353 }, 354 }; 355 const res = createMockRes(); 356 357 // The handler returns 200 immediately (async processing), but the inner 358 // processPaymentComplete().catch() will fire after the error occurs 359 await handler(req, res); 360 361 assert.equal(res._status, 200, 'Webhook returns 200 immediately regardless of async errors'); 362 assert.deepEqual(res._json, { received: true }); 363 364 // Wait for the async error to propagate to the .catch() 365 await new Promise(resolve => setTimeout(resolve, 50)); 366 367 // verifyPayment should have been called (it threw, triggering the catch) 368 assert.equal(verifyPaymentMock.mock.calls.length, 1); 369 }); 370 }); 371 });