feat: ajout 6 fichiers de tests automatisés — 39/39 verts
This commit is contained in:
parent
708517edef
commit
f4849571c4
10 changed files with 422 additions and 3 deletions
|
|
@ -480,14 +480,14 @@ npm run test:coverage
|
||||||
**Résultat attendu (tous les tests au vert) :**
|
**Résultat attendu (tous les tests au vert) :**
|
||||||
```
|
```
|
||||||
✓ canUserSimulate (7 tests)
|
✓ canUserSimulate (7 tests)
|
||||||
✓ getPlanPermissions (7 tests)
|
✓ getPlanPermissions (4 tests)
|
||||||
✓ checkFeatureAccess (13 tests)
|
✓ checkFeatureAccess (14 tests)
|
||||||
✓ updateUserPlan (4 tests)
|
✓ updateUserPlan (4 tests)
|
||||||
✓ verifyStripeWebhook (4 tests)
|
✓ verifyStripeWebhook (4 tests)
|
||||||
✓ calculateProrata (6 tests)
|
✓ calculateProrata (6 tests)
|
||||||
|
|
||||||
Test Files 6 passed (6)
|
Test Files 6 passed (6)
|
||||||
Tests 41 passed (41)
|
Tests 39 passed (39)
|
||||||
Duration ~1.2s
|
Duration ~1.2s
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
70
src/lib/__tests__/calculateProrata.test.ts
Normal file
70
src/lib/__tests__/calculateProrata.test.ts
Normal file
|
|
@ -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()
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
49
src/lib/__tests__/canUserSimulate.test.ts
Normal file
49
src/lib/__tests__/canUserSimulate.test.ts
Normal file
|
|
@ -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')
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
66
src/lib/__tests__/checkFeatureAccess.test.ts
Normal file
66
src/lib/__tests__/checkFeatureAccess.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
52
src/lib/__tests__/getPlanPermissions.test.ts
Normal file
52
src/lib/__tests__/getPlanPermissions.test.ts
Normal file
|
|
@ -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()
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
47
src/lib/__tests__/updateUserPlan.test.ts
Normal file
47
src/lib/__tests__/updateUserPlan.test.ts
Normal file
|
|
@ -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')
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
61
src/lib/__tests__/verifyStripeWebhook.test.ts
Normal file
61
src/lib/__tests__/verifyStripeWebhook.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
22
src/lib/planController.ts
Normal file
22
src/lib/planController.ts
Normal file
|
|
@ -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 }
|
||||||
|
}
|
||||||
46
src/lib/stripe.ts
Normal file
46
src/lib/stripe.ts
Normal file
|
|
@ -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 }
|
||||||
|
}
|
||||||
6
src/lib/supabase.ts
Normal file
6
src/lib/supabase.ts
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue