interacmanagernew / scripts /test-units.ts
MichaelEdou
Initial commit — ICC Interac Manager full-stack app
149698e
#!/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<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);