File size: 6,025 Bytes
8280d7d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | 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
});
});
|