import { afterEach, describe, expect, it, vi } from 'vitest' const originalEnv = { ...process.env } async function importService(env: Record = {}) { vi.resetModules() process.env = { ...originalEnv, ...env } return import('./paypalService') } describe('paypalService', () => { afterEach(() => { process.env = { ...originalEnv } vi.unstubAllGlobals() vi.resetModules() }) it('reports unconfigured when client credentials are placeholders or missing', async () => { const service = await importService({ PAYPAL_CLIENT_ID: 'your-paypal-client-id', PAYPAL_CLIENT_SECRET: 'secret' }) expect(service.isConfigured()).toBe(false) }) it('creates an order after obtaining and caching an access token', async () => { const fetchMock = vi.fn() .mockResolvedValueOnce({ ok: true, json: vi.fn().mockResolvedValue({ access_token: 'access_1', expires_in: 3600 }), }) .mockResolvedValueOnce({ ok: true, json: vi.fn().mockResolvedValue({ id: 'paypal_order_1', status: 'CREATED', links: [{ rel: 'approve', href: 'https://paypal.test/approve' }], }), }) .mockResolvedValueOnce({ ok: true, json: vi.fn().mockResolvedValue({ id: 'paypal_order_2', status: 'CREATED', links: [] }), }) vi.stubGlobal('fetch', fetchMock) const service = await importService({ PAYPAL_BASE_URL: 'https://paypal.test', PAYPAL_CLIENT_ID: 'client_1', PAYPAL_CLIENT_SECRET: 'secret_1', }) await expect(service.createOrder({ amount: 12345, currency: 'MAD', orderId: 'reservation_1', description: 'Reservation deposit', returnUrl: 'https://app.test/return', cancelUrl: 'https://app.test/cancel', })).resolves.toEqual({ orderId: 'paypal_order_1', approveUrl: 'https://paypal.test/approve', status: 'CREATED', }) await service.createOrder({ amount: 500, currency: 'MAD', orderId: 'reservation_2', description: 'Second payment', returnUrl: 'https://app.test/return', cancelUrl: 'https://app.test/cancel', }) expect(fetchMock).toHaveBeenCalledTimes(3) expect(fetchMock).toHaveBeenNthCalledWith(1, 'https://paypal.test/v1/oauth2/token', expect.objectContaining({ method: 'POST', body: 'grant_type=client_credentials', headers: expect.objectContaining({ Authorization: `Basic ${Buffer.from('client_1:secret_1').toString('base64')}` }), })) expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://paypal.test/v2/checkout/orders', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ Authorization: 'Bearer access_1' }), body: expect.stringContaining('"value":"123.45"'), })) }) it('captures and refunds through authenticated PayPal endpoints with cent-to-decimal formatting', async () => { const fetchMock = vi.fn() .mockResolvedValueOnce({ ok: true, json: vi.fn().mockResolvedValue({ access_token: 'access_1', expires_in: 3600 }) }) .mockResolvedValueOnce({ ok: true, json: vi.fn().mockResolvedValue({ id: 'capture_result' }) }) .mockResolvedValueOnce({ ok: true, json: vi.fn().mockResolvedValue({ id: 'refund_result' }) }) vi.stubGlobal('fetch', fetchMock) const service = await importService({ PAYPAL_BASE_URL: 'https://paypal.test', PAYPAL_CLIENT_ID: 'client_1', PAYPAL_CLIENT_SECRET: 'secret_1' }) await expect(service.captureOrder('order_1')).resolves.toEqual({ id: 'capture_result' }) await expect(service.refundCapture('capture_1', 2500, 'MAD', 'Customer cancellation')).resolves.toEqual({ id: 'refund_result' }) expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://paypal.test/v2/checkout/orders/order_1/capture', expect.objectContaining({ method: 'POST', body: '{}', })) expect(fetchMock).toHaveBeenNthCalledWith(3, 'https://paypal.test/v2/payments/captures/capture_1/refund', expect.objectContaining({ method: 'POST', body: JSON.stringify({ amount: { currency_code: 'MAD', value: '25.00' }, note_to_payer: 'Customer cancellation' }), })) }) it('returns false instead of throwing when webhook verification cannot be completed', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))) const service = await importService({ PAYPAL_CLIENT_ID: 'client_1', PAYPAL_CLIENT_SECRET: 'secret_1' }) await expect(service.verifyWebhookEvent({}, '{"id":"event_1"}')).resolves.toBe(false) }) })