Implement subscription-based AI access with 250 generations/month at $5/month or $50/year. Changes: - Backend: Stripe service, payment routes, webhook handlers, generation tracking - Frontend: Upgrade page with pricing, payment success/cancel pages, UI prompts - Database: Add subscription fields to users, payments table, migrations - Config: Stripe env vars to .env.example, docker-compose.prod.yml, PRODUCTION.md - Tests: Payment route tests, component tests, subscription hook tests Users without AI access see upgrade prompts; subscribers see remaining generation count.
243 lines
7.9 KiB
TypeScript
243 lines
7.9 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { randomUUID } from 'crypto';
|
|
|
|
const API_URL = process.env.API_URL || 'http://localhost:3001';
|
|
const TOKEN = process.env.TEST_TOKEN;
|
|
|
|
if (!TOKEN) {
|
|
console.error('ERROR: TEST_TOKEN environment variable is required');
|
|
process.exit(1);
|
|
}
|
|
|
|
interface TestResult {
|
|
name: string;
|
|
passed: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
const results: TestResult[] = [];
|
|
|
|
async function request(
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
expectStatus = 200,
|
|
useToken = true
|
|
): Promise<{ status: number; data: unknown; headers: Headers }> {
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
};
|
|
if (useToken) {
|
|
headers['Authorization'] = `Bearer ${TOKEN}`;
|
|
}
|
|
|
|
const response = await fetch(`${API_URL}${path}`, {
|
|
method,
|
|
headers,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
|
|
const data = response.headers.get('content-type')?.includes('application/json')
|
|
? await response.json()
|
|
: null;
|
|
|
|
if (response.status !== expectStatus) {
|
|
throw new Error(`Expected ${expectStatus}, got ${response.status}: ${JSON.stringify(data)}`);
|
|
}
|
|
|
|
return { status: response.status, data, headers: response.headers };
|
|
}
|
|
|
|
async function test(name: string, fn: () => Promise<void>) {
|
|
try {
|
|
await fn();
|
|
results.push({ name, passed: true });
|
|
console.log(` ✓ ${name}`);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
results.push({ name, passed: false, error: message });
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` ${message}`);
|
|
}
|
|
}
|
|
|
|
async function runTests() {
|
|
console.log('\n=== Kaboot Payments API Tests ===\n');
|
|
console.log(`API: ${API_URL}`);
|
|
console.log('');
|
|
|
|
console.log('Payment Config Tests:');
|
|
|
|
await test('GET /api/payments/config returns configuration', async () => {
|
|
const res = await fetch(`${API_URL}/api/payments/config`);
|
|
const data = await res.json();
|
|
if (typeof data.configured !== 'boolean') {
|
|
throw new Error('Missing configured field');
|
|
}
|
|
if (typeof data.generationLimit !== 'number') {
|
|
throw new Error('Missing generationLimit field');
|
|
}
|
|
if (data.generationLimit !== 250) {
|
|
throw new Error(`Expected generationLimit 250, got ${data.generationLimit}`);
|
|
}
|
|
});
|
|
|
|
console.log('\nPayment Status Tests:');
|
|
|
|
await test('GET /api/payments/status without auth returns 401', async () => {
|
|
const res = await fetch(`${API_URL}/api/payments/status`);
|
|
if (res.status !== 401) {
|
|
throw new Error(`Expected 401, got ${res.status}`);
|
|
}
|
|
});
|
|
|
|
await test('GET /api/payments/status with invalid token returns 401', async () => {
|
|
const res = await fetch(`${API_URL}/api/payments/status`, {
|
|
headers: { Authorization: 'Bearer invalid-token-here' },
|
|
});
|
|
if (res.status !== 401) {
|
|
throw new Error(`Expected 401, got ${res.status}`);
|
|
}
|
|
});
|
|
|
|
await test('GET /api/payments/status with valid token returns status', async () => {
|
|
const { data } = await request('GET', '/api/payments/status');
|
|
const status = data as Record<string, unknown>;
|
|
|
|
if (typeof status.hasAccess !== 'boolean') {
|
|
throw new Error('Missing hasAccess field');
|
|
}
|
|
if (!['group', 'subscription', 'none'].includes(status.accessType as string)) {
|
|
throw new Error(`Invalid accessType: ${status.accessType}`);
|
|
}
|
|
if (!['none', 'active', 'past_due', 'canceled'].includes(status.status as string)) {
|
|
throw new Error(`Invalid status: ${status.status}`);
|
|
}
|
|
});
|
|
|
|
console.log('\nCheckout Tests:');
|
|
|
|
await test('POST /api/payments/checkout without auth returns 401', async () => {
|
|
const res = await fetch(`${API_URL}/api/payments/checkout`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ planType: 'monthly', successUrl: 'http://test.com/success', cancelUrl: 'http://test.com/cancel' }),
|
|
});
|
|
if (res.status !== 401) {
|
|
throw new Error(`Expected 401, got ${res.status}`);
|
|
}
|
|
});
|
|
|
|
await test('POST /api/payments/checkout without planType returns 400', async () => {
|
|
const { status, data } = await request('POST', '/api/payments/checkout', {
|
|
successUrl: 'http://test.com/success',
|
|
cancelUrl: 'http://test.com/cancel',
|
|
}, 400);
|
|
const error = data as { error: string };
|
|
if (!error.error.includes('plan')) {
|
|
throw new Error(`Expected plan type error, got: ${error.error}`);
|
|
}
|
|
});
|
|
|
|
await test('POST /api/payments/checkout with invalid planType returns 400', async () => {
|
|
const { data } = await request('POST', '/api/payments/checkout', {
|
|
planType: 'invalid',
|
|
successUrl: 'http://test.com/success',
|
|
cancelUrl: 'http://test.com/cancel',
|
|
}, 400);
|
|
const error = data as { error: string };
|
|
if (!error.error.includes('monthly') && !error.error.includes('yearly')) {
|
|
throw new Error(`Expected plan type validation error, got: ${error.error}`);
|
|
}
|
|
});
|
|
|
|
await test('POST /api/payments/checkout without successUrl returns 400', async () => {
|
|
const { data } = await request('POST', '/api/payments/checkout', {
|
|
planType: 'monthly',
|
|
cancelUrl: 'http://test.com/cancel',
|
|
}, 400);
|
|
const error = data as { error: string };
|
|
if (!error.error.includes('successUrl')) {
|
|
throw new Error(`Expected successUrl error, got: ${error.error}`);
|
|
}
|
|
});
|
|
|
|
await test('POST /api/payments/checkout without cancelUrl returns 400', async () => {
|
|
const { data } = await request('POST', '/api/payments/checkout', {
|
|
planType: 'monthly',
|
|
successUrl: 'http://test.com/success',
|
|
}, 400);
|
|
const error = data as { error: string };
|
|
if (!error.error.includes('cancelUrl')) {
|
|
throw new Error(`Expected cancelUrl error, got: ${error.error}`);
|
|
}
|
|
});
|
|
|
|
console.log('\nPortal Tests:');
|
|
|
|
await test('POST /api/payments/portal without auth returns 401', async () => {
|
|
const res = await fetch(`${API_URL}/api/payments/portal`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ returnUrl: 'http://test.com' }),
|
|
});
|
|
if (res.status !== 401) {
|
|
throw new Error(`Expected 401, got ${res.status}`);
|
|
}
|
|
});
|
|
|
|
await test('POST /api/payments/portal without returnUrl returns 400', async () => {
|
|
const { data } = await request('POST', '/api/payments/portal', {}, 400);
|
|
const error = data as { error: string };
|
|
if (!error.error.includes('returnUrl')) {
|
|
throw new Error(`Expected returnUrl error, got: ${error.error}`);
|
|
}
|
|
});
|
|
|
|
console.log('\nWebhook Tests:');
|
|
|
|
await test('POST /api/payments/webhook without signature returns 400', async () => {
|
|
const res = await fetch(`${API_URL}/api/payments/webhook`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ type: 'test' }),
|
|
});
|
|
if (res.status !== 400 && res.status !== 503) {
|
|
throw new Error(`Expected 400 or 503, got ${res.status}`);
|
|
}
|
|
});
|
|
|
|
await test('POST /api/payments/webhook with invalid signature returns 400', async () => {
|
|
const res = await fetch(`${API_URL}/api/payments/webhook`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'stripe-signature': 'invalid-signature',
|
|
},
|
|
body: JSON.stringify({ type: 'test' }),
|
|
});
|
|
if (res.status !== 400 && res.status !== 503) {
|
|
throw new Error(`Expected 400 or 503, got ${res.status}`);
|
|
}
|
|
});
|
|
|
|
console.log('\n=== Results ===\n');
|
|
const passed = results.filter((r) => r.passed).length;
|
|
const failed = results.filter((r) => !r.passed).length;
|
|
console.log(`Passed: ${passed}`);
|
|
console.log(`Failed: ${failed}`);
|
|
console.log(`Total: ${results.length}`);
|
|
|
|
if (failed > 0) {
|
|
console.log('\nFailed tests:');
|
|
results
|
|
.filter((r) => !r.passed)
|
|
.forEach((r) => console.log(` - ${r.name}: ${r.error}`));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
runTests().catch((err) => {
|
|
console.error('Test runner error:', err);
|
|
process.exit(1);
|
|
});
|