Files
turf_saas/tests/load/locustfile.py
2026-04-25 17:18:43 +02:00

306 lines
8.9 KiB
Python

"""
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