webhook-handler.test.js
1 /** 2 * PayPal Webhook Handler Unit Tests 3 * 4 * Tests processPaymentComplete(), createWebhookServer(), startWebhookServer() 5 * with mocked db.js, express, verifyPayment, dotenv, and Logger. 6 * 7 * Key behaviors tested: 8 * - processPaymentComplete: payment verification, DB updates, idempotency, error handling 9 * - createWebhookServer: route registration, event dispatch, error responses 10 * - startWebhookServer: server binding on specified port 11 */ 12 13 import { describe, test, mock, beforeEach } from 'node:test'; 14 import assert from 'node:assert/strict'; 15 16 // ─── Environment ───────────────────────────────────────────────────────────── 17 18 process.env.LOGS_DIR = '/tmp/test-logs'; 19 process.env.NODE_ENV = 'test'; 20 // Set PayPal env vars for webhook signature verification tests 21 process.env.PAYPAL_WEBHOOK_ID = 'WH-TEST-WEBHOOK-ID'; 22 process.env.PAYPAL_CLIENT_ID = 'test-client-id'; 23 process.env.PAYPAL_CLIENT_SECRET = 'test-client-secret'; 24 process.env.PAYPAL_MODE = 'sandbox'; 25 26 // ─── Mocks (must be registered BEFORE importing the module under test) ─────── 27 28 // verifyPayment mock 29 const verifyPaymentMock = mock.fn(); 30 31 mock.module('../../src/payment/paypal.js', { 32 namedExports: { 33 verifyPayment: verifyPaymentMock, 34 }, 35 }); 36 37 // country-pricing mock — returns pricing that matches the country code 38 const pricingByCountry = { 39 US: { countryCode: 'US', currency: 'USD', priceLocal: 297, formattedPrice: '$297' }, 40 AU: { countryCode: 'AU', currency: 'AUD', priceLocal: 447, formattedPrice: 'A$447' }, 41 GB: { countryCode: 'GB', currency: 'GBP', priceLocal: 159, formattedPrice: '£159' }, 42 }; 43 const getPriceMock = (cc) => pricingByCountry[cc] || pricingByCountry.US; 44 45 mock.module('../../src/utils/country-pricing.js', { 46 namedExports: { getPrice: getPriceMock }, 47 defaultExport: { getPrice: getPriceMock }, 48 }); 49 50 // dotenv mock 51 mock.module('dotenv', { 52 defaultExport: { config: () => {} }, 53 namedExports: { config: () => {} }, 54 }); 55 56 // Logger mock — returns a no-op logger instance 57 mock.module('../../src/utils/logger.js', { 58 defaultExport: class MockLogger { 59 info() {} 60 warn() {} 61 error() {} 62 success() {} 63 debug() {} 64 }, 65 }); 66 67 // ─── db.js mock ─────────────────────────────────────────────────────────────── 68 // 69 // We track query results via a registry so each test can control what the DB 70 // "returns" for specific SQL patterns. The API mirrors the real db.js helpers. 71 72 /** @type {Map<string, {get?: *, run?: *}>} */ 73 let dbQueryResults = new Map(); 74 let dbRunCalls = []; 75 76 function resetDbMock() { 77 dbQueryResults = new Map(); 78 dbRunCalls = []; 79 } 80 81 /** 82 * Register a mock result for a SQL pattern. 83 * @param {string} pattern - substring that the SQL must contain 84 * @param {object} opts - { get, run } return values or functions 85 */ 86 function mockDbQuery(pattern, opts = {}) { 87 dbQueryResults.set(pattern, opts); 88 } 89 90 function resolveGetOne(sql, params) { 91 for (const [pattern, opts] of dbQueryResults) { 92 if (sql.includes(pattern)) { 93 if (typeof opts.get === 'function') return opts.get(...(params || [])); 94 return opts.get; 95 } 96 } 97 return undefined; 98 } 99 100 function resolveRun(sql, params) { 101 dbRunCalls.push({ sql, params }); 102 for (const [pattern, opts] of dbQueryResults) { 103 if (sql.includes(pattern)) { 104 if (typeof opts.run === 'function') return opts.run(...(params || [])); 105 return opts.run || { changes: 1, lastInsertRowid: undefined }; 106 } 107 } 108 return { changes: 0, lastInsertRowid: undefined }; 109 } 110 111 mock.module('../../src/utils/db.js', { 112 namedExports: { 113 getOne: async (sql, params) => resolveGetOne(sql, params), 114 run: async (sql, params) => resolveRun(sql, params), 115 getAll: async () => [], 116 query: async () => ({ rows: [], rowCount: 0 }), 117 withTransaction: async (fn) => fn({ query: async () => ({ rows: [], rowCount: 0 }) }), 118 }, 119 }); 120 121 // ─── express mock ──────────────────────────────────────────────────────────── 122 // 123 // Records registered routes so tests can invoke handlers directly. 124 125 let registeredRoutes = {}; 126 let middlewares = []; 127 128 function createMockApp() { 129 registeredRoutes = {}; 130 middlewares = []; 131 132 const app = { 133 use(mw) { 134 middlewares.push(mw); 135 }, 136 get(path, handler) { 137 registeredRoutes[`GET ${path}`] = handler; 138 }, 139 post(path, handler) { 140 registeredRoutes[`POST ${path}`] = handler; 141 }, 142 listen(port, cb) { 143 if (cb) cb(); 144 return { close: () => {} }; 145 }, 146 }; 147 148 return app; 149 } 150 151 const expressMock = () => createMockApp(); 152 expressMock.json = () => 'json-middleware'; 153 154 mock.module('express', { 155 defaultExport: expressMock, 156 }); 157 158 // Mock report-orchestrator and report-delivery to avoid llm-provider initialization 159 mock.module('../../src/reports/report-orchestrator.js', { 160 namedExports: { 161 generateAuditReportForPurchase: mock.fn(async () => ({ url: 'https://example.com/report' })), 162 }, 163 }); 164 165 mock.module('../../src/reports/report-delivery.js', { 166 namedExports: { 167 deliverReport: mock.fn(async () => ({ success: true })), 168 }, 169 }); 170 171 // ─── Global fetch mock for PayPal signature verification ───────────────────── 172 // 173 // The Express webhook handler now calls verifyWebhookSignature() which uses 174 // global fetch to call PayPal's OAuth + verify-webhook-signature APIs. 175 // We mock fetch to return SUCCESS for all test webhook requests. 176 177 const originalFetch = global.fetch; 178 const fetchMock = mock.fn(async (url, _opts) => { 179 if (typeof url === 'string' && url.includes('/v1/oauth2/token')) { 180 return { 181 ok: true, 182 json: async () => ({ access_token: 'test-access-token' }), 183 }; 184 } 185 if (typeof url === 'string' && url.includes('/v1/notifications/verify-webhook-signature')) { 186 return { 187 ok: true, 188 json: async () => ({ verification_status: 'SUCCESS' }), 189 }; 190 } 191 // Fall through to original fetch for anything else 192 return originalFetch(url, _opts); 193 }); 194 global.fetch = fetchMock; 195 196 // ─── Import module under test ──────────────────────────────────────────────── 197 198 const { processPaymentComplete, createWebhookServer, startWebhookServer, verifyWebhookSignature } = 199 await import('../../src/payment/webhook-handler.js'); 200 201 // ─── Helpers ───────────────────────────────────────────────────────────────── 202 203 /** 204 * Build a mock Express response object that records status + json calls. 205 */ 206 function createMockRes() { 207 const res = { 208 _status: 200, 209 _json: null, 210 status(code) { 211 res._status = code; 212 return res; 213 }, 214 json(body) { 215 res._json = body; 216 return res; 217 }, 218 }; 219 return res; 220 } 221 222 /** 223 * Build a standard verified-payment response object. 224 */ 225 function makeVerifiedPayment(overrides = {}) { 226 return { 227 isPaid: true, 228 status: 'COMPLETED', 229 orderId: 'ORDER_123', 230 payerEmail: 'buyer@example.com', 231 payerName: 'John Doe', 232 amount: 297, 233 currency: 'USD', 234 referenceId: 'site_42_conv_7', 235 ...overrides, 236 }; 237 } 238 239 /** 240 * Build a mock Express request with PayPal signature headers. 241 * The verifyWebhookSignature() function reads these headers via req.headers[]. 242 */ 243 function createMockReq(body, overrides = {}) { 244 return { 245 body, 246 rawBody: JSON.stringify(body), 247 headers: { 248 'paypal-transmission-id': 'test-transmission-id', 249 'paypal-transmission-time': '2026-03-26T00:00:00Z', 250 'paypal-transmission-sig': 'test-sig', 251 'paypal-cert-url': 'https://api.sandbox.paypal.com/v1/notifications/certs/cert.pem', 252 'paypal-auth-algo': 'SHA256withRSA', 253 ...overrides, 254 }, 255 }; 256 } 257 258 // ─── Tests ─────────────────────────────────────────────────────────────────── 259 260 describe('webhook-handler', () => { 261 beforeEach(() => { 262 resetDbMock(); 263 verifyPaymentMock.mock.resetCalls(); 264 fetchMock.mock.resetCalls(); 265 // Reset fetch mock to default (successful verification) 266 fetchMock.mock.mockImplementation(async (url, _opts) => { 267 if (typeof url === 'string' && url.includes('/v1/oauth2/token')) { 268 return { ok: true, json: async () => ({ access_token: 'test-access-token' }) }; 269 } 270 if (typeof url === 'string' && url.includes('/v1/notifications/verify-webhook-signature')) { 271 return { ok: true, json: async () => ({ verification_status: 'SUCCESS' }) }; 272 } 273 return originalFetch(url, _opts); 274 }); 275 verifyPaymentMock.mock.mockImplementation(async () => { 276 throw new Error('verifyPayment mock not configured for this test'); 277 }); 278 // Default mocks for security gates (idempotency + amount verification). 279 // INSERT ... ON CONFLICT DO NOTHING returns changes=1 (new, not duplicate). 280 mockDbQuery('INSERT INTO processed_webhooks', { run: { changes: 1, lastInsertRowid: undefined } }); 281 mockDbQuery('SELECT country_code FROM sites WHERE id', { get: { country_code: 'US' } }); 282 mockDbQuery('DELETE FROM processed_webhooks', { run: { changes: 1, lastInsertRowid: undefined } }); 283 }); 284 285 // ─── processPaymentComplete ────────────────────────────────────────────── 286 287 describe('processPaymentComplete()', () => { 288 test('successful payment processing (happy path)', async () => { 289 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment()); 290 291 // No existing payment (first time) 292 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 293 // Conversation with site_id 294 mockDbQuery('SELECT site_id FROM messages', { 295 get: { site_id: 99 }, 296 }); 297 // UPDATE queries succeed 298 mockDbQuery('UPDATE messages', { run: { changes: 1 } }); 299 mockDbQuery('UPDATE messages', { run: { changes: 1 } }); 300 301 const result = await processPaymentComplete('ORDER_123'); 302 303 assert.equal(result.success, true); 304 assert.equal(result.message, 'Payment processed successfully'); 305 assert.equal(result.messageId, 7); 306 assert.equal(result.siteId, 42); 307 assert.equal(result.orderId, 'ORDER_123'); 308 assert.equal(result.amount, 297); 309 assert.equal(verifyPaymentMock.mock.calls.length, 1); 310 assert.equal(verifyPaymentMock.mock.calls[0].arguments[0], 'ORDER_123'); 311 }); 312 313 test('payment not completed (isPaid=false)', async () => { 314 verifyPaymentMock.mock.mockImplementation(async () => 315 makeVerifiedPayment({ isPaid: false, status: 'CREATED' }) 316 ); 317 318 const result = await processPaymentComplete('ORDER_PENDING'); 319 320 assert.equal(result.success, false); 321 assert.ok(result.message.includes('CREATED')); 322 }); 323 324 test('invalid reference ID format (null)', async () => { 325 verifyPaymentMock.mock.mockImplementation(async () => 326 makeVerifiedPayment({ referenceId: null }) 327 ); 328 329 await assert.rejects( 330 () => processPaymentComplete('ORDER_BAD_REF'), 331 err => { 332 assert.ok(err.message.includes('Invalid reference ID format')); 333 return true; 334 } 335 ); 336 }); 337 338 test('invalid reference ID format (wrong pattern)', async () => { 339 verifyPaymentMock.mock.mockImplementation(async () => 340 makeVerifiedPayment({ referenceId: 'bad-format-123' }) 341 ); 342 343 await assert.rejects( 344 () => processPaymentComplete('ORDER_BAD_REF2'), 345 err => { 346 assert.ok(err.message.includes('Invalid reference ID format')); 347 assert.ok(err.message.includes('bad-format-123')); 348 return true; 349 } 350 ); 351 }); 352 353 test('invalid reference ID format (missing conv part)', async () => { 354 verifyPaymentMock.mock.mockImplementation(async () => 355 makeVerifiedPayment({ referenceId: 'site_42' }) 356 ); 357 358 await assert.rejects( 359 () => processPaymentComplete('ORDER_PARTIAL'), 360 err => { 361 assert.ok(err.message.includes('Invalid reference ID format')); 362 return true; 363 } 364 ); 365 }); 366 367 test('already-processed payment returns success with idempotency message (paid status)', async () => { 368 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment()); 369 370 mockDbQuery('SELECT id, payment_id FROM messages', { 371 get: { id: 7, payment_id: 'ORDER_DUPE' }, 372 }); 373 374 const result = await processPaymentComplete('ORDER_DUPE'); 375 376 assert.equal(result.success, true); 377 assert.equal(result.message, 'Payment already processed'); 378 assert.equal(result.messageId, 7); 379 }); 380 381 test('already-processed payment returns success (report_delivered status)', async () => { 382 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment()); 383 384 mockDbQuery('SELECT id, payment_id FROM messages', { 385 get: { id: 7, payment_id: 'ORDER_DELIVERED' }, 386 }); 387 388 const result = await processPaymentComplete('ORDER_DELIVERED'); 389 390 assert.equal(result.success, true); 391 assert.equal(result.message, 'Payment already processed'); 392 }); 393 394 test('conversation with pending status is NOT treated as already processed', async () => { 395 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment()); 396 397 // payment_id not yet set, so it should proceed 398 mockDbQuery('SELECT id, payment_id FROM messages', { 399 get: { id: 7, payment_id: null }, 400 }); 401 mockDbQuery('SELECT site_id FROM messages', { 402 get: { site_id: 50 }, 403 }); 404 405 const result = await processPaymentComplete('ORDER_PENDING_CONV'); 406 407 assert.equal(result.success, true); 408 assert.equal(result.message, 'Payment processed successfully'); 409 }); 410 411 test('updates conversation status to paid with payment details', async () => { 412 const updateArgs = []; 413 414 verifyPaymentMock.mock.mockImplementation(async () => 415 makeVerifiedPayment({ amount: 447, currency: 'AUD', referenceId: 'site_10_conv_20' }) 416 ); 417 418 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 419 mockDbQuery('UPDATE messages', { 420 run: (...args) => { 421 updateArgs.push(...args); 422 return { changes: 1 }; 423 }, 424 }); 425 mockDbQuery('SELECT site_id FROM messages', { 426 get: { site_id: null }, 427 }); 428 // Override country_code for AU pricing (amount=447, currency=AUD) 429 mockDbQuery('SELECT country_code FROM sites WHERE id', { get: { country_code: 'AU' } }); 430 431 const result = await processPaymentComplete('ORDER_UPDATE'); 432 433 assert.equal(result.amount, 447); 434 assert.equal(result.messageId, 20); 435 assert.equal(result.siteId, 10); 436 // Verify params passed to UPDATE messages: [$1=orderId, $2=amount, $3=messageId] 437 const updateCall = dbRunCalls.find(c => c.sql.includes('UPDATE messages')); 438 assert.ok(updateCall, 'Should have called UPDATE messages'); 439 assert.equal(updateCall.params[0], 'ORDER_UPDATE'); 440 assert.equal(updateCall.params[1], 447); 441 assert.equal(updateCall.params[2], 20); 442 }); 443 444 test('marks outreach as sale when site_id exists', async () => { 445 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment({ amount: 297 })); 446 447 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 448 mockDbQuery('UPDATE messages', { run: { changes: 1 } }); 449 mockDbQuery('SELECT site_id FROM messages', { 450 get: { site_id: 77 }, 451 }); 452 453 await processPaymentComplete('ORDER_SALE'); 454 455 // UPDATE sites SET resulted_in_sale=1, sale_amount=$1 WHERE id=$2 456 const siteUpdate = dbRunCalls.find(c => c.sql.includes('UPDATE sites')); 457 assert.ok(siteUpdate, 'Should update sites table'); 458 assert.equal(siteUpdate.params[0], 297, 'sale_amount should be 297'); 459 assert.equal(siteUpdate.params[1], 77, 'site_id should be 77'); 460 }); 461 462 test('skips outreach update when site_id is missing (null)', async () => { 463 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment()); 464 465 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 466 mockDbQuery('UPDATE messages', { run: { changes: 1 } }); 467 mockDbQuery('SELECT site_id FROM messages', { 468 get: { site_id: null }, 469 }); 470 471 await processPaymentComplete('ORDER_NO_OUTREACH'); 472 473 const siteUpdate = dbRunCalls.find(c => c.sql.includes('UPDATE sites')); 474 assert.equal(siteUpdate, undefined, 'Should not update sites when site_id is null'); 475 }); 476 477 test('skips outreach update when conversation has no site_id field', async () => { 478 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment()); 479 480 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 481 mockDbQuery('UPDATE messages', { run: { changes: 1 } }); 482 mockDbQuery('SELECT site_id FROM messages', { 483 get: undefined, // conversation not found 484 }); 485 486 // Should not throw 487 const result = await processPaymentComplete('ORDER_NO_CONV'); 488 489 assert.equal(result.success, true); 490 }); 491 492 test('verifyPayment error propagates', async () => { 493 verifyPaymentMock.mock.mockImplementation(async () => { 494 throw new Error('PayPal API timeout'); 495 }); 496 497 await assert.rejects( 498 () => processPaymentComplete('ORDER_API_FAIL'), 499 err => { 500 assert.ok(err.message.includes('PayPal API timeout')); 501 return true; 502 } 503 ); 504 }); 505 506 test('database error during update propagates', async () => { 507 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment()); 508 509 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 510 mockDbQuery('UPDATE messages', { 511 run: () => { 512 throw new Error('PG_ERROR: connection lost'); 513 }, 514 }); 515 516 await assert.rejects( 517 () => processPaymentComplete('ORDER_DB_FAIL'), 518 err => { 519 assert.ok(err.message.includes('PG_ERROR')); 520 return true; 521 } 522 ); 523 }); 524 525 test('parses siteId and conversationId correctly from reference', async () => { 526 verifyPaymentMock.mock.mockImplementation(async () => 527 makeVerifiedPayment({ referenceId: 'site_999_conv_12345' }) 528 ); 529 530 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 531 mockDbQuery('SELECT site_id FROM messages', { get: { site_id: null } }); 532 533 const result = await processPaymentComplete('ORDER_PARSE'); 534 535 assert.equal(result.siteId, 999); 536 assert.equal(result.messageId, 12345); 537 }); 538 539 test('uses existing purchase record when paypal_order_id already in purchases table', async () => { 540 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment()); 541 542 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 543 mockDbQuery('SELECT site_id FROM messages', { get: { site_id: 10 } }); 544 // Return existing purchase — covers the existingPurchase truthy branch 545 mockDbQuery('SELECT id FROM purchases', { get: { id: 55 } }); 546 547 const result = await processPaymentComplete('ORDER_EXISTING_PURCHASE'); 548 549 assert.equal(result.success, true); 550 assert.equal(result.purchaseId, 55); 551 }); 552 553 test('creates new purchase record when site is found but no existing purchase', async () => { 554 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment()); 555 556 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 557 mockDbQuery('SELECT site_id FROM messages', { get: { site_id: 20 } }); 558 // No existing purchase 559 mockDbQuery('SELECT id FROM purchases', { get: undefined }); 560 // Site found — covers INSERT INTO purchases branch 561 mockDbQuery('SELECT landing_page_url', { 562 get: { landing_page_url: 'https://example.com', country_code: 'AU' }, 563 }); 564 let insertCalled = false; 565 mockDbQuery('INSERT INTO purchases', { 566 run: () => { 567 insertCalled = true; 568 return { lastInsertRowid: 77 }; 569 }, 570 }); 571 572 const result = await processPaymentComplete('ORDER_NEW_PURCHASE'); 573 574 assert.equal(result.success, true); 575 assert.equal(insertCalled, true, 'Should have inserted a new purchase record'); 576 }); 577 }); 578 579 // ─── createWebhookServer ───────────────────────────────────────────────── 580 581 describe('createWebhookServer()', () => { 582 let app; 583 584 beforeEach(() => { 585 app = createWebhookServer(); 586 }); 587 588 test('returns an app object with expected route methods', () => { 589 assert.ok(app, 'Should return an app object'); 590 assert.ok(typeof app.listen === 'function', 'Should have listen method'); 591 }); 592 593 test('health check endpoint returns ok', async () => { 594 const handler = registeredRoutes['GET /health']; 595 assert.ok(handler, 'GET /health should be registered'); 596 597 const res = createMockRes(); 598 handler({}, res); 599 600 assert.deepEqual(res._json, { status: 'ok', service: 'paypal-webhook' }); 601 }); 602 603 test('CHECKOUT.ORDER.APPROVED event processing returns 200', async () => { 604 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment()); 605 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 606 mockDbQuery('SELECT site_id FROM messages', { 607 get: { site_id: 1 }, 608 }); 609 610 const handler = registeredRoutes['POST /webhook/paypal']; 611 assert.ok(handler, 'POST /webhook/paypal should be registered'); 612 613 const req = createMockReq({ 614 event_type: 'CHECKOUT.ORDER.APPROVED', 615 resource: { id: 'ORDER_APPROVED_123' }, 616 }); 617 const res = createMockRes(); 618 619 await handler(req, res); 620 621 assert.equal(res._status, 200); 622 assert.deepEqual(res._json, { received: true }); 623 }); 624 625 test('PAYMENT.CAPTURE.COMPLETED event processing returns 200', async () => { 626 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment()); 627 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 628 mockDbQuery('SELECT site_id FROM messages', { 629 get: { site_id: 1 }, 630 }); 631 632 const handler = registeredRoutes['POST /webhook/paypal']; 633 const req = createMockReq({ 634 event_type: 'PAYMENT.CAPTURE.COMPLETED', 635 resource: { id: 'CAPTURE_ORDER_456' }, 636 }); 637 const res = createMockRes(); 638 639 await handler(req, res); 640 641 assert.equal(res._status, 200); 642 assert.deepEqual(res._json, { received: true }); 643 }); 644 645 test('PAYMENT.CAPTURE.COMPLETED extracts order_id from supplementary_data', async () => { 646 verifyPaymentMock.mock.mockImplementation(async () => makeVerifiedPayment()); 647 mockDbQuery('SELECT id, payment_id FROM messages', { get: undefined }); 648 mockDbQuery('SELECT site_id FROM messages', { 649 get: { site_id: 1 }, 650 }); 651 652 const handler = registeredRoutes['POST /webhook/paypal']; 653 const req = createMockReq({ 654 event_type: 'PAYMENT.CAPTURE.COMPLETED', 655 resource: { 656 // No direct id, but supplementary_data has order_id 657 supplementary_data: { 658 related_ids: { order_id: 'SUPPLEMENTARY_ORDER_789' }, 659 }, 660 }, 661 }); 662 const res = createMockRes(); 663 664 await handler(req, res); 665 666 assert.equal(res._status, 200); 667 assert.deepEqual(res._json, { received: true }); 668 }); 669 670 test('PAYMENT.CAPTURE.DENIED event returns 200', async () => { 671 const handler = registeredRoutes['POST /webhook/paypal']; 672 const req = createMockReq({ 673 event_type: 'PAYMENT.CAPTURE.DENIED', 674 resource: { id: 'DENIED_ORDER' }, 675 }); 676 const res = createMockRes(); 677 678 await handler(req, res); 679 680 assert.equal(res._status, 200); 681 assert.deepEqual(res._json, { received: true }); 682 // verifyPayment should NOT be called for denied events 683 assert.equal(verifyPaymentMock.mock.calls.length, 0); 684 }); 685 686 test('PAYMENT.CAPTURE.REFUNDED event returns 200', async () => { 687 const handler = registeredRoutes['POST /webhook/paypal']; 688 const req = createMockReq({ 689 event_type: 'PAYMENT.CAPTURE.REFUNDED', 690 resource: { id: 'REFUNDED_ORDER' }, 691 }); 692 const res = createMockRes(); 693 694 await handler(req, res); 695 696 assert.equal(res._status, 200); 697 assert.deepEqual(res._json, { received: true }); 698 assert.equal(verifyPaymentMock.mock.calls.length, 0); 699 }); 700 701 test('missing order ID returns 400', async () => { 702 const handler = registeredRoutes['POST /webhook/paypal']; 703 const req = createMockReq({ 704 event_type: 'CHECKOUT.ORDER.APPROVED', 705 resource: {}, // No id, no supplementary_data 706 }); 707 const res = createMockRes(); 708 709 await handler(req, res); 710 711 assert.equal(res._status, 400); 712 assert.deepEqual(res._json, { error: 'Missing order ID' }); 713 }); 714 715 test('missing resource entirely returns 400', async () => { 716 const handler = registeredRoutes['POST /webhook/paypal']; 717 const req = createMockReq({ 718 event_type: 'CHECKOUT.ORDER.APPROVED', 719 // No resource at all 720 }); 721 const res = createMockRes(); 722 723 await handler(req, res); 724 725 assert.equal(res._status, 400); 726 assert.deepEqual(res._json, { error: 'Missing order ID' }); 727 }); 728 729 test('unknown event type returns 200 (acknowledge receipt)', async () => { 730 const handler = registeredRoutes['POST /webhook/paypal']; 731 const req = createMockReq({ 732 event_type: 'BILLING.SUBSCRIPTION.CREATED', 733 resource: { id: 'SUB_123' }, 734 }); 735 const res = createMockRes(); 736 737 await handler(req, res); 738 739 assert.equal(res._status, 200); 740 assert.deepEqual(res._json, { received: true }); 741 // Should not call verifyPayment for unhandled events 742 assert.equal(verifyPaymentMock.mock.calls.length, 0); 743 }); 744 745 test('server error returns 500', async () => { 746 const handler = registeredRoutes['POST /webhook/paypal']; 747 // Construct a req that causes a throw in the handler. 748 // verifyWebhookSignature will run first; the error must occur after it returns. 749 // We simulate by making the body getter throw only on the SECOND access 750 // (first access is in verifyWebhookSignature via rawBody/JSON.stringify, which uses headers). 751 const req = { 752 headers: { 753 'paypal-transmission-id': 'test-id', 754 'paypal-transmission-time': '2026-03-26T00:00:00Z', 755 'paypal-transmission-sig': 'test-sig', 756 'paypal-cert-url': 'https://api.sandbox.paypal.com/v1/notifications/certs/cert.pem', 757 'paypal-auth-algo': 'SHA256withRSA', 758 }, 759 rawBody: '{}', 760 get body() { 761 throw new Error('Unexpected parsing error'); 762 }, 763 }; 764 const res = createMockRes(); 765 766 await handler(req, res); 767 768 assert.equal(res._status, 500); 769 assert.deepEqual(res._json, { error: 'Internal server error' }); 770 }); 771 772 test('webhook processes payment asynchronously (does not block response)', async () => { 773 // Make verifyPayment hang by not resolving 774 let resolvePayment; 775 verifyPaymentMock.mock.mockImplementation(() => { 776 return new Promise(resolve => { 777 resolvePayment = resolve; 778 }); 779 }); 780 781 const handler = registeredRoutes['POST /webhook/paypal']; 782 const req = createMockReq({ 783 event_type: 'CHECKOUT.ORDER.APPROVED', 784 resource: { id: 'ORDER_ASYNC' }, 785 }); 786 const res = createMockRes(); 787 788 // Handler should return immediately (200) while payment processes in background 789 await handler(req, res); 790 791 assert.equal(res._status, 200, 'Should respond 200 immediately'); 792 assert.deepEqual(res._json, { received: true }); 793 794 // Clean up the hanging promise 795 if (resolvePayment) { 796 resolvePayment(makeVerifiedPayment()); 797 } 798 }); 799 800 test('registers json middleware', () => { 801 createWebhookServer(); 802 // The mock express.json() returns a value that gets passed to app.use() 803 assert.ok(middlewares.length > 0, 'Should register middleware'); 804 }); 805 806 test('uses default port 3001', () => { 807 // createWebhookServer signature uses 3001 as default 808 const server = createWebhookServer(); 809 assert.ok(server, 'Should create server with default port'); 810 }); 811 }); 812 813 // ─── startWebhookServer ────────────────────────────────────────────────── 814 815 describe('startWebhookServer()', () => { 816 test('starts server and returns app', () => { 817 const app = startWebhookServer(4444); 818 assert.ok(app, 'Should return the app object'); 819 }); 820 821 test('uses default port 3001 when not specified', () => { 822 const app = startWebhookServer(); 823 assert.ok(app, 'Should create server with default port'); 824 }); 825 826 test('listen callback executes without error', () => { 827 // startWebhookServer calls app.listen(port, callback) 828 // Our mock immediately calls the callback, which logs startup info 829 // This test verifies no errors are thrown during startup logging 830 assert.doesNotThrow(() => { 831 startWebhookServer(5555); 832 }); 833 }); 834 }); 835 836 // ─── verifyWebhookSignature ─────────────────────────────────────────────── 837 838 describe('verifyWebhookSignature()', () => { 839 test('returns verified: true when PayPal confirms signature', async () => { 840 const req = createMockReq({ event_type: 'PAYMENT.CAPTURE.COMPLETED' }); 841 const result = await verifyWebhookSignature(req); 842 assert.equal(result.verified, true); 843 }); 844 845 test('rejects when PAYPAL_WEBHOOK_ID is not set', async () => { 846 const saved = process.env.PAYPAL_WEBHOOK_ID; 847 delete process.env.PAYPAL_WEBHOOK_ID; 848 849 try { 850 const req = createMockReq({ event_type: 'PAYMENT.CAPTURE.COMPLETED' }); 851 const result = await verifyWebhookSignature(req); 852 assert.equal(result.verified, false); 853 assert.ok(result.error.includes('not configured')); 854 } finally { 855 process.env.PAYPAL_WEBHOOK_ID = saved; 856 } 857 }); 858 859 test('rejects when signature headers are missing', async () => { 860 const req = createMockReq({ event_type: 'PAYMENT.CAPTURE.COMPLETED' }, { 861 'paypal-transmission-id': undefined, 862 'paypal-transmission-time': undefined, 863 'paypal-transmission-sig': undefined, 864 'paypal-cert-url': undefined, 865 'paypal-auth-algo': undefined, 866 }); 867 // Override headers to be empty (createMockReq spreads overrides) 868 req.headers = {}; 869 870 const result = await verifyWebhookSignature(req); 871 assert.equal(result.verified, false); 872 assert.ok(result.error.includes('Missing PayPal signature headers')); 873 }); 874 875 test('rejects when PayPal returns FAILURE verification status', async () => { 876 fetchMock.mock.mockImplementation(async (url) => { 877 if (typeof url === 'string' && url.includes('/v1/oauth2/token')) { 878 return { ok: true, json: async () => ({ access_token: 'test-token' }) }; 879 } 880 if (typeof url === 'string' && url.includes('/v1/notifications/verify-webhook-signature')) { 881 return { ok: true, json: async () => ({ verification_status: 'FAILURE' }) }; 882 } 883 return originalFetch(url); 884 }); 885 886 const req = createMockReq({ event_type: 'PAYMENT.CAPTURE.COMPLETED' }); 887 const result = await verifyWebhookSignature(req); 888 assert.equal(result.verified, false); 889 }); 890 891 test('rejects when PayPal OAuth token request fails', async () => { 892 fetchMock.mock.mockImplementation(async (url) => { 893 if (typeof url === 'string' && url.includes('/v1/oauth2/token')) { 894 return { ok: false, status: 401, text: async () => 'Unauthorized' }; 895 } 896 return originalFetch(url); 897 }); 898 899 const req = createMockReq({ event_type: 'PAYMENT.CAPTURE.COMPLETED' }); 900 const result = await verifyWebhookSignature(req); 901 assert.equal(result.verified, false); 902 assert.ok(result.error.includes('Failed to obtain PayPal access token')); 903 }); 904 905 test('rejects when verification API returns non-200', async () => { 906 fetchMock.mock.mockImplementation(async (url) => { 907 if (typeof url === 'string' && url.includes('/v1/oauth2/token')) { 908 return { ok: true, json: async () => ({ access_token: 'test-token' }) }; 909 } 910 if (typeof url === 'string' && url.includes('/v1/notifications/verify-webhook-signature')) { 911 return { ok: false, status: 500, text: async () => 'Internal Server Error' }; 912 } 913 return originalFetch(url); 914 }); 915 916 const req = createMockReq({ event_type: 'PAYMENT.CAPTURE.COMPLETED' }); 917 const result = await verifyWebhookSignature(req); 918 assert.equal(result.verified, false); 919 assert.ok(result.error.includes('Signature verification request failed')); 920 }); 921 922 test('rejects when PayPal credentials are missing', async () => { 923 const savedId = process.env.PAYPAL_CLIENT_ID; 924 const savedSecret = process.env.PAYPAL_CLIENT_SECRET; 925 delete process.env.PAYPAL_CLIENT_ID; 926 delete process.env.PAYPAL_CLIENT_SECRET; 927 928 try { 929 const req = createMockReq({ event_type: 'PAYMENT.CAPTURE.COMPLETED' }); 930 const result = await verifyWebhookSignature(req); 931 assert.equal(result.verified, false); 932 assert.ok(result.error.includes('credentials not configured')); 933 } finally { 934 process.env.PAYPAL_CLIENT_ID = savedId; 935 process.env.PAYPAL_CLIENT_SECRET = savedSecret; 936 } 937 }); 938 939 test('Express webhook rejects forged request (no signature headers)', async () => { 940 createWebhookServer(); 941 const handler = registeredRoutes['POST /webhook/paypal']; 942 943 const req = { 944 body: { event_type: 'CHECKOUT.ORDER.APPROVED', resource: { id: 'FORGED_ORDER' } }, 945 rawBody: JSON.stringify({ event_type: 'CHECKOUT.ORDER.APPROVED', resource: { id: 'FORGED_ORDER' } }), 946 headers: {}, // No PayPal signature headers 947 }; 948 const res = createMockRes(); 949 950 await handler(req, res); 951 952 assert.equal(res._status, 401, 'Should reject forged webhook with 401'); 953 assert.deepEqual(res._json, { error: 'Webhook signature verification failed' }); 954 // verifyPayment (PayPal order check) should never be called for unsigned requests 955 assert.equal(verifyPaymentMock.mock.calls.length, 0); 956 }); 957 }); 958 });