#!/usr/bin/env node // 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) { 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), }, }); 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);