306 lines
8.9 KiB
Python
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
|