expria-backend/docs/TESTS_AUTOMATISES.md

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 (4 tests)
✓ checkFeatureAccess (14 tests)
✓ updateUserPlan (4 tests)
✓ verifyStripeWebhook (4 tests)
✓ calculateProrata (6 tests)

Test Files  6 passed (6)
Tests      39 passed (39)
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é)