/ tests / payments / webhook-handler.test.js
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  });