Spaces:
Running
Running
| // 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<string, string> = { | |
| "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); | |