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