Testing with Stub Mode
This guide shows you how to test SolvaPay SDK using stub mode, which simulates the SolvaPay backend without making real API calls.
Table of Contents
What is Stub Mode?
Stub mode is a testing mode that simulates the SolvaPay backend API without making real HTTP requests. It's perfect for:
- Local Development - Test without API keys
- Unit Testing - Fast, isolated tests
- Integration Testing - Test full flows without external dependencies
- CI/CD Pipelines - Run tests without API credentials
Features
- Free tier tracking with configurable limits
- Customer management simulation
- In-memory or file-based persistence
- Realistic API delay simulation
- No external dependencies
Using Stub Mode
Automatic Stub Mode
SolvaPay automatically uses stub mode when no API key is provided:
import { createSolvaPay } from '@solvapay/server';
// Automatically uses stub mode (no API key)
const solvaPay = createSolvaPay();
Explicit Stub Client
For more control, use a stub client:
import { createSolvaPay } from '@solvapay/server';
import { createStubClient } from './stub-api-client'; // From examples/shared
const stubClient = createStubClient({
freeTierLimit: 5, // 5 free calls per day
debug: true, // Enable debug logging
});
const solvaPay = createSolvaPay({
apiClient: stubClient,
});
Stub Client Configuration
Basic Configuration
import { createStubClient } from './stub-api-client';
const stubClient = createStubClient({
// Number of free calls per day per plan
freeTierLimit: 3,
// Enable debug logging
debug: true,
// Use file-based persistence (default: false, in-memory only)
useFileStorage: false,
// Directory for persistent data (when useFileStorage is true)
dataDir: '.test-data',
});
Advanced Configuration
const stubClient = createStubClient({
freeTierLimit: 10,
debug: true,
useFileStorage: true,
dataDir: '.test-data',
// Simulate API delays (milliseconds)
delays: {
checkLimits: 100,
trackUsage: 50,
customer: 50,
},
// Base URL for checkout URLs
baseUrl: 'http://localhost:3000',
});
Testing Strategies
Unit Tests
Test individual functions with stub mode:
import { describe, it, expect, beforeEach } from 'vitest';
import { createSolvaPay } from '@solvapay/server';
import { createStubClient } from './stub-api-client';
describe('Task Creation', () => {
let solvaPay: ReturnType<typeof createSolvaPay>;
let payable: ReturnType<typeof solvaPay.payable>;
beforeEach(() => {
const stubClient = createStubClient({
freeTierLimit: 5,
useFileStorage: false, // In-memory for tests
});
solvaPay = createSolvaPay({ apiClient: stubClient });
payable = solvaPay.payable({
agent: 'agt_test',
plan: 'pln_test',
});
});
it('should create task within free tier', async () => {
const createTask = async () => ({ success: true, task: { id: '1' } });
const handler = payable.http(createTask);
const result = await handler(
{ body: { title: 'Test' }, headers: { 'x-customer-ref': 'user_1' } },
{}
);
expect(result.success).toBe(true);
});
it('should trigger paywall after free tier limit', async () => {
const createTask = async () => ({ success: true, task: { id: '1' } });
const handler = payable.http(createTask);
const req = { body: {}, headers: { 'x-customer-ref': 'user_1' } };
const res = {};
// Make requests up to free tier limit
for (let i = 0; i < 5; i++) {
await handler(req, res);
}
// Next request should trigger paywall
await expect(handler(req, res)).rejects.toThrow('Payment required');
});
});
Integration Tests
Test full API flows:
import { describe, it, expect } from 'vitest';
import express from 'express';
import request from 'supertest';
import { createSolvaPay } from '@solvapay/server';
import { createStubClient } from './stub-api-client';
describe('API Integration', () => {
let app: express.Application;
beforeEach(() => {
app = express();
app.use(express.json());
const stubClient = createStubClient({ freeTierLimit: 3 });
const solvaPay = createSolvaPay({ apiClient: stubClient });
const payable = solvaPay.payable({ agent: 'agt_test', plan: 'pln_test' });
app.post('/api/tasks', payable.http(async (req) => {
return { success: true, task: { title: req.body.title } };
}));
});
it('should handle requests within free tier', async () => {
const response = await request(app)
.post('/api/tasks')
.set('x-customer-ref', 'user_1')
.send({ title: 'Test Task' });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});
it('should return 402 after free tier limit', async () => {
const customerRef = 'user_2';
// Exhaust free tier
for (let i = 0; i < 3; i++) {
await request(app)
.post('/api/tasks')
.set('x-customer-ref', customerRef)
.send({ title: `Task ${i}` });
}
// Next request should hit paywall
const response = await request(app)
.post('/api/tasks')
.set('x-customer-ref', customerRef)
.send({ title: 'Task 4' });
expect(response.status).toBe(402);
expect(response.body.error).toBe('Payment required');
expect(response.body.checkoutUrl).toBeDefined();
});
});
Testing Different Scenarios
Test Free Tier Behavior
it('should track free tier usage per customer', async () => {
const stubClient = createStubClient({ freeTierLimit: 3 });
const solvaPay = createSolvaPay({ apiClient: stubClient });
const payable = solvaPay.payable({ agent: 'agt_test', plan: 'pln_test' });
const handler = payable.http(async () => ({ success: true }));
const req = { body: {}, headers: {} };
const res = {};
// Customer 1 uses free tier
req.headers['x-customer-ref'] = 'user_1';
await handler(req, res);
await handler(req, res);
await handler(req, res);
// Customer 2 should have separate free tier
req.headers['x-customer-ref'] = 'user_2';
await handler(req, res); // Should work
// Customer 1 should hit paywall
req.headers['x-customer-ref'] = 'user_1';
await expect(handler(req, res)).rejects.toThrow('Payment required');
});
Test Customer Creation
it('should create customers automatically', async () => {
const stubClient = createStubClient();
const solvaPay = createSolvaPay({ apiClient: stubClient });
// Customer is created on first use
const customer = await solvaPay.ensureCustomer('user_123', 'user_123');
expect(customer).toBeDefined();
});
Test Paywall Error Structure
it('should throw PaywallError with structured content', async () => {
const stubClient = createStubClient({ freeTierLimit: 1 });
const solvaPay = createSolvaPay({ apiClient: stubClient });
const payable = solvaPay.payable({ agent: 'agt_test', plan: 'pln_test' });
const handler = payable.http(async () => ({ success: true }));
const req = { body: {}, headers: { 'x-customer-ref': 'user_1' } };
const res = {};
// First request works
await handler(req, res);
// Second request triggers paywall
try {
await handler(req, res);
expect.fail('Should have thrown PaywallError');
} catch (error) {
expect(error).toBeInstanceOf(PaywallError);
expect(error.structuredContent.checkoutUrl).toBeDefined();
expect(error.structuredContent.agent).toBe('agt_test');
}
});
Complete Examples
Vitest Test Setup
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: true,
},
});
// __tests__/paywall.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createSolvaPay, PaywallError } from '@solvapay/server';
import { createStubClient } from '../../examples/shared/stub-api-client';
describe('Paywall Protection', () => {
let solvaPay: ReturnType<typeof createSolvaPay>;
let payable: ReturnType<typeof solvaPay.payable>;
beforeEach(() => {
const stubClient = createStubClient({
freeTierLimit: 3,
useFileStorage: false,
debug: false,
});
solvaPay = createSolvaPay({ apiClient: stubClient });
payable = solvaPay.payable({
agent: 'agt_test',
plan: 'pln_test',
});
});
describe('Free Tier', () => {
it('should allow requests within free tier limit', async () => {
const handler = payable.http(async () => ({ success: true }));
const req = { body: {}, headers: { 'x-customer-ref': 'user_1' } };
const res = {};
// Should work for first 3 requests
for (let i = 0; i < 3; i++) {
const result = await handler(req, res);
expect(result.success).toBe(true);
}
});
it('should trigger paywall after free tier limit', async () => {
const handler = payable.http(async () => ({ success: true }));
const req = { body: {}, headers: { 'x-customer-ref': 'user_2' } };
const res = {};
// Exhaust free tier
for (let i = 0; i < 3; i++) {
await handler(req, res);
}
// Next request should fail
await expect(handler(req, res)).rejects.toThrow(PaywallError);
});
});
describe('Error Handling', () => {
it('should include checkout URL in PaywallError', async () => {
const stubClient = createStubClient({ freeTierLimit: 1 });
const solvaPay = createSolvaPay({ apiClient: stubClient });
const payable = solvaPay.payable({ agent: 'agt_test', plan: 'pln_test' });
const handler = payable.http(async () => ({ success: true }));
const req = { body: {}, headers: { 'x-customer-ref': 'user_3' } };
const res = {};
await handler(req, res); // First request works
try {
await handler(req, res);
expect.fail('Should have thrown');
} catch (error) {
if (error instanceof PaywallError) {
expect(error.structuredContent.checkoutUrl).toBeDefined();
expect(error.structuredContent.agent).toBe('agt_test');
}
}
});
});
});
Jest Test Setup
// jest.config.js
module.exports = {
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
};
Mock vs Stub
Use Stub Mode When:
- Testing paywall behavior
- Testing free tier limits
- Testing customer creation
- Integration testing without external dependencies
Use Mocks When:
- Testing error handling
- Testing specific API responses
- Unit testing individual functions
// Example: Mock for specific error scenario
import { vi } from 'vitest';
it('should handle API errors', async () => {
const mockClient = {
checkLimits: vi.fn().mockRejectedValue(new Error('API Error')),
trackUsage: vi.fn(),
ensureCustomer: vi.fn().mockResolvedValue('cust_123'),
};
const solvaPay = createSolvaPay({ apiClient: mockClient });
// Test error handling...
});
Best Practices
-
Use In-Memory Storage for Tests: Set
useFileStorage: falseto avoid file system dependencies. -
Reset State Between Tests: Create a new stub client in
beforeEachto ensure test isolation. -
Test Edge Cases: Test free tier limits, paywall triggers, and error scenarios.
-
Use Realistic Limits: Set
freeTierLimitto realistic values (e.g., 3-5) for better test coverage. -
Test Error Structure: Verify that
PaywallErrorincludes all expected properties. -
Clean Up: If using file storage, clean up test data directories after tests.
Next Steps
- Error Handling Strategies - Handle errors in tests
- Performance Optimization - Test performance
- API Reference - Full API documentation