/ tests / payments / poll-paypal-events.test.js
poll-paypal-events.test.js
  1  /**
  2   * Tests for src/payment/poll-paypal-events.js
  3   *
  4   * Covers:
  5   * - Missing PAYPAL_EVENTS_WORKER_URL env var → throws
  6   * - Empty / non-array events response → returns zero counts
  7   * - Unsupported event_type → skipped (not counted as processed)
  8   * - CHECKOUT.ORDER.APPROVED with resource.id → processed successfully
  9   * - PAYMENT.CAPTURE.COMPLETED with supplementary_data order_id → processed
 10   * - Event missing order ID → failed++
 11   * - processPaymentComplete returns success: false → failed++
 12   * - processPaymentComplete throws → failed++, does not rethrow per-event
 13   * - HTTP error response → throws
 14   * - Network fetch error → throws (re-thrown from outer catch)
 15   * - Multiple events, partial failure
 16   */
 17  
 18  import { describe, test, mock, beforeEach } from 'node:test';
 19  import assert from 'node:assert/strict';
 20  
 21  // ──────────────────────────────────────────────
 22  // Mock fetch (global)
 23  // ──────────────────────────────────────────────
 24  let mockFetchImpl = async () => ({ ok: true, json: async () => [] });
 25  global.fetch = async (...args) => mockFetchImpl(...args);
 26  
 27  // ──────────────────────────────────────────────
 28  // Mock processPaymentComplete
 29  // ──────────────────────────────────────────────
 30  let mockProcessPaymentImpl = async () => ({
 31    success: true,
 32    conversationId: 'conv-1',
 33    amount: '297',
 34  });
 35  const processPaymentCompleteMock = mock.fn(async (...args) => mockProcessPaymentImpl(...args));
 36  
 37  await mock.module('../../src/payment/webhook-handler.js', {
 38    namedExports: {
 39      processPaymentComplete: processPaymentCompleteMock,
 40    },
 41  });
 42  
 43  // ──────────────────────────────────────────────
 44  // Mock load-env (no-op)
 45  // ──────────────────────────────────────────────
 46  await mock.module('../../src/utils/load-env.js', {
 47    defaultExport: {},
 48  });
 49  
 50  // ──────────────────────────────────────────────
 51  // Mock logger
 52  // ──────────────────────────────────────────────
 53  await mock.module('../../src/utils/logger.js', {
 54    defaultExport: class {
 55      info() {}
 56      warn() {}
 57      error() {}
 58      success() {}
 59      debug() {}
 60    },
 61  });
 62  
 63  process.env.PAYPAL_EVENTS_WORKER_URL = 'https://worker.example.com';
 64  process.env.NODE_ENV = 'test';
 65  
 66  const { pollPayPalEvents } = await import('../../src/payment/poll-paypal-events.js');
 67  
 68  // ──────────────────────────────────────────────
 69  // Helper event builders
 70  // ──────────────────────────────────────────────
 71  function makeApprovedEvent(id = 'EVT-1', orderId = 'ORDER-ABC') {
 72    return { id, event_type: 'CHECKOUT.ORDER.APPROVED', resource: { id: orderId } };
 73  }
 74  
 75  function makeCapturedEvent(id = 'EVT-2', orderId = 'ORDER-XYZ') {
 76    // resource.id is the capture ID for CAPTURE.COMPLETED events.
 77    // To exercise the supplementary_data fallback path, omit resource.id.
 78    return {
 79      id,
 80      event_type: 'PAYMENT.CAPTURE.COMPLETED',
 81      resource: {
 82        supplementary_data: { related_ids: { order_id: orderId } },
 83      },
 84    };
 85  }
 86  
 87  function makeUnknownEvent(id = 'EVT-U') {
 88    return { id, event_type: 'PAYMENT.SALE.COMPLETED', resource: { id: 'SALE-001' } };
 89  }
 90  
 91  // ──────────────────────────────────────────────
 92  beforeEach(() => {
 93    processPaymentCompleteMock.mock.resetCalls();
 94    mockProcessPaymentImpl = async () => ({ success: true, conversationId: 'conv-1', amount: '297' });
 95    process.env.PAYPAL_EVENTS_WORKER_URL = 'https://worker.example.com';
 96  });
 97  
 98  // ══════════════════════════════════════════════
 99  describe('pollPayPalEvents', () => {
100    describe('configuration validation', () => {
101      test('throws when PAYPAL_EVENTS_WORKER_URL is not set', async () => {
102        delete process.env.PAYPAL_EVENTS_WORKER_URL;
103  
104        await assert.rejects(() => pollPayPalEvents(), /PAYPAL_EVENTS_WORKER_URL not configured/);
105      });
106  
107      test('fetches from correct URL path', async () => {
108        let fetchedUrl = null;
109        mockFetchImpl = async url => {
110          fetchedUrl = url;
111          return { ok: true, json: async () => [] };
112        };
113  
114        await pollPayPalEvents();
115  
116        assert.equal(fetchedUrl, 'https://worker.example.com/paypal-events.json');
117      });
118    });
119  
120    describe('empty / no-op responses', () => {
121      test('returns zero counts when events array is empty', async () => {
122        mockFetchImpl = async () => ({ ok: true, json: async () => [] });
123  
124        const result = await pollPayPalEvents();
125  
126        assert.equal(result.processed, 0);
127        assert.equal(result.successful, 0);
128        assert.equal(result.failed, 0);
129        assert.equal(processPaymentCompleteMock.mock.calls.length, 0);
130      });
131  
132      test('returns zero counts when response is not an array', async () => {
133        mockFetchImpl = async () => ({ ok: true, json: async () => ({ error: 'bad' }) });
134  
135        const result = await pollPayPalEvents();
136  
137        assert.equal(result.processed, 0);
138        assert.equal(result.successful, 0);
139        assert.equal(result.failed, 0);
140      });
141  
142      test('returns zero counts when response is null', async () => {
143        mockFetchImpl = async () => ({ ok: true, json: async () => null });
144  
145        const result = await pollPayPalEvents();
146  
147        assert.equal(result.processed, 0);
148      });
149    });
150  
151    describe('HTTP error handling', () => {
152      test('throws when fetch returns non-ok status', async () => {
153        mockFetchImpl = async () => ({
154          ok: false,
155          status: 503,
156          statusText: 'Service Unavailable',
157        });
158  
159        await assert.rejects(
160          () => pollPayPalEvents(),
161          /Failed to fetch events: 503 Service Unavailable/
162        );
163      });
164  
165      test('throws when fetch throws a network error', async () => {
166        mockFetchImpl = async () => {
167          throw new Error('ECONNREFUSED');
168        };
169  
170        await assert.rejects(() => pollPayPalEvents(), /ECONNREFUSED/);
171      });
172    });
173  
174    describe('event type filtering', () => {
175      test('skips unsupported event types and does not count them', async () => {
176        mockFetchImpl = async () => ({ ok: true, json: async () => [makeUnknownEvent()] });
177  
178        const result = await pollPayPalEvents();
179  
180        assert.equal(result.processed, 0);
181        assert.equal(result.successful, 0);
182        assert.equal(result.failed, 0);
183        assert.equal(processPaymentCompleteMock.mock.calls.length, 0);
184      });
185  
186      test('processes CHECKOUT.ORDER.APPROVED events', async () => {
187        mockFetchImpl = async () => ({ ok: true, json: async () => [makeApprovedEvent()] });
188  
189        const result = await pollPayPalEvents();
190  
191        assert.equal(result.processed, 1);
192        assert.equal(result.successful, 1);
193        assert.equal(processPaymentCompleteMock.mock.calls.length, 1);
194        assert.equal(processPaymentCompleteMock.mock.calls[0].arguments[0], 'ORDER-ABC');
195      });
196  
197      test('processes PAYMENT.CAPTURE.COMPLETED events using supplementary_data order_id', async () => {
198        mockFetchImpl = async () => ({ ok: true, json: async () => [makeCapturedEvent()] });
199  
200        const result = await pollPayPalEvents();
201  
202        assert.equal(result.processed, 1);
203        assert.equal(result.successful, 1);
204        assert.equal(processPaymentCompleteMock.mock.calls[0].arguments[0], 'ORDER-XYZ');
205      });
206  
207      test('prefers resource.id over supplementary_data for CHECKOUT.ORDER.APPROVED', async () => {
208        const event = {
209          id: 'EVT-3',
210          event_type: 'CHECKOUT.ORDER.APPROVED',
211          resource: {
212            id: 'DIRECT-ORDER-ID',
213            supplementary_data: { related_ids: { order_id: 'SUPP-ORDER-ID' } },
214          },
215        };
216        mockFetchImpl = async () => ({ ok: true, json: async () => [event] });
217  
218        await pollPayPalEvents();
219  
220        assert.equal(processPaymentCompleteMock.mock.calls[0].arguments[0], 'DIRECT-ORDER-ID');
221      });
222    });
223  
224    describe('order ID extraction', () => {
225      test('increments failed when event has no order ID', async () => {
226        const event = { id: 'EVT-NO-ID', event_type: 'CHECKOUT.ORDER.APPROVED', resource: {} };
227        mockFetchImpl = async () => ({ ok: true, json: async () => [event] });
228  
229        const result = await pollPayPalEvents();
230  
231        assert.equal(result.failed, 1);
232        assert.equal(result.processed, 0);
233        assert.equal(processPaymentCompleteMock.mock.calls.length, 0);
234      });
235  
236      test('increments failed when resource is missing entirely', async () => {
237        const event = { id: 'EVT-NO-RES', event_type: 'CHECKOUT.ORDER.APPROVED' };
238        mockFetchImpl = async () => ({ ok: true, json: async () => [event] });
239  
240        const result = await pollPayPalEvents();
241  
242        assert.equal(result.failed, 1);
243      });
244    });
245  
246    describe('processPaymentComplete result handling', () => {
247      test('counts failed when processPaymentComplete returns success: false', async () => {
248        mockProcessPaymentImpl = async () => ({ success: false, message: 'Not paid yet' });
249        mockFetchImpl = async () => ({ ok: true, json: async () => [makeApprovedEvent()] });
250  
251        const result = await pollPayPalEvents();
252  
253        assert.equal(result.processed, 1);
254        assert.equal(result.successful, 0);
255        assert.equal(result.failed, 1);
256      });
257  
258      test('counts successful when processPaymentComplete returns success: true', async () => {
259        mockProcessPaymentImpl = async () => ({ success: true, conversationId: 42, amount: '159' });
260        mockFetchImpl = async () => ({ ok: true, json: async () => [makeApprovedEvent()] });
261  
262        const result = await pollPayPalEvents();
263  
264        assert.equal(result.successful, 1);
265        assert.equal(result.failed, 0);
266      });
267  
268      test('increments failed and continues when processPaymentComplete throws', async () => {
269        // The inner try/catch wraps all event processing including processed++.
270        // On throw → inner catch increments failed++, but processed++ is skipped for that event.
271        // So for 2 events (1 throw, 1 success): processed=1, successful=1, failed=1.
272        processPaymentCompleteMock.mock.mockImplementation(async orderId => {
273          if (orderId === 'ORDER-FAIL') throw new Error('DB error');
274          return { success: true, conversationId: 'c1', amount: '100' };
275        });
276        const events = [
277          makeApprovedEvent('EVT-ERR-1', 'ORDER-FAIL'),
278          makeApprovedEvent('EVT-OK-1', 'ORDER-OK'),
279        ];
280        mockFetchImpl = async () => ({ ok: true, json: async () => events });
281  
282        const result = await pollPayPalEvents();
283  
284        assert.equal(result.failed, 1, 'failed event should increment failed');
285        assert.equal(result.successful, 1, 'successful event should still be counted');
286        // processed is only incremented for events that did not throw
287        assert.equal(result.processed, 1);
288      });
289  
290      test('does not rethrow per-event errors — continues processing remaining events', async () => {
291        processPaymentCompleteMock.mock.mockImplementation(async orderId => {
292          if (orderId === 'ORDER-1') throw new Error('Transient error');
293          return { success: true, conversationId: 'c2', amount: '297' };
294        });
295  
296        const events = [makeApprovedEvent('EVT-1', 'ORDER-1'), makeApprovedEvent('EVT-2', 'ORDER-2')];
297        mockFetchImpl = async () => ({ ok: true, json: async () => events });
298  
299        const result = await pollPayPalEvents();
300  
301        // ORDER-2 should be processed successfully despite ORDER-1 failing
302        assert.ok(result.successful >= 1, 'second event should succeed');
303      });
304    });
305  
306    describe('multiple events', () => {
307      test('processes multiple events and returns correct counts', async () => {
308        processPaymentCompleteMock.mock.mockImplementation(async () => ({
309          success: true,
310          conversationId: 'c1',
311          amount: '100',
312        }));
313  
314        const events = [
315          makeApprovedEvent('E1', 'O1'),
316          makeApprovedEvent('E2', 'O2'),
317          makeCapturedEvent('E3', 'O3'),
318        ];
319        mockFetchImpl = async () => ({ ok: true, json: async () => events });
320  
321        const result = await pollPayPalEvents();
322  
323        assert.equal(result.processed, 3);
324        assert.equal(result.successful, 3);
325        assert.equal(result.failed, 0);
326      });
327  
328      test('correctly tallies mixed success/failure across events', async () => {
329        let callIndex = 0;
330        processPaymentCompleteMock.mock.mockImplementation(async () => {
331          callIndex++;
332          if (callIndex % 2 === 0) {
333            return { success: false, message: 'Payment not captured' };
334          }
335          return { success: true, conversationId: 'c1', amount: '297' };
336        });
337  
338        const events = [
339          makeApprovedEvent('E1', 'O1'), // success
340          makeApprovedEvent('E2', 'O2'), // fail
341          makeApprovedEvent('E3', 'O3'), // success
342          makeApprovedEvent('E4', 'O4'), // fail
343        ];
344        mockFetchImpl = async () => ({ ok: true, json: async () => events });
345  
346        const result = await pollPayPalEvents();
347  
348        assert.equal(result.processed, 4);
349        assert.equal(result.successful, 2);
350        assert.equal(result.failed, 2);
351      });
352  
353      test('unknown event types mixed with valid ones are skipped', async () => {
354        processPaymentCompleteMock.mock.mockImplementation(async () => ({
355          success: true,
356          conversationId: 'c1',
357          amount: '100',
358        }));
359  
360        const events = [
361          makeUnknownEvent('U1'),
362          makeApprovedEvent('E1', 'O1'),
363          makeUnknownEvent('U2'),
364          makeCapturedEvent('E2', 'O2'),
365        ];
366        mockFetchImpl = async () => ({ ok: true, json: async () => events });
367  
368        const result = await pollPayPalEvents();
369  
370        assert.equal(result.processed, 2);
371        assert.equal(result.successful, 2);
372        assert.equal(processPaymentCompleteMock.mock.calls.length, 2);
373      });
374    });
375  
376    describe('return value structure', () => {
377      test('always returns object with processed/successful/failed keys', async () => {
378        mockFetchImpl = async () => ({ ok: true, json: async () => [] });
379  
380        const result = await pollPayPalEvents();
381  
382        assert.ok('processed' in result);
383        assert.ok('successful' in result);
384        assert.ok('failed' in result);
385      });
386    });
387  });