paypal.test.js
1 /** 2 * PayPal Payment Integration Unit Tests 3 * 4 * Tests createPaymentOrder(), capturePayment(), verifyPayment(), 5 * and generatePaymentMessage() with mocked axios. 6 * (refundPayment is covered separately in paypal-refund.test.js) 7 * 8 * Note: capturePayment() in production requires a buyer to approve the order 9 * via PayPal's hosted UI before calling /capture. This is a hard constraint 10 * in the PayPal API — there is no REST endpoint to bypass buyer approval. 11 * We test error handling and success paths here using mocked responses. 12 */ 13 14 import { describe, test, mock, beforeEach } from 'node:test'; 15 import assert from 'node:assert/strict'; 16 17 // Mock axios before any imports that use it 18 const axiosPostMock = mock.fn(); 19 const axiosGetMock = mock.fn(); 20 21 mock.module('axios', { 22 defaultExport: { 23 post: axiosPostMock, 24 get: axiosGetMock, 25 }, 26 }); 27 28 // Mock dotenv 29 mock.module('dotenv', { 30 defaultExport: { config: () => {} }, 31 namedExports: { config: () => {} }, 32 }); 33 34 // Mock country-pricing (uses DB, not needed here) 35 mock.module('../../src/utils/country-pricing.js', { 36 namedExports: { 37 getPrice: countryCode => { 38 const pricing = { 39 US: { 40 currency: 'USD', 41 priceLocal: 297, 42 priceUsd: 297, 43 formattedPrice: '$297.00 USD', 44 exchangeRate: 1, 45 }, 46 AU: { 47 currency: 'AUD', 48 priceLocal: 447, 49 priceUsd: 297, 50 formattedPrice: 'A$447.00 AUD', 51 exchangeRate: 1.505, 52 }, 53 DE: { 54 currency: 'EUR', 55 priceLocal: 270, 56 priceUsd: 297, 57 formattedPrice: '€270 EUR', 58 exchangeRate: 0.909, 59 }, 60 }; 61 return pricing[countryCode] || null; 62 }, 63 }, 64 }); 65 66 // Set env before importing 67 process.env.PAYPAL_CLIENT_ID = 'test-client-id'; 68 process.env.PAYPAL_CLIENT_SECRET = 'test-client-secret'; 69 process.env.PAYPAL_MODE = 'sandbox'; 70 process.env.PAYPAL_BRAND_NAME = 'Test Brand'; 71 process.env.BASE_URL = 'https://test.example.com'; 72 process.env.PERSONA_NAME = 'Marcus Webb'; 73 process.env.PERSONA_FIRST_NAME = 'Marcus'; 74 process.env.BRAND_NAME = 'Test Brand'; 75 process.env.BRAND_DOMAIN = 'example.com'; 76 77 const { createPaymentOrder, capturePayment, verifyPayment, generatePaymentMessage } = 78 await import('../../src/payment/paypal.js'); 79 80 // Reusable access token mock 81 function mockAccessToken() { 82 return { data: { access_token: 'mock-token-abc123', expires_in: 3600 } }; 83 } 84 85 describe('PayPal Integration', () => { 86 beforeEach(() => { 87 axiosPostMock.mock.resetCalls(); 88 axiosGetMock.mock.resetCalls(); 89 // Reset to a safe default so mock state doesn't leak between tests 90 axiosPostMock.mock.mockImplementation(async () => { 91 throw new Error('Mock not set for this test'); 92 }); 93 axiosGetMock.mock.mockImplementation(async () => { 94 throw new Error('Mock not set for this test'); 95 }); 96 }); 97 98 describe('createPaymentOrder()', () => { 99 test('creates order and returns orderId and payer-action link', async () => { 100 axiosPostMock.mock.mockImplementation(async url => { 101 if (url.includes('/oauth2/token')) return mockAccessToken(); 102 return { 103 data: { 104 id: 'SANDBOX_ORDER_001', 105 status: 'PAYER_ACTION_REQUIRED', 106 links: [ 107 { 108 rel: 'self', 109 href: 'https://api-m.sandbox.paypal.com/v2/checkout/orders/SANDBOX_ORDER_001', 110 }, 111 { 112 rel: 'payer-action', 113 href: 'https://www.sandbox.paypal.com/checkoutnow?token=SANDBOX_ORDER_001', 114 }, 115 ], 116 }, 117 }; 118 }); 119 120 const result = await createPaymentOrder({ 121 domain: 'example.com', 122 email: 'buyer@test.com', 123 siteId: 42, 124 conversationId: 7, 125 countryCode: 'US', 126 }); 127 128 assert.equal(result.orderId, 'SANDBOX_ORDER_001'); 129 assert.ok(result.paymentLink.includes('SANDBOX_ORDER_001')); 130 assert.equal(result.amount, 297); 131 assert.equal(result.currency, 'USD'); 132 assert.equal(result.amountUsd, 297); 133 assert.equal(result.status, 'PAYER_ACTION_REQUIRED'); 134 }); 135 136 test('uses country-specific pricing for AU', async () => { 137 axiosPostMock.mock.mockImplementation(async url => { 138 if (url.includes('/oauth2/token')) return mockAccessToken(); 139 return { 140 data: { 141 id: 'SANDBOX_ORDER_AU', 142 status: 'PAYER_ACTION_REQUIRED', 143 links: [{ rel: 'payer-action', href: 'https://paypal.com/pay?token=AU_ORDER' }], 144 }, 145 }; 146 }); 147 148 const result = await createPaymentOrder({ 149 domain: 'aussie-biz.com.au', 150 email: 'buyer@test.com', 151 siteId: 1, 152 conversationId: 1, 153 countryCode: 'AU', 154 }); 155 156 // Verify AUD pricing was used in the order request 157 const orderCall = axiosPostMock.mock.calls.find(c => 158 c.arguments[0].includes('/v2/checkout/orders') 159 ); 160 assert.ok(orderCall, 'Order create call should exist'); 161 const body = orderCall.arguments[1]; 162 assert.equal(body.purchase_units[0].amount.currency_code, 'AUD'); 163 assert.equal(body.purchase_units[0].amount.value, '447.00'); 164 165 assert.equal(result.currency, 'AUD'); 166 assert.equal(result.amountUsd, 297); 167 }); 168 169 test('includes site and conversation reference_id', async () => { 170 axiosPostMock.mock.mockImplementation(async url => { 171 if (url.includes('/oauth2/token')) return mockAccessToken(); 172 return { 173 data: { 174 id: 'ORDER_REF_TEST', 175 status: 'PAYER_ACTION_REQUIRED', 176 links: [{ rel: 'payer-action', href: 'https://paypal.com/pay' }], 177 }, 178 }; 179 }); 180 181 await createPaymentOrder({ 182 domain: 'test.com', 183 email: 'x@test.com', 184 siteId: 99, 185 conversationId: 55, 186 countryCode: 'US', 187 }); 188 189 const orderCall = axiosPostMock.mock.calls.find(c => 190 c.arguments[0].includes('/v2/checkout/orders') 191 ); 192 const body = orderCall.arguments[1]; 193 assert.equal(body.purchase_units[0].reference_id, 'site_99_conv_55'); 194 }); 195 196 test('throws when no payer-action link in response', async () => { 197 axiosPostMock.mock.mockImplementation(async url => { 198 if (url.includes('/oauth2/token')) return mockAccessToken(); 199 return { 200 data: { 201 id: 'ORDER_NO_LINK', 202 status: 'CREATED', 203 links: [{ rel: 'self', href: 'https://api.paypal.com/...' }], 204 }, 205 }; 206 }); 207 208 await assert.rejects( 209 () => 210 createPaymentOrder({ 211 domain: 'x.com', 212 email: 'x@x.com', 213 siteId: 1, 214 conversationId: 1, 215 countryCode: 'US', 216 }), 217 err => { 218 assert.ok(err.message.includes('No approval link')); 219 return true; 220 } 221 ); 222 }); 223 224 test('throws for unknown country code', async () => { 225 await assert.rejects( 226 () => 227 createPaymentOrder({ 228 domain: 'x.com', 229 email: 'x@x.com', 230 siteId: 1, 231 conversationId: 1, 232 countryCode: 'XX', 233 }), 234 err => { 235 assert.ok(err.message.includes('Unable to get pricing')); 236 return true; 237 } 238 ); 239 }); 240 241 test('throws on PayPal API error with message passthrough', async () => { 242 axiosPostMock.mock.mockImplementation(async url => { 243 if (url.includes('/oauth2/token')) return mockAccessToken(); 244 const err = new Error('Request failed'); 245 err.response = { data: { message: 'INVALID_REQUEST', name: 'VALIDATION_ERROR' } }; 246 throw err; 247 }); 248 249 await assert.rejects( 250 () => 251 createPaymentOrder({ 252 domain: 'x.com', 253 email: 'x@x.com', 254 siteId: 1, 255 conversationId: 1, 256 countryCode: 'US', 257 }), 258 err => { 259 assert.ok(err.message.includes('PayPal order creation failed')); 260 assert.ok(err.message.includes('INVALID_REQUEST')); 261 return true; 262 } 263 ); 264 }); 265 }); 266 267 describe('capturePayment()', () => { 268 /** 269 * NOTE: In production, capturePayment() will return 270 * ORDER_NOT_APPROVED if called before the buyer clicks "Approve" 271 * in PayPal's hosted UI. The buyer approval step has no REST API 272 * equivalent — it's a hard PayPal design constraint. 273 * These tests use mocked responses to cover success and error paths. 274 */ 275 test('returns capture details on success', async () => { 276 axiosPostMock.mock.mockImplementation(async url => { 277 if (url.includes('/oauth2/token')) return mockAccessToken(); 278 return { 279 data: { 280 id: 'ORDER_CAPTURED', 281 purchase_units: [ 282 { 283 payments: { 284 captures: [ 285 { 286 id: 'CAPTURE_ID_ABC', 287 status: 'COMPLETED', 288 amount: { value: '297.00', currency_code: 'USD' }, 289 }, 290 ], 291 }, 292 }, 293 ], 294 }, 295 }; 296 }); 297 298 const result = await capturePayment('ORDER_CAPTURED'); 299 300 assert.equal(result.orderId, 'ORDER_CAPTURED'); 301 assert.equal(result.captureId, 'CAPTURE_ID_ABC'); 302 assert.equal(result.status, 'COMPLETED'); 303 assert.equal(result.amount, 297); 304 assert.equal(result.currency, 'USD'); 305 }); 306 307 test('formats error message with PayPal error name and details', async () => { 308 axiosPostMock.mock.mockImplementation(async url => { 309 if (url.includes('/oauth2/token')) return mockAccessToken(); 310 const err = new Error('Request failed with status 422'); 311 err.response = { 312 data: { 313 name: 'UNPROCESSABLE_ENTITY', 314 message: 'The requested action could not be performed', 315 details: [{ issue: 'ORDER_NOT_APPROVED' }, { issue: 'INSTRUMENT_DECLINED' }], 316 }, 317 }; 318 throw err; 319 }); 320 321 await assert.rejects( 322 () => capturePayment('UNAPPROVED_ORDER'), 323 err => { 324 assert.ok(err.message.includes('PayPal capture failed')); 325 assert.ok(err.message.includes('UNPROCESSABLE_ENTITY')); 326 assert.ok(err.message.includes('ORDER_NOT_APPROVED')); 327 assert.ok(err.message.includes('INSTRUMENT_DECLINED')); 328 return true; 329 } 330 ); 331 }); 332 333 test('uses sandbox API base URL', async () => { 334 axiosPostMock.mock.mockImplementation(async url => { 335 if (url.includes('/oauth2/token')) return mockAccessToken(); 336 return { 337 data: { 338 id: 'ORDER_SB', 339 purchase_units: [ 340 { 341 payments: { 342 captures: [ 343 { 344 id: 'CAP_SB', 345 status: 'COMPLETED', 346 amount: { value: '297.00', currency_code: 'USD' }, 347 }, 348 ], 349 }, 350 }, 351 ], 352 }, 353 }; 354 }); 355 356 await capturePayment('ORDER_SB'); 357 358 // Verify sandbox URL was used for the capture call 359 const captureCall = axiosPostMock.mock.calls.find(c => c.arguments[0].includes('/capture')); 360 assert.ok(captureCall.arguments[0].includes('sandbox.paypal.com')); 361 }); 362 }); 363 364 describe('verifyPayment()', () => { 365 test('returns isPaid=true for COMPLETED order', async () => { 366 axiosPostMock.mock.mockImplementation(async () => mockAccessToken()); 367 axiosGetMock.mock.mockImplementation(async () => ({ 368 data: { 369 id: 'ORDER_COMPLETED', 370 status: 'COMPLETED', 371 payer: { 372 email_address: 'buyer@example.com', 373 name: { given_name: 'John', surname: 'Doe' }, 374 }, 375 purchase_units: [ 376 { amount: { value: '297.00', currency_code: 'USD' }, reference_id: 'site_42_conv_7' }, 377 ], 378 create_time: '2025-01-01T12:00:00Z', 379 update_time: '2025-01-01T12:01:00Z', 380 }, 381 })); 382 383 const result = await verifyPayment('ORDER_COMPLETED'); 384 385 assert.equal(result.isPaid, true); 386 assert.equal(result.status, 'COMPLETED'); 387 assert.equal(result.orderId, 'ORDER_COMPLETED'); 388 assert.equal(result.payerEmail, 'buyer@example.com'); 389 assert.equal(result.payerName, 'John Doe'); 390 assert.equal(result.amount, 297); 391 assert.equal(result.currency, 'USD'); 392 assert.equal(result.referenceId, 'site_42_conv_7'); 393 }); 394 395 test('returns isPaid=false for CREATED (not yet approved) order', async () => { 396 axiosPostMock.mock.mockImplementation(async () => mockAccessToken()); 397 axiosGetMock.mock.mockImplementation(async () => ({ 398 data: { 399 id: 'ORDER_PENDING', 400 status: 'CREATED', 401 payer: null, 402 purchase_units: [ 403 { amount: { value: '297.00', currency_code: 'USD' }, reference_id: 'site_1_conv_1' }, 404 ], 405 create_time: '2025-01-01T12:00:00Z', 406 update_time: '2025-01-01T12:00:00Z', 407 }, 408 })); 409 410 const result = await verifyPayment('ORDER_PENDING'); 411 412 assert.equal(result.isPaid, false); 413 assert.equal(result.status, 'CREATED'); 414 assert.equal(result.payerEmail, undefined); 415 }); 416 417 test('handles payer with no surname', async () => { 418 axiosPostMock.mock.mockImplementation(async () => mockAccessToken()); 419 axiosGetMock.mock.mockImplementation(async () => ({ 420 data: { 421 id: 'ORDER_MONO', 422 status: 'COMPLETED', 423 payer: { email_address: 'mono@test.com', name: { given_name: 'Prince' } }, 424 purchase_units: [ 425 { amount: { value: '297.00', currency_code: 'USD' }, reference_id: 'x' }, 426 ], 427 create_time: '2025-01-01T12:00:00Z', 428 update_time: '2025-01-01T12:01:00Z', 429 }, 430 })); 431 432 const result = await verifyPayment('ORDER_MONO'); 433 assert.equal(result.payerName, 'Prince'); 434 }); 435 436 test('throws on API error', async () => { 437 axiosPostMock.mock.mockImplementation(async () => mockAccessToken()); 438 axiosGetMock.mock.mockImplementation(async () => { 439 const err = new Error('Not Found'); 440 err.response = { data: { message: 'ORDER_NOT_FOUND' } }; 441 throw err; 442 }); 443 444 await assert.rejects( 445 () => verifyPayment('BAD_ORDER'), 446 err => { 447 assert.ok(err.message.includes('PayPal verification failed')); 448 assert.ok(err.message.includes('ORDER_NOT_FOUND')); 449 return true; 450 } 451 ); 452 }); 453 }); 454 455 describe('generatePaymentMessage()', () => { 456 test('generates SMS message under 160 chars for US', async () => { 457 const link = 'https://paypal.com/pay?token=ABC123'; 458 const msg = await generatePaymentMessage(link, 'sms', 'mybusiness.com', 'US'); 459 460 assert.ok(msg.length <= 320, `SMS too long: ${msg.length} chars`); 461 assert.ok(msg.includes('mybusiness.com')); 462 assert.ok(msg.includes(link)); 463 assert.ok(msg.includes('$297.00 USD')); 464 }); 465 466 test('generates detailed email message with payment link', async () => { 467 const link = 'https://paypal.com/pay?token=XYZ789'; 468 const msg = await generatePaymentMessage(link, 'email', 'acme.com.au', 'AU'); 469 470 assert.ok(msg.includes(link)); 471 assert.ok(msg.includes('acme.com.au')); 472 assert.ok(msg.includes('A$447.00 AUD')); 473 // Email should have multiple lines 474 assert.ok(msg.split('\n').length > 5); 475 }); 476 477 test('uses round number pricing for DE', async () => { 478 const link = 'https://paypal.com/pay'; 479 const msg = await generatePaymentMessage(link, 'sms', 'german-biz.de', 'DE'); 480 481 assert.ok(msg.includes('€270 EUR')); 482 }); 483 484 test('throws for unknown country code', async () => { 485 await assert.rejects( 486 () => generatePaymentMessage('https://link', 'sms', 'x.com', 'ZZ'), 487 err => { 488 assert.ok(err.message.includes('Unable to get pricing')); 489 return true; 490 } 491 ); 492 }); 493 }); 494 495 describe('getAccessToken() (via createPaymentOrder)', () => { 496 /** 497 * Note: PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET are captured as module-level 498 * constants in paypal.js, so their values cannot be changed after import. 499 * The "credentials not configured" path is only testable in a fresh module context. 500 * We test the authentication failure path (bad credentials → API rejects) here instead. 501 */ 502 503 test('wraps OAuth failure in PayPal order creation error', async () => { 504 axiosPostMock.mock.mockImplementation(async url => { 505 if (url.includes('/oauth2/token')) { 506 const err = new Error('401 Unauthorized'); 507 err.response = { 508 data: { error: 'invalid_client', error_description: 'Client authentication failed' }, 509 }; 510 throw err; 511 } 512 throw new Error('Should not reach order creation'); 513 }); 514 515 await assert.rejects( 516 () => 517 createPaymentOrder({ 518 domain: 'x.com', 519 email: 'x@x.com', 520 siteId: 1, 521 conversationId: 1, 522 countryCode: 'US', 523 }), 524 err => { 525 // getAccessToken wraps → PayPal authentication failed: 401 Unauthorized 526 // createPaymentOrder wraps → PayPal order creation failed: PayPal authentication failed: ... 527 assert.ok( 528 err.message.includes('PayPal order creation failed') || 529 err.message.includes('PayPal authentication failed'), 530 `Expected auth failure message, got: ${err.message}` 531 ); 532 return true; 533 } 534 ); 535 }); 536 537 test('uses sandbox API endpoint when PAYPAL_MODE=sandbox', async () => { 538 axiosPostMock.mock.mockImplementation(async url => { 539 if (url.includes('/oauth2/token')) return mockAccessToken(); 540 return { 541 data: { 542 id: 'SANDBOX_SB_TEST', 543 status: 'PAYER_ACTION_REQUIRED', 544 links: [{ rel: 'payer-action', href: 'https://sandbox.paypal.com/pay' }], 545 }, 546 }; 547 }); 548 549 await createPaymentOrder({ 550 domain: 'x.com', 551 email: 'x@x.com', 552 siteId: 1, 553 conversationId: 1, 554 countryCode: 'US', 555 }); 556 557 // Both token and order calls should use sandbox URL 558 const { calls } = axiosPostMock.mock; 559 for (const call of calls) { 560 assert.ok( 561 call.arguments[0].includes('sandbox.paypal.com'), 562 `Expected sandbox URL, got: ${call.arguments[0]}` 563 ); 564 } 565 }); 566 }); 567 });