CognxSafeTrack
feat(billing): complete billing system, push notifications, and tech debt fixes
8280d7d | 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<string, unknown> = {}) { | |
| 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<string, { label: string }> = { | |
| 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<string, { aiCalls: number; whatsappMessages: number; costUsd: number }> = {}; | |
| 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 | |
| }); | |
| }); | |