import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock infrastructure before any imports vi.mock('../src/services/prisma', () => ({ prisma: { organization: { findUnique: vi.fn(), update: vi.fn(), }, usageEvent: { aggregate: vi.fn(), groupBy: vi.fn(), findMany: vi.fn(), }, }, })); import { prisma } from '../src/services/prisma'; // ─── Helpers ────────────────────────────────────────────────────────────────── function makeOrgFixture(overrides: Record = {}) { return { subscriptionPlan: 'STARTER', subscriptionStatus: 'ACTIVE', aiCreditsUsed: 120, aiCreditsLimit: 500, whatsappMessagesSent: 300, billingPeriodStart: new Date('2026-05-01'), ...overrides, }; } function makeAggFixture(costUsd = 0.05, count = 120) { return { _sum: { costUsd, tokensIn: 80000, tokensOut: 20000 }, _count: { id: count }, }; } // ─── Unit tests for billing summary logic ──────────────────────────────────── describe('Billing — summary computation', () => { beforeEach(() => vi.clearAllMocks()); it('computes percentUsed correctly for STARTER plan', () => { const org = makeOrgFixture({ aiCreditsUsed: 425, aiCreditsLimit: 500 }); const pct = org.aiCreditsLimit > 0 ? Math.round((org.aiCreditsUsed / org.aiCreditsLimit) * 100) : 0; expect(pct).toBe(85); }); it('caps percentUsed at 100 when over-limit', () => { const org = makeOrgFixture({ aiCreditsUsed: 600, aiCreditsLimit: 500 }); const pct = Math.min( org.aiCreditsLimit > 0 ? Math.round((org.aiCreditsUsed / org.aiCreditsLimit) * 100) : 0, 100 ); expect(pct).toBe(100); }); it('converts costUsd to FCFA at 600x rate', () => { const costUsd = 0.05; const costFcfa = Math.round(costUsd * 600); expect(costFcfa).toBe(30); }); it('plan label maps correctly', () => { const PLAN_LIMITS: Record = { STARTER: { label: 'Démarrage' }, GROWTH: { label: 'Croissance' }, SCALE: { label: 'Scale' }, ENTERPRISE: { label: 'Enterprise' }, }; expect(PLAN_LIMITS['GROWTH'].label).toBe('Croissance'); expect(PLAN_LIMITS['ENTERPRISE'].label).toBe('Enterprise'); }); }); // ─── Unit tests for billing history grouping ───────────────────────────────── describe('Billing — history day grouping', () => { it('groups events by YYYY-MM-DD', () => { const events = [ { type: 'AI_TEXT', costUsd: 0.01, createdAt: new Date('2026-05-10T10:00:00Z') }, { type: 'AI_TEXT', costUsd: 0.02, createdAt: new Date('2026-05-10T15:00:00Z') }, { type: 'WHATSAPP_SENT', costUsd: 0, createdAt: new Date('2026-05-11T08:00:00Z') }, ] as any[]; const byDay: Record = {}; for (const e of events) { const day = e.createdAt.toISOString().substring(0, 10); if (!byDay[day]) byDay[day] = { aiCalls: 0, whatsappMessages: 0, costUsd: 0 }; if (e.type === 'WHATSAPP_SENT') byDay[day].whatsappMessages++; else { byDay[day].aiCalls++; byDay[day].costUsd += e.costUsd; } } expect(byDay['2026-05-10'].aiCalls).toBe(2); expect(byDay['2026-05-10'].costUsd).toBeCloseTo(0.03); expect(byDay['2026-05-11'].whatsappMessages).toBe(1); expect(byDay['2026-05-11'].aiCalls).toBe(0); }); it('caps history to max 90 days', () => { const days = Math.min(parseInt('200', 10), 90); expect(days).toBe(90); }); }); // ─── Unit tests for API key plan guard logic ────────────────────────────────── describe('Billing — API key plan guard', () => { it('allows SCALE plan to set own API keys', () => { const allowedPlans = ['SCALE', 'ENTERPRISE']; expect(allowedPlans.includes('SCALE')).toBe(true); expect(allowedPlans.includes('ENTERPRISE')).toBe(true); }); it('rejects STARTER plan from setting own API keys', () => { const allowedPlans = ['SCALE', 'ENTERPRISE']; expect(allowedPlans.includes('STARTER')).toBe(false); expect(allowedPlans.includes('GROWTH')).toBe(false); }); }); // ─── Unit tests for quota alert threshold ──────────────────────────────────── describe('Billing — quota alert threshold', () => { it('detects 85% threshold crossing', () => { const THRESHOLD = 85; const limit = 500; function crossesThreshold(creditsBefore: number, creditsAfter: number): boolean { const pctBefore = Math.floor((creditsBefore / limit) * 100); const pctAfter = Math.floor((creditsAfter / limit) * 100); return pctBefore < THRESHOLD && pctAfter >= THRESHOLD; } expect(crossesThreshold(424, 425)).toBe(true); // 84% → 85% expect(crossesThreshold(425, 426)).toBe(false); // 85% → 85% (already crossed) expect(crossesThreshold(400, 410)).toBe(false); // 80% → 82% (not yet) expect(crossesThreshold(499, 500)).toBe(false); // 99% → 100% (already past) }); it('does not alert when limit is 0', () => { const limit = 0; const pct = limit > 0 ? Math.floor((425 / limit) * 100) : 0; expect(pct).toBe(0); // Never alert when no limit }); });