diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c72453e..ad7c542 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,556 +1,688 @@ -# ARCHITECTURE.md — Expria / Coach TCF Canada +# ARCHITECTURE.md — Expria Frontend -> **Document de référence — Version 1.1** -> Ce document décrit l'architecture technique complète du projet. -> Toute décision architecturale majeure doit être documentée ici avant d'être implémentée. -> À lire conjointement avec PLANS_TARIFAIRES.md et PARCOURS_UTILISATEURS.md. +> **Document de référence — Version 1.0** +> Ce document décrit l'architecture technique du frontend Expria. +> À lire conjointement avec `ONBOARDING.md`, `SECURITY.md`, et les documents de référence du backend (`expria-backend/docs/`). +> +> **Règle absolue :** toute décision architecturale majeure doit être documentée ici ou dans un ADR avant d'être implémentée. --- ## 1. Vue d'ensemble -Expria est une application web SaaS de coaching TCF Canada, construite sur une architecture -frontend / backend séparés, avec une pièce dédiée pour la fonctionnalité temps réel (T2 EO live). +Expria Frontend est une application web SaaS, construite comme un SPA (Single Page Application) pur. Elle ne contient **aucune logique métier** : elle affiche des données reçues du backend et relaie les actions de l'utilisateur vers ce même backend. ``` Utilisateur (navigateur) ↓ ┌─────────────────────────────┐ -│ FRONTEND │ -│ React + Vite │ -│ Cloudflare Pages (gratuit) │ +│ EXPRIA FRONTEND │ +│ React 18 + Vite 5 │ +│ TypeScript 5 │ +│ Tailwind + shadcn/ui │ +│ TanStack Query │ +│ React Router v6 │ +│ Cloudflare Pages (EU + Afrique) │ └─────────────┬───────────────┘ - │ appels API REST + │ HTTPS + WebSocket + │ Bearer JWT Supabase + │ X-API-Version ┌─────────────▼───────────────┐ -│ BACKEND │ -│ Hono.js (Node.js) │ -│ Render (Frankfurt) │ -│ — toutes les routes API │ -│ — WebSocket proxy T2 EO │ +│ EXPRIA BACKEND │ +│ Hono.js sur Render (Frankfurt) │ +│ api.expria.app │ └──────┬──────────────┬───────┘ │ │ ┌──────▼──────┐ ┌────▼────────────────┐ │ Supabase │ │ APIs externes │ -│ PostgreSQL │ │ — DeepSeek (EE/EO) │ -│ Auth │ │ — Gemini (audio) │ -│ Storage │ │ — Stripe (paiement)│ +│ Auth + DB │ │ (gérées par backend)│ └─────────────┘ └─────────────────────┘ ``` ---- - -## 2. Décisions architecturales - -### Pourquoi frontend séparé (React + Vite) et non Next.js - -Next.js est un framework full-stack qui mélange frontend et backend dans le même projet. -Ce mélange a été la principale source de chaos dans la version précédente d'Expria : -régressions fréquentes, difficultés à tester, couplage fort entre affichage et logique métier. - -React + Vite est un frontend pur — il ne fait qu'afficher et appeler des API. -Il ne connaît pas Supabase, ne connaît pas Gemini, ne connaît pas Stripe. -Il reçoit des données depuis le backend et les affiche. C'est tout. - -### Pourquoi Hono.js pour le backend - -Hono est un framework backend moderne, léger, TypeScript natif, conçu pour Node.js. -Il offre une structure claire de routes, middlewares, et contrôleurs — similaire à Express -mais plus moderne et mieux typé. Il tourne sur Render sans configuration particulière. - -### Pourquoi Render pour le backend (et non Cloudflare Workers) - -La T2 EO live nécessite une connexion WebSocket persistante entre le serveur et Gemini Live API, -pouvant durer plusieurs minutes. Cloudflare Workers impose une durée d'exécution maximale -incompatible avec cette contrainte. Render supporte nativement les connexions WebSocket longue durée. - -### Pourquoi Cloudflare Pages pour le frontend - -Le frontend est un ensemble de fichiers statiques (HTML, CSS, JS). Cloudflare Pages les distribue -depuis un CDN mondial — latence minimale pour les utilisateurs en Algérie, Maroc, Cameroun. -Tier gratuit, déploiement automatique depuis GitHub. - -### Pourquoi Supabase est conservé - -Supabase fournit trois services critiques déjà en production : -- Authentification complète (email, OAuth Google/Apple, sessions JWT) -- Base de données PostgreSQL avec Row Level Security -- Stockage de fichiers (enregistrements audio EO) - -Remplacer Supabase par une Render DB nue demanderait de recoder l'authentification -et les politiques de sécurité de zéro — un coût disproportionné sans bénéfice. +Le frontend communique avec Supabase **uniquement pour l'authentification** (login, logout, register, récupération de token). Toute lecture et écriture de données métier passe exclusivement par le backend Hono. --- -## 3. Structure des dépôts +## 2. Stack technique -Deux dépôts GitHub séparés : +Versions officielles au 2026-04-17 (cf. ADR 006 pour la justification) : -``` -expria-frontend/ → React + Vite -expria-backend/ → Hono.js -``` +| Domaine | Choix | Version | Justification | +|---|---|---|---| +| Framework UI | React | 19.2.x | Compilateur React, Actions, useOptimistic | +| Build tool | Vite | 8.0.x | HMR rapide, moteur Rolldown, config minimale | +| Langage | TypeScript (strict mode) | 6.0.x | Typage fort obligatoire pour détecter les bugs de permissions à la compilation | +| Styling | Tailwind CSS | 4.2.x | Configuration CSS-first via `@theme`, moteur Oxide (builds microseconde) | +| UI components | shadcn/ui | CLI latest | Copy-paste, total contrôle, supporte Tailwind 4 + React 19 depuis 2025 | +| Routing | React Router | v7.14.x | Compatible API v6, data loaders disponibles | +| État serveur | TanStack Query | 5.x | Cache, refetch, invalidation, remplace Redux/SWR | +| État local | `useState` / `useReducer` | React 19 built-in | Pas de store global pour la V2 (voir ADR 003) | +| Auth | Supabase JS | 2.103.x | Côté frontend : auth uniquement. Cf. `ARCHITECTURE.md` backend §2 | +| Validation | Zod | latest | Validation des inputs formulaires (cf. SECURITY.md SEC-04) | +| Rendu Markdown | react-markdown | latest | Rendu sécurisé des rapports IA (cf. SECURITY.md SEC-05) | +| Tests | Vitest + React Testing Library | latest | Parité avec backend (qui utilise Vitest) | +| Lint + Format | ESLint + Prettier | 9.x + latest | Standard | +| CI | GitHub Actions | — | Typecheck + tests + `npm audit` | -Supabase est géré via son propre dashboard et CLI (migrations SQL versionnées). - -### Pourquoi deux dépôts séparés - -- Claude Code travaille sur un périmètre clair : frontend OU backend, jamais les deux en même temps -- Une modification frontend ne peut pas casser le backend par accident -- Déploiements indépendants : on peut déployer le frontend sans toucher au backend +**Choix motivés par ADR :** +- ADR 001 : Cloudflare Pages (hébergement) +- ADR 002 : Découplage `auth-client` / `api-client` +- ADR 003 : Pas de Zustand pour la V2 +- ADR 004 : Duplication des types (pas de monorepo) +- ADR 005 : `hasAccess()` typé strict + `canSimulate()` séparé +- ADR 006 : Stack React 19 / Vite 8 / TypeScript 6 / Tailwind 4 / RR7 --- -## 4. Structure des dossiers - -### Frontend — expria-frontend/ +## 3. Structure des dossiers ``` expria-frontend/ -├── public/ # Assets statiques (favicon, images) +├── docs/ +│ ├── ARCHITECTURE.md ← ce document +│ ├── ONBOARDING.md ← guide dev +│ ├── SECURITY.md ← trous identifiés + patterns +│ ├── DEVELOPMENT_PRINCIPLES.md ← règles de dev (copie adaptée du backend) +│ ├── PLANS_TARIFAIRES.md ← copie miroir du backend (source de vérité) +│ ├── PARCOURS_UTILISATEURS.md ← copie miroir du backend +│ ├── GOLDEN_DATASET.md ← tests manuels frontend +│ ├── CHANGELOG.md ← historique des sessions +│ └── adr/ +│ ├── 001-cloudflare-pages-vs-vercel.md +│ ├── 002-auth-api-decoupling.md +│ ├── 003-no-zustand.md +│ ├── 004-types-duplication.md +│ └── 005-has-access-typed-strict.md +│ ├── src/ -│ ├── api/ # Fonctions d'appel API (une par domaine) -│ │ ├── auth.ts # Appels auth (login, logout, register) -│ │ ├── simulations.ts # Appels simulations (créer, récupérer) -│ │ ├── corrections.ts # Appels corrections (rapport, score) -│ │ ├── plans.ts # Appels plans (upgrade, status) -│ │ └── stripe.ts # Appels paiement (checkout, prorata) -│ ├── components/ # Composants React réutilisables -│ │ ├── ui/ # Composants génériques (Button, Modal, Badge) -│ │ ├── simulation/ # Composants spécifiques aux simulations -│ │ ├── rapport/ # Composants d'affichage des rapports -│ │ ├── dashboard/ # Composants du dashboard utilisateur -│ │ └── paywall/ # Composants de blocage / upgrade -│ ├── pages/ # Pages de l'application (une par route) -│ │ ├── Home.tsx # Page d'accueil (visiteur non connecté) -│ │ ├── Login.tsx # Connexion / inscription -│ │ ├── Dashboard.tsx # Dashboard utilisateur (tous plans) -│ │ ├── Simulation.tsx # Interface de simulation (EE + EO) -│ │ ├── Rapport.tsx # Affichage d'un rapport de correction -│ │ ├── Methodologie.tsx # Page méthodologie -│ │ ├── Tarifs.tsx # Page tarifaire -│ │ └── T2Live.tsx # Interface T2 EO live (Premium uniquement) -│ ├── hooks/ # Hooks React personnalisés -│ │ ├── useAuth.ts # Gestion de l'authentification -│ │ ├── usePlan.ts # Lecture du plan actuel + permissions -│ │ ├── useSimulation.ts # Logique de simulation -│ │ └── useT2Live.ts # Connexion WebSocket T2 live -│ ├── lib/ -│ │ ├── access.ts # Source de vérité des permissions par plan -│ │ ├── supabase.ts # Client Supabase (auth uniquement côté frontend) -│ │ └── constants.ts # Constantes globales (URLs, config) -│ ├── types/ # Types TypeScript partagés -│ │ ├── plans.ts # Types Plan, Permission -│ │ ├── simulation.ts # Types Simulation, Tache, Production -│ │ └── rapport.ts # Types Rapport, Critere, Score -│ ├── App.tsx # Router principal -│ └── main.tsx # Point d'entrée -├── .env.example # Variables d'environnement (sans valeurs) +│ ├── app/ # CONFIGURATION ET ENTRY POINTS +│ │ ├── providers.tsx # QueryClientProvider + AuthProvider + Router +│ │ ├── router.tsx # Routes déclaratives +│ │ └── main.tsx # Entry point React +│ │ +│ ├── entities/ # OBJETS MÉTIER (indépendants de l'UI) +│ │ ├── user/ +│ │ │ ├── types.ts # Plan, UserAccess, UserProfile +│ │ │ ├── lib.ts # hasAccess(), canSimulate() +│ │ │ ├── access.ts # IDENTIQUE à expria-backend/src/lib/access.ts +│ │ │ ├── api.ts # GET /plans/status, POST /auth/verify-token +│ │ │ └── __tests__/ +│ │ │ ├── hasAccess.test.ts +│ │ │ └── canSimulate.test.ts +│ │ │ +│ │ ├── production/ +│ │ │ ├── types.ts # Production, Tache, Mode +│ │ │ ├── lib.ts # helpers (format tache, etc.) +│ │ │ └── api.ts # POST /simulations, GET /simulations/:id +│ │ │ +│ │ └── report/ +│ │ ├── types.ts # Report, Critere +│ │ ├── lib.ts # Logique de floutage selon plan +│ │ ├── api.ts # POST /corrections/ee, POST /corrections/eo +│ │ └── __tests__/ +│ │ └── floutage.test.ts +│ │ +│ ├── features/ # UI (composants + pages + hooks) +│ │ ├── auth/ +│ │ │ ├── components/ # LoginForm, RegisterForm, ProtectedRoute +│ │ │ ├── pages/ # LoginPage, RegisterPage +│ │ │ └── hooks/ # useAuth +│ │ │ +│ │ ├── dashboard/ +│ │ │ ├── components/ # DashboardFreeView, DashboardStandardView, DashboardPremiumView +│ │ │ ├── pages/ # DashboardPage (orchestre les vues selon le plan) +│ │ │ └── hooks/ # usePlan +│ │ │ +│ │ ├── simulations/ +│ │ │ ├── components/ # SimulationForm, AudioRecorder, TimerExam +│ │ │ ├── pages/ # SimulationPage, RapportPage +│ │ │ └── hooks/ # useSimulation, useExamMode +│ │ │ +│ │ ├── t2-live/ +│ │ │ ├── components/ # DialogueView, AudioVisualizer +│ │ │ ├── pages/ # T2LivePage +│ │ │ ├── hooks/ # useT2LiveSession +│ │ │ ├── lib/ +│ │ │ │ ├── ws-client.ts # WebSocket + reconnexion +│ │ │ │ └── audio.ts # Capture PCM + lecture réponse +│ │ │ └── state/ +│ │ │ └── t2-machine.ts # State machine (idle → connecting → listening → ...) +│ │ │ +│ │ └── billing/ +│ │ ├── components/ # PaymentSummary +│ │ ├── pages/ # PricingPage, CheckoutPage, UpgradePage +│ │ └── hooks/ # useStripeCheckout +│ │ +│ ├── shared/ # CODE RÉUTILISABLE NON MÉTIER +│ │ ├── components/ +│ │ │ ├── ui/ # Button, Modal, Badge (shadcn/ui) +│ │ │ ├── PaywallModal.tsx # Blocage + boutons upgrade +│ │ │ └── Spinner.tsx +│ │ ├── hooks/ # useDebounce, useLocalStorage +│ │ ├── lib/ +│ │ │ ├── auth-client.ts # Supabase Auth uniquement (ADR 002) +│ │ │ ├── api-client.ts # Fetch + retry + timeout + logging (ADR 002) +│ │ │ ├── query-client.ts # Configuration TanStack Query +│ │ │ └── logger.ts # Logging structuré frontend +│ │ ├── types/ +│ │ │ ├── api.ts # ApiResponse, ApiError +│ │ │ └── common.ts # Types utilitaires +│ │ └── config/ +│ │ └── env.ts # Validation des variables d'environnement au démarrage +│ │ +│ └── index.tsx # Entry point DOM +│ +├── .github/ +│ └── workflows/ +│ └── ci.yml # typecheck + vitest + npm audit +│ +├── public/ # Assets statiques (favicon, images) +├── .env.example # Template sans valeurs +├── .gitignore +├── package.json +├── tsconfig.json +├── tailwind.config.ts ├── vite.config.ts -├── tsconfig.json -└── package.json +└── README.md ``` -### Backend — expria-backend/ +### Règles de dépendance entre dossiers ``` -expria-backend/ -├── src/ -│ ├── routes/ # Définition des routes API (une par domaine) -│ │ ├── auth.ts # POST /auth/verify-token -│ │ ├── simulations.ts # POST /simulations, GET /simulations/:id -│ │ ├── corrections.ts # POST /corrections/ee, POST /corrections/eo -│ │ ├── plans.ts # GET /plans/status, POST /plans/upgrade -│ │ ├── stripe.ts # POST /stripe/checkout, POST /stripe/webhook -│ │ └── t2live.ts # WebSocket /t2/live (T2 EO proxy Gemini) -│ ├── controllers/ # Logique métier (une par domaine) -│ │ ├── simulationController.ts -│ │ ├── correctionController.ts -│ │ ├── planController.ts -│ │ ├── stripeController.ts -│ │ └── t2LiveController.ts -│ ├── middleware/ # Middlewares Hono -│ │ ├── auth.ts # Vérification JWT Supabase -│ │ ├── plan.ts # Vérification des permissions par plan -│ │ └── rateLimit.ts # Limitation des appels API -│ ├── lib/ -│ │ ├── supabase.ts # Client Supabase admin (service role) -│ │ ├── deepseek.ts # Client DeepSeek (corrections EE/EO) -│ │ ├── gemini.ts # Client Gemini (transcription audio) -│ │ ├── stripe.ts # Client Stripe -│ │ └── access.ts # Copie de la source de vérité des plans -│ ├── types/ # Types TypeScript partagés -│ └── index.ts # Point d'entrée Hono -├── .env.example -├── tsconfig.json -└── package.json +app/ peut importer de : entities/, features/, shared/ +features/ peut importer de : entities/, shared/ +entities/ peut importer de : shared/ +shared/ ne doit RIEN importer des autres dossiers ``` +Cette hiérarchie garantit que la logique métier (`entities/`) ne dépend jamais de l'UI (`features/`), et que les briques réutilisables (`shared/`) restent portables. + --- -## 5. Tables Supabase - -### Table : profiles - -Créée automatiquement à l'inscription. Liée à `auth.users`. - -```sql -CREATE TABLE profiles ( - id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, - email TEXT NOT NULL, - plan TEXT NOT NULL DEFAULT 'free' - CHECK (plan IN ('free', 'standard', 'premium')), - simulations_used INTEGER NOT NULL DEFAULT 0, - stripe_customer_id TEXT, - stripe_subscription_id TEXT, - plan_expires_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - --- Trigger : mise à jour automatique de updated_at -CREATE OR REPLACE FUNCTION update_updated_at() -RETURNS TRIGGER AS $$ -BEGIN NEW.updated_at = NOW(); RETURN NEW; END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER profiles_updated_at -BEFORE UPDATE ON profiles -FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - --- RLS : chaque utilisateur ne voit que son propre profil -ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Utilisateur voit son profil" -ON profiles FOR SELECT -USING (auth.uid() = id); - -CREATE POLICY "Utilisateur modifie son profil" -ON profiles FOR UPDATE -USING (auth.uid() = id); -``` - -### Table : productions - -Enregistrement de chaque simulation soumise pour correction. - -```sql -CREATE TABLE productions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - tache TEXT NOT NULL - CHECK (tache IN ('EE_T1','EE_T2','EE_T3','EO_T1','EO_T3','EO_T2_LIVE')), - mode TEXT NOT NULL - CHECK (mode IN ('entrainement', 'examen')), - contenu TEXT, -- texte pour EE, transcript pour EO - audio_url TEXT, -- URL Supabase Storage (EO uniquement) - score NUMERIC(4,1), -- score /20 - nclc INTEGER, -- niveau NCLC estimé - rapport JSONB, -- rapport complet (critères, erreurs, tips) - created_at TIMESTAMPTZ DEFAULT NOW() -); - --- Index pour les requêtes fréquentes -CREATE INDEX productions_user_id_idx ON productions(user_id); -CREATE INDEX productions_created_at_idx ON productions(created_at DESC); - --- RLS -ALTER TABLE productions ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Utilisateur voit ses productions" -ON productions FOR SELECT -USING (auth.uid() = user_id); - -CREATE POLICY "Utilisateur crée ses productions" -ON productions FOR INSERT -WITH CHECK (auth.uid() = user_id); -``` - -### Table : pattern_analyses - -Résultats de l'analyse des patterns (Premium — 5 dernières productions). - -```sql -CREATE TABLE pattern_analyses ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - productions_ids UUID[] NOT NULL, -- IDs des 5 productions analysées - patterns JSONB NOT NULL, -- erreurs récurrentes détectées - exercises JSONB NOT NULL, -- exercices long terme générés - preparation_index INTEGER, -- indice de préparation 0-100 - created_at TIMESTAMPTZ DEFAULT NOW() -); - -ALTER TABLE pattern_analyses ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Utilisateur voit ses analyses" -ON pattern_analyses FOR SELECT -USING (auth.uid() = user_id); -``` - ---- - -## 6. Routes API backend +## 4. Flux de données ### Authentification + ``` -POST /auth/verify-token Vérifie le JWT Supabase, retourne le profil + plan +1. User submit login form (features/auth/components/LoginForm) +2. useAuth() hook → supabase.auth.signInWithPassword() +3. Supabase returns JWT → stocké par Supabase (cookie + localStorage) +4. Frontend redirige vers /dashboard +5. TanStack Query lance automatiquement usePlan() → GET /plans/status avec Bearer JWT +6. Backend vérifie le JWT (Supabase) + retourne le plan + permissions +7. Dashboard s'affiche selon le plan ``` -### Simulations +### Appel API typique (lecture) + ``` -POST /simulations Crée une simulation, vérifie les quotas selon le plan -GET /simulations/:id Récupère une simulation par ID -GET /simulations Liste les simulations de l'utilisateur connecté +Composant React + → hook TanStack Query (useQuery) + → fonction dans entities/*/api.ts + → apiFetch() dans shared/lib/api-client.ts + → getAccessToken() dans shared/lib/auth-client.ts + → fetch vers api.expria.app + Bearer token + X-API-Version + ← succès : payload typé directement + erreur : throw d'une ApiError typée (catch par TanStack Query) + ← data typée + ← rendu selon hasAccess() ``` -### Corrections +### Upgrade Stripe (mutation) + ``` -POST /corrections/ee Soumet une production EE pour correction (DeepSeek) -POST /corrections/eo Soumet une production EO pour correction (Gemini) +1. User clique "Passer en Standard" (features/billing/pages/PricingPage) +2. useStripeCheckout() → POST /stripe/checkout +3. Backend crée session Stripe + retourne URL +4. Frontend redirige full page vers Stripe Checkout +5. User paie sur Stripe +6. Stripe redirige vers /billing/success?session_id=xxx +7. Frontend invalide queryClient.invalidateQueries(['plan']) +8. usePlan() refetch → nouveau plan affiché ``` -### Plans +### T2 Live (WebSocket) + ``` -GET /plans/status Retourne le plan actuel + permissions de l'utilisateur -POST /plans/upgrade Crée une session Stripe Checkout (nouveau abonnement) -POST /plans/upgrade-prorata Upgrade en cours d'abonnement (prorata Stripe) +1. User clique "Démarrer le dialogue" (features/t2-live/pages/T2LivePage) +2. useT2LiveSession() → state machine passe en 'connecting' +3. ws-client.ts ouvre wss://api.expria.app/t2/live?token= + (JWT en query string car les navigateurs ne permettent pas de headers custom sur WS init) +4. Backend vérifie JWT + plan 'premium' + ouvre connexion Gemini + - Si JWT invalide → close code 4001 (AUTH_REQUIRED) + message {"error":true,"code":"..."} + - Si plan insuffisant → close code 4003 (PLAN_INSUFFICIENT) + message +5. State machine → 'listening' (IA parle en premier) +6. audio.ts capture PCM 16kHz → envoi via WebSocket +7. État oscille 'listening' ↔ 'speaking' pendant tout le dialogue +8. Fin de dialogue → 'processing' → backend appelle Gemini REST pour rapport +9. State machine → 'ended' + rapport affiché ``` -### Stripe -``` -POST /stripe/checkout Crée une Checkout Session Stripe -POST /stripe/webhook Reçoit les events Stripe (checkout, invoice, deleted) -``` +**Gestion des close codes côté frontend :** -### T2 EO Live -``` -WS /t2/live WebSocket — proxy Gemini Live API (Premium uniquement) -``` +| Close code | Cause | Action côté frontend | +|---|---|---| +| 1000 | Fermeture normale | State → 'ended', afficher le rapport | +| 4001 | AUTH_REQUIRED | State → 'error', redirect `/login` | +| 4003 | PLAN_INSUFFICIENT | State → 'error', afficher PaywallModal Premium | +| Autre | Erreur réseau ou serveur | State → 'error', message générique + bouton "Réessayer" | --- -## 7. Flux de données clés +## 5. Communication frontend ↔ backend -### Flux : Simulation EE (mode entraînement) +### Format des requêtes -``` -1. Frontend → POST /simulations (créer la simulation, vérifier quota) -2. Backend → Supabase profiles (vérifier plan + simulations_used) -3. Backend → POST /corrections/ee (envoyer à DeepSeek) -4. DeepSeek → Backend (rapport JSON) -5. Backend → Supabase productions (enregistrer production + rapport) -6. Backend → Frontend (retourner le rapport) -7. Frontend → Afficher selon le plan (rapport complet ou flouté) +Toutes les requêtes HTTP vers le backend incluent : + +```typescript +{ + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${jwtSupabase}`, // sauf /health + 'X-API-Version': '1.0', // versioning obligatoire + }, + signal: AbortSignal.timeout(timeoutMs), // 5s défaut, 30s pour corrections +} ``` -### Flux : Paiement nouveau abonnement +### Format de réponse (matche le backend réel — audit du 2026-04-17) -``` -1. Frontend → POST /stripe/checkout (créer Checkout Session) -2. Backend → Stripe API (session avec price_id) -3. Stripe → Frontend (redirect vers page paiement Stripe) -4. Utilisateur paie -5. Stripe → POST /stripe/webhook (checkout.session.completed) -6. Backend → Supabase profiles (plan = 'standard' ou 'premium') -7. Frontend → Redirect dashboard (plan mis à jour) +**Le backend ne wrappe pas les réponses dans une enveloppe.** Il renvoie directement le payload métier en cas de succès, et un objet d'erreur structuré en cas d'échec. + +**Succès :** le backend renvoie directement l'objet métier. + +```typescript +// Exemple : GET /plans/status → HTTP 200 +{ + plan: 'standard', + permissions: { dashboard: true, exam_mode: false, ... }, + simulations_used: 12, + simulations_remaining: null, // null si plan illimité, number sinon + plan_expires_at: '2026-05-01T00:00:00.000Z' +} + +// Exemple : POST /simulations → HTTP 201 +{ + id: 'uuid', + tache: 'EE_T1', + mode: 'entrainement', + created_at: '2026-04-17T10:00:00.000Z' +} ``` -### Flux : Upgrade Standard → Premium (prorata) +**Erreur :** le backend renvoie un objet structuré avec `error: true`. -``` -1. Frontend → POST /plans/upgrade-prorata -2. Backend → Stripe API (subscription.update + preview invoice) -3. Backend → Frontend (montant prorata calculé) -4. Frontend → Confirmation utilisateur (affiche le montant exact) -5. Frontend → POST /plans/upgrade-prorata (confirmation) -6. Backend → Stripe API (subscription.update confirmé) -7. Stripe → POST /stripe/webhook (invoice.paid) -8. Backend → Supabase profiles (plan = 'premium') -9. Frontend → Dashboard Premium (accès immédiat) +```typescript +// Format standard (toutes les erreurs sauf quelques quirks) +{ + error: true, + code: 'QUOTA_REACHED', + message: 'Vous avez utilisé vos 5 simulations gratuites...' +} + +// Quirk connu sur POST /simulations et POST /corrections/ee,eo +// Un champ 'status' duplique le code HTTP dans le body (effet de bord du c.json(result, result.status)) +{ + error: true, + code: 'QUOTA_REACHED', + message: '...', + status: 403 +} ``` -### Flux : T2 EO Live (WebSocket) +**Type côté frontend (dans `src/shared/types/api.ts`) :** +```typescript +// SOURCE OF TRUTH: expria-backend — format réel des erreurs, confirmé par audit 2026-04-17 + +/** + * Forme des erreurs renvoyées par le backend. + * Le champ `status` est présent sur certaines routes (simulations, corrections) — + * il duplique le code HTTP. Ignorable côté frontend, documenté pour référence. + */ +export interface ApiError { + error: true + code: ApiErrorCode + message: string + status?: number // quirk backend (simulations, corrections) +} + +export type ApiErrorCode = + | 'AUTH_REQUIRED' // 401 — JWT absent, invalide ou expiré + | 'PLAN_INSUFFICIENT' // 403 — feature non disponible pour ce plan + | 'QUOTA_REACHED' // 403 — quota de simulations Free épuisé + | 'VALIDATION_ERROR' // 400 — corps de requête invalide (simulations, corrections) + | 'INVALID_BODY' // 400 — corps de requête invalide (plans, stripe) — voir note + | 'INVALID_PLAN' // 400 — valeur de plan inconnue + | 'NO_ACTIVE_SUBSCRIPTION' // 400 — tentative d'upgrade sans abonnement actif + | 'SIMULATION_NOT_FOUND' // 404 — simulation inexistante ou non accessible + | 'STRIPE_WEBHOOK_INVALID' // 400 — signature webhook invalide + | 'INTERNAL_ERROR' // 500 — erreur serveur inattendue + +// Erreurs générées côté frontend uniquement (pas envoyées par le backend) +export type FrontendErrorCode = + | 'TIMEOUT' // timeout côté client (AbortController) + | 'NETWORK_ERROR' // pas de réponse réseau + | 'PARSE_ERROR' // réponse non-JSON ``` -1. Frontend → WS /t2/live (connexion WebSocket, JWT vérifié) -2. Backend → Vérification plan (premium uniquement) -3. Backend → WS Gemini Live API (ouverture connexion avec GEMINI_API_KEY) -4. Frontend → Backend → Gemini (audio candidat en temps réel) -5. Gemini → Backend → Frontend (audio examinateur IA en temps réel) -6. Fin dialogue -7. Backend → POST Gemini REST (évaluation de la session) -8. Backend → Supabase productions (enregistrement production + rapport) -9. Backend → Frontend (rapport complet) + +> **Note sur `VALIDATION_ERROR` vs `INVALID_BODY`** : le backend utilise deux codes pour la même classe d'erreur (corps invalide). `VALIDATION_ERROR` dans les routes simulations/corrections, `INVALID_BODY` dans les routes plans/stripe. Cette inconsistance est documentée dans `TECH_DEBT.md` backend (TD-15 à créer). Côté frontend, les deux codes sont gérés de la même manière dans l'UI. + +### Codes d'erreur — mapping HTTP + +| Code backend | HTTP | Signification | Routes émettrices | +|---|---|---|---| +| `AUTH_REQUIRED` | 401 | JWT absent, invalide, expiré, ou profil introuvable | middleware, corrections | +| `PLAN_INSUFFICIENT` | 403 | Feature réservée à un plan supérieur | middleware, simulations | +| `QUOTA_REACHED` | 403 | 5/5 simulations utilisées (plan Free) | simulations | +| `VALIDATION_ERROR` | 400 | Corps de requête invalide (simulations, corrections) | simulations, corrections | +| `INVALID_BODY` | 400 | Corps de requête invalide (plans, stripe) | plans, stripe | +| `INVALID_PLAN` | 400 | Valeur de plan inconnue dans le payload | plans, stripe | +| `NO_ACTIVE_SUBSCRIPTION` | 400 | Upgrade prorata sans abonnement actif | plans | +| `SIMULATION_NOT_FOUND` | 404 | Simulation inexistante ou non accessible | corrections | +| `STRIPE_WEBHOOK_INVALID` | 400 | Signature webhook invalide | stripe | +| `INTERNAL_ERROR` | 500 | Erreur serveur inattendue | plans, stripe, corrections, simulations | + +### Pattern `apiFetch` + +Le wrapper dans `src/shared/lib/api-client.ts` retourne directement le payload typé `T` en cas de succès, ou throw une `ApiError` en cas d'échec. Le pattern standard TanStack Query catch l'erreur et la rend disponible dans `error`. + +```typescript +// Côté hook +const { data, error, isLoading } = useQuery({ + queryKey: ['plan'], + queryFn: () => apiFetch('/plans/status'), +}) + +// error est de type ApiError | null +if (error) { + switch (error.code) { + case 'AUTH_REQUIRED': redirectToLogin(); break + case 'QUOTA_REACHED': openUpgradeModal(); break + default: showGenericErrorToast() + } +} ``` +### Versioning API + +Le header `X-API-Version: 1.0` est envoyé par le frontend sur toutes les requêtes. Pour l'instant, le backend ne le vérifie pas (cf. FTD-02 dans `TECH_DEBT.md` frontend). Quand le backend évoluera de façon breaking, il pourra commencer à vérifier ce header et retourner `HTTP 426 Upgrade Required` avec le code `API_VERSION_MISMATCH`. + +Cette discipline est critique dès qu'un dev externe rejoint l'équipe — sans elle, une modification backend silencieuse peut casser le frontend en production. + +### Retry et timeout + +`api-client.ts` implémente : + +- **Retry** : 3 tentatives avec backoff exponentiel (500ms, 1s, 2s) pour les erreurs réseau et les 5xx. Pas de retry sur les 4xx. +- **Timeout** : 5s par défaut, configurable par appel (30s pour `/corrections/ee` et `/corrections/eo` qui appellent DeepSeek, 10s pour les autres endpoints). +- **Logging** : chaque erreur est loggée via `shared/lib/logger.ts` avec le path, le code d'erreur, et le statut HTTP (jamais le payload pour éviter de fuiter des données utilisateur). + +### Authentification + +- **HTTP** : le JWT Supabase est envoyé dans le header `Authorization: Bearer `. +- **WebSocket T2 Live** : le JWT est envoyé en query string (`wss://api.expria.app/t2/live?token=`) — les navigateurs ne permettent pas d'ajouter des headers custom à l'initiation d'une connexion WebSocket. Voir SEC-13 dans `SECURITY.md` pour les implications de sécurité. + --- -## 8. Variables d'environnement +## 6. Permissions et plans -### Frontend (.env) +### Source de vérité unique + +Le fichier `src/entities/user/access.ts` est une **copie bit-à-bit** de `expria-backend/src/lib/access.ts`. Cette règle est héritée d'`ARCHITECTURE.md` backend §10 Règle 2. + +Contenu exact (confirmé par audit 2026-04-17) : + +```typescript +// src/entities/user/access.ts +// SOURCE OF TRUTH: expria-backend/src/lib/access.ts +// Synchronisé le : 2026-04-17 + +export type Plan = 'free' | 'standard' | 'premium' + +export type Feature = + | 'oral_t2_live' + | 'detailed_report' + | 'tips' + | 'dashboard' + | 'exam_mode' + | 'pattern_analysis' + | 'preparation_index' + | 'basic_report' + +export const PLANS = { + free: { + simulations_lifetime: 5, + oral_t2_live: false, + detailed_report: false, + tips: false, + dashboard: false, + exam_mode: false, + pattern_analysis: false, + preparation_index: false, + }, + standard: { + simulations_lifetime: null, + oral_t2_live: false, + detailed_report: true, + tips: true, + dashboard: true, + exam_mode: false, + pattern_analysis: false, + preparation_index: false, + }, + premium: { + simulations_lifetime: null, + oral_t2_live: true, + detailed_report: true, + tips: true, + dashboard: true, + exam_mode: true, + pattern_analysis: true, + preparation_index: true, + }, +} + +export function getPlanPermissions(plan: Plan) { /* ... */ } +export function canUserSimulate(user: { plan: string; simulations_used: number }): { allowed, reason? } { /* ... */ } +export function checkFeatureAccess(plan: Plan, feature: Feature): boolean { /* ... */ } ``` -VITE_API_URL=https://api.expria.app # URL du backend Render + +### Alias frontend-idiomatiques + +Le fichier `src/entities/user/lib.ts` ré-exporte ces fonctions sous des noms standard React (cf. ADR 005) : + +```typescript +// src/entities/user/lib.ts +import { + canUserSimulate, + checkFeatureAccess, + getPlanPermissions, +} from './access' + +/** + * Alias frontend-idiomatique de checkFeatureAccess. + * Vérifie si un plan a accès à une feature booléenne donnée. + */ +export const hasAccess = checkFeatureAccess + +/** + * Alias frontend-idiomatique de canUserSimulate. + * Signature ergonomique (plan, used) au lieu de ({ plan, simulations_used }). + */ +export function canSimulate(plan: Plan, simulationsUsed: number) { + return canUserSimulate({ plan, simulations_used: simulationsUsed }) +} + +export { getPlanPermissions } +``` + +### API obligatoire pour vérifier une permission + +Voir ADR 005 pour le détail complet. Résumé : + +```typescript +import { hasAccess, canSimulate } from '@/entities/user/lib' + +// ✅ Permission booléenne +if (hasAccess(plan, 'oral_t2_live')) { ... } + +// ✅ Quota de simulations +const { allowed, reason } = canSimulate(plan, simulationsUsed) + +// ❌ INTERDIT +if (plan === 'premium') { ... } +if (PLANS[plan].exam_mode) { ... } +``` + +Le backend reste l'autorité finale : toute vérification côté frontend est de l'UX (afficher ou masquer un bouton). La vraie protection est dans les middlewares backend. + +--- + +## 7. Variables d'environnement + +### Frontend (.env à la racine) + +``` +VITE_API_URL=https://api.expria.app VITE_SUPABASE_URL=https://xxx.supabase.co -VITE_SUPABASE_ANON_KEY=xxx # Clé publique uniquement +VITE_SUPABASE_ANON_KEY=xxx +VITE_ENABLE_T2_LIVE=false # flag pour cacher T2 en prod tant que pas prêt +VITE_SENTRY_DSN=xxx # optionnel, pour monitoring ``` -### Backend (.env) -``` -# Supabase -SUPABASE_URL=https://xxx.supabase.co -SUPABASE_SERVICE_ROLE_KEY=xxx # Clé privée — ne jamais exposer +### Règle absolue -# APIs -DEEPSEEK_API_KEY=xxx -GEMINI_API_KEY=xxx # Ne jamais exposer côté frontend +**Aucune clé privée ne doit jamais exister dans le frontend.** Les variables suivantes n'existent que dans le backend : -# Stripe -STRIPE_SECRET_KEY=xxx # Clé privée Stripe -STRIPE_WEBHOOK_SECRET=xxx # Secret webhook Stripe -STRIPE_PRICE_STANDARD=price_xxx # price_id plan Standard -STRIPE_PRICE_PREMIUM=price_xxx # price_id plan Premium +- `SUPABASE_SERVICE_ROLE_KEY` +- `GEMINI_API_KEY` +- `DEEPSEEK_API_KEY` +- `STRIPE_SECRET_KEY` +- `STRIPE_WEBHOOK_SECRET` -# Config -PORT=3000 -APP_URL=https://expria.app -API_URL=https://api.expria.app -NODE_ENV=production -``` +Cette règle est vérifiée par : +- Le plugin Security Guidance de Claude Code (voir `SECURITY.md`) +- Une règle Semgrep dans la CI +- Le scan de secrets GitHub (Dependabot) -> **Règle absolue :** aucune clé privée (SUPABASE_SERVICE_ROLE_KEY, GEMINI_API_KEY, -> STRIPE_SECRET_KEY) ne doit jamais se retrouver dans le frontend ou dans un commit Git. +### Validation au démarrage + +`src/shared/config/env.ts` valide la présence de toutes les variables `VITE_*` au démarrage de l'app. Si une variable manque, l'app refuse de démarrer avec un message clair — pas de bug silencieux 3h après. --- -## 9. Déploiement +## 8. Déploiement -### Contexte — Contrainte d'hébergement Git +### Infrastructure cible (cf. ADR 001) -GitHub et GitLab appliquent les sanctions américaines OFAC qui restreignent l'accès -aux résidents de Crimée. Ces plateformes ne sont pas utilisables de façon fiable -pour ce projet. +| Composant | Plateforme | URL | +|---|---|---| +| Frontend | Cloudflare Pages | `https://expria.app` | +| Backend API | Render (Frankfurt) | `https://api.expria.app` | +| DNS | Vercel (actuellement) | — | +| Base de données | Supabase (Frankfurt) | — | -**Solution actuelle (Phase 1 — MVP) :** -Codeberg (plateforme européenne, Allemagne) pour l'hébergement Git privé. -Render ne supporte pas l'auto-deploy depuis Codeberg — le déploiement est donc manuel. +### Workflow de déploiement -**Évolution prévue (Phase 2 — après premiers revenus) :** -Migration vers un VPS Hetzner (3,29€/mois) avec Coolify. -Coolify supporte l'auto-deploy depuis n'importe quel serveur Git privé (Codeberg, Gitea). -Cette migration supprime la dépendance à Render et restaure l'auto-deploy complet. +``` +1. Développer sur branche feature (ex: feat/dashboard-conditionnel) +2. npm run typecheck (TypeScript strict) +3. npm run test (Vitest — tous verts) +4. npm run build (Vite) +5. Commit + push sur branche feature → GitHub Actions CI +6. PR vers main → revue (ou auto-merge pour le fondateur solo) +7. Merge sur main → auto-deploy Cloudflare Pages déclenché +8. Vérifier le déploiement sur https://expria.app +9. Rejouer le Golden Dataset groupe concerné +``` ---- - -### Hébergement Git — Codeberg (Phase 1) - -- Plateforme : codeberg.org (Allemagne — hors juridiction américaine) -- Dépôts : privés -- Dépôt frontend : `https://codeberg.org/Hermann_Kitio/expria-frontend` -- Dépôt backend : `https://codeberg.org/Hermann_Kitio/expria-backend` -- Dépôt archive (ancienne version) : `https://codeberg.org/Hermann_Kitio/Expria` -- Limitation : pas d'auto-deploy natif vers Render - -### Frontend — Cloudflare Pages - -- Source : dépôt Codeberg `expria-frontend` -- Build command : `npm run build` -- Output directory : `dist` -- Domaine : `expria.app` (DNS pointé depuis Vercel vers Cloudflare Pages) -- Déploiement : **manuel via Cloudflare Pages CLI** +### Commande de déploiement manuel (fallback) ```bash -# Commande de déploiement frontend npm run build npx wrangler pages deploy dist --project-name=expria ``` -### Backend — Render +À n'utiliser qu'en cas de panne de l'auto-deploy GitHub → Cloudflare. -- Source : dépôt Codeberg `expria-backend` -- Type : Web Service (Node.js) -- Région : Frankfurt (EU) — proximité utilisateurs Afrique du Nord -- Build command : `npm run build` -- Start command : `npm start` -- Domaine : `api.expria.app` -- WebSocket : activé nativement sur Render -- Déploiement : **manuel via Render CLI ou dashboard** +--- -```bash -# Commande de déploiement backend -# Option 1 : via Render CLI -render deploy +## 9. Tests -# Option 2 : via dashboard Render -# → Manual Deploy → Deploy latest commit +### Philosophie + +Tests ciblés sur la logique critique, pas exhaustifs. On copie la stratégie backend (cf. `TESTS_AUTOMATISES.md` backend §11) : tester ce qui vérifie un droit, modifie des données, ou calcule un résultat métier. + +### Fichiers obligatoirement couverts + +| Fichier | Nombre de tests minimum | +|---|---| +| `src/entities/user/__tests__/hasAccess.test.ts` | 14+ | +| `src/entities/user/__tests__/canSimulate.test.ts` | 7 | +| `src/entities/report/__tests__/floutage.test.ts` | 8+ (un par critère à flouter × 3 plans) | +| `src/features/t2-live/state/__tests__/t2-machine.test.ts` | 6+ (transitions d'états) | +| `src/features/dashboard/hooks/__tests__/usePlan.test.ts` | 3+ (cache, refetch, invalidation) | + +### Fichiers non testés (par design) + +- Composants d'affichage (`*.tsx` dans `features/*/components/`) : trop fragiles, couverts par le Golden Dataset manuel. +- Wrappers évidents (ex : `formatDate()`). +- Supabase SDK : c'est leur responsabilité. + +### CI GitHub Actions + +```yaml +# .github/workflows/ci.yml +- typecheck (tsc --noEmit) +- vitest run +- npm audit --audit-level=high +- semgrep scan (via plugin) ``` -### Base de données — Supabase - -- Région : Frankfurt (EU) -- Migrations : versionnées dans `supabase/migrations/` -- Déploiement : `supabase db push` (manuel, après validation) - -### Procédure de déploiement complète (Phase 1) - -``` -1. Tester localement (npm run test — tous les tests verts) -2. Rejouer le Golden Dataset -3. Commit + push sur Codeberg (branche main) -4. Déployer le backend : render deploy (ou dashboard Render) -5. Déployer le frontend : npm run build && npx wrangler pages deploy dist -6. Vérifier les URLs de production (expria.app + api.expria.app) -7. Rejouer le Smoke Test (Groupe Z du Golden Dataset) -``` - -### Évolution Phase 2 — VPS Hetzner + Coolify - -Quand Expria génère ses premiers revenus, migrer vers : - -``` -Codeberg (Git privé — inchangé) - ↓ auto-deploy via webhook -Coolify sur VPS Hetzner CAX11 (3,29€/mois) - — remplace Render pour le backend - — auto-deploy natif depuis Codeberg - — Docker, SSL automatique, logs intégrés - ↓ -Supabase (inchangé) -``` - -Avantages de la Phase 2 : -- Auto-deploy restauré (push → déploiement automatique) -- Coût réduit (3,29€/mois vs Render Starter) -- Aucune dépendance à une plateforme américaine pour le backend -- Cloudflare Pages reste pour le frontend (gratuit, CDN mondial) +Un échec sur l'un de ces jobs bloque le merge. --- ## 10. Règles de développement +Ces règles sont héritées de `DEVELOPMENT_PRINCIPLES.md` backend et adaptées au frontend. + ### Règle 1 — Séparation stricte -Le frontend ne contient aucune logique métier. -Il appelle le backend et affiche ce qu'il reçoit. -Toute vérification de plan, de quota, de droit d'accès se fait côté backend. +Le frontend affiche des données et relaie des actions. Aucune logique métier. -### Règle 2 — Source de vérité unique des plans -`lib/access.ts` existe dans les deux dépôts (frontend et backend). -Le fichier doit être identique dans les deux. -Toute modification des plans tarifaires met à jour ce fichier en premier, -dans les deux dépôts, avant tout autre changement de code. +### Règle 2 — Source de vérité unique pour les plans +`src/entities/user/access.ts` est identique à `expria-backend/src/lib/access.ts`. Toute modification se fait dans les deux dépôts, dans le même commit logique. -### Règle 3 — Jamais plus de 3 fichiers touchés par session Claude -Si une modification nécessite de toucher plus de 3 fichiers, -elle doit être découpée en plusieurs sessions avec validation intermédiaire. +### Règle 3 — Maximum 3 fichiers par étape +Hérité du backend. Si une tâche nécessite plus de 3 fichiers, elle est découpée. ### Règle 4 — Plan avant code -Claude Code ne commence jamais à coder sans avoir d'abord produit -un plan détaillé (fichiers impactés, risques, étapes). -Le plan est validé par Hermann avant l'exécution. +Aucune session Claude Code ne commence à coder sans plan validé. -### Règle 5 — Tests manuels après chaque session -Après chaque session Claude Code, rejouer le golden dataset -(voir GOLDEN_DATASET.md) avant de passer à la session suivante. +### Règle 5 — Tests verts avant de continuer +`npm run test` et `npm run typecheck` doivent passer après chaque étape. -### Règle 6 — Variables d'environnement -Aucune valeur de variable d'environnement n'est jamais écrite en dur dans le code. -Toujours lire depuis `process.env` (backend) ou `import.meta.env` (frontend). +### Règle 6 — Jamais de clé privée dans le frontend +Variables `VITE_*` uniquement. Cf. section 7. + +### Règle 7 — Jamais de `if (plan === 'xxx')` +Toute vérification de permission passe par `hasAccess()` ou `canSimulate()`. Cf. ADR 005. + +### Règle 8 — Jamais de logique métier dans `features/` +Les règles de floutage, de quotas, de permissions vivent dans `entities/*/lib.ts`. Les composants de `features/` appellent ces fonctions. + +### Règle 9 — Jamais d'appel direct à Supabase pour les données métier +Supabase côté frontend est **uniquement** pour l'authentification. Toute lecture/écriture passe par le backend Hono. + +### Règle 10 — Signaler tout écart par rapport au plan +Identique à la Règle H backend. + +--- + +## 11. Historique des versions de ce document + +| Version | Date | Auteur | Changements | +|---|---|---|---| +| 1.0 | 2026-04-17 | Hermann (avec assistance Claude) | Création initiale | diff --git a/docs/DEVELOPMENT_PRINCIPLES.md b/docs/DEVELOPMENT_PRINCIPLES.md index 263399c..9d70607 100644 --- a/docs/DEVELOPMENT_PRINCIPLES.md +++ b/docs/DEVELOPMENT_PRINCIPLES.md @@ -1,10 +1,8 @@ -# DEVELOPMENT_PRINCIPLES.md — Expria / Coach TCF Canada +# DEVELOPMENT_PRINCIPLES.md — Expria Frontend > **Document de référence — Version 1.0** -> Ce document définit les règles que Claude Code doit lire et respecter -> à chaque session de développement, sans exception. -> Il est conçu pour un projet maintenu par un fondateur non-technique -> assisté par l'IA. +> Ce document définit les règles que toute session Claude Code sur le frontend doit lire et respecter, sans exception. +> Adapté de `DEVELOPMENT_PRINCIPLES.md` du backend — les principes sont les mêmes, les règles spécifiques sont ajustées. --- @@ -13,10 +11,12 @@ **Avant d'écrire la moindre ligne de code, tu dois :** 1. Lire ce fichier en entier -2. Lire ARCHITECTURE.md -3. Lire PLANS_TARIFAIRES.md -4. Annoncer : "Documents lus. Voici mon plan pour cette session." -5. Produire un plan détaillé et attendre la validation +2. Lire `ARCHITECTURE.md` frontend +3. Lire `PLANS_TARIFAIRES.md` (copie du backend) +4. Lire `PARCOURS_UTILISATEURS.md` (section du plan concerné) +5. Consulter les ADRs pertinents dans `docs/adr/` +6. Annoncer : "Documents lus. Voici mon plan pour cette session." +7. Produire un plan détaillé et attendre la validation **Tu ne passes à l'implémentation que quand Hermann dit "GO".** @@ -24,7 +24,7 @@ ## 1. Le cycle de travail obligatoire -Chaque modification, petite ou grande, suit ce cycle sans exception : +Identique au backend : ``` ÉTAPE 1 — ANALYSE @@ -47,13 +47,15 @@ Chaque modification, petite ou grande, suit ce cycle sans exception : Maximum 3 fichiers touchés par étape ÉTAPE 4 — VÉRIFICATION - Lancer npm run test (backend) - Annoncer le résultat : "X tests passés, 0 échecs" + Lancer npm run typecheck + Lancer npm run test + Annoncer le résultat : "Typecheck OK. X tests passés, 0 échecs." Si un test échoue : STOP — corriger avant de continuer ÉTAPE 5 — RÉSUMÉ Lister exactement ce qui a été modifié - Indiquer les tests manuels à rejouer (référence au Golden Dataset) + Indiquer les tests manuels à rejouer (Golden Dataset) + Proposer un message de commit ``` --- @@ -63,141 +65,184 @@ Chaque modification, petite ou grande, suit ce cycle sans exception : ### Règle A — Plan avant code Claude Code ne commence jamais à coder sans plan validé. Même pour une modification "simple" d'une ligne. -La simplicité apparente est trompeuse dans un projet avec des permissions par plan. ### Règle B — Maximum 3 fichiers par étape -Si une tâche nécessite de modifier plus de 3 fichiers, -elle est découpée en plusieurs étapes avec validation intermédiaire. -Chaque étape est testée avant de passer à la suivante. +Si une tâche nécessite de modifier plus de 3 fichiers, elle est découpée en plusieurs étapes avec validation intermédiaire. ### Règle C — Tests verts avant de continuer Après chaque étape d'implémentation : +`npm run typecheck` doit retourner 0 erreur. `npm run test` doit retourner 0 échec. -Si un test échoue, on corrige avant de passer à l'étape suivante. -On ne livre jamais une étape avec des tests rouges. ### Règle D — Jamais de logique de plan en dur dans le code -Toutes les vérifications de permissions lisent depuis `lib/access.ts`. -Exemple interdit : ```typescript // ❌ JAMAIS if (user.plan === 'premium') { ... } if (user.plan !== 'free') { ... } -``` -Exemple correct : -```typescript + // ✅ TOUJOURS -const perms = getPlanPermissions(user.plan) -if (perms.exam_mode) { ... } +import { hasAccess } from '@/entities/user/lib' +if (hasAccess(user.plan, 'exam_mode')) { ... } ``` +Voir ADR 005 pour le détail. + ### Règle E — Jamais de clé privée dans le frontend -Les variables suivantes n'existent que dans le backend : +Seules ces variables sont autorisées dans le frontend : +- `VITE_API_URL` +- `VITE_SUPABASE_URL` +- `VITE_SUPABASE_ANON_KEY` (clé publique) +- `VITE_ENABLE_T2_LIVE` (flag) +- `VITE_SENTRY_DSN` (si monitoring actif) + +Les variables suivantes **n'existent jamais** dans le frontend : - `SUPABASE_SERVICE_ROLE_KEY` - `GEMINI_API_KEY` - `DEEPSEEK_API_KEY` - `STRIPE_SECRET_KEY` - `STRIPE_WEBHOOK_SECRET` -Le frontend n'a accès qu'à : -- `VITE_SUPABASE_ANON_KEY` (clé publique Supabase) -- `VITE_API_URL` (URL du backend) +### Règle F — Jamais d'appel direct à Supabase pour des données métier +Supabase côté frontend est **exclusivement** utilisé pour l'auth : +```typescript +// ✅ Autorisé +supabase.auth.signInWithPassword(...) +supabase.auth.signOut() +supabase.auth.getSession() -### Règle F — Jamais de modification de la base de données sans migration SQL -Toute modification du schéma Supabase passe par un fichier de migration -dans `supabase/migrations/`. -Jamais de modification directe dans le dashboard Supabase sans fichier de migration correspondant. +// ❌ Interdit +supabase.from('productions').select() +supabase.from('profiles').update(...) +``` -### Règle G — Jamais de suppression de données utilisateur -Aucune opération `DELETE` sur les tables `productions` ou `profiles` -sauf dans les scripts de test explicitement balisés. -En cas de doute : archiver, ne pas supprimer. +Toute lecture/écriture de données métier passe par le backend Hono. -### Règle H — Signaler tout écart par rapport au plan -Si pendant l'implémentation Claude Code réalise que le plan doit être modifié, -il STOP, signale le changement, explique pourquoi, et attend une nouvelle validation. +### Règle G — access.ts doit rester identique au backend +`src/entities/user/access.ts` est une **copie bit-à-bit** de `expria-backend/src/lib/access.ts`. +Toute modification se fait dans les deux dépôts, dans le même commit logique. +Voir ADR 004. + +### Règle H — Jamais de logique métier dans `features/` +La logique métier (permissions, quotas, floutage, règles de validation) vit exclusivement dans `src/entities//lib.ts`. +Les composants React de `features/` appellent ces fonctions, sans les réimplémenter. + +### Règle I — Signaler tout écart par rapport au plan +Si pendant l'implémentation Claude Code réalise que le plan doit être modifié, il STOP, signale le changement, explique pourquoi, et attend une nouvelle validation. Il ne prend jamais de décision architecturale de sa propre initiative. +### Règle J — Sécurité +- Jamais `dangerouslySetInnerHTML` sans `DOMPurify` +- Jamais `eval`, `new Function`, `setTimeout(string, ...)` +- Jamais de secret dans `localStorage` ou `sessionStorage` +- Jamais de `console.log` de données utilisateur (email, JWT, payload API) +- Toujours passer par `apiFetch` (jamais `fetch` nu) + +Voir `SECURITY.md` pour le détail. + +### Règle K — Pas de worktree Git +Claude Code ne crée jamais de worktree Git. +Toutes les modifications se font directement dans le dossier du projet principal. + --- ## 3. Structure du code — conventions ### Nommage des fichiers -``` -frontend/src/ - pages/ → PascalCase.tsx (Dashboard.tsx, Simulation.tsx) - components/ → PascalCase.tsx (RapportCard.tsx, PaywallModal.tsx) - hooks/ → camelCase.ts (useAuth.ts, usePlan.ts) - api/ → camelCase.ts (simulations.ts, corrections.ts) - lib/ → camelCase.ts (access.ts, supabase.ts) - types/ → camelCase.ts (plans.ts, simulation.ts) -backend/src/ - routes/ → camelCase.ts (simulations.ts, stripe.ts) - controllers/ → camelCase.ts (simulationController.ts) - middleware/ → camelCase.ts (auth.ts, plan.ts) - lib/ → camelCase.ts (access.ts, deepseek.ts) - __tests__/ → camelCase.test.ts (canUserSimulate.test.ts) +``` +src/ + app/ → camelCase.tsx (providers.tsx, router.tsx) + entities/ + / + types.ts → interfaces et types + lib.ts → fonctions pures métier + api.ts → fonctions d'appel API + access.ts → cas spécial : identique au backend + features/ + / + components/ → PascalCase.tsx (LoginForm.tsx, PaywallModal.tsx) + pages/ → PascalCase.tsx (LoginPage.tsx, DashboardPage.tsx) + hooks/ → camelCase.ts (useAuth.ts, usePlan.ts) + lib/ → camelCase.ts (ws-client.ts, audio.ts) + state/ → kebab-case.ts (t2-machine.ts) + shared/ + components/ → PascalCase.tsx + hooks/ → camelCase.ts + lib/ → kebab-case.ts (api-client.ts, auth-client.ts) + types/ → camelCase.ts + config/ → camelCase.ts + +__tests__/ → .test.ts ``` ### Nommage des variables et fonctions + ```typescript // Variables : camelCase const userPlan = 'premium' const simulationsUsed = 5 // Fonctions : camelCase, verbe en premier -function getUserPlan(userId: string) { } -function canUserSimulate(profile: Profile) { } -function updateUserPlan(userId: string, plan: Plan) { } +function hasAccess(plan: Plan, feature: BooleanPermission): boolean { } +function canSimulate(plan: Plan, used: number): SimulationCheck { } + +// Composants React : PascalCase +function DashboardPage() { } +function PaywallModal() { } + +// Hooks : use + PascalCase (convention React) +function useAuth() { } +function usePlan() { } // Types et interfaces : PascalCase type Plan = 'free' | 'standard' | 'premium' interface UserProfile { } -interface SimulationResult { } // Constantes globales : SCREAMING_SNAKE_CASE const MAX_FREE_SIMULATIONS = 5 -const PLANS = { free: {...}, standard: {...}, premium: {...} } +const PLANS = { ... } ``` -### Structure d'une route Hono (backend) +### Structure d'un composant de feature + ```typescript -// Pattern obligatoire pour toutes les routes -app.post('/simulations', authMiddleware, planMiddleware('simulation'), async (c) => { - // 1. Récupérer et valider les données entrantes - const body = await c.req.json() +// features/dashboard/pages/DashboardPage.tsx - // 2. Logique métier (déléguer au controller) - const result = await simulationController.create(body, c.get('user')) +import { usePlan } from '../hooks/usePlan' +import { hasAccess } from '@/entities/user/lib' - // 3. Retourner la réponse - return c.json(result, 201) -}) -``` - -### Structure d'un composant React (frontend) -```typescript -// Pattern obligatoire pour tous les composants -interface Props { - // Toujours typer les props -} - -export function NomDuComposant({ prop1, prop2 }: Props) { +export function DashboardPage() { // 1. Hooks en premier - const { user } = useAuth() - const perms = usePlan() + const { plan, isLoading, error } = usePlan() - // 2. Handlers - const handleClick = () => { } + // 2. Gestion des états de chargement et d'erreur + if (isLoading) return + if (error) return - // 3. Rendu conditionnel selon le plan - if (!perms.dashboard) return + // 3. Logique de permission via hasAccess + if (!hasAccess(plan.plan, 'dashboard')) { + return + } - // 4. JSX - return ( -
...
- ) + // 4. Rendu principal + return +} +``` + +### Structure d'un hook custom + +```typescript +// features/dashboard/hooks/usePlan.ts + +import { useQuery } from '@tanstack/react-query' +import { getPlanStatus } from '@/entities/user/api' + +export function usePlan() { + return useQuery({ + queryKey: ['plan'], + queryFn: getPlanStatus, + staleTime: 5 * 60 * 1000, // 5 min + }) } ``` @@ -205,147 +250,160 @@ export function NomDuComposant({ prop1, prop2 }: Props) { ## 4. Gestion des erreurs -### Backend — toujours retourner une erreur structurée +### Côté api-client + ```typescript -// ✅ Format d'erreur standard -return c.json({ - error: true, - code: 'QUOTA_REACHED', - message: 'Quota de simulations atteint pour ce plan', -}, 403) +// shared/lib/api-client.ts +// En cas de succès : retourne le payload typé T directement (pas d'enveloppe) +// En cas d'erreur : throw une ApiError typée +// TanStack Query catch automatiquement et rend l'erreur disponible dans .error -// ❌ Jamais -return c.json({ error: 'error' }, 500) -throw new Error('Something went wrong') -``` - -### Codes d'erreur standardisés -``` -AUTH_REQUIRED → 401 Pas de JWT ou JWT invalide -PLAN_INSUFFICIENT → 403 Feature non disponible pour ce plan -QUOTA_REACHED → 403 Quota de simulations épuisé -INVALID_PLAN → 400 Valeur de plan inconnue -SIMULATION_NOT_FOUND → 404 Simulation inexistante ou non accessible -STRIPE_WEBHOOK_INVALID → 400 Signature webhook invalide -INTERNAL_ERROR → 500 Erreur serveur inattendue -``` - -### Frontend — toujours gérer les erreurs API -```typescript -// ✅ Pattern obligatoire pour tout appel API -try { - const result = await api.simulations.create(data) - // succès -} catch (error) { - if (error.code === 'QUOTA_REACHED') { - // afficher modal upgrade - } else if (error.code === 'PLAN_INSUFFICIENT') { - // afficher paywall - } else { - // afficher message d'erreur générique - } +export async function apiFetch( + path: string, + options: RequestInit & { timeoutMs?: number } = {} +): Promise { + // 1. Ajouter headers (Authorization Bearer, X-API-Version, Content-Type) + // 2. Appliquer AbortController pour timeout + // 3. Retry avec backoff exponentiel sur erreurs réseau + 5xx + // 4. Parser la réponse : + // - Si 2xx → retourner le JSON tel quel typé T + // - Si 4xx/5xx → parser l'erreur backend { error: true, code, message } → throw ApiError } ``` +Le format exact des erreurs backend est documenté dans `ARCHITECTURE.md` section 5 (confirmé par audit du 2026-04-17). + +### Côté hook (utilisation) + +```typescript +const mutation = useMutation({ + mutationFn: submitProduction, + onError: (err: ApiError) => { + switch (err.code) { + case 'QUOTA_REACHED': + openUpgradeModal() + break + case 'PLAN_INSUFFICIENT': + openPaywall() + break + case 'AUTH_REQUIRED': + redirectToLogin() + break + case 'VALIDATION_ERROR': + case 'INVALID_BODY': + toast.error('Données invalides. Vérifiez votre saisie.') + break + default: + toast.error('Une erreur est survenue. Réessayez dans quelques instants.') + } + }, +}) +``` + +> **Note** : `VALIDATION_ERROR` et `INVALID_BODY` sont deux codes backend pour la même classe d'erreur (corps invalide). Côté frontend, on les gère de la même manière. L'unification côté backend est tracée dans `TECH_DEBT.md` backend (TD-15). + +### Messages utilisateur + +- En français +- Clairs et non techniques +- Orientés action + +``` +// ✅ Bons messages +"Vous avez utilisé vos 5 simulations gratuites. Passez en Standard pour continuer." +"Le mode Examen est réservé au plan Premium." +"Une erreur est survenue. Réessayez dans quelques instants." + +// ❌ Mauvais messages +"Erreur 403 : quota_reached" +"TypeError: Cannot read property 'plan' of undefined" +``` + --- -## 5. Sécurité — vérifications obligatoires - -### Toute route backend qui touche à des données utilisateur doit : - -```typescript -// 1. Vérifier l'authentification -app.use(authMiddleware) // vérifie le JWT Supabase - -// 2. Vérifier le plan si nécessaire -app.use(planMiddleware('exam_mode')) // vérifie la permission - -// 3. Vérifier que la ressource appartient à l'utilisateur -const simulation = await db.getSimulation(id) -if (simulation.user_id !== currentUser.id) { - return c.json({ error: true, code: 'AUTH_REQUIRED' }, 401) -} -``` - -### La vérification des permissions se fait TOUJOURS côté backend -Le frontend peut masquer des boutons selon le plan — c'est de l'UX. -Mais la vérification réelle se fait dans le middleware backend. -Un utilisateur malveillant peut appeler l'API directement sans passer par le frontend. - ---- - -## 6. Communication avec Hermann +## 5. Communication avec Hermann ### Format du plan (avant implémentation) + ``` 📋 PLAN — [nom de la tâche] Fichiers modifiés : - - src/routes/simulations.ts (ajouter la vérification de quota) - - src/lib/access.ts (ajouter la fonction canUserSimulate) + - src/features/dashboard/pages/DashboardPage.tsx (ajouter affichage conditionnel) + - src/entities/user/lib.ts (ajouter helper formatRemainingSimulations) Fichiers créés : - - src/lib/__tests__/canUserSimulate.test.ts + - src/features/dashboard/components/RemainingSimulationsBadge.tsx + - src/features/dashboard/components/__tests__/RemainingSimulationsBadge.test.tsx -Fichiers supprimés : - - aucun +Fichiers supprimés : aucun Risques identifiés : - - La modification de access.ts peut affecter toutes les routes - qui utilisent getPlanPermissions → vérifier après modification + - La modification de DashboardPage peut affecter l'affichage pour les 3 plans + → vérifier les 3 comptes de test après implémentation Étapes : - 1. Créer la fonction canUserSimulate dans access.ts - 2. Écrire le test canUserSimulate.test.ts - 3. Ajouter la vérification dans la route POST /simulations + 1. Créer le composant RemainingSimulationsBadge + son test + 2. Créer le helper formatRemainingSimulations dans entities/user/lib.ts + test + 3. Intégrer dans DashboardPage En attente de validation avant de commencer. ``` ### Format du résumé (après implémentation) + ``` ✅ RÉSUMÉ — [nom de la tâche] Modifié : - - src/routes/simulations.ts : ajout vérification quota ligne 42 - - src/lib/access.ts : ajout fonction canUserSimulate + - src/features/dashboard/pages/DashboardPage.tsx : intégration badge ligne 42 Créé : - - src/lib/__tests__/canUserSimulate.test.ts (7 tests) + - src/features/dashboard/components/RemainingSimulationsBadge.tsx + - src/entities/user/lib.ts : fonction formatRemainingSimulations + + 2 fichiers de tests (3 tests supplémentaires) -Tests : 41/41 passés ✅ +Typecheck : OK +Tests : 27/27 passés ✅ Tests manuels à rejouer : - - Golden Dataset Groupe 2 (B2, B7) — quota Free - - Golden Dataset Groupe 3 (C2) — illimité Standard + - Golden Dataset Groupe 2 (B1) — Dashboard Free avec compteur + - Golden Dataset Groupe 3 (C1) — Dashboard Standard sans compteur + +Message de commit proposé : +feat(dashboard): afficher le compteur de simulations restantes (Free) ``` ### Quand Claude Code doit s'arrêter et demander + - Un test automatisé échoue après modification - La tâche nécessite de modifier plus de 3 fichiers par étape - Une décision architecturale non documentée est requise - Une ambiguïté sur le comportement attendu selon un plan tarifaire +- Le backend doit être modifié (session différente, dépôt différent) - Une clé privée serait nécessaire côté frontend --- -## 7. Checklist de démarrage de session +## 6. Checklist de démarrage de session Avant chaque session Claude Code, vérifier : ``` -[ ] Les 4 documents de référence ont été lus - (DEVELOPMENT_PRINCIPLES.md, ARCHITECTURE.md, +[ ] Les documents de référence ont été lus + (ARCHITECTURE.md, DEVELOPMENT_PRINCIPLES.md, PLANS_TARIFAIRES.md, PARCOURS_UTILISATEURS.md) -[ ] L'environnement de test est dans l'état attendu - (voir TEST_ENVIRONMENT.md — script de réinitialisation) +[ ] Les ADRs pertinents ont été consultés + (docs/adr/) + +[ ] L'environnement de dev fonctionne + (npm run dev, pas d'erreur au démarrage) [ ] Les tests automatisés sont tous verts - (npm run test → 0 échec) + (npm run typecheck && npm run test → 0 échec) [ ] Un commit Git propre existe avant de commencer - (état de repli en cas de régression) [ ] La tâche de la session est clairement définie (une seule tâche par session) @@ -353,70 +411,53 @@ Avant chaque session Claude Code, vérifier : --- -## 8. Checklist de fin de session - -Avant de clôturer chaque session Claude Code : +## 7. Checklist de fin de session ``` [ ] Les tests automatisés sont tous verts - (npm run test → 0 échec) + (npm run typecheck && npm run test → 0 échec) [ ] Le résumé de session a été produit - (fichiers modifiés, tests passés) [ ] Les tests manuels du Golden Dataset ont été rejoués - (groupes concernés par les modifications) [ ] Un commit Git a été fait avec un message clair - Format : "feat: [description]" ou "fix: [description]" + Format : "feat(): ...", "fix(): ...", "refactor(): ..." + Scopes possibles : auth, dashboard, simulation, t2-live, billing, shared, docs + Exemples : - "feat: ajout vérification quota simulations free" - "fix: correction rapport flouté plan découverte" - "refactor: extraction logique permissions dans access.ts" + "feat(dashboard): affichage conditionnel selon le plan" + "fix(auth): corriger la redirection après logout" + "refactor(entities/user): extraire hasAccess dans un fichier dédié" -[ ] DEVELOPMENT_PRINCIPLES.md et ARCHITECTURE.md - sont à jour si une décision a changé +[ ] docs/CHANGELOG.md mis à jour avec un résumé de la session + +[ ] ARCHITECTURE.md / SECURITY.md mis à jour si une décision a changé ``` --- -## 9. Messages d'erreur utilisateur — ton et format - -Les messages affichés à l'utilisateur doivent être : -- En français -- Clairs et non techniques -- Orientés action (que faire ensuite) -- Non condescendants +## 8. Ce que Claude Code ne doit jamais faire ``` -// ✅ Bons messages -"Vous avez utilisé vos 5 simulations gratuites. - Passez en Standard pour continuer votre préparation." - -"Le mode Examen est réservé au plan Premium. - Passez en Premium pour vous entraîner en conditions réelles." - -"Une erreur est survenue. Veuillez réessayer dans quelques instants." - -// ❌ Mauvais messages -"Erreur 403 : quota_reached" -"Vous n'êtes pas autorisé à effectuer cette action." -"Internal server error" -``` - ---- - -## 10. Ce que Claude Code ne doit jamais faire - -``` -❌ Modifier lib/access.ts sans signaler que TOUTES les permissions sont impactées +❌ Modifier entities/user/access.ts sans prévenir (source de vérité partagée avec le backend) ❌ Ajouter une dépendance npm sans demander la validation -❌ Modifier le schéma Supabase directement dans le dashboard +❌ Appeler Supabase pour autre chose que l'auth ❌ Écrire une logique de plan en dur (if plan === 'premium') ❌ Exposer une clé privée dans le frontend -❌ Supprimer des données utilisateur (productions, profiles) ❌ Modifier plus de 3 fichiers sans validation intermédiaire ❌ Passer à l'étape suivante si un test est rouge ❌ Prendre une décision architecturale sans la documenter ❌ Coder sans plan validé, même pour "juste une petite modification" +❌ Utiliser dangerouslySetInnerHTML sans DOMPurify +❌ Créer un worktree Git +❌ Modifier le backend depuis une session frontend (dépôt différent) ``` + +--- + +## 9. Historique + +| Version | Date | Changements | +|---|---|---| +| 1.0 | 2026-04-17 | Création, adaptée de la version backend | diff --git a/docs/GOLDEN_DATASET.md b/docs/GOLDEN_DATASET.md index e9c6cb4..4fcf69c 100644 --- a/docs/GOLDEN_DATASET.md +++ b/docs/GOLDEN_DATASET.md @@ -1,154 +1,170 @@ -# GOLDEN_DATASET.md — Expria / Coach TCF Canada +# GOLDEN_DATASET.md — Expria Frontend > **Document de référence — Version 1.0** -> Ce fichier contient les tests manuels à rejouer après CHAQUE session Claude Code, -> avant de passer à la session suivante ou de déployer en production. +> Ce fichier contient les tests manuels à rejouer après CHAQUE session Claude Code frontend, avant de déployer sur Cloudflare Pages. > Un seul test en rouge = la modification est refusée, on revient en arrière. +> +> Complément frontend du `GOLDEN_DATASET.md` backend. Les deux doivent passer avant un déploiement couplé. --- ## Principe d'utilisation -1. Avant chaque session Claude Code : sauvegarder le code (commit Git) +1. Avant chaque session Claude Code : commit Git propre 2. Après chaque session Claude Code : rejouer TOUS les tests du groupe concerné 3. Si un test échoue : ne pas continuer, identifier la régression, corriger d'abord -4. En cas de doute : rejouer le dataset complet (section 7) +4. En cas de doute : rejouer le groupe Z (smoke test complet) -**Comptes de test à créer dans Supabase avant de commencer :** +**Environnement de test :** +- URL frontend local : `http://localhost:5173` +- URL backend : `https://api.expria.app` (ou local si dev simultané) +- Navigateurs à couvrir : Chrome + Firefox + Safari mobile (via DevTools mobile emulation minimum) -| Compte | Plan | Usage | +**Comptes de test (identiques au backend) :** + +| Compte | Plan | Mot de passe | |---|---|---| -| test.free@expria.local | free | Tester les parcours Free | -| test.standard@expria.local | standard | Tester les parcours Standard | -| test.premium@expria.local | premium | Tester les parcours Premium | -| test.quota@expria.local | free (5/5 simulations utilisées) | Tester le blocage quota | - -> Ces comptes sont créés via script SQL dans Supabase (ne pas les créer manuellement). -> Voir `supabase/seeds/test_accounts.sql`. +| test.free@gmail.com | free | Expria2025!test | +| test.standard@gmail.com | standard | Expria2025!test | +| test.premium@gmail.com | premium | Expria2025!test | +| test.quota@gmail.com | free (5/5 utilisées) | Expria2025!test | --- -## Groupe 1 — Authentification +## Groupe A — Authentification et routing -Ces tests vérifient que l'accès à l'application fonctionne correctement. - -| # | Test | Compte | Résultat attendu | ✅ / ❌ | +| # | Test | Compte | Résultat attendu | ✅/❌ | |---|---|---|---|---| -| A1 | Inscription avec email valide | Nouveau compte | Compte créé, plan = "free", redirection dashboard | | -| A2 | Connexion avec email + mot de passe valides | test.free | Dashboard Free affiché, compteur simulations visible | | -| A3 | Connexion avec mot de passe incorrect | test.free | Message d'erreur, pas de redirection | | -| A4 | Déconnexion | test.free | Redirection page d'accueil, session détruite | | -| A5 | Accès direct à /dashboard sans être connecté | — | Redirection vers /login | | +| A1 | Arriver sur `/` sans être connecté | — | Page Home publique affichée | | +| A2 | Cliquer "Se connecter" depuis Home | — | Redirection `/login`, formulaire visible | | +| A3 | Inscription avec email + mot de passe valides | nouveau | Compte créé, plan=free, redirection `/dashboard` | | +| A4 | Connexion avec identifiants corrects | test.free | Redirection `/dashboard`, plan Free affiché | | +| A5 | Connexion avec mot de passe incorrect | test.free | Message d'erreur en français, pas de redirection | | +| A6 | Déconnexion depuis le menu utilisateur | test.free | Redirection `/`, session invalidée | | +| A7 | Accès direct à `/dashboard` sans auth | — | Redirection `/login` (ProtectedRoute) | | +| A8 | Accès direct à `/t2-live` en tant que Free | test.free | Redirection ou PaywallModal "Exclusivité Premium" | | +| A9 | Session JWT expirée pendant navigation | test.free | Message "Session expirée", redirection `/login` | | +| A10 | Rafraîchir la page après login | test.free | Reste connecté, dashboard réaffiché | | --- -## Groupe 2 — Plan Free (Découverte) +## Groupe B — Plan Free (parcours complet) -Ces tests vérifient le parcours complet d'un utilisateur Free. - -| # | Test | Compte | Résultat attendu | ✅ / ❌ | +| # | Test | Compte | Résultat attendu | ✅/❌ | |---|---|---|---|---| -| B1 | Dashboard Free affiché après connexion | test.free | Compteur "X/5 simulations", aperçu flouté du dashboard Premium visible | | -| B2 | Lancer une simulation EE Tâche 1 | test.free (quota < 5) | Interface de production affichée, pas de tips | | -| B3 | Soumettre une production EE | test.free (quota < 5) | Rapport affiché : score + NCLC visibles, critères floutés avec cadenas | | -| B4 | Vérifier le rapport flouté | test.free | Au moins 1 élément flouté avec mention "Disponible en Standard" | | -| B5 | Lancer une simulation EO Tâche 1 | test.free (quota < 5) | Interface d'enregistrement audio affichée | | -| B6 | Cliquer sur EO Tâche 2 live | test.free | Cadenas affiché + message "Exclusivité Premium" | | -| B7 | Tenter une 6e simulation | test.quota | Modal de blocage avec boutons "Standard" et "Premium" | | -| B8 | Cliquer "Plus tard" dans le modal de blocage | test.quota | Modal fermé, pas de redirection | | -| B9 | Cliquer "Mode Examen" | test.free | Cadenas + message "Exclusivité Premium" | | +| B1 | Dashboard Free après connexion | test.free | Compteur "X/5 simulations", aperçu flouté du dashboard Premium visible | | +| B2 | Badge compteur simulations affiché | test.free | Visible en permanence dans le header du dashboard | | +| B3 | Lancer une simulation EE T1 | test.free (quota < 5) | Interface de production affichée, pas de tips visibles | | +| B4 | Soumettre une production EE | test.free | Rapport affiché : score + NCLC visibles, critères floutés avec cadenas | | +| B5 | Rapport flouté avec mentions correctes | test.free | "Disponible en Standard" + bouton upgrade visible | | +| B6 | Lancer une simulation EO T1 | test.free | Interface d'enregistrement audio, pas d'erreur microphone | | +| B7 | Tenter EO T2 live depuis le sélecteur de tâches | test.free | Cadenas + message "Exclusivité Premium" | | +| B8 | Atteindre la 6e simulation | test.quota | Modal de blocage : "5/5 utilisées" + 2 boutons (Standard/Premium) + "Plus tard" | | +| B9 | Cliquer "Plus tard" dans le modal | test.quota | Modal fermé, dashboard visible, pas de redirection | | +| B10 | Cliquer "Mode Examen" | test.free | Cadenas + message "Exclusivité Premium" | | +| B11 | Tenter accès URL direct `/exam-mode` | test.free | Redirection ou PaywallModal | | --- -## Groupe 3 — Plan Standard +## Groupe C — Plan Standard -Ces tests vérifient le parcours complet d'un utilisateur Standard. - -| # | Test | Compte | Résultat attendu | ✅ / ❌ | +| # | Test | Compte | Résultat attendu | ✅/❌ | |---|---|---|---|---| -| C1 | Dashboard Standard après connexion | test.standard | Historique visible, bouton "Choisir une tâche" actif, mode examen verrouillé | | -| C2 | Lancer une simulation EE sans limite | test.standard | Simulation accessible sans vérification de quota | | -| C3 | Toggle "Suggestions d'idées" activé | test.standard | Suggestions d'idées visibles pendant la simulation | | +| C1 | Dashboard Standard après connexion | test.standard | Historique visible, pas de compteur simulations, bouton "Choisir une tâche" actif | | +| C2 | Lancer simulation EE sans limite | test.standard | Accès direct, aucune vérification de quota visible | | +| C3 | Toggle "Suggestions d'idées" activé | test.standard | Suggestions visibles pendant la simulation | | | C4 | Toggle "Mode focus" activé | test.standard | Tips masqués pendant la simulation | | -| C5 | Soumettre une production EE | test.standard | Rapport complet : score, critères, erreurs, modèle, exercices — rien flouté | | -| C6 | Vérifier l'enregistrement dans le dashboard | test.standard | Production apparaît dans l'historique avec date, tâche, score | | -| C7 | Cliquer sur une production dans l'historique | test.standard | Rapport complet de cette production affiché | | -| C8 | Cliquer sur "Mode Examen" | test.standard | Message "Réservé au plan Premium" + bouton upgrade | | -| C9 | Cliquer sur "EO Tâche 2 live" | test.standard | Cadenas + message "Exclusivité Premium" | | -| C10 | Indice de préparation après 5 productions | test.standard (5 prod.) | Section indice visible avec score et message interprétatif | | +| C5 | Rapport complet après soumission EE | test.standard | Score, critères détaillés, erreurs expliquées, modèle, exercices — rien flouté | | +| C6 | Production apparaît dans le dashboard | test.standard | Date, tâche, score affichés dans la liste | | +| C7 | Cliquer une production dans l'historique | test.standard | Rapport complet de cette production réaffiché | | +| C8 | Cliquer "Mode Examen" | test.standard | Message "Réservé au plan Premium" + bouton upgrade | | +| C9 | Cliquer "EO Tâche 2 live" | test.standard | Cadenas + message "Exclusivité Premium" | | +| C10 | Après 5 productions : indice de préparation | test.standard | Section indice visible avec score et message interprétatif | | +| C11 | Upgrade Standard → Premium : prorata affiché | test.standard | Avant confirmation, montant prorata visible (ex : "~10€ aujourd'hui") | | --- -## Groupe 4 — Plan Premium +## Groupe D — Plan Premium -Ces tests vérifient le parcours complet d'un utilisateur Premium. - -| # | Test | Compte | Résultat attendu | ✅ / ❌ | +| # | Test | Compte | Résultat attendu | ✅/❌ | |---|---|---|---|---| -| D1 | Dashboard Premium après connexion | test.premium | Historique, indice, bouton "Lancer un examen" actif, T2 live accessible | | -| D2 | Accéder à EO Tâche 2 live | test.premium | Page de préparation T2 affichée, bouton "Démarrer le dialogue" | | -| D3 | Démarrer le dialogue T2 | test.premium | L'IA prend la parole en premier, audio reçu et joué | | -| D4 | Répondre en audio (T2) | test.premium | L'IA réagit après la réponse du candidat | | +| D1 | Dashboard Premium après connexion | test.premium | Historique, indice, patterns, bouton examen actif, T2 live accessible | | +| D2 | Accéder à EO T2 live | test.premium | Page préparation T2, bouton "Démarrer le dialogue" actif | | +| D3 | Démarrer le dialogue T2 | test.premium | État "Connecting" puis "Listening", l'IA prend la parole en premier | | +| D4 | Répondre en audio à l'IA | test.premium | L'IA réagit après la réponse du candidat, état oscille listening/speaking | | | D5 | Fin de dialogue T2 | test.premium | Rapport complet affiché, production enregistrée avec tag "T2 Live" | | -| D6 | Lancer mode Examen EE | test.premium | Page d'avertissement affichée avant démarrage | | -| D7 | Confirmer le lancement Examen EE | test.premium | 3 tâches visibles, timer 60:00 démarré, inarrêtable | | -| D8 | Vérifier le blocage à T=0 (Examen EE) | test.premium | Zone de texte figée, message "Temps écoulé", envoi automatique | | -| D9 | Lancer mode Examen EO | test.premium | Timer 12:00 démarré, enregistrement actif | | -| D10 | Analyse des patterns (5+ productions) | test.premium | Section "Mon profil" affiche erreurs récurrentes classées par type | | -| D11 | Exercices long terme générés | test.premium | Exercices distincts de ceux du rapport individuel | | +| D6 | Déconnexion WebSocket en cours de T2 | test.premium | État "Error" affiché, message utilisateur clair, option de reprise | | +| D7 | Lancer mode Examen EE | test.premium | Page d'avertissement affichée avant démarrage | | +| D8 | Confirmer Examen EE | test.premium | 3 tâches visibles, timer 60:00 démarré, inarrêtable | | +| D9 | Blocage à T=0 (Examen EE) | test.premium | Zone de texte figée, message "Temps écoulé", envoi auto | | +| D10 | Lancer mode Examen EO | test.premium | Timer 12:00, enregistrement actif, tâches enchaînées | | +| D11 | Analyse patterns (5+ productions) | test.premium | Section "Mon profil" avec erreurs récurrentes classées | | --- -## Groupe 5 — Paiements et changements de plan +## Groupe E — Paiements Stripe -Ces tests vérifient que le système de paiement fonctionne sans régression. +> ⚠️ Utiliser les cartes de test Stripe : +> - Carte valide : `4242 4242 4242 4242` (date future, CVC libre) +> - Carte refusée : `4000 0000 0000 0002` -> ⚠️ Ces tests utilisent les cartes de test Stripe. -> Carte valide : `4242 4242 4242 4242` — expiry : n'importe quelle date future — CVC : n'importe lequel. -> Carte refusée : `4000 0000 0000 0002` - -| # | Test | Compte | Résultat attendu | ✅ / ❌ | +| # | Test | Compte | Résultat attendu | ✅/❌ | |---|---|---|---|---| -| E1 | Upgrade Free → Standard | test.free | Redirection Stripe, paiement, retour dashboard Standard, plan = "standard" | | -| E2 | Upgrade Free → Premium | test.free | Redirection Stripe, paiement, retour dashboard Premium, plan = "premium" | | -| E3 | Upgrade Standard → Premium (prorata) | test.standard | Montant prorata affiché avant confirmation, accès Premium après paiement | | -| E4 | Paiement refusé | test.free | Message d'erreur Stripe, plan inchangé, pas de régression | | -| E5 | Accès immédiat après paiement | test.free | Dashboard du nouveau plan affiché sans délai ni reconnexion | | -| E6 | Plan mis à jour dans Supabase | test.free | Colonne `plan` dans la table `profiles` reflète le nouveau plan | | +| E1 | Upgrade Free → Standard (Stripe Checkout) | test.free | Redirection full page vers Stripe, paiement, retour dashboard Standard | | +| E2 | Invalidation du cache plan après paiement | test.free → standard | usePlan() refetch automatiquement, dashboard bascule sans recharger la page | | +| E3 | Upgrade Free → Premium | test.free | Même flux que E1, plan=premium après retour | | +| E4 | Upgrade Standard → Premium avec prorata | test.standard | Montant prorata affiché avant confirmation, accès Premium immédiat | | +| E5 | Paiement refusé (carte 4000 0000 0000 0002) | test.free | Message d'erreur Stripe clair, plan inchangé | | +| E6 | Annuler au milieu du Checkout | test.free | Retour sur `/billing` ou `/pricing`, plan inchangé | | --- -## Groupe 6 — Sécurité et permissions +## Groupe F — Sécurité et permissions -Ces tests vérifient qu'un utilisateur ne peut pas accéder à ce qu'il ne devrait pas. - -| # | Test | Compte | Résultat attendu | ✅ / ❌ | +| # | Test | Compte | Résultat attendu | ✅/❌ | |---|---|---|---|---| -| F1 | Accès direct URL /t2-live sans être Premium | test.standard | Redirection ou message "Accès réservé au Premium" | | -| F2 | Appel API POST /corrections/ee sans JWT | — (non connecté) | Réponse 401 Unauthorized | | -| F3 | Appel API GET /simulations d'un autre utilisateur | test.free | Réponse 403 ou liste vide (RLS Supabase) | | -| F4 | Tentative de lancer examen via API (plan standard) | test.standard | Réponse 403 — plan insuffisant | | -| F5 | Variable GEMINI_API_KEY visible dans le frontend | — | Introuvable dans le code source JavaScript chargé par le navigateur | | +| F1 | URL directe `/t2-live` en Standard | test.standard | Redirection ou PaywallModal, pas d'accès à la page | | +| F2 | Inspecter DevTools → clés privées | — | Aucune clé `SERVICE_ROLE`, `GEMINI`, `STRIPE_SECRET` visible | | +| F3 | Inspecter DevTools → JWT en clair dans localStorage | test.free | JWT Supabase visible (normal, c'est un access token) mais pas de refresh token exposé | | +| F4 | Modifier le plan dans DevTools via Redux/state | test.free | La modification locale n'a aucun effet — le backend reste l'autorité | | +| F5 | Rapport contenant des caractères HTML potentiellement malicieux | test.standard | Rendu comme texte, pas comme HTML (aucune exécution) | | +| F6 | CSP header présent dans la réponse HTTP | — | `Content-Security-Policy` défini dans les headers Cloudflare Pages | | +| F7 | Console navigateur : pas de log de JWT ou données perso | test.free | Aucun `console.log` contenant email, token, payload API | | --- -## Groupe 7 — Dataset complet (smoke test) +## Groupe G — Responsive mobile -À rejouer avant chaque déploiement en production. -Ce sont les 10 scénarios les plus critiques, un par type de parcours. +Tests à rejouer sur DevTools mobile emulation (iPhone SE, iPhone 12, Samsung Galaxy) ET sur vrai mobile si possible. + +| # | Test | Résultat attendu | ✅/❌ | +|---|---|---|---| +| G1 | Page Home lisible sur écran 375px | Pas de débordement horizontal, CTA accessible | | +| G2 | Formulaire de login sur mobile | Champs bien dimensionnés, clavier virtuel ne cache pas le bouton | | +| G3 | Dashboard Free sur mobile | Compteur visible, aperçu flouté lisible | | +| G4 | Simulation EE sur mobile | Zone de texte utilisable, pas de zoom intempestif | | +| G5 | Enregistrement audio EO sur mobile | Permission microphone demandée, enregistrement fonctionnel | | +| G6 | T2 live sur mobile (Premium) | WebSocket fonctionne, audio bidirectionnel OK | | +| G7 | Modal PaywallModal sur mobile | Scrollable si contenu déborde, bouton fermeture accessible | | + +--- + +## Groupe Z — Smoke test (avant chaque déploiement) + +Les 10 scénarios les plus critiques, à rejouer dans l'ordre avant chaque déploiement production. | # | Test | Description rapide | |---|---|---| -| Z1 | Inscription + simulation Free | Nouvel utilisateur → simulation → rapport flouté | +| Z1 | Inscription + première simulation Free | Compte créé → simulation → rapport flouté visible | | Z2 | Blocage quota Free | 6e simulation → modal de blocage | | Z3 | Simulation Standard complète | Login → simulation → rapport complet → dashboard | | Z4 | Mode examen bloqué en Standard | Bouton mode examen → message upgrade | | Z5 | T2 live Premium | Login → T2 live → dialogue → rapport | | Z6 | Mode examen EE complet | Lancement → timer → T=0 → envoi auto → rapport | -| Z7 | Paiement Free → Standard | Checkout Stripe → webhook → dashboard Standard | -| Z8 | Prorata Standard → Premium | Montant affiché → confirmation → accès Premium | -| Z9 | Sécurité JWT | Appel API sans token → 401 | -| Z10 | Déconnexion + accès protégé | Déconnexion → accès /dashboard → redirection /login | +| Z7 | Paiement Free → Standard | Stripe Checkout → retour dashboard Standard sans rechargement | +| Z8 | Prorata Standard → Premium | Montant affiché → confirmation → accès Premium immédiat | +| Z9 | Déconnexion + accès protégé | Logout → accès `/dashboard` → redirection `/login` | +| Z10 | Responsive mobile Home + Login | Affichage correct sur iPhone SE | --- @@ -160,7 +176,7 @@ Test échoue 1. Noter le numéro du test et le comportement observé 2. NE PAS continuer la session Claude Code 3. Identifier le fichier modifié qui a causé la régression -4. Revenir au commit Git précédent (git revert ou git checkout) +4. Revenir au commit Git précédent 5. Analyser la cause avec Claude (chat, pas code) 6. Reformuler le prompt en ajoutant la contrainte manquante 7. Relancer la session Claude Code @@ -171,8 +187,16 @@ Test échoue ## Historique des sessions -> Remplir après chaque session Claude Code. +> Remplir après chaque session Claude Code frontend. | Date | Session | Tests rejoués | Résultat | Notes | |---|---|---|---|---| | — | — | — | — | — | + +--- + +## Historique de ce document + +| Version | Date | Changements | +|---|---|---| +| 1.0 | 2026-04-17 | Création initiale, 55 tests frontend | diff --git a/docs/TESTS_AUTOMATISES.md b/docs/TESTS_AUTOMATISES.md index c4661d3..ee9bf6f 100644 --- a/docs/TESTS_AUTOMATISES.md +++ b/docs/TESTS_AUTOMATISES.md @@ -1,36 +1,85 @@ -# TESTS_AUTOMATISES.md — Expria / Coach TCF Canada +# TESTS_AUTOMATISES.md — Expria Frontend > **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. +> Ce document contient les tests Vitest automatisés à implémenter dans le frontend. +> 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. +> +> Calqué sur `TESTS_AUTOMATISES.md` du backend pour garantir la parité et appliquer le même standard de rigueur. --- ## 1. Principe -**Ces 6 fonctions sont critiques.** Si l'une d'elles casse, toute l'application tombe : +**Tests ciblés, pas exhaustifs.** On teste ce qui vérifie un droit, modifie des données, calcule un résultat, ou orchestre un flux critique. On ne teste pas les composants d'affichage (trop fragiles, couverts par le Golden Dataset manuel). + +### Fonctions critiques à tester | 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 | +| `checkFeatureAccess` / `hasAccess` | Vérifie l'accès à une feature selon le plan | Feature Premium accessible gratuitement | +| `canUserSimulate` / `canSimulate` | Vérifie quota simulations Free | Blocage quota cassé = coût explosif DeepSeek | +| `getPlanPermissions` | Retourne les permissions d'un plan | Mauvaises features affichées | +| `apiFetch` | Wrapper HTTP avec retry, timeout, parsing erreur | Tous les appels API peuvent échouer silencieusement | +| `parseApiError` | Parse les erreurs backend vers `ApiError` typée | Mauvaise gestion des codes erreur côté UI | +| `applyReportFloutage` | Filtre le rapport selon le plan (Free = flouté) | Rapport Premium accessible en Free (fuite de valeur) | +| `t2LiveReducer` | State machine T2 Live | Transitions d'états cassées = bugs audio/WebSocket | +| `usePlan` (hook) | Cache du plan utilisateur + refetch après upgrade | Plan stale après paiement = UX brisée | --- ## 2. Installation -Dans le dépôt `expria-backend`, installer Vitest : +### Dépendances à ajouter ```bash -npm install --save-dev vitest @vitest/coverage-v8 +npm install --save-dev vitest @vitest/coverage-v8 @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom ``` -Ajouter dans `package.json` : +### Configuration `vitest.config.ts` à la racine + +```typescript +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import path from 'node:path' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test-setup.ts'], + coverage: { + reporter: ['text', 'html'], + include: [ + 'src/entities/**', + 'src/shared/lib/**', + 'src/features/*/hooks/**', + 'src/features/*/state/**', + ], + exclude: ['**/__tests__/**', '**/*.test.ts', '**/*.test.tsx'], + }, + }, +}) +``` + +### Fichier `src/test-setup.ts` + +```typescript +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() +}) +``` + +### Scripts dans `package.json` ```json { @@ -42,487 +91,519 @@ Ajouter dans `package.json` : } ``` -Créer le fichier `vitest.config.ts` : +--- -```typescript -import { defineConfig } from 'vitest/config' +## 3. Tests déjà produits (session documentation — avant scaffold) -export default defineConfig({ - test: { - environment: 'node', - globals: true, - coverage: { - reporter: ['text', 'html'], - include: ['src/lib/**', 'src/controllers/**'], - }, - }, -}) -``` +### `src/entities/user/__tests__/access.test.ts` — 26 assertions + +Teste la parité avec le backend sur les 3 fonctions de `access.ts` : +- `canUserSimulate` : 7 tests (free dans limites, free au quota, standard, premium, plan inconnu) +- `getPlanPermissions` : 4 tests (les 3 plans + plan inconnu lève une erreur) +- `checkFeatureAccess` : 14 tests (1 basic_report + 4 blocages free + 5 standard + 4 premium) +- `PLANS` structure : 1 test (parité des clés entre plans) + +### `src/entities/user/__tests__/lib.test.ts` — 11 assertions + +Teste les alias `hasAccess` et `canSimulate` : +- `hasAccess` : 3 tests (comportement identique à `checkFeatureAccess` pour chaque plan) +- `canSimulate` : 7 tests (parité + signature ergonomique) +- `getPlanPermissions` réexporté : 1 test + +**Total déjà produit : 37 tests.** --- -## 3. Tests — canUserSimulate +## 4. Tests à implémenter par sprint -**Fichier :** `src/lib/__tests__/canUserSimulate.test.ts` +### Sprint 1 — Auth + API layer -```typescript -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` - -```typescript -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` - -```typescript -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. +#### `src/shared/lib/__tests__/api-client.test.ts` +Tester `apiFetch` dans toutes ses variantes. ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest' -import { updateUserPlan } from '../planController' +import { apiFetch } from '../api-client' -// 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, - })), - })), - })), - })), - })), - }, -})) +// Mock de fetch global +global.fetch = vi.fn() -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') +describe('apiFetch', () => { + beforeEach(() => { + vi.resetAllMocks() }) - it('met à jour le plan vers premium', async () => { - const result = await updateUserPlan('test-user-id', 'premium') - expect(result.success).toBe(true) + describe('succès', () => { + it('retourne le payload JSON pour une réponse 2xx', async () => { + const mockResponse = { plan: 'premium', simulations_used: 0 } + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockResponse, + } as Response) + + const result = await apiFetch('/plans/status') + expect(result).toEqual(mockResponse) + }) + + it('ajoute automatiquement le header Authorization si un token est fourni', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + } as Response) + + await apiFetch('/plans/status', {}, 'jwt-token-xyz') + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Authorization': 'Bearer jwt-token-xyz', + }), + }) + ) + }) + + it('ajoute X-API-Version à toutes les requêtes', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + } as Response) + + await apiFetch('/plans/status') + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-API-Version': '1.0', + }), + }) + ) + }) }) - it('refuse une valeur de plan invalide', async () => { - await expect( - updateUserPlan('test-user-id', 'super_premium' as any) - ).rejects.toThrow('Plan invalide') + describe('erreurs backend', () => { + it('throw une ApiError sur 401 AUTH_REQUIRED', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ + error: true, + code: 'AUTH_REQUIRED', + message: 'Authentification requise.', + }), + } as Response) + + await expect(apiFetch('/plans/status')).rejects.toMatchObject({ + code: 'AUTH_REQUIRED', + }) + }) + + it('throw une ApiError sur 403 QUOTA_REACHED', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 403, + json: async () => ({ + error: true, + code: 'QUOTA_REACHED', + message: 'Quota atteint.', + status: 403, // quirk backend (voir ARCHITECTURE.md §5) + }), + } as Response) + + await expect(apiFetch('/simulations', { method: 'POST' })).rejects.toMatchObject({ + code: 'QUOTA_REACHED', + }) + }) + + it('throw NETWORK_ERROR si fetch lui-même échoue', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new TypeError('Failed to fetch')) + + await expect(apiFetch('/plans/status')).rejects.toMatchObject({ + code: 'NETWORK_ERROR', + }) + }) }) - it('refuse un userId vide', async () => { - await expect( - updateUserPlan('', 'standard') - ).rejects.toThrow('userId requis') - }) + describe('retry et timeout', () => { + it('retry 3 fois sur 500 INTERNAL_ERROR', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({ error: true, code: 'INTERNAL_ERROR', message: '...' }) } as Response) + .mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({ error: true, code: 'INTERNAL_ERROR', message: '...' }) } as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ plan: 'free' }) } as Response) + const result = await apiFetch('/plans/status') + expect(result).toEqual({ plan: 'free' }) + expect(fetch).toHaveBeenCalledTimes(3) + }) + + it('ne retry PAS sur 400 VALIDATION_ERROR', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: true, code: 'VALIDATION_ERROR', message: '...' }), + } as Response) + + await expect(apiFetch('/simulations', { method: 'POST' })).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + }) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it('applique le timeout configuré', async () => { + vi.useFakeTimers() + vi.mocked(fetch).mockImplementationOnce( + () => new Promise((resolve) => setTimeout(resolve, 10_000)) + ) + + const promise = apiFetch('/plans/status', { timeoutMs: 1000 }) + vi.advanceTimersByTime(1500) + + await expect(promise).rejects.toMatchObject({ code: 'TIMEOUT' }) + vi.useRealTimers() + }) + }) }) ``` ---- +**Nombre minimum de tests : 12.** -## 7. Tests — verifyStripeWebhook +#### `src/shared/lib/__tests__/auth-client.test.ts` -**Fichier :** `src/lib/__tests__/verifyStripeWebhook.test.ts` +Teste la gestion du token Supabase (avec mock de `supabase.auth`). ```typescript 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') - }), +vi.mock('../supabase', () => ({ + supabase: { + auth: { + getSession: vi.fn(), + signInWithPassword: vi.fn(), + signOut: vi.fn(), }, - })), + }, })) -describe('verifyStripeWebhook', () => { +import { getAccessToken, signIn, signOut } from '../auth-client' +import { supabase } from '../supabase' - 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') +describe('getAccessToken', () => { + it('retourne le token si session valide', async () => { + vi.mocked(supabase.auth.getSession).mockResolvedValueOnce({ + data: { session: { access_token: 'token-xyz' } }, + error: null, + } as any) + + const token = await getAccessToken() + expect(token).toBe('token-xyz') }) - 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('retourne null si pas de session', async () => { + vi.mocked(supabase.auth.getSession).mockResolvedValueOnce({ + data: { session: null }, + error: null, + } as any) - it('rejette un payload vide', () => { - const result = verifyStripeWebhook( - Buffer.from(''), - 'valid_signature', - 'whsec_test_secret' - ) - expect(result.valid).toBe(false) + const token = await getAccessToken() + expect(token).toBeNull() }) - - it('rejette une signature vide', () => { - const result = verifyStripeWebhook( - Buffer.from('payload'), - '', - 'whsec_test_secret' - ) - expect(result.valid).toBe(false) - }) - }) + +// + tests signIn, signOut ``` +**Nombre minimum de tests : 6.** + --- -## 8. Tests — calculateProrata +### Sprint 2 — Dashboard conditionnel -**Fichier :** `src/lib/__tests__/calculateProrata.test.ts` +#### `src/features/dashboard/hooks/__tests__/usePlan.test.tsx` + +Teste le hook `usePlan` avec TanStack Query (cache, refetch, invalidation). + +```typescript +import { describe, it, expect, vi } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { usePlan } from '../usePlan' + +vi.mock('@/entities/user/api', () => ({ + getPlanStatus: vi.fn(), +})) + +import { getPlanStatus } from '@/entities/user/api' + +describe('usePlan', () => { + it('retourne les données de /plans/status en cas de succès', async () => { + vi.mocked(getPlanStatus).mockResolvedValueOnce({ + plan: 'standard', + permissions: { /* ... */ }, + simulations_used: 5, + simulations_remaining: null, + plan_expires_at: null, + }) + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const wrapper = ({ children }: any) => ( + {children} + ) + + const { result } = renderHook(() => usePlan(), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.plan).toBe('standard') + }) + + // + tests : cache reuse, error handling, invalidation after upgrade +}) +``` + +**Nombre minimum de tests : 3.** + +--- + +### Sprint 3 — Simulations EE + affichage rapport + +#### `src/entities/report/__tests__/floutage.test.ts` + +Teste la logique de floutage du rapport selon le plan. ```typescript import { describe, it, expect } from 'vitest' -import { calculateProrata } from '../stripe' +import { applyReportFloutage } from '../lib' -describe('calculateProrata', () => { +const fullReport = { + score: 15, + nclc: 8, + feedback_court: 'Bon travail', + criteres: [ + { nom: 'Cohérence', score: 4, explication: '...' }, + { nom: 'Lexique', score: 3, explication: '...' }, + ], + erreurs: [{ type: 'grammaire', explication: '...' }], + production_modele: 'Voici un modèle complet...', + suggestions_idees: ['Idée 1', 'Idée 2', 'Idée 3'], + exercices: [{ titre: 'Exo 1', contenu: '...' }], +} - 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, +describe('applyReportFloutage', () => { + describe('Plan FREE', () => { + it('garde score et NCLC visibles', () => { + const floute = applyReportFloutage(fullReport, 'free') + expect(floute.score).toBe(15) + expect(floute.nclc).toBe(8) }) - 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, + it('garde feedback_court visible', () => { + const floute = applyReportFloutage(fullReport, 'free') + expect(floute.feedback_court).toBe('Bon travail') }) - 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, + it('floute les critères détaillés', () => { + const floute = applyReportFloutage(fullReport, 'free') + expect(floute.criteres).toBeNull() }) - 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, + it('floute les erreurs détaillées', () => { + const floute = applyReportFloutage(fullReport, 'free') + expect(floute.erreurs).toBeNull() + }) + + it('tronque la production modèle à 1 phrase', () => { + const floute = applyReportFloutage(fullReport, 'free') + expect(floute.production_modele?.split('.').length).toBeLessThanOrEqual(2) + }) + + it('tronque les suggestions à 1 idée', () => { + const floute = applyReportFloutage(fullReport, 'free') + expect(floute.suggestions_idees?.length).toBeLessThanOrEqual(1) + }) + + it('masque les exercices', () => { + const floute = applyReportFloutage(fullReport, 'free') + expect(floute.exercices).toBeNull() }) - 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() - }) + describe('Plan STANDARD et PREMIUM', () => { + it('retourne le rapport complet pour standard', () => { + const result = applyReportFloutage(fullReport, 'standard') + expect(result).toEqual(fullReport) + }) - it('refuse daysRemaining > totalDays', () => { - expect(() => calculateProrata({ - currentPlanPrice: 19.90, - newPlanPrice: 39.90, - totalDays: 28, - daysRemaining: 30, - })).toThrow() + it('retourne le rapport complet pour premium', () => { + const result = applyReportFloutage(fullReport, 'premium') + expect(result).toEqual(fullReport) + }) }) - }) ``` +**Nombre minimum de tests : 9.** + --- -## 9. Lancer les tests +### Sprint 2.5 — Spike T2 Live + +#### `src/features/t2-live/state/__tests__/t2-machine.test.ts` + +Teste la state machine (transitions d'états). + +```typescript +import { describe, it, expect } from 'vitest' +import { t2LiveReducer, initialT2State } from '../t2-machine' + +describe('t2LiveReducer', () => { + it('transition idle → connecting sur CONNECT', () => { + const next = t2LiveReducer(initialT2State, { type: 'CONNECT' }) + expect(next.status).toBe('connecting') + }) + + it('transition connecting → listening sur CONNECTED', () => { + const state = { ...initialT2State, status: 'connecting' as const } + const next = t2LiveReducer(state, { type: 'CONNECTED' }) + expect(next.status).toBe('listening') + }) + + it('transition * → error sur ERROR avec close code 4001', () => { + const state = { ...initialT2State, status: 'listening' as const } + const next = t2LiveReducer(state, { type: 'ERROR', code: 4001, message: 'Auth' }) + expect(next.status).toBe('error') + expect(next.errorCode).toBe(4001) + }) + + it('transition listening → speaking sur USER_SPEAKING', () => { + const state = { ...initialT2State, status: 'listening' as const } + const next = t2LiveReducer(state, { type: 'USER_SPEAKING' }) + expect(next.status).toBe('speaking') + }) + + it('transition speaking → listening sur USER_SILENT', () => { + const state = { ...initialT2State, status: 'speaking' as const } + const next = t2LiveReducer(state, { type: 'USER_SILENT' }) + expect(next.status).toBe('listening') + }) + + it('transition * → ended sur END', () => { + const state = { ...initialT2State, status: 'listening' as const } + const next = t2LiveReducer(state, { type: 'END' }) + expect(next.status).toBe('ended') + }) + + it('ignore les actions invalides selon l\'état', () => { + const state = { ...initialT2State, status: 'ended' as const } + const next = t2LiveReducer(state, { type: 'USER_SPEAKING' }) + expect(next.status).toBe('ended') // pas de changement + }) +}) +``` + +**Nombre minimum de tests : 7.** + +--- + +### Sprint 5 — Billing + +#### `src/features/billing/hooks/__tests__/useStripeCheckout.test.ts` + +Teste le flux de checkout Stripe (mock de l'API backend + redirection). + +**Nombre minimum de tests : 5.** + +--- + +## 5. Objectif de couverture + +| Sprint | Tests cumulés | Fonctions critiques couvertes | +|---|---|---| +| Pré-scaffold | 37 | access.ts + lib.ts | +| Sprint 1 | 55 (+18) | + api-client + auth-client | +| Sprint 2 | 58 (+3) | + usePlan | +| Sprint 2.5 | 65 (+7) | + t2-machine | +| Sprint 3 | 74 (+9) | + floutage rapport | +| Sprint 5 | 79 (+5) | + useStripeCheckout | + +**Cible MVP : ≥ 80 tests Vitest verts**, couvrant toutes les fonctions critiques identifiées au §1. + +Pour comparaison, le backend a 124 tests (son périmètre de fonctions critiques est plus large — Stripe, Supabase, DeepSeek, Gemini). 80 tests frontend sur un périmètre plus restreint est un objectif proportionnellement équivalent. + +--- + +## 6. Lancer les tests ```bash -# Dans expria-backend/ +# Dans D:\expria-frontend\ -# Lancer tous les tests une fois +# Tous les tests une fois npm run test -# Lancer en mode watch (relance automatiquement à chaque modification) +# Mode watch (relance automatiquement à chaque modification) npm run test:watch -# Générer un rapport de couverture +# Avec rapport de couverture npm run test:coverage ``` -**Résultat attendu (tous les tests au vert) :** +**Résultat attendu après Sprint 3 (exemple) :** ``` -✓ canUserSimulate (7 tests) -✓ getPlanPermissions (7 tests) -✓ checkFeatureAccess (13 tests) -✓ updateUserPlan (4 tests) -✓ verifyStripeWebhook (4 tests) -✓ calculateProrata (6 tests) +✓ src/entities/user/__tests__/access.test.ts (26 tests) +✓ src/entities/user/__tests__/lib.test.ts (11 tests) +✓ src/shared/lib/__tests__/api-client.test.ts (12 tests) +✓ src/shared/lib/__tests__/auth-client.test.ts (6 tests) +✓ src/features/dashboard/hooks/__tests__/usePlan.test.tsx (3 tests) +✓ src/features/t2-live/state/__tests__/t2-machine.test.ts (7 tests) +✓ src/entities/report/__tests__/floutage.test.ts (9 tests) -Test Files 6 passed (6) -Tests 41 passed (41) -Duration ~1.2s +Test Files 7 passed (7) +Tests 74 passed (74) +Duration ~2s ``` --- -## 10. Règle d'utilisation avec Claude Code +## 7. Règle d'utilisation avec Claude Code -**Avant chaque session Claude Code qui touche au backend :** +**Avant chaque session Claude Code :** ```bash npm run test # Tous les tests doivent être verts avant de commencer ``` -**Après chaque session Claude Code qui touche au backend :** +**Après chaque session Claude Code :** ```bash -npm run test -# Si un test passe au rouge → régression détectée → ne pas continuer +npm run typecheck && npm run test +# Typecheck OK + tous les tests verts = ok pour commit +# Typecheck KO ou test rouge = régression, 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." +> "Après chaque modification, lance `npm run typecheck` puis `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 +## 8. 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 +- Vérifie un droit d'accès ou une permission +- Modifie des données (état local, mutation API) +- Calcule un montant, un score, un pourcentage +- Parse une réponse d'API externe +- Orchestre un flux métier complexe (state machine, workflow) **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é) +- Les composants purement visuels (Button, Badge, Modal wrappers) — trop fragiles +- Les wrappers évidents (ex : `formatDate` qui wrap `Intl.DateTimeFormat`) +- Les appels Supabase SDK (c'est leur responsabilité) +- Les intégrations TanStack Query au niveau composant (couvert par `usePlan` au niveau hook) + +--- + +## 9. Historique + +| Version | Date | Changements | +|---|---|---| +| 1.0 | 2026-04-17 | Création initiale, plan de tests par sprint avec objectif 80 tests MVP | diff --git a/docs/TEST_ENVIRONMENT.md b/docs/TEST_ENVIRONMENT.md index 1f6d4f0..4d9e7c0 100644 --- a/docs/TEST_ENVIRONMENT.md +++ b/docs/TEST_ENVIRONMENT.md @@ -1,365 +1,260 @@ -# TEST_ENVIRONMENT.md — Expria / Coach TCF Canada +# TEST_ENVIRONMENT.md — Expria Frontend > **Document de référence — Version 1.0** -> Ce document décrit comment créer et réinitialiser l'environnement de test. -> Les comptes de test permettent de rejouer tous les parcours utilisateur -> sans créer de vrais abonnements ni passer par Stripe. +> Ce document décrit comment configurer et utiliser l'environnement de test frontend. +> Complément au `TEST_ENVIRONMENT.md` backend (qui décrit la base de données Supabase et les comptes de test). --- ## 1. Principe -L'environnement de test est une configuration connue et reproductible de la base de données. -Il consiste en 4 comptes Supabase préconfigurés, un par situation critique. +L'environnement de test frontend permet de faire tourner le code React localement, connecté soit au backend de production (`api.expria.app`), soit à une instance backend locale (pour tests isolés). **Règles absolues :** -- Ces comptes n'existent que dans l'environnement de développement / staging -- Jamais en production -- Les emails se terminent par `@expria.local` — bloqués à l'inscription dans le code -- Les mots de passe sont documentés ici — ne jamais les utiliser pour de vrais comptes +- Ne jamais utiliser de comptes réels (utilisateurs payants) pour les tests +- Utiliser exclusivement les 4 comptes `@gmail.com` documentés dans le backend +- Ne jamais committer les fichiers `.env` (présents dans `.gitignore`) --- -## 2. Les 4 comptes de test +## 2. Prérequis -| Compte | Plan | simulations_used | Cas testé | +| Outil | Version minimum | Vérification | +|---|---|---| +| Node.js | 20.x LTS | `node --version` | +| npm | 10.x | `npm --version` | +| Git | 2.x | `git --version` | +| Navigateur moderne | Chrome 120+, Firefox 120+, Safari 17+ | — | + +Recommandé : +- VS Code (ou Cursor) avec extensions : ESLint, Prettier, Tailwind CSS IntelliSense, Vitest +- DevTools React (extension navigateur) + +--- + +## 3. Configuration — mode dev connecté au backend de production + +C'est le mode par défaut. Le frontend local appelle `https://api.expria.app`. + +### Fichier `.env` à créer à la racine du projet + +``` +# URL du backend de production +VITE_API_URL=https://api.expria.app + +# Supabase (clé publique uniquement) +VITE_SUPABASE_URL=https://.supabase.co +VITE_SUPABASE_ANON_KEY= + +# Flags de features (à ajuster selon le sprint) +VITE_ENABLE_T2_LIVE=false + +# Monitoring (optionnel) +VITE_SENTRY_DSN= +``` + +**Les vraies valeurs** sont à récupérer dans : +- Le coffre-fort de Hermann (1Password / Bitwarden) +- Ou auprès de Hermann directement + +Après modification du `.env`, redémarrer le dev server (Vite charge les env au démarrage seulement). + +### Lancement + +```bash +npm install +npm run dev +``` + +Le frontend est disponible sur `http://localhost:5173` (port Vite par défaut). + +--- + +## 4. Configuration — mode dev connecté au backend local + +Utile quand on veut : +- Tester une modification backend avant déploiement +- Déboguer un flux complet frontend + backend +- Travailler sans connexion internet stable + +### Prérequis + +Le backend doit tourner localement dans un autre terminal : +```bash +cd D:\expria-backend +npm run dev +# Backend disponible sur http://localhost:3000 (vérifier le port exact) +``` + +### Fichier `.env` (ajustement) + +``` +VITE_API_URL=http://localhost:3000 +# Le reste reste identique +``` + +### Points d'attention + +- **CORS** : le backend doit autoriser `http://localhost:5173` dans sa configuration CORS. À vérifier dans `expria-backend/src/index.ts`. Si CORS bloque les requêtes, voir SEC-02 dans `SECURITY.md`. +- **WebSocket T2** : devient `ws://localhost:3000/t2/live` (non sécurisé en dev local). Acceptable pour le dev. +- **Supabase** : reste en production même en dev local (Supabase n'a pas de mode local simple). Les comptes de test `@gmail.com` fonctionnent identiquement. + +--- + +## 5. Comptes de test (rappel) + +Identiques aux comptes documentés dans `expria-backend/docs/TEST_ENVIRONMENT.md` : + +| Email | Plan | Simulations utilisées | Cas testé | |---|---|---|---| -| test.free@expria.local | free | 0 | Parcours Free normal | -| test.standard@expria.local | standard | 12 | Parcours Standard complet | -| test.premium@expria.local | premium | 28 | Parcours Premium complet | -| test.quota@expria.local | free | 5 | Blocage quota Free | +| test.free@gmail.com | free | 0 | Parcours Free normal | +| test.standard@gmail.com | standard | 12 | Parcours Standard complet | +| test.premium@gmail.com | premium | 28 | Parcours Premium complet | +| test.quota@gmail.com | free | 5 | Blocage quota Free | -**Mot de passe pour tous les comptes de test :** `Expria2025!test` +**Mot de passe commun :** `Expria2025!test` + +Les comptes sont créés par le script SQL de `expria-backend/docs/TEST_ENVIRONMENT.md` §3. Si un compte manque ou est corrompu, demander à Hermann de rejouer le script côté Supabase. --- -## 3. Script de création — à exécuter dans Supabase SQL Editor +## 6. Scénarios de test spécifiques -> ⚠️ À exécuter UNE SEULE FOIS dans l'environnement de développement. -> Ne jamais exécuter en production. +### 6.1 Simuler un utilisateur Free qui atteint le quota -```sql --- ============================================================= --- EXPRIA — Création des comptes de test --- Environnement : développement / staging uniquement --- ============================================================= +1. Se connecter avec `test.free@gmail.com` +2. Utiliser `test.quota@gmail.com` à la place (qui a déjà 5/5 utilisées) +3. Tenter de lancer une simulation → modal de blocage doit apparaître --- Étape 1 : Créer les utilisateurs dans auth.users --- (Supabase gère le hash du mot de passe automatiquement) +**Ne pas** essayer de forcer le compteur en soumettant 5 simulations réelles — ça consomme des appels à DeepSeek (payant) et pollue la base. -INSERT INTO auth.users ( - id, - email, - encrypted_password, - email_confirmed_at, - created_at, - updated_at, - raw_app_meta_data, - raw_user_meta_data, - is_super_admin, - role -) VALUES - ( - '00000000-0000-0000-0000-000000000001', - 'test.free@expria.local', - crypt('Expria2025!test', gen_salt('bf')), - NOW(), NOW(), NOW(), - '{"provider":"email","providers":["email"]}', - '{}', false, 'authenticated' - ), - ( - '00000000-0000-0000-0000-000000000002', - 'test.standard@expria.local', - crypt('Expria2025!test', gen_salt('bf')), - NOW(), NOW(), NOW(), - '{"provider":"email","providers":["email"]}', - '{}', false, 'authenticated' - ), - ( - '00000000-0000-0000-0000-000000000003', - 'test.premium@expria.local', - crypt('Expria2025!test', gen_salt('bf')), - NOW(), NOW(), NOW(), - '{"provider":"email","providers":["email"]}', - '{}', false, 'authenticated' - ), - ( - '00000000-0000-0000-0000-000000000004', - 'test.quota@expria.local', - crypt('Expria2025!test', gen_salt('bf')), - NOW(), NOW(), NOW(), - '{"provider":"email","providers":["email"]}', - '{}', false, 'authenticated' - ) -ON CONFLICT (id) DO NOTHING; +### 6.2 Simuler un changement de plan --- Étape 2 : Créer les profils dans la table profiles -INSERT INTO profiles ( - id, - email, - plan, - simulations_used, - stripe_customer_id, - stripe_subscription_id, - plan_expires_at, - created_at, - updated_at -) VALUES - ( - '00000000-0000-0000-0000-000000000001', - 'test.free@expria.local', - 'free', 0, NULL, NULL, NULL, - NOW(), NOW() - ), - ( - '00000000-0000-0000-0000-000000000002', - 'test.standard@expria.local', - 'standard', 12, 'cus_test_standard', 'sub_test_standard', - NOW() + INTERVAL '14 days', - NOW(), NOW() - ), - ( - '00000000-0000-0000-0000-000000000003', - 'test.premium@expria.local', - 'premium', 28, 'cus_test_premium', 'sub_test_premium', - NOW() + INTERVAL '21 days', - NOW(), NOW() - ), - ( - '00000000-0000-0000-0000-000000000004', - 'test.quota@expria.local', - 'free', 5, NULL, NULL, NULL, - NOW(), NOW() - ) -ON CONFLICT (id) DO UPDATE SET - plan = EXCLUDED.plan, - simulations_used = EXCLUDED.simulations_used, - stripe_customer_id = EXCLUDED.stripe_customer_id, - stripe_subscription_id = EXCLUDED.stripe_subscription_id, - plan_expires_at = EXCLUDED.plan_expires_at, - updated_at = NOW(); +Pour tester le flux d'upgrade sans vraiment payer : +1. Aller sur `/pricing` avec `test.free@gmail.com` +2. Cliquer "Choisir Standard" +3. Sur la page Stripe Checkout, utiliser la carte de test `4242 4242 4242 4242` +4. Date d'expiration : n'importe laquelle dans le futur +5. CVC : n'importe lequel (3 chiffres) --- Étape 3 : Insérer des productions de test pour les comptes Standard et Premium --- (nécessaire pour tester le dashboard, l'historique, et l'analyse des patterns) +Le webhook Stripe met à jour `plan = 'standard'` dans Supabase. Pour revenir à l'état initial après test, Hermann peut rejouer le script de reset de `expria-backend/docs/TEST_ENVIRONMENT.md` §5. -INSERT INTO productions ( - id, user_id, tache, mode, contenu, score, nclc, rapport, created_at -) VALUES - -- 5 productions pour test.standard (active l'indice de préparation) - (gen_random_uuid(), '00000000-0000-0000-0000-000000000002', - 'EE_T1', 'entrainement', - 'Texte de production test EE T1 — compte standard', - 14.5, 8, - '{"criteres":[{"nom":"Cohérence","score":3},{"nom":"Lexique","score":4}],"erreurs":["Connecteurs logiques insuffisants"],"exercices":["Exercice connecteurs"]}', - NOW() - INTERVAL '10 days'), - (gen_random_uuid(), '00000000-0000-0000-0000-000000000002', - 'EE_T2', 'entrainement', - 'Texte de production test EE T2 — compte standard', - 15.0, 8, - '{"criteres":[{"nom":"Cohérence","score":4},{"nom":"Lexique","score":3}],"erreurs":["Vocabulaire limité"],"exercices":["Exercice vocabulaire"]}', - NOW() - INTERVAL '8 days'), - (gen_random_uuid(), '00000000-0000-0000-0000-000000000002', - 'EE_T3', 'entrainement', - 'Texte de production test EE T3 — compte standard', - 13.5, 7, - '{"criteres":[{"nom":"Cohérence","score":3},{"nom":"Lexique","score":3}],"erreurs":["Structure argumentative faible"],"exercices":["Exercice argumentation"]}', - NOW() - INTERVAL '6 days'), - (gen_random_uuid(), '00000000-0000-0000-0000-000000000002', - 'EO_T1', 'entrainement', - NULL, 14.0, 8, - '{"criteres":[{"nom":"Phonologie","score":4},{"nom":"Lexique","score":3}],"erreurs":["Liaisons manquantes"],"exercices":["Exercice liaisons"]}', - NOW() - INTERVAL '4 days'), - (gen_random_uuid(), '00000000-0000-0000-0000-000000000002', - 'EO_T3', 'entrainement', - NULL, 15.5, 9, - '{"criteres":[{"nom":"Phonologie","score":4},{"nom":"Lexique","score":4}],"erreurs":[],"exercices":[]}', - NOW() - INTERVAL '2 days'), +### 6.3 Simuler un paiement refusé - -- 7 productions pour test.premium (active patterns + indice) - (gen_random_uuid(), '00000000-0000-0000-0000-000000000003', - 'EE_T1', 'entrainement', - 'Texte production EE T1 — premium', - 16.0, 9, - '{"criteres":[{"nom":"Cohérence","score":4},{"nom":"Lexique","score":4}],"erreurs":["Connecteurs logiques"],"exercices":["Connecteurs"]}', - NOW() - INTERVAL '20 days'), - (gen_random_uuid(), '00000000-0000-0000-0000-000000000003', - 'EE_T2', 'entrainement', - 'Texte production EE T2 — premium', - 15.5, 9, - '{"criteres":[{"nom":"Cohérence","score":4},{"nom":"Lexique","score":4}],"erreurs":["Connecteurs logiques"],"exercices":["Connecteurs"]}', - NOW() - INTERVAL '16 days'), - (gen_random_uuid(), '00000000-0000-0000-0000-000000000003', - 'EE_T3', 'examen', - 'Texte production EE T3 — premium examen', - 17.0, 10, - '{"criteres":[{"nom":"Cohérence","score":5},{"nom":"Lexique","score":4}],"erreurs":[],"exercices":[]}', - NOW() - INTERVAL '12 days'), - (gen_random_uuid(), '00000000-0000-0000-0000-000000000003', - 'EO_T1', 'entrainement', - NULL, 16.5, 9, - '{"criteres":[{"nom":"Phonologie","score":4},{"nom":"Lexique","score":4}],"erreurs":["Connecteurs logiques"],"exercices":["Connecteurs oraux"]}', - NOW() - INTERVAL '9 days'), - (gen_random_uuid(), '00000000-0000-0000-0000-000000000003', - 'EO_T2_LIVE', 'entrainement', - NULL, 15.0, 8, - '{"criteres":[{"nom":"Interaction","score":4},{"nom":"Phonologie","score":3}],"erreurs":["Hésitations fréquentes"],"exercices":["Fluidité orale"]}', - NOW() - INTERVAL '6 days'), - (gen_random_uuid(), '00000000-0000-0000-0000-000000000003', - 'EO_T3', 'entrainement', - NULL, 16.0, 9, - '{"criteres":[{"nom":"Phonologie","score":4},{"nom":"Lexique","score":4}],"erreurs":["Connecteurs logiques"],"exercices":["Connecteurs oraux"]}', - NOW() - INTERVAL '3 days'), - (gen_random_uuid(), '00000000-0000-0000-0000-000000000003', - 'EE_T1', 'examen', - 'Texte production EE T1 examen — premium', - 17.5, 10, - '{"criteres":[{"nom":"Cohérence","score":5},{"nom":"Lexique","score":4}],"erreurs":[],"exercices":[]}', - NOW() - INTERVAL '1 day') -ON CONFLICT DO NOTHING; +Même flux que 6.2 mais avec la carte `4000 0000 0000 0002`. Stripe retourne "paiement refusé". Le plan reste inchangé, le message d'erreur doit s'afficher clairement. + +### 6.4 Simuler une session expirée + +1. Se connecter normalement +2. Ouvrir DevTools → Application → Local Storage +3. Trouver la clé contenant le JWT Supabase (`sb--auth-token`) +4. Modifier la valeur pour casser le token (changer 1 caractère) +5. Rafraîchir la page +6. Le frontend doit détecter le JWT invalide → message "Session expirée" + redirect `/login` + +Ce test vérifie SEC-06 (gestion des sessions expirées). + +### 6.5 Simuler T2 Live sans avoir Premium + +1. Se connecter avec `test.free@gmail.com` ou `test.standard@gmail.com` +2. Accéder à l'URL `/t2-live` directement +3. Le frontend doit afficher un PaywallModal ou rediriger +4. Si malgré tout une requête WebSocket est envoyée, le backend doit fermer la connexion avec close code 4003 (`PLAN_INSUFFICIENT`) + +--- + +## 7. Matrice de compatibilité navigateurs + +Ces navigateurs sont ceux que Claude Code doit considérer comme supportés. Tester au minimum sur les deux premiers. + +| Navigateur | Version minimum | Priorité de test | Notes | +|---|---|---|---| +| Chrome (desktop) | 120 | 🔴 obligatoire | Majoritaire chez les utilisateurs | +| Chrome Mobile (Android) | 120 | 🔴 obligatoire | Audience Afrique = mobile-first | +| Safari Mobile (iOS) | 17 | 🟡 recommandé | Audience Canada = iPhone courant | +| Firefox (desktop) | 120 | 🟢 optionnel | Usage faible | +| Safari Desktop | 17 | 🟢 optionnel | Niche | + +**Attention particulière pour mobile Android (Afrique) :** +- Connexions 3G/4G instables → vérifier que retry dans `api-client.ts` gère bien +- RAM limitée → éviter les listes illimitées, paginer +- Clavier virtuel qui masque les inputs → vérifier scroll automatique + +Test mobile rapide via DevTools : Chrome → F12 → Toggle device toolbar → sélectionner "iPhone 12" ou "Galaxy S20". + +Test mobile réel : idéal avant chaque release production, via un vrai téléphone ou un service comme BrowserStack (payant). + +--- + +## 8. Simulation de conditions réseau dégradées + +Dans Chrome DevTools → Network tab → Throttling dropdown : +- **Fast 3G** : simule une connexion 3G typique (Afrique) +- **Slow 3G** : simule une connexion 2G (zones rurales) +- **Offline** : simule une perte réseau + +Tests à effectuer en mode "Fast 3G" : +- Login : doit répondre en < 10s +- Submit simulation : timeout à 30s doit fonctionner +- T2 Live : doit tenir la connexion WebSocket ou échouer gracieusement + +--- + +## 9. Procédure avant chaque session Claude Code + +``` +[ ] D:\expria-frontend existe et contient docs/ à jour +[ ] .env est configuré (demander à Hermann si nécessaire) +[ ] npm install a été exécuté récemment (dernière modification package.json) +[ ] npm run dev démarre sans erreur +[ ] Login test.free@gmail.com fonctionne +[ ] Un commit Git propre existe avant de commencer +[ ] Les documents de référence ont été lus (cf. ONBOARDING.md §7) ``` --- -## 4. Script de vérification — confirmer que les comptes existent +## 10. Procédure après chaque session Claude Code -```sql --- Vérifier les profils créés -SELECT - id, - email, - plan, - simulations_used, - plan_expires_at -FROM profiles -WHERE email LIKE '%@expria.local' -ORDER BY email; - --- Vérifier les productions créées -SELECT - p.email, - prod.tache, - prod.mode, - prod.score, - prod.created_at -FROM productions prod -JOIN profiles p ON p.id = prod.user_id -WHERE p.email LIKE '%@expria.local' -ORDER BY p.email, prod.created_at; ``` - -**Résultat attendu :** 4 profils, 12 productions au total. - ---- - -## 5. Script de réinitialisation — remettre l'environnement à zéro - -> À utiliser quand les comptes de test ont été modifiés par des sessions de test -> et qu'on veut repartir d'un état propre. - -```sql --- Supprimer les productions de test -DELETE FROM productions -WHERE user_id IN ( - SELECT id FROM profiles WHERE email LIKE '%@expria.local' -); - --- Remettre les profils à leur état initial -UPDATE profiles SET - plan = 'free', - simulations_used = 0, - stripe_customer_id = NULL, - stripe_subscription_id = NULL, - plan_expires_at = NULL, - updated_at = NOW() -WHERE email = 'test.free@expria.local'; - -UPDATE profiles SET - plan = 'standard', - simulations_used = 12, - stripe_customer_id = 'cus_test_standard', - stripe_subscription_id = 'sub_test_standard', - plan_expires_at = NOW() + INTERVAL '14 days', - updated_at = NOW() -WHERE email = 'test.standard@expria.local'; - -UPDATE profiles SET - plan = 'premium', - simulations_used = 28, - stripe_customer_id = 'cus_test_premium', - stripe_subscription_id = 'sub_test_premium', - plan_expires_at = NOW() + INTERVAL '21 days', - updated_at = NOW() -WHERE email = 'test.premium@expria.local'; - -UPDATE profiles SET - plan = 'free', - simulations_used = 5, - stripe_customer_id = NULL, - stripe_subscription_id = NULL, - plan_expires_at = NULL, - updated_at = NOW() -WHERE email = 'test.quota@expria.local'; - --- Réinsérer les productions (copier-coller le bloc INSERT de la section 3) +[ ] npm run typecheck : 0 erreur +[ ] npm run test : 0 échec +[ ] Les tests du Golden Dataset concernés ont été rejoués (cf. GOLDEN_DATASET.md) +[ ] Si modifications d'état (profils de test modifiés par des actions de test), + rejouer le script de reset backend +[ ] Un commit Git a été fait avec un message clair +[ ] CHANGELOG.md mis à jour ``` --- -## 6. Bloquer les inscriptions @expria.local en production +## 11. Debugging -Ajouter cette validation dans le backend (middleware d'inscription) : +### Erreur "CORS blocked" au démarrage +- Vérifier que le backend autorise `http://localhost:5173` +- Vérifier que `VITE_API_URL` dans `.env` pointe vers la bonne URL backend +- Voir SEC-02 dans `SECURITY.md` -```typescript -// src/middleware/auth.ts — backend Hono +### Erreur "Invalid JWT" après login +- Le JWT expire au bout de ~1h, se déconnecter et se reconnecter +- Vider le localStorage (DevTools → Application → Clear site data) +- Vérifier que `VITE_SUPABASE_URL` correspond bien au projet Supabase utilisé par le backend -const BLOCKED_EMAIL_DOMAINS = ['@expria.local'] +### Les variables d'environnement ne sont pas prises en compte +- Vite charge `.env` au démarrage uniquement — redémarrer `npm run dev` après modification +- Les variables doivent commencer par `VITE_` pour être exposées côté client +- Vérifier qu'il n'y a pas d'espace autour du `=` dans `.env` -export function validateEmail(email: string): boolean { - const isBlocked = BLOCKED_EMAIL_DOMAINS.some(domain => - email.toLowerCase().endsWith(domain) - ) - if (isBlocked) return false - return true -} -``` - -Et dans la route d'inscription : - -```typescript -// src/routes/auth.ts - -app.post('/auth/register', async (c) => { - const { email, password } = await c.req.json() - - if (!validateEmail(email)) { - return c.json({ error: 'Email non autorisé' }, 400) - } - // ... suite de l'inscription -}) -``` +### WebSocket T2 Live ne se connecte pas +- Vérifier `VITE_ENABLE_T2_LIVE=true` dans `.env` +- Vérifier que l'utilisateur est bien Premium +- Ouvrir DevTools → Network → filtrer par "WS" pour voir le handshake WebSocket +- Vérifier les close codes : 4001 = auth, 4003 = plan insuffisant --- -## 7. Procédure complète — première mise en place +## 12. Historique -``` -Étape 1 : Ouvrir Supabase Dashboard → SQL Editor -Étape 2 : Copier-coller le script de la section 3 -Étape 3 : Exécuter -Étape 4 : Copier-coller le script de vérification (section 4) -Étape 5 : Vérifier : 4 profils + 12 productions affichés -Étape 6 : Tester une connexion avec test.free@expria.local - dans l'application (mot de passe : Expria2025!test) -Étape 7 : Vérifier que le dashboard Free s'affiche correctement -``` - ---- - -## 8. Procédure — avant chaque session Golden Dataset - -``` -Étape 1 : Exécuter le script de réinitialisation (section 5) -Étape 2 : Exécuter le script de vérification (section 4) -Étape 3 : Confirmer que les 4 profils sont dans l'état attendu -Étape 4 : Lancer les tests du Golden Dataset -``` +| Version | Date | Changements | +|---|---|---| +| 1.0 | 2026-04-17 | Création initiale |