From f4849571c43af22ca51c5f2a0900bebed4a0428f Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Thu, 16 Apr 2026 06:46:43 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20ajout=206=20fichiers=20de=20tests=20aut?= =?UTF-8?q?omatis=C3=A9s=20=E2=80=94=2039/39=20verts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TESTS_AUTOMATISES.md | 6 +- src/lib/__tests__/calculateProrata.test.ts | 70 +++++++++++++++++++ src/lib/__tests__/canUserSimulate.test.ts | 49 +++++++++++++ src/lib/__tests__/checkFeatureAccess.test.ts | 66 +++++++++++++++++ src/lib/__tests__/getPlanPermissions.test.ts | 52 ++++++++++++++ src/lib/__tests__/updateUserPlan.test.ts | 47 +++++++++++++ src/lib/__tests__/verifyStripeWebhook.test.ts | 61 ++++++++++++++++ src/lib/planController.ts | 22 ++++++ src/lib/stripe.ts | 46 ++++++++++++ src/lib/supabase.ts | 6 ++ 10 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 src/lib/__tests__/calculateProrata.test.ts create mode 100644 src/lib/__tests__/canUserSimulate.test.ts create mode 100644 src/lib/__tests__/checkFeatureAccess.test.ts create mode 100644 src/lib/__tests__/getPlanPermissions.test.ts create mode 100644 src/lib/__tests__/updateUserPlan.test.ts create mode 100644 src/lib/__tests__/verifyStripeWebhook.test.ts create mode 100644 src/lib/planController.ts create mode 100644 src/lib/stripe.ts create mode 100644 src/lib/supabase.ts diff --git a/docs/TESTS_AUTOMATISES.md b/docs/TESTS_AUTOMATISES.md index c4661d3..88dd182 100644 --- a/docs/TESTS_AUTOMATISES.md +++ b/docs/TESTS_AUTOMATISES.md @@ -480,14 +480,14 @@ npm run test:coverage **Résultat attendu (tous les tests au vert) :** ``` ✓ canUserSimulate (7 tests) -✓ getPlanPermissions (7 tests) -✓ checkFeatureAccess (13 tests) +✓ getPlanPermissions (4 tests) +✓ checkFeatureAccess (14 tests) ✓ updateUserPlan (4 tests) ✓ verifyStripeWebhook (4 tests) ✓ calculateProrata (6 tests) Test Files 6 passed (6) -Tests 41 passed (41) +Tests 39 passed (39) Duration ~1.2s ``` diff --git a/src/lib/__tests__/calculateProrata.test.ts b/src/lib/__tests__/calculateProrata.test.ts new file mode 100644 index 0000000..0054482 --- /dev/null +++ b/src/lib/__tests__/calculateProrata.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest' +import { calculateProrata } from '../stripe' + +describe('calculateProrata', () => { + + it('calcule le prorata correct pour un upgrade à mi-période', () => { + // Standard = 19.90€ / 28 jours, 14 jours restants + // Premium = 39.90€ / 28 jours, 14 jours restants + // Crédit Standard = 19.90 * (14/28) = 9.95€ + // Coût Premium = 39.90 * (14/28) = 19.95€ + // À payer = 19.95 - 9.95 = 10.00€ + const result = calculateProrata({ + currentPlanPrice: 19.90, + newPlanPrice: 39.90, + totalDays: 28, + daysRemaining: 14, + }) + expect(result.amount).toBeCloseTo(10.00, 1) + }) + + it('retourne 0 si les plans ont le même prix', () => { + const result = calculateProrata({ + currentPlanPrice: 19.90, + newPlanPrice: 19.90, + totalDays: 28, + daysRemaining: 14, + }) + expect(result.amount).toBeCloseTo(0, 1) + }) + + it('retourne le plein tarif si aucun jour n\'a été consommé', () => { + const result = calculateProrata({ + currentPlanPrice: 19.90, + newPlanPrice: 39.90, + totalDays: 28, + daysRemaining: 28, + }) + expect(result.amount).toBeCloseTo(20.00, 1) + }) + + it('retourne un montant minimal si 1 seul jour reste', () => { + const result = calculateProrata({ + currentPlanPrice: 19.90, + newPlanPrice: 39.90, + totalDays: 28, + daysRemaining: 1, + }) + expect(result.amount).toBeGreaterThan(0) + expect(result.amount).toBeLessThan(5) + }) + + it('refuse des valeurs négatives', () => { + expect(() => calculateProrata({ + currentPlanPrice: -1, + newPlanPrice: 39.90, + totalDays: 28, + daysRemaining: 14, + })).toThrow() + }) + + it('refuse daysRemaining > totalDays', () => { + expect(() => calculateProrata({ + currentPlanPrice: 19.90, + newPlanPrice: 39.90, + totalDays: 28, + daysRemaining: 30, + })).toThrow() + }) + +}) diff --git a/src/lib/__tests__/canUserSimulate.test.ts b/src/lib/__tests__/canUserSimulate.test.ts new file mode 100644 index 0000000..ec79dc7 --- /dev/null +++ b/src/lib/__tests__/canUserSimulate.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { canUserSimulate } from '../access' + +describe('canUserSimulate', () => { + + // Plan FREE — dans les limites + it('autorise un utilisateur free avec 0 simulation utilisée', () => { + const result = canUserSimulate({ plan: 'free', simulations_used: 0 }) + expect(result.allowed).toBe(true) + }) + + it('autorise un utilisateur free avec 4 simulations utilisées', () => { + const result = canUserSimulate({ plan: 'free', simulations_used: 4 }) + expect(result.allowed).toBe(true) + }) + + // Plan FREE — quota atteint + it('bloque un utilisateur free avec 5 simulations utilisées', () => { + const result = canUserSimulate({ plan: 'free', simulations_used: 5 }) + expect(result.allowed).toBe(false) + expect(result.reason).toBe('quota_reached') + }) + + it('bloque un utilisateur free avec plus de 5 simulations (sécurité)', () => { + const result = canUserSimulate({ plan: 'free', simulations_used: 99 }) + expect(result.allowed).toBe(false) + expect(result.reason).toBe('quota_reached') + }) + + // Plan STANDARD — illimité + it('autorise toujours un utilisateur standard', () => { + const result = canUserSimulate({ plan: 'standard', simulations_used: 999 }) + expect(result.allowed).toBe(true) + }) + + // Plan PREMIUM — illimité + it('autorise toujours un utilisateur premium', () => { + const result = canUserSimulate({ plan: 'premium', simulations_used: 999 }) + expect(result.allowed).toBe(true) + }) + + // Plan inconnu — sécurité défensive + it('bloque un plan inconnu', () => { + const result = canUserSimulate({ plan: 'unknown' as any, simulations_used: 0 }) + expect(result.allowed).toBe(false) + expect(result.reason).toBe('invalid_plan') + }) + +}) diff --git a/src/lib/__tests__/checkFeatureAccess.test.ts b/src/lib/__tests__/checkFeatureAccess.test.ts new file mode 100644 index 0000000..cae4e40 --- /dev/null +++ b/src/lib/__tests__/checkFeatureAccess.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { checkFeatureAccess } from '../access' + +describe('checkFeatureAccess', () => { + + // Features accessibles en FREE + it('free peut accéder au rapport basique', () => { + expect(checkFeatureAccess('free', 'basic_report')).toBe(true) + }) + + // Features BLOQUÉES en FREE + it('free ne peut pas accéder au rapport détaillé', () => { + expect(checkFeatureAccess('free', 'detailed_report')).toBe(false) + }) + + it('free ne peut pas accéder au mode examen', () => { + expect(checkFeatureAccess('free', 'exam_mode')).toBe(false) + }) + + it('free ne peut pas accéder à la T2 live', () => { + expect(checkFeatureAccess('free', 'oral_t2_live')).toBe(false) + }) + + it('free ne peut pas accéder au dashboard', () => { + expect(checkFeatureAccess('free', 'dashboard')).toBe(false) + }) + + // Features STANDARD + it('standard peut accéder au rapport détaillé', () => { + expect(checkFeatureAccess('standard', 'detailed_report')).toBe(true) + }) + + it('standard peut accéder au dashboard', () => { + expect(checkFeatureAccess('standard', 'dashboard')).toBe(true) + }) + + it('standard ne peut PAS accéder au mode examen', () => { + expect(checkFeatureAccess('standard', 'exam_mode')).toBe(false) + }) + + it('standard ne peut PAS accéder à la T2 live', () => { + expect(checkFeatureAccess('standard', 'oral_t2_live')).toBe(false) + }) + + it('standard ne peut PAS accéder à l\'analyse des patterns', () => { + expect(checkFeatureAccess('standard', 'pattern_analysis')).toBe(false) + }) + + // Features PREMIUM + it('premium peut accéder au mode examen', () => { + expect(checkFeatureAccess('premium', 'exam_mode')).toBe(true) + }) + + it('premium peut accéder à la T2 live', () => { + expect(checkFeatureAccess('premium', 'oral_t2_live')).toBe(true) + }) + + it('premium peut accéder à l\'analyse des patterns', () => { + expect(checkFeatureAccess('premium', 'pattern_analysis')).toBe(true) + }) + + it('premium peut accéder à l\'indice de préparation', () => { + expect(checkFeatureAccess('premium', 'preparation_index')).toBe(true) + }) + +}) diff --git a/src/lib/__tests__/getPlanPermissions.test.ts b/src/lib/__tests__/getPlanPermissions.test.ts new file mode 100644 index 0000000..cb20a3b --- /dev/null +++ b/src/lib/__tests__/getPlanPermissions.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { getPlanPermissions } from '../access' + +describe('getPlanPermissions', () => { + + describe('Plan FREE', () => { + it('retourne les bonnes permissions pour free', () => { + const perms = getPlanPermissions('free') + expect(perms.simulations_lifetime).toBe(5) + expect(perms.oral_t2_live).toBe(false) + expect(perms.detailed_report).toBe(false) + expect(perms.tips).toBe(false) + expect(perms.dashboard).toBe(false) + expect(perms.exam_mode).toBe(false) + expect(perms.pattern_analysis).toBe(false) + expect(perms.preparation_index).toBe(false) + }) + }) + + describe('Plan STANDARD', () => { + it('retourne les bonnes permissions pour standard', () => { + const perms = getPlanPermissions('standard') + expect(perms.simulations_lifetime).toBeNull() + expect(perms.oral_t2_live).toBe(false) + expect(perms.detailed_report).toBe(true) + expect(perms.tips).toBe(true) + expect(perms.dashboard).toBe(true) + expect(perms.exam_mode).toBe(false) // Standard n'a PAS le mode examen + expect(perms.pattern_analysis).toBe(false) + expect(perms.preparation_index).toBe(false) + }) + }) + + describe('Plan PREMIUM', () => { + it('retourne les bonnes permissions pour premium', () => { + const perms = getPlanPermissions('premium') + expect(perms.simulations_lifetime).toBeNull() + expect(perms.oral_t2_live).toBe(true) + expect(perms.detailed_report).toBe(true) + expect(perms.tips).toBe(true) + expect(perms.dashboard).toBe(true) + expect(perms.exam_mode).toBe(true) + expect(perms.pattern_analysis).toBe(true) + expect(perms.preparation_index).toBe(true) + }) + }) + + it('lève une erreur pour un plan inconnu', () => { + expect(() => getPlanPermissions('unknown' as any)).toThrow() + }) + +}) diff --git a/src/lib/__tests__/updateUserPlan.test.ts b/src/lib/__tests__/updateUserPlan.test.ts new file mode 100644 index 0000000..02a94b2 --- /dev/null +++ b/src/lib/__tests__/updateUserPlan.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { updateUserPlan } from '../planController' + +// Mock du client Supabase +vi.mock('../supabase', () => ({ + supabase: { + from: vi.fn(() => ({ + update: vi.fn(() => ({ + eq: vi.fn(() => ({ + select: vi.fn(() => ({ + single: vi.fn(() => ({ + data: { id: 'test-user-id', plan: 'standard' }, + error: null, + })), + })), + })), + })), + })), + }, +})) + +describe('updateUserPlan', () => { + + it('met à jour le plan vers standard', async () => { + const result = await updateUserPlan('test-user-id', 'standard') + expect(result.success).toBe(true) + expect(result.plan).toBe('standard') + }) + + it('met à jour le plan vers premium', async () => { + const result = await updateUserPlan('test-user-id', 'premium') + expect(result.success).toBe(true) + }) + + it('refuse une valeur de plan invalide', async () => { + await expect( + updateUserPlan('test-user-id', 'super_premium' as any) + ).rejects.toThrow('Plan invalide') + }) + + it('refuse un userId vide', async () => { + await expect( + updateUserPlan('', 'standard') + ).rejects.toThrow('userId requis') + }) + +}) diff --git a/src/lib/__tests__/verifyStripeWebhook.test.ts b/src/lib/__tests__/verifyStripeWebhook.test.ts new file mode 100644 index 0000000..970aaf1 --- /dev/null +++ b/src/lib/__tests__/verifyStripeWebhook.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest' +import { verifyStripeWebhook } from '../stripe' + +// Mock de la librairie Stripe +vi.mock('stripe', () => ({ + default: vi.fn(() => ({ + webhooks: { + constructEvent: vi.fn((payload, signature, secret) => { + if (signature === 'valid_signature') { + return { + type: 'checkout.session.completed', + data: { object: { client_reference_id: 'user-123' } }, + } + } + throw new Error('No signatures found matching the expected signature') + }), + }, + })), +})) + +describe('verifyStripeWebhook', () => { + + it('valide un webhook avec une signature correcte', () => { + const result = verifyStripeWebhook( + Buffer.from('payload'), + 'valid_signature', + 'whsec_test_secret' + ) + expect(result.valid).toBe(true) + expect(result.event?.type).toBe('checkout.session.completed') + }) + + it('rejette un webhook avec une signature incorrecte', () => { + const result = verifyStripeWebhook( + Buffer.from('payload'), + 'invalid_signature', + 'whsec_test_secret' + ) + expect(result.valid).toBe(false) + expect(result.error).toBeDefined() + }) + + it('rejette un payload vide', () => { + const result = verifyStripeWebhook( + Buffer.from(''), + 'valid_signature', + 'whsec_test_secret' + ) + expect(result.valid).toBe(false) + }) + + it('rejette une signature vide', () => { + const result = verifyStripeWebhook( + Buffer.from('payload'), + '', + 'whsec_test_secret' + ) + expect(result.valid).toBe(false) + }) + +}) diff --git a/src/lib/planController.ts b/src/lib/planController.ts new file mode 100644 index 0000000..733f4b5 --- /dev/null +++ b/src/lib/planController.ts @@ -0,0 +1,22 @@ +import { supabase } from './supabase' +import { PLANS } from './access' +import type { Plan } from './access' + +export async function updateUserPlan( + userId: string, + plan: Plan +): Promise<{ success: boolean; plan: Plan }> { + if (!userId) throw new Error('userId requis') + if (!(plan in PLANS)) throw new Error('Plan invalide') + + const { data, error } = await supabase + .from('profiles') + .update({ plan }) + .eq('id', userId) + .select() + .single() + + if (error) throw new Error(error.message) + + return { success: true, plan: data.plan } +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 0000000..266eb71 --- /dev/null +++ b/src/lib/stripe.ts @@ -0,0 +1,46 @@ +import Stripe from 'stripe' + +function getStripe() { + return new Stripe(process.env.STRIPE_SECRET_KEY ?? '') +} + +export function verifyStripeWebhook( + payload: Buffer, + signature: string, + secret: string +): { valid: boolean; event?: Stripe.Event; error?: string } { + if (!payload.length || !signature) { + return { valid: false, error: 'Payload ou signature manquant' } + } + try { + const event = getStripe().webhooks.constructEvent(payload, signature, secret) + return { valid: true, event } + } catch (err) { + return { valid: false, error: (err as Error).message } + } +} + +interface ProrataParams { + currentPlanPrice: number + newPlanPrice: number + totalDays: number + daysRemaining: number +} + +export function calculateProrata(params: ProrataParams): { amount: number } { + const { currentPlanPrice, newPlanPrice, totalDays, daysRemaining } = params + + if (currentPlanPrice < 0 || newPlanPrice < 0 || totalDays < 0 || daysRemaining < 0) { + throw new Error('Les valeurs ne peuvent pas être négatives') + } + if (daysRemaining > totalDays) { + throw new Error('daysRemaining ne peut pas dépasser totalDays') + } + + const ratio = daysRemaining / totalDays + const credit = currentPlanPrice * ratio + const cost = newPlanPrice * ratio + const amount = Math.max(0, cost - credit) + + return { amount } +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..eaa1ac0 --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,6 @@ +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = process.env.SUPABASE_URL ?? '' +const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? '' + +export const supabase = createClient(supabaseUrl, supabaseServiceRoleKey)