'use strict'; // ── Integration test: paypal.provider.js against a mocked HTTP boundary ─────── // Only axios (the true external boundary) is mocked — everything else in the // provider (token exchange, URL selection, request shaping) runs for real. // This is what tells us the provider is ready to go live: wrong env vars, a // broken sandbox/live switch, or a malformed request body will fail here // exactly like it would against the real PayPal API. jest.mock('axios'); const BASE_ENV = { PAYPAL_CLIENT_ID: 'test-client-id', PAYPAL_CLIENT_SECRET: 'test-client-secret', PAYPAL_BRAND_NAME: 'STARR', FRONTEND_URL: 'https://app.new-starr.test', }; // BASE_URL is computed once at module load time from PAYPAL_ENV, so every // test that cares about sandbox/live must reset the module registry first. // axios must be re-required from the same fresh registry, otherwise its mock // calls land on a different automock instance than the one the provider uses. function loadProvider(envOverrides = {}) { jest.resetModules(); Object.assign(process.env, BASE_ENV, envOverrides); const axios = require('axios'); const provider = require('../../providers/paypal.provider'); return { provider, axios }; } function mockAccessToken(axios, token = 'test-access-token') { axios.post.mockImplementationOnce((url) => { expect(url).toMatch(/\/v1\/oauth2\/token$/); return Promise.resolve({ data: { access_token: token } }); }); } beforeEach(() => { delete process.env.PAYPAL_ENV; }); // ── Config: sandbox vs live host selection ──────────────────────────────────── describe('environment / config wiring', () => { test('defaults to the sandbox host when PAYPAL_ENV is unset', async () => { const { provider, axios } = loadProvider({}); mockAccessToken(axios); axios.post.mockResolvedValueOnce({ data: {} }); await provider.createOrder({ amount: 10, referenceId: 'ref-1' }); expect(axios.post.mock.calls[0][0]).toBe('https://api-m.sandbox.paypal.com/v1/oauth2/token'); expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders'); }); test('switches to the live host when PAYPAL_ENV=live', async () => { const { provider, axios } = loadProvider({ PAYPAL_ENV: 'live' }); mockAccessToken(axios); axios.post.mockResolvedValueOnce({ data: {} }); await provider.createOrder({ amount: 10, referenceId: 'ref-1' }); expect(axios.post.mock.calls[0][0]).toBe('https://api-m.paypal.com/v1/oauth2/token'); expect(axios.post.mock.calls[1][0]).toBe('https://api-m.paypal.com/v2/checkout/orders'); }); test('token request authenticates with PAYPAL_CLIENT_ID / PAYPAL_CLIENT_SECRET via HTTP Basic auth', async () => { const { provider, axios } = loadProvider({}); mockAccessToken(axios); axios.post.mockResolvedValueOnce({ data: {} }); await provider.createOrder({ amount: 10, referenceId: 'ref-1' }); const [, , config] = axios.post.mock.calls[0]; expect(config.auth).toEqual({ username: 'test-client-id', password: 'test-client-secret' }); expect(config.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); }); test('a rejected token exchange (bad credentials) surfaces as a rejected promise, not a silent failure', async () => { const { provider, axios } = loadProvider({}); axios.post.mockRejectedValueOnce(new Error('401 invalid_client')); await expect(provider.createOrder({ amount: 10, referenceId: 'ref-1' })) .rejects.toThrow('401 invalid_client'); }); }); // ── createOrder ──────────────────────────────────────────────────────────────── describe('createOrder()', () => { test('sends a CAPTURE intent order with the reference id, amount and currency', async () => { const { provider, axios } = loadProvider({}); mockAccessToken(axios, 'tok-abc'); axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'CREATED' } }); const result = await provider.createOrder({ amount: 19.99, currency: 'PHP', referenceId: 'plan-42', }); const [url, body, config] = axios.post.mock.calls[1]; expect(url).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders'); expect(body.intent).toBe('CAPTURE'); expect(body.purchase_units[0]).toMatchObject({ reference_id: 'plan-42', amount: { currency_code: 'PHP', value: '19.99' }, }); expect(config.headers.Authorization).toBe('Bearer tok-abc'); expect(result).toEqual({ id: 'ORDER-1', status: 'CREATED' }); }); test('defaults currency to USD when not provided', async () => { const { provider, axios } = loadProvider({}); mockAccessToken(axios); axios.post.mockResolvedValueOnce({ data: {} }); await provider.createOrder({ amount: 5, referenceId: 'ref-1' }); const [, body] = axios.post.mock.calls[1]; expect(body.purchase_units[0].amount.currency_code).toBe('USD'); }); test('falls back to FRONTEND_URL for return/cancel urls when not provided', async () => { const { provider, axios } = loadProvider({}); mockAccessToken(axios); axios.post.mockResolvedValueOnce({ data: {} }); await provider.createOrder({ amount: 5, referenceId: 'ref-1' }); const [, body] = axios.post.mock.calls[1]; expect(body.application_context.return_url).toBe('https://app.new-starr.test/subscriptions/checkout'); expect(body.application_context.cancel_url).toBe('https://app.new-starr.test/subscriptions/checkout?cancelled=true'); }); test('honors explicit return/cancel urls when provided', async () => { const { provider, axios } = loadProvider({}); mockAccessToken(axios); axios.post.mockResolvedValueOnce({ data: {} }); await provider.createOrder({ amount: 5, referenceId: 'ref-1', returnUrl: 'https://custom.test/ok', cancelUrl: 'https://custom.test/cancel', }); const [, body] = axios.post.mock.calls[1]; expect(body.application_context.return_url).toBe('https://custom.test/ok'); expect(body.application_context.cancel_url).toBe('https://custom.test/cancel'); }); }); // ── captureOrder ─────────────────────────────────────────────────────────────── describe('captureOrder()', () => { test('posts to the order capture endpoint with a bearer token and returns the capture payload', async () => { const { provider, axios } = loadProvider({}); mockAccessToken(axios, 'tok-xyz'); axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'COMPLETED' } }); const result = await provider.captureOrder('ORDER-1'); const [url, body, config] = axios.post.mock.calls[1]; expect(url).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders/ORDER-1/capture'); expect(body).toEqual({}); expect(config.headers.Authorization).toBe('Bearer tok-xyz'); expect(result).toEqual({ id: 'ORDER-1', status: 'COMPLETED' }); }); }); // ── refundCapture ────────────────────────────────────────────────────────────── describe('refundCapture()', () => { test('posts a refund with the given amount and currency', async () => { const { provider, axios } = loadProvider({}); mockAccessToken(axios); axios.post.mockResolvedValueOnce({ data: { id: 'REFUND-1', status: 'COMPLETED' } }); const result = await provider.refundCapture('CAPTURE-1', 9.5, 'PHP'); const [url, body] = axios.post.mock.calls[1]; expect(url).toBe('https://api-m.sandbox.paypal.com/v2/payments/captures/CAPTURE-1/refund'); expect(body).toEqual({ amount: { value: '9.5', currency_code: 'PHP' } }); expect(result).toEqual({ id: 'REFUND-1', status: 'COMPLETED' }); }); test('defaults currency to USD when not provided', async () => { const { provider, axios } = loadProvider({}); mockAccessToken(axios); axios.post.mockResolvedValueOnce({ data: {} }); await provider.refundCapture('CAPTURE-1', 9.5); const [, body] = axios.post.mock.calls[1]; expect(body.amount.currency_code).toBe('USD'); }); });