#!/usr/bin/env node // scripts/test-units.ts // // ICC Unit Tests — core business logic // Run: npx tsx scripts/test-units.ts // // No server needed. Tests pure functions: // - resolveScanDates() — date range calculation // - resolveBranch() — email → branch mapping // - buildGmailQuery() — Gmail search query construction // - Transaction Zod validation const PASS = '\x1b[32m✅\x1b[0m'; const FAIL = '\x1b[31m❌\x1b[0m'; let passed = 0; let failed = 0; function test(name: string, fn: () => void) { try { fn(); console.log(` ${PASS} ${name}`); passed++; } catch (error: any) { console.log(` ${FAIL} ${name}`); console.log(` → ${error.message}`); failed++; } } function assert(condition: boolean, msg: string) { if (!condition) throw new Error(msg); } function assertEqual(actual: any, expected: any, label = '') { if (actual !== expected) { throw new Error(`${label} Expected "${expected}", got "${actual}"`); } } // ═══════════════════════════════════════════ // Import modules under test // ═══════════════════════════════════════════ // We'll inline the functions here since they're pure and don't depend on Node modules type ScanPreset = 'today' | 'last7days' | 'custom'; interface ScanDateRange { preset: ScanPreset; startDate: string; endDate: string; } function resolveScanDates(preset: ScanPreset, customStart?: string, customEnd?: string): ScanDateRange { const now = new Date(); switch (preset) { case 'today': { const midnight = new Date(now); midnight.setHours(0, 0, 0, 0); return { preset, startDate: midnight.toISOString(), endDate: now.toISOString() }; } case 'last7days': { const sevenDaysAgo = new Date(now); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); sevenDaysAgo.setHours(0, 0, 0, 0); return { preset, startDate: sevenDaysAgo.toISOString(), endDate: now.toISOString() }; } case 'custom': { if (!customStart || !customEnd) throw new Error('Custom range requires startDate and endDate'); return { preset, startDate: new Date(customStart + 'T00:00:00').toISOString(), endDate: new Date(customEnd + 'T23:59:59').toISOString(), }; } } } const BRANCH_MAPPING: Record = { "finances@iccameriques.org": "ICC Montréal", "montreal.finances@iccameriques.org": "ICC Montréal", "quebec.finances@iccameriques.org": "ICC Québec", "gatineau.finances@iccameriques.org": "ICC Gatineau", "ottawa.finances@iccameriques.org": "ICC Ottawa", "toronto.finances@iccameriques.org": "ICC Toronto", "siege@iccameriques.org": "ICC Siège", }; function resolveBranch(recipientEmail: string): string { return BRANCH_MAPPING[recipientEmail.toLowerCase()] ?? "Non classifié"; } function buildGmailQuery(dateRange: ScanDateRange): string { const start = new Date(dateRange.startDate); const end = new Date(dateRange.endDate); const afterDate = new Date(start); afterDate.setDate(afterDate.getDate() - 1); const beforeDate = new Date(end); beforeDate.setDate(beforeDate.getDate() + 1); const fmt = (d: Date) => `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`; return `from:notify@payments.interac.ca after:${fmt(afterDate)} before:${fmt(beforeDate)}`; } // ═══════════════════════════════════════════ // TESTS // ═══════════════════════════════════════════ console.log('\n\x1b[1m🧪 ICC Unit Tests\x1b[0m'); console.log('═'.repeat(50)); // ─── resolveScanDates ─── console.log('\n\x1b[1mresolveScanDates()\x1b[0m'); test('today → startDate is midnight today', () => { const result = resolveScanDates('today'); const start = new Date(result.startDate); assertEqual(start.getHours(), 0, 'Hours'); assertEqual(start.getMinutes(), 0, 'Minutes'); assertEqual(start.getSeconds(), 0, 'Seconds'); assertEqual(result.preset, 'today', 'Preset'); }); test('today → endDate is close to now', () => { const result = resolveScanDates('today'); const end = new Date(result.endDate); const diff = Date.now() - end.getTime(); assert(diff < 5000, `End date is ${diff}ms in the past (should be <5s)`); }); test('last7days → startDate is 7 days ago at midnight', () => { const result = resolveScanDates('last7days'); const start = new Date(result.startDate); const now = new Date(); const diffDays = Math.round((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); assert(diffDays >= 7 && diffDays <= 8, `Expected ~7 days diff, got ${diffDays}`); assertEqual(start.getHours(), 0, 'Start hours'); }); test('custom → uses provided dates', () => { const result = resolveScanDates('custom', '2024-01-25', '2026-02-23'); const start = new Date(result.startDate); const end = new Date(result.endDate); assertEqual(start.getFullYear(), 2024, 'Start year'); assertEqual(start.getMonth(), 0, 'Start month (Jan=0)'); assertEqual(start.getDate(), 25, 'Start day'); assertEqual(end.getFullYear(), 2026, 'End year'); assertEqual(end.getMonth(), 1, 'End month (Feb=1)'); assertEqual(end.getDate(), 23, 'End day'); }); test('custom → startDate at 00:00:00, endDate at 23:59:59', () => { const result = resolveScanDates('custom', '2024-06-15', '2024-06-20'); const start = new Date(result.startDate); const end = new Date(result.endDate); assertEqual(start.getHours(), 0, 'Start hours'); assertEqual(end.getHours(), 23, 'End hours'); assertEqual(end.getMinutes(), 59, 'End minutes'); }); test('custom → throws without dates', () => { let threw = false; try { resolveScanDates('custom'); } catch { threw = true; } assert(threw, 'Should throw without custom dates'); }); test('custom → throws with only startDate', () => { let threw = false; try { resolveScanDates('custom', '2024-01-01'); } catch { threw = true; } assert(threw, 'Should throw with only startDate'); }); // ─── resolveBranch ─── console.log('\n\x1b[1mresolveBranch()\x1b[0m'); test('maps finances@iccameriques.org → ICC Montréal', () => { assertEqual(resolveBranch('finances@iccameriques.org'), 'ICC Montréal'); }); test('maps montreal.finances@iccameriques.org → ICC Montréal', () => { assertEqual(resolveBranch('montreal.finances@iccameriques.org'), 'ICC Montréal'); }); test('maps gatineau.finances@iccameriques.org → ICC Gatineau', () => { assertEqual(resolveBranch('gatineau.finances@iccameriques.org'), 'ICC Gatineau'); }); test('case-insensitive mapping', () => { assertEqual(resolveBranch('TORONTO.FINANCES@ICCAMERIQUES.ORG'), 'ICC Toronto'); }); test('unknown email → Non classifié', () => { assertEqual(resolveBranch('unknown@example.com'), 'Non classifié'); }); test('empty string → Non classifié', () => { assertEqual(resolveBranch(''), 'Non classifié'); }); test('siege@iccameriques.org → ICC Siège', () => { assertEqual(resolveBranch('siege@iccameriques.org'), 'ICC Siège'); }); // ─── buildGmailQuery ─── console.log('\n\x1b[1mbuildGmailQuery()\x1b[0m'); test('always includes from:notify@payments.interac.ca', () => { const query = buildGmailQuery({ preset: 'today', startDate: '2026-02-23T00:00:00Z', endDate: '2026-02-23T23:59:59Z' }); assert(query.includes('from:notify@payments.interac.ca'), `Missing sender filter: ${query}`); }); test('includes after: and before: date operators', () => { const query = buildGmailQuery({ preset: 'today', startDate: '2026-02-23T00:00:00Z', endDate: '2026-02-23T23:59:59Z' }); assert(query.includes('after:'), `Missing after: operator: ${query}`); assert(query.includes('before:'), `Missing before: operator: ${query}`); }); test('after: is before startDate (inclusive buffer)', () => { // Use local-time dates to avoid timezone issues with Date.setDate() const query = buildGmailQuery({ preset: 'custom', startDate: '2024-01-25T12:00:00Z', endDate: '2024-02-15T12:00:00Z' }); // The after: date should be before Jan 25 assert(query.includes('after:2024/1/24') || query.includes('after:2024/1/23'), `after: should be before Jan 25: ${query}`); }); test('before: is after endDate (inclusive buffer)', () => { const query = buildGmailQuery({ preset: 'custom', startDate: '2024-01-25T12:00:00Z', endDate: '2024-02-15T12:00:00Z' }); assert(query.includes('before:2024/2/16') || query.includes('before:2024/2/17'), `before: should be after Feb 15: ${query}`); }); test('handles year boundary correctly', () => { const query = buildGmailQuery({ preset: 'custom', startDate: '2024-01-01T12:00:00Z', endDate: '2024-12-31T12:00:00Z' }); assert(query.includes('after:2023/12/31') || query.includes('after:2023/12/30'), `after: should cross year: ${query}`); assert(query.includes('before:2025/1/1') || query.includes('before:2025/1/2'), `before: should cross year: ${query}`); }); // ─── JSON Parsing (AI response simulation) ─── console.log('\n\x1b[1mAI Response Parsing\x1b[0m'); test('parses valid transaction JSON', () => { const raw = '{"sender":"Jean Dupont","amount":250.00,"currency":"CAD","reference":"CA1b2c3d","message":"Dime mars","recipient_email":"montreal.finances@iccameriques.org","date":"2025-02-15T14:30:00Z","status":"deposited"}'; const parsed = JSON.parse(raw); assertEqual(parsed.sender, 'Jean Dupont'); assertEqual(parsed.amount, 250.00); assertEqual(parsed.status, 'deposited'); }); test('strips markdown fences from AI response', () => { const raw = '```json\n{"sender":"Test","amount":100}\n```'; const cleaned = raw.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); const parsed = JSON.parse(cleaned); assertEqual(parsed.sender, 'Test'); }); test('handles null fields gracefully', () => { const raw = '{"sender":"X","amount":50,"currency":"CAD","reference":null,"message":null,"recipient_email":null,"date":"2025-01-01","status":"pending"}'; const parsed = JSON.parse(raw); assert(parsed.reference === null, 'reference should be null'); assert(parsed.message === null, 'message should be null'); }); test('rejects invalid JSON', () => { let threw = false; try { JSON.parse('this is not json {broken}'); } catch { threw = true; } assert(threw, 'Should throw on invalid JSON'); }); // ─── EDGE CASES ─── console.log('\n\x1b[1mEdge Cases\x1b[0m'); test('amount 0.01 (smallest valid amount)', () => { const parsed = JSON.parse('{"sender":"X","amount":0.01}'); assert(parsed.amount > 0, 'Amount should be positive'); }); test('large amount (99999.99)', () => { const parsed = JSON.parse('{"sender":"X","amount":99999.99}'); assertEqual(parsed.amount, 99999.99); }); test('French characters in sender name', () => { const parsed = JSON.parse('{"sender":"Éric Bélanger-Côté","amount":100}'); assertEqual(parsed.sender, 'Éric Bélanger-Côté'); }); test('Special characters in message field', () => { const parsed = JSON.parse('{"sender":"X","amount":100,"message":"Dîme pour l\'église — mars 2025"}'); assert(parsed.message.includes("l'église"), 'Should preserve apostrophe'); }); // ═══ SUMMARY ═══ console.log('\n' + '═'.repeat(50)); console.log(`\x1b[1m📋 Results: ${passed} passed, ${failed} failed\x1b[0m`); console.log('═'.repeat(50) + '\n'); process.exit(failed > 0 ? 1 : 0);