Spaces:
Running
Running
| // scripts/test-api.ts | |
| // | |
| // ICC API Endpoint Test Suite | |
| // Run: npx tsx scripts/test-api.ts | |
| // | |
| // Requires the server to be running on PORT (default 3001). | |
| import 'dotenv/config'; | |
| import jwt from 'jsonwebtoken'; | |
| const PORT = process.env.PORT || '3001'; | |
| const BASE = `http://localhost:${PORT}`; | |
| const JWT_SECRET = process.env.JWT_SECRET || 'icc-dev-secret-change-in-production'; | |
| // Create a test JWT (simulates a logged-in user) | |
| const TEST_USER_ID = 'test-user-healthcheck'; | |
| const testToken = jwt.sign( | |
| { userId: TEST_USER_ID, email: 'test@iccameriques.org', name: 'Test User' }, | |
| JWT_SECRET, | |
| { expiresIn: '1h' } | |
| ); | |
| const PASS = '\x1b[32mβ \x1b[0m'; | |
| const FAIL = '\x1b[31mβ\x1b[0m'; | |
| let passed = 0; | |
| let failed = 0; | |
| const failures: string[] = []; | |
| async function test(name: string, fn: () => Promise<void>) { | |
| try { | |
| await fn(); | |
| console.log(` ${PASS} ${name}`); | |
| passed++; | |
| } catch (error: any) { | |
| console.log(` ${FAIL} ${name}`); | |
| console.log(` β ${error.message}`); | |
| failed++; | |
| failures.push(`${name}: ${error.message}`); | |
| } | |
| } | |
| function assert(condition: boolean, msg: string) { | |
| if (!condition) throw new Error(msg); | |
| } | |
| async function fetchJSON(path: string, options: RequestInit = {}) { | |
| const res = await fetch(`${BASE}${path}`, { | |
| ...options, | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${testToken}`, | |
| ...(options.headers as Record<string, string>), | |
| }, | |
| }); | |
| return { res, data: await res.json().catch(() => null) }; | |
| } | |
| async function main() { | |
| console.log('\n\x1b[1mπ§ͺ ICC API Endpoint Tests\x1b[0m'); | |
| console.log(` Server: ${BASE}`); | |
| console.log('β'.repeat(50)); | |
| // Check server is running | |
| try { | |
| await fetch(`${BASE}/api/health`, { signal: AbortSignal.timeout(2000) }); | |
| } catch { | |
| console.log(`\n ${FAIL} Server not running on ${BASE}`); | |
| console.log(' β Start with: cd packages/server && npx tsx src/index.ts\n'); | |
| process.exit(1); | |
| } | |
| // βββ HEALTH βββ | |
| console.log('\n\x1b[1mHealth\x1b[0m'); | |
| await test('GET /api/health β 200 + status ok', async () => { | |
| const { res, data } = await fetchJSON('/api/health'); | |
| assert(res.status === 200, `Expected 200, got ${res.status}`); | |
| assert(data?.status === 'ok', `Expected status=ok, got ${data?.status}`); | |
| assert(!!data?.timestamp, 'Missing timestamp'); | |
| }); | |
| // βββ AUTH βββ | |
| console.log('\n\x1b[1mAuthentication\x1b[0m'); | |
| await test('GET /api/auth/google β 302 redirect to Google', async () => { | |
| const res = await fetch(`${BASE}/api/auth/google`, { redirect: 'manual' }); | |
| assert(res.status === 302 || res.status === 301, `Expected 302, got ${res.status}`); | |
| const loc = res.headers.get('location') || ''; | |
| assert(loc.includes('accounts.google.com') || loc.includes('google'), `Redirect not to Google: ${loc.substring(0, 60)}`); | |
| }); | |
| await test('GET /api/auth/me β 401 without token', async () => { | |
| const res = await fetch(`${BASE}/api/auth/me`); | |
| assert(res.status === 401, `Expected 401, got ${res.status}`); | |
| }); | |
| await test('GET /api/auth/me β 404 with test token (user not in DB)', async () => { | |
| const { res } = await fetchJSON('/api/auth/me'); | |
| assert(res.status === 404 || res.status === 200, `Expected 404 or 200, got ${res.status}`); | |
| }); | |
| await test('Auth middleware rejects expired token', async () => { | |
| const expiredToken = jwt.sign({ userId: 'x' }, JWT_SECRET, { expiresIn: '-1h' }); | |
| const res = await fetch(`${BASE}/api/transactions`, { | |
| headers: { Authorization: `Bearer ${expiredToken}` }, | |
| }); | |
| assert(res.status === 401, `Expected 401, got ${res.status}`); | |
| }); | |
| await test('Auth middleware rejects garbage token', async () => { | |
| const res = await fetch(`${BASE}/api/transactions`, { | |
| headers: { Authorization: 'Bearer this-is-not-a-valid-jwt' }, | |
| }); | |
| assert(res.status === 401, `Expected 401, got ${res.status}`); | |
| }); | |
| // βββ SCAN βββ | |
| console.log('\n\x1b[1mScan Endpoints\x1b[0m'); | |
| await test('POST /api/scan/start β 401 without auth', async () => { | |
| const res = await fetch(`${BASE}/api/scan/start`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ preset: 'today' }), | |
| }); | |
| assert(res.status === 401, `Expected 401, got ${res.status}`); | |
| }); | |
| await test('POST /api/scan/start β 400 with invalid preset', async () => { | |
| const { res, data } = await fetchJSON('/api/scan/start', { | |
| method: 'POST', | |
| body: JSON.stringify({ preset: 'invalid_preset' }), | |
| }); | |
| assert(res.status === 400, `Expected 400, got ${res.status}`); | |
| }); | |
| await test('POST /api/scan/start β 400 custom without dates', async () => { | |
| const { res } = await fetchJSON('/api/scan/start', { | |
| method: 'POST', | |
| body: JSON.stringify({ preset: 'custom' }), | |
| }); | |
| // custom preset requires resolveScanDates which throws β 500 | |
| assert(res.status === 400 || res.status === 500, `Expected 400 or 500, got ${res.status}`); | |
| }); | |
| await test('GET /api/scan/history β 200 + response object', async () => { | |
| const { res, data } = await fetchJSON('/api/scan/history'); | |
| assert(res.status === 200, `Expected 200, got ${res.status}`); | |
| assert(data?.scans !== undefined || Array.isArray(data), 'Expected scans in response'); | |
| }); | |
| await test('GET /api/scan/status/nonexistent β 404', async () => { | |
| const { res } = await fetchJSON('/api/scan/status/fake-job-id'); | |
| assert(res.status === 404, `Expected 404, got ${res.status}`); | |
| }); | |
| // βββ TRANSACTIONS βββ | |
| console.log('\n\x1b[1mTransaction Endpoints\x1b[0m'); | |
| await test('GET /api/transactions β 200 + paginated response', async () => { | |
| const { res, data } = await fetchJSON('/api/transactions'); | |
| assert(res.status === 200, `Expected 200, got ${res.status}`); | |
| // Response format: { transactions, total, page, limit, totalPages } | |
| assert(typeof data?.total === 'number', `Missing total, got keys: ${Object.keys(data || {})}`); | |
| assert(Array.isArray(data?.transactions), 'transactions should be array'); | |
| assert(typeof data?.page === 'number', 'Missing page number'); | |
| assert(typeof data?.limit === 'number', 'Missing limit'); | |
| }); | |
| await test('GET /api/transactions?page=1&limit=5 β respects pagination', async () => { | |
| const { res, data } = await fetchJSON('/api/transactions?page=1&limit=5'); | |
| assert(res.status === 200, `Expected 200, got ${res.status}`); | |
| assert(data?.limit === 5, `Expected limit=5, got ${data?.limit}`); | |
| assert(data?.transactions?.length <= 5, `Expected <=5 items, got ${data?.transactions?.length}`); | |
| }); | |
| await test('GET /api/transactions?limit=999 β caps at 500', async () => { | |
| const { res, data } = await fetchJSON('/api/transactions?limit=999'); | |
| assert(res.status === 200, `Expected 200, got ${res.status}`); | |
| assert(data?.limit <= 500, `Expected limit<=500, got ${data?.limit}`); | |
| }); | |
| await test('GET /api/transactions/stats β 200 + totals', async () => { | |
| const { res, data } = await fetchJSON('/api/transactions/stats'); | |
| assert(res.status === 200, `Expected 200, got ${res.status}`); | |
| // Response format: { totalCount, totalAmount, byStatus: { deposited, ... }, byBranch, byMonth } | |
| assert(typeof data?.totalCount === 'number', `Missing totalCount, got keys: ${Object.keys(data || {})}`); | |
| assert(typeof data?.totalAmount === 'number', 'Missing totalAmount'); | |
| assert(typeof data?.byStatus === 'object', 'Missing byStatus object'); | |
| assert(Array.isArray(data?.byBranch), 'Missing byBranch array'); | |
| }); | |
| await test('GET /api/transactions β 401 without auth', async () => { | |
| const res = await fetch(`${BASE}/api/transactions`); | |
| assert(res.status === 401, `Expected 401, got ${res.status}`); | |
| }); | |
| // βββ CHAT βββ | |
| console.log('\n\x1b[1mChat Endpoint\x1b[0m'); | |
| await test('POST /api/chat/message β 401 without auth', async () => { | |
| const res = await fetch(`${BASE}/api/chat/message`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ message: 'bonjour' }), | |
| }); | |
| assert(res.status === 401, `Expected 401, got ${res.status}`); | |
| }); | |
| await test('POST /api/chat/message β 400 with empty message', async () => { | |
| const { res } = await fetchJSON('/api/chat/message', { | |
| method: 'POST', | |
| body: JSON.stringify({ message: '' }), | |
| }); | |
| assert(res.status === 400, `Expected 400, got ${res.status}`); | |
| }); | |
| await test('POST /api/chat/message β 400 with no body', async () => { | |
| const { res } = await fetchJSON('/api/chat/message', { | |
| method: 'POST', | |
| body: JSON.stringify({}), | |
| }); | |
| assert(res.status === 400, `Expected 400, got ${res.status}`); | |
| }); | |
| // Only test AI-powered chat if API key is available | |
| if (process.env.GROQ_API_KEY || process.env.MISTRAL_API_KEY) { | |
| await test('POST /api/chat/message β responds to "bonjour"', async () => { | |
| const { res, data } = await fetchJSON('/api/chat/message', { | |
| method: 'POST', | |
| body: JSON.stringify({ message: 'Bonjour' }), | |
| }); | |
| assert(res.status === 200, `Expected 200, got ${res.status}`); | |
| assert(typeof data?.message === 'string' || typeof data?.response === 'string', 'Missing response message'); | |
| }); | |
| } else { | |
| console.log(' βοΈ Skipping AI chat tests (no API key set)'); | |
| } | |
| // βββ SUMMARY βββ | |
| console.log('\n' + 'β'.repeat(50)); | |
| console.log(`\x1b[1mπ Results: ${passed} passed, ${failed} failed\x1b[0m`); | |
| if (failures.length > 0) { | |
| console.log('\n\x1b[31mFailures:\x1b[0m'); | |
| failures.forEach((f) => console.log(` β ${f}`)); | |
| } | |
| console.log('β'.repeat(50) + '\n'); | |
| process.exit(failed > 0 ? 1 : 0); | |
| } | |
| main().catch(console.error); | |