202 lines
8.3 KiB
JavaScript
202 lines
8.3 KiB
JavaScript
'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');
|
|
});
|
|
});
|