@profullstack/payment-gateway
Version:
Unified payment gateway abstraction for multiple payment providers
512 lines (426 loc) • 17.3 kB
JavaScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PaymentGateway, createPaymentGateway, createMockProvider } from '../src/index.js';
import EventEmitter from 'eventemitter3';
describe('PaymentGateway', () => {
let gateway;
let mockProvider;
let emittedEvents;
// Helper to track emitted events
const trackEvent = (eventName, data) => {
emittedEvents.push({ eventName, data });
};
beforeEach(() => {
// Reset emitted events
emittedEvents = [];
// Create a mock provider with controlled behavior
mockProvider = {
createCheckoutSession: vi.fn(async (opts) => ({ id: 'sess_123', ...opts })),
createSubscription: vi.fn(async (opts) => ({ id: 'sub_123', ...opts })),
getSubscription: vi.fn(async (opts) => ({ id: 'sub_123', ...opts })),
cancelSubscription: vi.fn(async (opts) => ({ canceled: true, ...opts })),
handleWebhook: vi.fn(async (opts) => ({ event: 'test_event', ...opts })),
createCustomer: vi.fn(async (opts) => ({ id: 'cus_123', ...opts })),
getCustomer: vi.fn(async (opts) => ({ id: 'cus_123', ...opts })),
updateCustomer: vi.fn(async (opts) => ({ id: 'cus_123', ...opts })),
createPaymentMethod: vi.fn(async (opts) => ({ id: 'pm_123', ...opts })),
getPaymentMethods: vi.fn(async (opts) => [{ id: 'pm_123', ...opts }]),
createProduct: vi.fn(async (opts) => ({ id: 'prod_123', ...opts })),
createPrice: vi.fn(async (opts) => ({ id: 'price_123', ...opts })),
createPayment: vi.fn(async (opts) => ({ id: 'pay_123', ...opts })),
};
// Create the gateway with the mock provider
gateway = new PaymentGateway({
providers: { mock: mockProvider },
defaultProvider: 'mock',
debug: true,
products: {
'prod_existing': { id: 'prod_existing', default_price: 'price_default' }
},
prices: {
'price_direct': { id: 'price_direct' },
'prod_with_plans': {
'monthly_usd': { id: 'price_monthly' },
'yearly_usd': { id: 'price_yearly' }
}
}
});
// Track emitted events
gateway.on('checkout.created', (data) => trackEvent('checkout.created', data));
gateway.on('subscription.created', (data) => trackEvent('subscription.created', data));
gateway.on('subscription.canceled', (data) => trackEvent('subscription.canceled', data));
gateway.on('webhook.test_event', (data) => trackEvent('webhook.test_event', data));
gateway.on('customer.created', (data) => trackEvent('customer.created', data));
gateway.on('customer.updated', (data) => trackEvent('customer.updated', data));
gateway.on('paymentMethod.created', (data) => trackEvent('paymentMethod.created', data));
gateway.on('product.created', (data) => trackEvent('product.created', data));
gateway.on('price.created', (data) => trackEvent('price.created', data));
gateway.on('payment.created', (data) => trackEvent('payment.created', data));
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Initialization and Provider Management', () => {
it('initializes with default options when none provided', () => {
const emptyGateway = new PaymentGateway();
expect(emptyGateway.providers).toEqual({});
expect(emptyGateway.defaultProvider).toBeNull();
expect(emptyGateway.products).toEqual({});
expect(emptyGateway.prices).toEqual({});
});
it('registers and retrieves providers', () => {
expect(gateway.getProvider('mock')).toBe(mockProvider);
expect(() => gateway.getProvider('none')).toThrow();
});
it('throws if provider name is invalid', () => {
expect(() => gateway.registerProvider(null, mockProvider)).toThrow();
expect(() => gateway.registerProvider('', mockProvider)).toThrow();
expect(() => gateway.registerProvider(123, mockProvider)).toThrow();
});
it('throws if provider is invalid', () => {
expect(() => gateway.registerProvider('invalid', null)).toThrow();
expect(() => gateway.registerProvider('invalid', 'not-an-object')).toThrow();
});
it('throws if provider is missing required methods', () => {
const incompleteProvider = {
createCheckoutSession: () => {},
// Missing other required methods
};
expect(() => gateway.registerProvider('incomplete', incompleteProvider)).toThrow();
});
it('sets first registered provider as default if none specified', () => {
const newGateway = new PaymentGateway();
newGateway.registerProvider('test', mockProvider);
expect(newGateway.defaultProvider).toBe('test');
});
it('creates a gateway with factory function', () => {
const factoryGateway = createPaymentGateway({
providers: { mock: mockProvider },
defaultProvider: 'mock'
});
expect(factoryGateway).toBeInstanceOf(PaymentGateway);
expect(factoryGateway.getProvider('mock')).toBe(mockProvider);
});
});
describe('Checkout Sessions', () => {
it('creates a checkout session', async () => {
const session = await gateway.createCheckoutSession({ priceId: 'price_1' });
expect(session.id).toBe('sess_123');
expect(mockProvider.createCheckoutSession).toHaveBeenCalledWith({ priceId: 'price_1' });
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0].eventName).toBe('checkout.created');
});
it('resolves price ID from product ID and plan ID', async () => {
await gateway.createCheckoutSession({
productId: 'prod_with_plans',
planId: 'monthly',
currency: 'usd'
});
expect(mockProvider.createCheckoutSession).toHaveBeenCalledWith({
productId: 'prod_with_plans',
planId: 'monthly',
currency: 'usd',
priceId: 'price_monthly'
});
});
it('resolves price ID from product default price', async () => {
await gateway.createCheckoutSession({ productId: 'prod_existing' });
expect(mockProvider.createCheckoutSession).toHaveBeenCalledWith({
productId: 'prod_existing',
priceId: 'price_default'
});
});
it('handles errors in checkout session creation', async () => {
mockProvider.createCheckoutSession.mockRejectedValueOnce(new Error('API error'));
await expect(gateway.createCheckoutSession({ priceId: 'price_1' }))
.rejects.toThrow('API error');
expect(emittedEvents).toHaveLength(0);
});
});
describe('Subscriptions', () => {
it('creates a subscription', async () => {
const sub = await gateway.createSubscription({
priceId: 'price_1',
customerId: 'cus_1'
});
expect(sub.id).toBe('sub_123');
expect(mockProvider.createSubscription).toHaveBeenCalledWith({
priceId: 'price_1',
customerId: 'cus_1'
});
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0].eventName).toBe('subscription.created');
});
it('resolves price ID for subscription from plan ID', async () => {
await gateway.createSubscription({
planId: 'price_direct',
customerId: 'cus_1'
});
expect(mockProvider.createSubscription).toHaveBeenCalledWith({
planId: 'price_direct',
customerId: 'cus_1',
priceId: 'price_direct'
});
});
it('gets a subscription', async () => {
const sub = await gateway.getSubscription({ subscriptionId: 'sub_1' });
expect(sub.id).toBe('sub_123');
expect(mockProvider.getSubscription).toHaveBeenCalledWith({
subscriptionId: 'sub_1'
});
});
it('cancels a subscription', async () => {
const res = await gateway.cancelSubscription({ subscriptionId: 'sub_1' });
expect(res.canceled).toBe(true);
expect(mockProvider.cancelSubscription).toHaveBeenCalledWith({
subscriptionId: 'sub_1'
});
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0].eventName).toBe('subscription.canceled');
});
it('handles errors in subscription operations', async () => {
mockProvider.createSubscription.mockRejectedValueOnce(new Error('API error'));
await expect(gateway.createSubscription({
priceId: 'price_1',
customerId: 'cus_1'
})).rejects.toThrow('API error');
expect(emittedEvents).toHaveLength(0);
});
});
describe('Webhooks', () => {
it('handles webhooks', async () => {
const result = await gateway.handleWebhook({
body: '{}',
headers: {},
signature: 'sig'
});
expect(result.event).toBe('test_event');
expect(mockProvider.handleWebhook).toHaveBeenCalledWith({
body: '{}',
headers: {},
signature: 'sig'
});
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0].eventName).toBe('webhook.test_event');
});
it('handles webhook errors', async () => {
mockProvider.handleWebhook.mockRejectedValueOnce(new Error('Webhook error'));
await expect(gateway.handleWebhook({
body: '{}',
headers: {},
signature: 'sig'
})).rejects.toThrow('Webhook error');
expect(emittedEvents).toHaveLength(0);
});
});
describe('Customers', () => {
it('creates a customer', async () => {
const customer = await gateway.createCustomer({ email: 'a@b.com' });
expect(customer.id).toBe('cus_123');
expect(mockProvider.createCustomer).toHaveBeenCalledWith({
email: 'a@b.com'
});
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0].eventName).toBe('customer.created');
});
it('gets a customer', async () => {
const customer = await gateway.getCustomer({ customerId: 'cus_1' });
expect(customer.id).toBe('cus_123');
expect(mockProvider.getCustomer).toHaveBeenCalledWith({
customerId: 'cus_1'
});
});
it('updates a customer', async () => {
const customer = await gateway.updateCustomer({
customerId: 'cus_1',
name: 'Updated Name'
});
expect(customer.id).toBe('cus_123');
expect(mockProvider.updateCustomer).toHaveBeenCalledWith({
customerId: 'cus_1',
name: 'Updated Name'
});
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0].eventName).toBe('customer.updated');
});
it('throws if provider does not support customer methods', async () => {
delete mockProvider.createCustomer;
await expect(gateway.createCustomer({})).rejects.toThrow();
delete mockProvider.getCustomer;
await expect(gateway.getCustomer({ customerId: 'cus_1' })).rejects.toThrow();
delete mockProvider.updateCustomer;
await expect(gateway.updateCustomer({ customerId: 'cus_1' })).rejects.toThrow();
});
});
describe('Payment Methods', () => {
it('creates a payment method', async () => {
const pm = await gateway.createPaymentMethod({
customerId: 'cus_1',
type: 'card',
data: {}
});
expect(pm.id).toBe('pm_123');
expect(mockProvider.createPaymentMethod).toHaveBeenCalledWith({
customerId: 'cus_1',
type: 'card',
data: {}
});
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0].eventName).toBe('paymentMethod.created');
});
it('gets payment methods', async () => {
const methods = await gateway.getPaymentMethods({ customerId: 'cus_1' });
expect(methods[0].id).toBe('pm_123');
expect(mockProvider.getPaymentMethods).toHaveBeenCalledWith({
customerId: 'cus_1'
});
});
it('throws if provider does not support payment method methods', async () => {
delete mockProvider.createPaymentMethod;
await expect(gateway.createPaymentMethod({
customerId: 'cus_1',
type: 'card'
})).rejects.toThrow();
delete mockProvider.getPaymentMethods;
await expect(gateway.getPaymentMethods({
customerId: 'cus_1'
})).rejects.toThrow();
});
});
describe('Products and Prices', () => {
it('creates a product', async () => {
const product = await gateway.createProduct({
name: 'Test Product'
});
expect(product.id).toBe('prod_123');
expect(mockProvider.createProduct).toHaveBeenCalledWith({
name: 'Test Product'
});
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0].eventName).toBe('product.created');
expect(gateway.products[product.id]).toBeDefined();
});
it('creates a price', async () => {
const price = await gateway.createPrice({
productId: 'prod_123',
unitAmount: 1000,
currency: 'usd',
interval: 'month'
});
expect(price.id).toBe('price_123');
expect(mockProvider.createPrice).toHaveBeenCalledWith({
productId: 'prod_123',
unitAmount: 1000,
currency: 'usd',
interval: 'month'
});
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0].eventName).toBe('price.created');
expect(gateway.prices[price.id]).toBeDefined();
});
it('adds price to product-specific prices', async () => {
await gateway.createPrice({
productId: 'prod_123',
unitAmount: 1000,
currency: 'usd',
interval: 'month'
});
expect(gateway.prices['prod_123']).toBeDefined();
expect(gateway.prices['prod_123']['month_usd']).toBeDefined();
});
it('throws if provider does not support product/price methods', async () => {
delete mockProvider.createProduct;
await expect(gateway.createProduct({
name: 'Test Product'
})).rejects.toThrow();
delete mockProvider.createPrice;
await expect(gateway.createPrice({
productId: 'prod_123',
unitAmount: 1000
})).rejects.toThrow();
});
});
describe('Payments', () => {
it('creates a payment', async () => {
const payment = await gateway.createPayment({
customerId: 'cus_1',
amount: 1000,
currency: 'usd'
});
expect(payment.id).toBe('pay_123');
expect(mockProvider.createPayment).toHaveBeenCalledWith({
customerId: 'cus_1',
amount: 1000,
currency: 'usd'
});
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0].eventName).toBe('payment.created');
});
it('throws if provider does not support payment methods', async () => {
delete mockProvider.createPayment;
await expect(gateway.createPayment({
customerId: 'cus_1',
amount: 1000
})).rejects.toThrow();
});
});
describe('Price Resolution', () => {
it('resolves price ID from direct plan ID', () => {
const priceId = gateway._resolvePriceId({ planId: 'price_direct' });
expect(priceId).toBe('price_direct');
});
it('resolves price ID from product and plan', () => {
const priceId = gateway._resolvePriceId({
productId: 'prod_with_plans',
planId: 'monthly',
currency: 'usd'
});
expect(priceId).toBe('price_monthly');
});
it('resolves price ID from product default price', () => {
const priceId = gateway._resolvePriceId({ productId: 'prod_existing' });
expect(priceId).toBe('price_default');
});
it('throws if price ID cannot be resolved', () => {
expect(() => gateway._resolvePriceId({
productId: 'non_existent',
planId: 'unknown'
})).toThrow();
});
});
describe('Mock Provider Integration', () => {
it('creates and uses a real mock provider', async () => {
const realMockProvider = createMockProvider({ debug: true });
const mockGateway = new PaymentGateway({
providers: { mock: realMockProvider },
defaultProvider: 'mock'
});
// Test checkout session
const session = await mockGateway.createCheckoutSession({
priceId: 'price_test',
successUrl: 'https://example.com/success',
cancelUrl: 'https://example.com/cancel'
});
expect(session.id).toMatch(/^mock_session_/);
expect(session.url).toMatch(/^https:\/\/mock-payment-gateway\.com\/checkout\//);
// Test subscription
const customer = await mockGateway.createCustomer({
email: 'test@example.com'
});
const subscription = await mockGateway.createSubscription({
customerId: customer.id,
priceId: 'price_test'
});
expect(subscription.id).toMatch(/^mock_sub_/);
expect(subscription.status).toBe('active');
// Test webhook
const webhookResult = await mockGateway.handleWebhook({
body: JSON.stringify({
type: 'checkout.session.completed',
data: { id: session.id }
}),
headers: {}
});
expect(webhookResult.event).toBe('checkout.completed');
});
});
});