""" Tests de charge Locust — SaaS Turf Prédictions IA Sprint 8 — QA, Beta Fermee, Go/No-Go Ticket: HRT-34 Scénarios : - Test normal : 100 users simultanés, 10 min - Test spike : 500 users en 2 min Cibles : - p95 latence < 500ms - error rate < 0.1% Usage : # Test normal (100 users, 10 min) locust -f tests/load/locustfile.py --host http://localhost:8792 \ --users 100 --spawn-rate 10 --run-time 10m \ --headless --csv tests/reports/load_normal # Test spike (500 users, 2 min) locust -f tests/load/locustfile.py --host http://localhost:8792 \ --users 500 --spawn-rate 250 --run-time 2m \ --headless --csv tests/reports/load_spike # Interface web locust -f tests/load/locustfile.py --host http://localhost:8792 """ import random import json from locust import HttpUser, TaskSet, task, between, events from locust.env import Environment import logging logger = logging.getLogger(__name__) # === Credentials de test === TEST_USERS = [ {"email": f"loadtest+{i}@h3r7.tech", "password": "TestLoad_2026!"} for i in range(50) ] # === Helper de login === class AuthMixin: """Mixin pour authentification JWT.""" token: str = None def login(self): """Login et stocke le token JWT.""" user = random.choice(TEST_USERS) with self.client.post( "/api/auth/login", json={"email": user["email"], "password": user["password"]}, catch_response=True, name="/api/auth/login", ) as resp: if resp.status_code == 200: data = resp.json() self.token = data.get("access_token") or data.get("token") resp.success() else: self.token = None resp.failure(f"Login failed: {resp.status_code}") def auth_headers(self): if self.token: return {"Authorization": f"Bearer {self.token}"} return {} # === Comportement utilisateur Free === class FreePlanTaskSet(TaskSet): """Simule un utilisateur Free qui consulte les prédictions top 3.""" def on_start(self): self.user.login() @task(5) def voir_dashboard(self): with self.client.get( "/dashboard", catch_response=True, name="/dashboard", ) as resp: if resp.status_code in (200, 304): resp.success() else: resp.failure(f"Dashboard: {resp.status_code}") @task(8) def voir_predictions_aujourd_hui(self): with self.client.get( "/api", headers=self.user.auth_headers(), catch_response=True, name="/api (today predictions)", ) as resp: if resp.status_code == 200: data = resp.json() if isinstance(data, (list, dict)): resp.success() else: resp.failure("Réponse inattendue") elif resp.status_code in (401, 403): resp.success() # Normal si token expiré else: resp.failure(f"Predictions: {resp.status_code}") @task(3) def voir_courses(self): with self.client.get( "/api/races", headers=self.user.auth_headers(), catch_response=True, name="/api/races", ) as resp: if resp.status_code in (200, 401, 403): resp.success() else: resp.failure(f"Races: {resp.status_code}") @task(2) def voir_scoring(self): with self.client.get( "/api/scoring", headers=self.user.auth_headers(), catch_response=True, name="/api/scoring", ) as resp: if resp.status_code in (200, 401, 403): resp.success() else: resp.failure(f"Scoring: {resp.status_code}") @task(1) def voir_portail(self): with self.client.get( "/", catch_response=True, name="/ (portail)", ) as resp: if resp.status_code in (200, 304): resp.success() else: resp.failure(f"Portail: {resp.status_code}") # === Comportement utilisateur Premium === class PremiumPlanTaskSet(TaskSet): """Simule un utilisateur Premium avec accès complet.""" def on_start(self): self.user.login() @task(4) def voir_dashboard_complet(self): with self.client.get( "/dashboard", headers=self.user.auth_headers(), catch_response=True, name="/dashboard (premium)", ) as resp: if resp.status_code in (200, 304): resp.success() else: resp.failure(f"Dashboard premium: {resp.status_code}") @task(6) def voir_toutes_courses(self): with self.client.get( "/api/races?all=true", headers=self.user.auth_headers(), catch_response=True, name="/api/races?all=true", ) as resp: if resp.status_code in (200, 403): resp.success() else: resp.failure(f"All races: {resp.status_code}") @task(3) def export_csv(self): with self.client.get( "/api/export/csv", headers=self.user.auth_headers(), catch_response=True, name="/api/export/csv", ) as resp: if resp.status_code in (200, 403): resp.success() else: resp.failure(f"CSV export: {resp.status_code}") @task(2) def voir_historique(self): with self.client.get( "/api/odds_history", headers=self.user.auth_headers(), catch_response=True, name="/api/odds_history", ) as resp: if resp.status_code in (200, 403): resp.success() else: resp.failure(f"Odds history: {resp.status_code}") @task(1) def appel_api_externe(self): """Simule un accès API via clé (plan pro).""" with self.client.get( "/api/races", headers={"X-API-Key": "test-api-key-qa"}, catch_response=True, name="/api/races (api-key)", ) as resp: if resp.status_code in (200, 401, 403): resp.success() else: resp.failure(f"API key access: {resp.status_code}") # === Utilisateurs Locust === class FreeUser(AuthMixin, HttpUser): """Utilisateur plan free (70% du trafic).""" tasks = [FreePlanTaskSet] wait_time = between(1, 4) weight = 70 class PremiumUser(AuthMixin, HttpUser): """Utilisateur plan premium (30% du trafic).""" tasks = [PremiumPlanTaskSet] wait_time = between(0.5, 2) weight = 30 # === Events et rapports === @events.test_stop.add_listener def on_test_stop(environment: Environment, **kwargs): """Analyse les résultats et écrit un rapport.""" stats = environment.stats total = stats.total report_lines = [ "=" * 60, "RAPPORT TEST DE CHARGE — SaaS Turf IA", "=" * 60, f"Total requests : {total.num_requests}", f"Failures : {total.num_failures}", f"Error rate : {total.fail_ratio * 100:.2f}%", f"Avg response time : {total.avg_response_time:.1f}ms", f"95th percentile : {total.get_response_time_percentile(0.95):.1f}ms", f"99th percentile : {total.get_response_time_percentile(0.99):.1f}ms", f"Max response time : {total.max_response_time:.1f}ms", f"RPS (avg) : {total.total_rps:.2f}", "", "CRITERES DE SUCCES :", ] p95 = total.get_response_time_percentile(0.95) error_rate = total.fail_ratio * 100 p95_ok = p95 < 500 error_ok = error_rate < 0.1 report_lines.append( f" p95 < 500ms : {'✅ PASS' if p95_ok else '❌ FAIL'} ({p95:.1f}ms)" ) report_lines.append( f" error rate < 0.1% : {'✅ PASS' if error_ok else '❌ FAIL'} ({error_rate:.2f}%)" ) report_lines.append("") report_lines.append( f"VERDICT GLOBAL : {'✅ GO' if (p95_ok and error_ok) else '❌ NO-GO'}" ) report_lines.append("=" * 60) report = "\n".join(report_lines) print(report) # Écrire dans un fichier import os from pathlib import Path from datetime import datetime Path("tests/reports").mkdir(parents=True, exist_ok=True) report_file = ( f"tests/reports/load_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" ) with open(report_file, "w") as f: f.write(report) print(f"\nRapport sauvegardé : {report_file}") # Exit code non-0 si les critères ne sont pas respectés if not (p95_ok and error_ok): logger.error("CRITÈRES DE PERFORMANCE NON RESPECTÉS — NO-GO") environment.process_exit_code = 1