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