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 });