14 KiB
TESTS_AUTOMATISES.md — Expria / Coach TCF Canada
Document de référence — Version 1.0 Ce document contient les tests Vitest automatisés à implémenter dans le backend. Ces tests s'exécutent en quelques secondes et détectent les régressions invisibles que le Golden Dataset ne peut pas attraper par des tests manuels.
1. Principe
Ces 6 fonctions sont critiques. Si l'une d'elles casse, toute l'application tombe :
| Fonction | Rôle | Conséquence si cassée |
|---|---|---|
canUserSimulate |
Vérifie quota + plan avant simulation | N'importe qui peut simuler sans limite |
getPlanPermissions |
Retourne les permissions d'un plan | Mauvaises features affichées / refusées |
checkFeatureAccess |
Vérifie l'accès à une feature spécifique | Features Premium accessibles en Free |
updateUserPlan |
Met à jour le plan dans Supabase | Paiement reçu mais accès non débloqué |
verifyStripeWebhook |
Valide la signature Stripe | N'importe qui peut déclencher un webhook |
calculateProrata |
Calcule le montant d'upgrade | Mauvais montant affiché à l'utilisateur |
2. Installation
Dans le dépôt expria-backend, installer Vitest :
npm install --save-dev vitest @vitest/coverage-v8
Ajouter dans package.json :
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
Créer le fichier vitest.config.ts :
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
globals: true,
coverage: {
reporter: ['text', 'html'],
include: ['src/lib/**', 'src/controllers/**'],
},
},
})
3. Tests — canUserSimulate
Fichier : src/lib/__tests__/canUserSimulate.test.ts
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')
})
})
4. Tests — getPlanPermissions
Fichier : src/lib/__tests__/getPlanPermissions.test.ts
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()
})
})
5. Tests — checkFeatureAccess
Fichier : src/lib/__tests__/checkFeatureAccess.test.ts
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)
})
})
6. Tests — updateUserPlan
Fichier : src/lib/__tests__/updateUserPlan.test.ts
Ces tests utilisent un mock de Supabase — pas de vraie base de données.
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')
})
})
7. Tests — verifyStripeWebhook
Fichier : src/lib/__tests__/verifyStripeWebhook.test.ts
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)
})
})
8. Tests — calculateProrata
Fichier : src/lib/__tests__/calculateProrata.test.ts
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()
})
})
9. Lancer les tests
# Dans expria-backend/
# Lancer tous les tests une fois
npm run test
# Lancer en mode watch (relance automatiquement à chaque modification)
npm run test:watch
# Générer un rapport de couverture
npm run test:coverage
Résultat attendu (tous les tests au vert) :
✓ canUserSimulate (7 tests)
✓ getPlanPermissions (7 tests)
✓ checkFeatureAccess (13 tests)
✓ updateUserPlan (4 tests)
✓ verifyStripeWebhook (4 tests)
✓ calculateProrata (6 tests)
Test Files 6 passed (6)
Tests 41 passed (41)
Duration ~1.2s
10. Règle d'utilisation avec Claude Code
Avant chaque session Claude Code qui touche au backend :
npm run test
# Tous les tests doivent être verts avant de commencer
Après chaque session Claude Code qui touche au backend :
npm run test
# Si un test passe au rouge → régression détectée → ne pas continuer
Dans le prompt à Claude Code, toujours inclure :
"Après chaque modification, lance
npm run test. Si un test échoue, corrige la régression avant de passer à l'étape suivante. Ne me montre le résultat final que quand tous les tests sont verts."
11. Quand ajouter de nouveaux tests
Ajouter un test automatisé chaque fois que Claude Code crée une nouvelle fonction qui :
- Vérifie un droit d'accès
- Modifie des données en base
- Calcule un montant ou un score
- Appelle une API externe
Ne pas tester :
- Les composants d'affichage (trop fragiles, trop coûteux à maintenir)
- Les routes HTTP directement (c'est le rôle du Golden Dataset)
- La logique Supabase interne (c'est leur responsabilité)