diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..2c26e49ad --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[run] +source = server +omit = + tests/* + +[report] +show_missing = True +skip_covered = False diff --git a/.gitignore b/.gitignore index 2cba99d87..83d7423cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,11 @@ bin include lib +lib64 +pyvenv.cfg .Python -tests/ .envrc -__pycache__ \ No newline at end of file +__pycache__ +.pytest_cache +.coverage +htmlcov/ \ No newline at end of file diff --git a/competitions.json b/competitions.json index 039fc61bd..841a28e25 100644 --- a/competitions.json +++ b/competitions.json @@ -9,6 +9,26 @@ "name": "Fall Classic", "date": "2020-10-22 13:30:00", "numberOfPlaces": "13" + }, + { + "name": "Early Summer Classic", + "date": "2026-06-01 10:00:00", + "numberOfPlaces": "10" + }, + { + "name": "Summer Lift Off", + "date": "2026-08-15 09:00:00", + "numberOfPlaces": "20" + }, + { + "name": "Winter Games", + "date": "2026-12-05 14:00:00", + "numberOfPlaces": "15" + }, + { + "name": "Regional Championship", + "date": "2027-03-20 10:30:00", + "numberOfPlaces": "30" } ] -} \ No newline at end of file +} diff --git a/locustfile.py b/locustfile.py new file mode 100644 index 000000000..33a89c325 --- /dev/null +++ b/locustfile.py @@ -0,0 +1,83 @@ +""" +Tests de performance Locust — GUDLFT Registration Portal. + +Critères Phase 2 (rules.md) : + - Liste des compétitions : temps de réponse < 5 secondes + - Mise à jour des points : temps de réponse < 2 secondes + - Nombre d'utilisateurs : 6 (défaut) + +Lancer (Flask actif sur le port 5000) : + locust -f locustfile.py --headless -u 6 -r 2 --run-time 30s \\ + --host http://127.0.0.1:5000 +""" +from locust import HttpUser, between, task +import random + +MAX_COMPETITION_LIST_SECONDS = 5 +MAX_POINTS_UPDATE_SECONDS = 2 + +CLUBS = [ + {"email": "john@simplylift.co", "name": "Simply Lift"}, + {"email": "admin@irontemple.com", "name": "Iron Temple"}, + {"email": "kate@shelifts.co.uk", "name": "She Lifts"}, +] + +UPCOMING_COMPETITION = "Summer Lift Off" + + +class SecretaryUser(HttpUser): + """Simule un secrétaire de club connecté à l'application.""" + + wait_time = between(1, 2) + + def on_start(self): + """Connexion : associe chaque utilisateur virtuel à un club.""" + club = random.choice(CLUBS) + self.club_email = club["email"] + self.club_name = club["name"] + self.client.post("/showSummary", data={"email": self.club_email}) + + @task(3) + def load_competition_list(self): + """Charge la liste des compétitions à venir (GET /showSummary).""" + with self.client.get( + "/showSummary", + name="/showSummary [liste compétitions]", + catch_response=True, + ) as response: + if response.status_code != 200: + response.failure(f"HTTP {response.status_code}") + return + if response.elapsed.total_seconds() > MAX_COMPETITION_LIST_SECONDS: + response.failure( + f"Temps {response.elapsed.total_seconds():.3f}s " + f"> {MAX_COMPETITION_LIST_SECONDS}s" + ) + + @task(2) + def update_points_via_booking(self): + """Réserve 1 place et met à jour les points (POST /purchasePlaces).""" + with self.client.post( + "/purchasePlaces", + data={ + "club": self.club_name, + "competition": UPCOMING_COMPETITION, + "places": "1", + }, + name="/purchasePlaces [mise à jour points]", + catch_response=True, + allow_redirects=False, + ) as response: + if response.status_code not in (302, 200): + response.failure(f"HTTP {response.status_code}") + return + if response.elapsed.total_seconds() > MAX_POINTS_UPDATE_SECONDS: + response.failure( + f"Temps {response.elapsed.total_seconds():.3f}s " + f"> {MAX_POINTS_UPDATE_SECONDS}s" + ) + + @task(1) + def view_public_points_board(self): + """Consulte le tableau public des points (Phase 2).""" + self.client.get("/clubsPoints", name="/clubsPoints [tableau public]") diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..4ecb1ad2c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/reports/locust_output.txt b/reports/locust_output.txt new file mode 100644 index 000000000..420d09cc2 --- /dev/null +++ b/reports/locust_output.txt @@ -0,0 +1,156 @@ +[2026-07-16 09:39:50,239] moon-RLEF-XX/INFO/locust.main: Starting Locust 2.44.4 +[2026-07-16 09:39:50,240] moon-RLEF-XX/INFO/locust.main: Run time limit set to 30 seconds +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 0 0(0.00%) | 0 0 0 0 | 0.00 0.00 + +[2026-07-16 09:39:50,240] moon-RLEF-XX/INFO/locust.runners: Ramping to 6 users at a rate of 2.00 per second +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 2 0(0.00%) | 4 2 7 2 | 0.00 0.00 +POST /purchasePlaces [mise à jour points] 1 0(0.00%) | 5 5 5 5 | 0.00 0.00 +POST /showSummary 4 0(0.00%) | 18 9 31 11 | 0.00 0.00 +GET /showSummary [liste compétitions] 3 0(0.00%) | 4 3 5 4 | 0.00 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 10 0(0.00%) | 10 2 31 6 | 0.00 0.00 + +[2026-07-16 09:39:52,242] moon-RLEF-XX/INFO/locust.runners: All users spawned: {"SecretaryUser": 6} (6 total users) +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 2 0(0.00%) | 4 2 7 2 | 1.00 0.00 +POST /purchasePlaces [mise à jour points] 6 0(0.00%) | 3 3 5 4 | 0.50 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 2.00 0.00 +GET /showSummary [liste compétitions] 8 0(0.00%) | 4 2 5 4 | 1.50 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 22 0(0.00%) | 7 2 31 4 | 5.00 0.00 + +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 3 0(0.00%) | 4 2 7 3 | 0.50 0.00 +POST /purchasePlaces [mise à jour points] 7 0(0.00%) | 3 3 5 4 | 1.50 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 1.50 0.00 +GET /showSummary [liste compétitions] 12 0(0.00%) | 3 2 5 4 | 1.50 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 28 0(0.00%) | 6 2 31 4 | 5.00 0.00 + +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 4 0(0.00%) | 3 2 7 3 | 0.40 0.00 +POST /purchasePlaces [mise à jour points] 12 0(0.00%) | 3 2 5 4 | 1.20 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 1.20 0.00 +GET /showSummary [liste compétitions] 15 0(0.00%) | 3 2 5 4 | 2.00 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 37 0(0.00%) | 5 2 31 4 | 4.80 0.00 + +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 4 0(0.00%) | 3 2 7 3 | 0.43 0.00 +POST /purchasePlaces [mise à jour points] 16 0(0.00%) | 3 2 5 4 | 1.29 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 0.86 0.00 +GET /showSummary [liste compétitions] 19 0(0.00%) | 3 2 5 4 | 2.14 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 45 0(0.00%) | 5 2 31 4 | 4.71 0.00 + +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 4 0(0.00%) | 3 2 7 3 | 0.44 0.00 +POST /purchasePlaces [mise à jour points] 20 0(0.00%) | 3 2 5 4 | 1.44 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 0.67 0.00 +GET /showSummary [liste compétitions] 22 0(0.00%) | 3 2 5 4 | 1.78 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 52 0(0.00%) | 5 2 31 4 | 4.33 0.00 + +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 6 0(0.00%) | 3 2 7 3 | 0.30 0.00 +POST /purchasePlaces [mise à jour points] 23 0(0.00%) | 3 2 5 4 | 1.80 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 0.40 0.00 +GET /showSummary [liste compétitions] 27 0(0.00%) | 3 2 5 4 | 1.80 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 62 0(0.00%) | 4 2 31 4 | 4.30 0.00 + +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 7 0(0.00%) | 3 2 7 3 | 0.20 0.00 +POST /purchasePlaces [mise à jour points] 26 0(0.00%) | 3 2 5 4 | 1.80 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 0.00 0.00 +GET /showSummary [liste compétitions] 30 0(0.00%) | 3 2 5 4 | 2.10 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 69 0(0.00%) | 4 2 31 4 | 4.10 0.00 + +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 9 0(0.00%) | 3 2 7 3 | 0.40 0.00 +POST /purchasePlaces [mise à jour points] 30 0(0.00%) | 3 2 5 4 | 1.90 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 0.00 0.00 +GET /showSummary [liste compétitions] 34 0(0.00%) | 3 2 5 4 | 1.90 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 79 0(0.00%) | 4 2 31 4 | 4.20 0.00 + +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 10 0(0.00%) | 3 2 7 3 | 0.50 0.00 +POST /purchasePlaces [mise à jour points] 33 0(0.00%) | 3 2 5 4 | 1.70 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 0.00 0.00 +GET /showSummary [liste compétitions] 38 0(0.00%) | 3 2 5 4 | 1.80 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 87 0(0.00%) | 4 2 31 4 | 4.00 0.00 + +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 13 0(0.00%) | 3 2 7 3 | 0.60 0.00 +POST /purchasePlaces [mise à jour points] 33 0(0.00%) | 3 2 5 4 | 1.60 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 0.00 0.00 +GET /showSummary [liste compétitions] 43 0(0.00%) | 3 2 5 4 | 1.90 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 95 0(0.00%) | 4 2 31 4 | 4.10 0.00 + +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 14 0(0.00%) | 3 2 7 3 | 0.80 0.00 +POST /purchasePlaces [mise à jour points] 37 0(0.00%) | 3 2 5 4 | 1.30 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 0.00 0.00 +GET /showSummary [liste compétitions] 45 0(0.00%) | 3 2 5 3 | 2.10 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 102 0(0.00%) | 4 2 31 4 | 4.20 0.00 + +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 14 0(0.00%) | 3 2 7 3 | 0.90 0.00 +POST /purchasePlaces [mise à jour points] 42 0(0.00%) | 3 2 5 4 | 1.40 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 0.00 0.00 +GET /showSummary [liste compétitions] 47 0(0.00%) | 3 2 5 3 | 2.00 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 109 0(0.00%) | 4 2 31 4 | 4.30 0.00 + +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 17 0(0.00%) | 2 2 7 3 | 0.70 0.00 +POST /purchasePlaces [mise à jour points] 44 0(0.00%) | 3 2 5 4 | 1.60 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 0.00 0.00 +GET /showSummary [liste compétitions] 50 0(0.00%) | 3 2 5 3 | 1.70 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 117 0(0.00%) | 4 2 31 4 | 4.00 0.00 + +[2026-07-16 09:40:20,012] moon-RLEF-XX/INFO/locust.main: --run-time limit reached, shutting down +[2026-07-16 09:40:20,036] moon-RLEF-XX/INFO/locust.main: Shutting down (exit code 0) +Type Name # reqs # fails | Avg Min Max Med | req/s failures/s +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- +GET /clubsPoints [tableau public] 18 0(0.00%) | 2 2 7 3 | 0.61 0.00 +POST /purchasePlaces [mise à jour points] 47 0(0.00%) | 3 2 5 4 | 1.59 0.00 +POST /showSummary 6 0(0.00%) | 16 9 31 11 | 0.20 0.00 +GET /showSummary [liste compétitions] 53 0(0.00%) | 3 2 5 4 | 1.79 0.00 +--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|----------- + Aggregated 124 0(0.00%) | 4 2 31 4 | 4.19 0.00 + +Response time percentiles (approximated) +Type Name 50% 66% 75% 80% 90% 95% 98% 99% 99.9% 99.99% 100% # reqs +--------|--------------------------------------------------------------------------------|--------|------|------|------|------|------|------|------|------|------|------|------ +GET /clubsPoints [tableau public] 3 3 3 3 3 7 7 7 7 7 7 18 +POST /purchasePlaces [mise à jour points] 4 4 4 4 5 5 6 6 6 6 6 47 +POST /showSummary 11 11 24 24 31 31 31 31 31 31 31 6 +GET /showSummary [liste compétitions] 4 4 4 4 5 5 5 6 6 6 6 53 +--------|--------------------------------------------------------------------------------|--------|------|------|------|------|------|------|------|------|------|------|------ + Aggregated 4 4 4 4 5 7 11 24 31 31 31 124 + diff --git a/reports/rapport_performance.txt b/reports/rapport_performance.txt new file mode 100644 index 000000000..2dcd03a91 --- /dev/null +++ b/reports/rapport_performance.txt @@ -0,0 +1,79 @@ +================================================================ +RAPPORT DE PERFORMANCE — GUDLFT Registration Portal +Projet 11 — OpenClassrooms +Date du test : 16/07/2026 +================================================================ + +1. OUTIL ET CONFIGURATION +---------------------------------------------------------------- +Outil : Locust 2.44.4 +Fichier : locustfile.py +Hôte cible : http://127.0.0.1:5000 +Utilisateurs : 6 (conforme au guide de développement) +Taux de montée : 2 utilisateurs/seconde +Durée du test : 30 secondes +Profil simulé : Secrétaire de club connecté (SecretaryUser) + +Commande exécutée : + locust -f locustfile.py --headless -u 6 -r 2 --run-time 30s \ + --host http://127.0.0.1:5000 + +2. CRITÈRES DE LA PHASE 2 (rules.md) +---------------------------------------------------------------- +| Critère | Seuil max | Résultat | +|--------------------------------------|-----------|----------| +| Chargement liste des compétitions | 5 s | VALIDÉ | +| Mise à jour du total de points | 2 s | VALIDÉ | +| Nombre d'utilisateurs simultanés | 6 | VALIDÉ | + +3. RÉSULTATS DÉTAILLÉS +---------------------------------------------------------------- + +3.1 Liste des compétitions — GET /showSummary + Requêtes totales : 53 + Échecs : 0 (0 %) + Temps moyen : 3 ms + Temps médian : 4 ms + Temps maximum : 6 ms + 95e percentile : 5 ms + Verdict : VALIDÉ (< 5 s) + +3.2 Mise à jour des points — POST /purchasePlaces + Requêtes totales : 47 + Échecs : 0 (0 %) + Temps moyen : 3 ms + Temps médian : 4 ms + Temps maximum : 6 ms + 95e percentile : 5 ms + Verdict : VALIDÉ (< 2 s) + +3.3 Tableau public des points — GET /clubsPoints (Phase 2) + Requêtes totales : 18 + Échecs : 0 (0 %) + Temps moyen : 2 ms + Temps médian : 3 ms + Temps maximum : 7 ms + Verdict : VALIDÉ + +3.4 Synthèse globale + Requêtes totales : 124 + Échecs : 0 (0 %) + Débit moyen : ~4,2 requêtes/seconde + +4. SCÉNARIOS TESTÉS +---------------------------------------------------------------- +- Connexion par email (POST /showSummary) au démarrage de chaque utilisateur +- Consultation répétée de la liste des compétitions à venir +- Réservation de 1 place (déduction de points en mémoire) +- Consultation du tableau public des points (sans authentification) + +5. CONCLUSION +---------------------------------------------------------------- +L'application respecte les seuils de performance définis dans les +spécifications fonctionnelles Phase 2. Aucune requête n'a dépassé les +limites de 5 secondes (liste compétitions) ni de 2 secondes (mise à jour +des points), avec 6 utilisateurs simultanés pendant 30 secondes. + +Sortie brute Locust : reports/locust_output.txt + +================================================================ diff --git a/server.py b/server.py index 4084baeac..0dcd8e260 100644 --- a/server.py +++ b/server.py @@ -1,5 +1,8 @@ import json -from flask import Flask,render_template,request,redirect,flash,url_for +from datetime import datetime +from functools import wraps + +from flask import Flask, render_template, request, redirect, flash, url_for, session def loadClubs(): @@ -14,46 +17,245 @@ def loadCompetitions(): return listOfCompetitions +COMPETITION_DATE_FORMAT = '%Y-%m-%d %H:%M:%S' + + +def is_competition_upcoming(competition): + """Retourne True si la compétition n'a pas encore eu lieu.""" + competition_date = datetime.strptime(competition['date'], COMPETITION_DATE_FORMAT) + return competition_date > datetime.now() + + +def get_upcoming_competitions(): + """Retourne uniquement les compétitions à venir (Phase 1).""" + return [competition for competition in competitions if is_competition_upcoming(competition)] + + +def get_club_by_email(email): + """Retourne le club correspondant à un email, ou None si inconnu.""" + matching_clubs = [club for club in clubs if club['email'] == email] + return matching_clubs[0] if matching_clubs else None + + +def get_logged_in_club(): + """Lit la session Flask et retourne le club connecté, ou None.""" + email = session.get('club_email') + if not email: + return None + return get_club_by_email(email) + + +def login_required(view_func): + """ + Décorateur de protection des routes : vérifie qu'un secrétaire est connecté. + Si la session est absente, redirige vers la page de connexion. + Sinon, injecte logged_in_club dans la vue pour éviter de relire la session. + """ + @wraps(view_func) + def wrapped_view(*args, **kwargs): + logged_in_club = get_logged_in_club() + if not logged_in_club: + flash('Please log in to access this page.') + return redirect(url_for('index')) + return view_func(*args, logged_in_club=logged_in_club, **kwargs) + return wrapped_view + + +MAX_PLACES_PER_CLUB = 12 + +# Places déjà réservées par couple (club, compétition) — suivi cumulé en mémoire +club_competition_bookings = {} + + +def get_places_already_booked(club, competition): + """Retourne le nombre de places déjà réservées par un club pour une compétition.""" + booking_key = (club['name'], competition['name']) + return club_competition_bookings.get(booking_key, 0) + + +def record_purchase(club, competition, places): + """Enregistre une réservation dans le compteur cumulé.""" + booking_key = (club['name'], competition['name']) + club_competition_bookings[booking_key] = get_places_already_booked(club, competition) + places + + +def validate_purchase(club, competition, places_required): + """ + Vérifie les règles métier avant une réservation. + Retourne un message d'erreur, ou None si la réservation est valide. + """ + if places_required <= 0: + return 'Number of places must be at least 1.' + + if not is_competition_upcoming(competition): + return 'This competition has already taken place.' + + if places_required > MAX_PLACES_PER_CLUB: + return f'Cannot book more than {MAX_PLACES_PER_CLUB} places per competition.' + + already_booked = get_places_already_booked(club, competition) + if already_booked + places_required > MAX_PLACES_PER_CLUB: + remaining_allowed = MAX_PLACES_PER_CLUB - already_booked + if remaining_allowed <= 0: + return ( + f'Cannot book more than {MAX_PLACES_PER_CLUB} places per competition ' + f'for your club.' + ) + return ( + f'Cannot book more than {MAX_PLACES_PER_CLUB} places per competition. ' + f'You already have {already_booked}, you can only book {remaining_allowed} more.' + ) + + available_places = int(competition['numberOfPlaces']) + if available_places <= 0: + return 'Competition is full.' + if places_required > available_places: + return f'Not enough places available. Only {available_places} remaining.' + + club_points = int(club['points']) + if places_required > club_points: + return 'Not enough points available.' + + return None + + +def require_own_club(club, logged_in_club): + """ + Vérifie qu'un secrétaire ne réserve que pour son propre club. + Retourne une redirection en cas d'échec, None sinon. + """ + if logged_in_club['email'] != club['email']: + flash('You can only book places for your own club.') + return redirect(url_for('showSummary')) + return None + + app = Flask(__name__) app.secret_key = 'something_special' competitions = loadCompetitions() clubs = loadClubs() + @app.route('/') def index(): return render_template('index.html') -@app.route('/showSummary',methods=['POST']) + +@app.route('/showSummary', methods=['GET', 'POST']) def showSummary(): - club = [club for club in clubs if club['email'] == request.form['email']][0] - return render_template('welcome.html',club=club,competitions=competitions) + if request.method == 'POST': + # Connexion : vérifie l'email puis enregistre le club en session + matching_clubs = [club for club in clubs if club['email'] == request.form['email']] + if not matching_clubs: + flash('Email not found. Please use a registered secretary email.') + return redirect(url_for('index')) + session['club_email'] = matching_clubs[0]['email'] + # POST-redirect-GET : évite de re-soumettre le formulaire au retour arrière + return redirect(url_for('showSummary')) + return _render_summary() + +@login_required +def _render_summary(logged_in_club): + """Affiche le tableau de bord du club connecté (route GET /showSummary).""" + return render_template( + 'welcome.html', + club=logged_in_club, + competitions=get_upcoming_competitions(), + ) + +# Réservation : accès réservé aux secrétaires connectés (@login_required) @app.route('/book//') -def book(competition,club): +@login_required +def book(competition, club, logged_in_club): foundClub = [c for c in clubs if c['name'] == club][0] foundCompetition = [c for c in competitions if c['name'] == competition][0] + + redirect_response = require_own_club(foundClub, logged_in_club) + if redirect_response: + return redirect_response + + if not is_competition_upcoming(foundCompetition): + flash('This competition has already taken place.') + return redirect(url_for('showSummary')) + if foundClub and foundCompetition: - return render_template('booking.html',club=foundClub,competition=foundCompetition) - else: - flash("Something went wrong-please try again") - return render_template('welcome.html', club=club, competitions=competitions) + already_booked = get_places_already_booked(foundClub, foundCompetition) + max_places = min( + MAX_PLACES_PER_CLUB - already_booked, + int(foundCompetition['numberOfPlaces']), + int(foundClub['points']), + ) + if max_places <= 0: + flash( + f'Cannot book more than {MAX_PLACES_PER_CLUB} places per competition ' + f'for your club.' + ) + return redirect(url_for('showSummary')) + return render_template( + 'booking.html', + club=foundClub, + competition=foundCompetition, + max_places=max_places, + already_booked=already_booked, + ) + + flash("Something went wrong-please try again") + return redirect(url_for('showSummary')) -@app.route('/purchasePlaces',methods=['POST']) +@app.route('/purchasePlaces', methods=['GET', 'POST']) def purchasePlaces(): + if request.method == 'GET': + # Accès direct à l'URL après une réservation (retour arrière / refresh) + if get_logged_in_club(): + return redirect(url_for('showSummary')) + flash('Please log in to access this page.') + return redirect(url_for('index')) + return _process_purchase() + + +@login_required +def _process_purchase(logged_in_club): + """Traite la réservation (route POST /purchasePlaces).""" competition = [c for c in competitions if c['name'] == request.form['competition']][0] club = [c for c in clubs if c['name'] == request.form['club']][0] - placesRequired = int(request.form['places']) - competition['numberOfPlaces'] = int(competition['numberOfPlaces'])-placesRequired - flash('Great-booking complete!') - return render_template('welcome.html', club=club, competitions=competitions) + + redirect_response = require_own_club(club, logged_in_club) + if redirect_response: + return redirect_response + + try: + placesRequired = int(request.form['places']) + except (ValueError, TypeError, KeyError): + flash('Invalid number of places.') + return redirect(url_for('showSummary')) + + error_message = validate_purchase(club, competition, placesRequired) + if error_message: + flash(error_message) + return redirect(url_for('showSummary')) + + # Déduction des places et des points (1 point = 1 place) + competition['numberOfPlaces'] = str(int(competition['numberOfPlaces']) - placesRequired) + club['points'] = str(int(club['points']) - placesRequired) + record_purchase(club, competition, placesRequired) + flash(f'Great-booking complete! {placesRequired} places purchased.') + # POST-redirect-GET : l'URL ne reste plus bloquée sur /purchasePlaces + return redirect(url_for('showSummary')) -# TODO: Add route for points display +# Phase 2 : tableau public des points, consultable sans être connecté +@app.route('/clubsPoints') +def clubsPoints(): + """Affiche le solde de points de tous les clubs (lecture seule, accès public).""" + return render_template('clubs_points.html', clubs=clubs) @app.route('/logout') def logout(): - return redirect(url_for('index')) \ No newline at end of file + # Invalide la session côté serveur pour bloquer l'accès aux routes protégées + session.clear() + return redirect(url_for('index')) diff --git a/templates/booking.html b/templates/booking.html index 06ae1156c..c42e20fb5 100644 --- a/templates/booking.html +++ b/templates/booking.html @@ -7,10 +7,16 @@

{{competition['name']}}

Places available: {{competition['numberOfPlaces']}} +

Your club points: {{ club['points'] }}

+ {% if already_booked > 0 %} +

Already booked for this competition: {{ already_booked }}

+ {% endif %} +

You can book up to {{ max_places }} place(s) for this competition.

- + +
diff --git a/templates/clubs_points.html b/templates/clubs_points.html new file mode 100644 index 000000000..b65e44b24 --- /dev/null +++ b/templates/clubs_points.html @@ -0,0 +1,167 @@ + + + + + + Clubs Points Board | GUDLFT + + + +
+
+

Clubs Points Board

+

Public overview of every club's available points.

+
+ +
+ {% for club in clubs %} +
+ {{ club['name'] }} +
+ {{ club['points'] }} + points +
+
+ {% endfor %} +
+ + +
+ + diff --git a/templates/index.html b/templates/index.html index 926526b7d..1e3f85f9f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,11 +6,21 @@

Welcome to the GUDLFT Registration Portal!

+ {% with messages = get_flashed_messages() %} + {% if messages %} + + {% endif %} + {% endwith %} Please enter your secretary email to continue:
+

View clubs points board

\ No newline at end of file diff --git a/templates/welcome.html b/templates/welcome.html index ff6b261a2..15f891159 100644 --- a/templates/welcome.html +++ b/templates/welcome.html @@ -5,7 +5,9 @@ Summary | GUDLFT Registration -

Welcome, {{club['email']}}

Logout +

Welcome, {{club['email']}}

+ Clubs points board | + Logout {% with messages = get_flashed_messages()%} {% if messages %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..d53b34ce0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,61 @@ +""" +Fixtures partagées pour toute la suite de tests. + +L'application charge clubs.json et competitions.json au moment de l'import et +modifie ces listes en mémoire (déduction de points/places). Pour garantir des +tests déterministes et isolés, on remplace ces données globales par des jeux +de test contrôlés avant chaque test, et on réinitialise le compteur de +réservations cumulées. +""" +import pytest + +import server as server_module +from server import app + + +@pytest.fixture +def clubs_data(): + """Deux clubs de test : l'un avec beaucoup de points, l'autre avec peu.""" + return [ + {"name": "Test Club A", "email": "a@test.com", "points": "15"}, + {"name": "Test Club B", "email": "b@test.com", "points": "4"}, + ] + + +@pytest.fixture +def competitions_data(): + """ + Jeux de compétitions contrôlés : + - une clairement à venir (2050) + - une clairement passée (2000) + - une à venir mais complète (0 place) + """ + return [ + {"name": "Future Comp", "date": "2050-01-01 10:00:00", "numberOfPlaces": "25"}, + {"name": "Past Comp", "date": "2000-01-01 10:00:00", "numberOfPlaces": "25"}, + {"name": "Full Comp", "date": "2050-02-01 10:00:00", "numberOfPlaces": "0"}, + ] + + +@pytest.fixture(autouse=True) +def setup_data(clubs_data, competitions_data, monkeypatch): + """Remplace les données globales du serveur et réinitialise les compteurs.""" + monkeypatch.setattr(server_module, "clubs", clubs_data) + monkeypatch.setattr(server_module, "competitions", competitions_data) + server_module.club_competition_bookings.clear() + yield + server_module.club_competition_bookings.clear() + + +@pytest.fixture +def client(): + """Client de test Flask (mode TESTING pour des messages d'erreur propres).""" + app.config["TESTING"] = True + return app.test_client() + + +@pytest.fixture +def logged_in_client(client): + """Client déjà authentifié en tant que Test Club A.""" + client.post("/showSummary", data={"email": "a@test.com"}) + return client diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/functional/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/functional/test_user_journey.py b/tests/functional/test_user_journey.py new file mode 100644 index 000000000..48f61a529 --- /dev/null +++ b/tests/functional/test_user_journey.py @@ -0,0 +1,44 @@ +""" +Test fonctionnel : parcours complet d'un secrétaire de club. + +Reproduit le scénario décrit dans les spécifications : +consultation publique du tableau des points, connexion, réservation avec +déduction des points, puis déconnexion et blocage de l'accès. +""" + + +class TestFullSecretaryJourney: + def test_complete_booking_journey(self, client): + # 1. Le tableau public des points est consultable sans connexion + board = client.get("/clubsPoints") + assert board.status_code == 200 + assert b"Test Club A" in board.data + + # 2. Connexion avec un email de secrétaire valide + client.post("/showSummary", data={"email": "a@test.com"}) + summary = client.get("/showSummary") + assert summary.status_code == 200 + assert b"Points available: 15" in summary.data + + # 3. Ouverture de la page de réservation d'une compétition à venir + booking_page = client.get("/book/Future Comp/Test Club A") + assert booking_page.status_code == 200 + + # 4. Réservation de 4 places : confirmation + points déduits (15 -> 11) + purchase = client.post( + "/purchasePlaces", + data={"club": "Test Club A", "competition": "Future Comp", "places": "4"}, + follow_redirects=True, + ) + html = purchase.get_data(as_text=True) + assert "4 places purchased" in html + assert "Points available: 11" in html + + # 5. Déconnexion + client.get("/logout") + with client.session_transaction() as sess: + assert "club_email" not in sess + + # 6. Après déconnexion, la réservation n'est plus accessible + blocked = client.get("/book/Future Comp/Test Club A") + assert blocked.status_code == 302 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py new file mode 100644 index 000000000..8c173dc53 --- /dev/null +++ b/tests/integration/test_auth.py @@ -0,0 +1,53 @@ +"""Tests d'intégration de l'authentification, de la session et de la déconnexion.""" +from flask import session + + +class TestIndex: + def test_index_is_accessible(self, client): + response = client.get("/") + assert response.status_code == 200 + assert b"GUDLFT" in response.data + + +class TestLogin: + def test_login_with_valid_email_creates_session(self, client): + response = client.post("/showSummary", data={"email": "a@test.com"}) + assert response.status_code == 302 # POST-redirect-GET + with client.session_transaction() as sess: + assert sess["club_email"] == "a@test.com" + + def test_login_with_unknown_email_shows_error(self, client): + response = client.post( + "/showSummary", data={"email": "ghost@nowhere.com"}, follow_redirects=True + ) + assert response.status_code == 200 + assert b"Email not found" in response.data + + def test_login_with_unknown_email_does_not_crash(self, client): + # Régression : un email inconnu levait une IndexError (HTTP 500) + response = client.post("/showSummary", data={"email": "ghost@nowhere.com"}) + assert response.status_code == 302 + + +class TestSummaryAccess: + def test_summary_requires_login(self, client): + response = client.get("/showSummary") + assert response.status_code == 302 + + def test_summary_shown_when_logged_in(self, logged_in_client): + response = logged_in_client.get("/showSummary") + assert response.status_code == 200 + assert b"Points available" in response.data + + +class TestLogout: + def test_logout_clears_session(self, logged_in_client): + logout_response = logged_in_client.get("/logout") + assert logout_response.status_code == 302 + with logged_in_client.session_transaction() as sess: + assert "club_email" not in sess + + def test_routes_blocked_after_logout(self, logged_in_client): + logged_in_client.get("/logout") + response = logged_in_client.get("/showSummary") + assert response.status_code == 302 diff --git a/tests/integration/test_booking.py b/tests/integration/test_booking.py new file mode 100644 index 000000000..06220b8e7 --- /dev/null +++ b/tests/integration/test_booking.py @@ -0,0 +1,91 @@ +"""Tests d'intégration des routes de réservation (/book et /purchasePlaces).""" + + +class TestBookRoute: + def test_book_requires_login(self, client): + response = client.get("/book/Future Comp/Test Club A") + assert response.status_code == 302 + + def test_book_page_shown_for_own_club(self, logged_in_client): + response = logged_in_client.get("/book/Future Comp/Test Club A") + assert response.status_code == 200 + assert b"Future Comp" in response.data + + def test_book_other_club_is_rejected(self, logged_in_client): + response = logged_in_client.get( + "/book/Future Comp/Test Club B", follow_redirects=True + ) + assert b"only book places for your own club" in response.data + + def test_book_past_competition_is_rejected(self, logged_in_client): + response = logged_in_client.get( + "/book/Past Comp/Test Club A", follow_redirects=True + ) + assert b"already taken place" in response.data + + def test_book_rejected_when_club_limit_reached(self, logged_in_client): + # Réserve déjà 12 places, puis rouvre la page : plus rien de réservable + logged_in_client.post( + "/purchasePlaces", + data={"club": "Test Club A", "competition": "Future Comp", "places": "12"}, + ) + response = logged_in_client.get( + "/book/Future Comp/Test Club A", follow_redirects=True + ) + assert b"Cannot book more than 12" in response.data + + +class TestPurchaseGet: + def test_get_purchase_without_login_redirects_to_index(self, client): + response = client.get("/purchasePlaces") + assert response.status_code == 302 + assert response.headers["Location"].endswith("/") + + def test_get_purchase_with_login_redirects_to_summary(self, logged_in_client): + response = logged_in_client.get("/purchasePlaces") + assert response.status_code == 302 + + +class TestPurchasePost: + def _purchase(self, client, competition, places, club="Test Club A"): + return client.post( + "/purchasePlaces", + data={"club": club, "competition": competition, "places": str(places)}, + follow_redirects=True, + ) + + def test_valid_purchase_confirms_and_deducts(self, logged_in_client): + response = self._purchase(logged_in_client, "Future Comp", 3) + html = response.get_data(as_text=True) + assert "3 places purchased" in html + assert "Points available: 12" in html # 15 - 3 + + def test_purchase_with_invalid_number_is_rejected(self, logged_in_client): + response = self._purchase(logged_in_client, "Future Comp", "abc") + assert b"Invalid number of places" in response.data + + def test_purchase_over_points_is_rejected(self, client): + client.post("/showSummary", data={"email": "b@test.com"}) # 4 points + response = self._purchase(client, "Future Comp", 6, club="Test Club B") + assert b"Not enough points available" in response.data + + def test_purchase_over_twelve_is_rejected(self, logged_in_client): + response = self._purchase(logged_in_client, "Future Comp", 13) + assert b"Cannot book more than 12" in response.data + + def test_purchase_for_other_club_is_rejected(self, logged_in_client): + # Connecté en tant que Club A, tentative de réserver pour Club B + response = self._purchase(logged_in_client, "Future Comp", 2, club="Test Club B") + assert b"only book places for your own club" in response.data + + +class TestClubsPointsBoard: + def test_board_is_public(self, client): + response = client.get("/clubsPoints") + assert response.status_code == 200 + + def test_board_lists_all_clubs(self, client): + response = client.get("/clubsPoints") + html = response.get_data(as_text=True) + assert "Test Club A" in html + assert "Test Club B" in html diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/unit/test_dates.py b/tests/unit/test_dates.py new file mode 100644 index 000000000..498be1a68 --- /dev/null +++ b/tests/unit/test_dates.py @@ -0,0 +1,24 @@ +"""Tests unitaires du filtrage des compétitions par date (Phase 1).""" +import server + + +class TestIsCompetitionUpcoming: + def test_future_competition_is_upcoming(self): + competition = {"name": "X", "date": "2050-01-01 10:00:00", "numberOfPlaces": "10"} + assert server.is_competition_upcoming(competition) is True + + def test_past_competition_is_not_upcoming(self): + competition = {"name": "X", "date": "2000-01-01 10:00:00", "numberOfPlaces": "10"} + assert server.is_competition_upcoming(competition) is False + + +class TestGetUpcomingCompetitions: + def test_only_future_competitions_are_returned(self, competitions_data): + upcoming = server.get_upcoming_competitions() + names = [competition["name"] for competition in upcoming] + assert "Future Comp" in names + assert "Full Comp" in names + assert "Past Comp" not in names + + def test_returns_a_list(self): + assert isinstance(server.get_upcoming_competitions(), list) diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py new file mode 100644 index 000000000..b4ebec89b --- /dev/null +++ b/tests/unit/test_helpers.py @@ -0,0 +1,33 @@ +"""Tests unitaires des fonctions utilitaires (clubs, compteur de réservations).""" +import server + + +class TestGetClubByEmail: + def test_returns_club_for_known_email(self): + club = server.get_club_by_email("a@test.com") + assert club is not None + assert club["name"] == "Test Club A" + + def test_returns_none_for_unknown_email(self): + assert server.get_club_by_email("unknown@nowhere.com") is None + + +class TestBookingCounter: + def test_no_booking_returns_zero(self, clubs_data, competitions_data): + club = clubs_data[0] + competition = competitions_data[0] + assert server.get_places_already_booked(club, competition) == 0 + + def test_record_purchase_accumulates(self, clubs_data, competitions_data): + club = clubs_data[0] + competition = competitions_data[0] + server.record_purchase(club, competition, 3) + server.record_purchase(club, competition, 2) + assert server.get_places_already_booked(club, competition) == 5 + + def test_counter_is_isolated_per_club_and_competition(self, clubs_data, competitions_data): + club_a, club_b = clubs_data + competition = competitions_data[0] + server.record_purchase(club_a, competition, 4) + assert server.get_places_already_booked(club_a, competition) == 4 + assert server.get_places_already_booked(club_b, competition) == 0 diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py new file mode 100644 index 000000000..fcc248b7d --- /dev/null +++ b/tests/unit/test_validation.py @@ -0,0 +1,59 @@ +"""Tests unitaires de la validation métier des réservations (validate_purchase).""" +import server + + +class TestValidatePurchaseHappyPath: + def test_valid_purchase_returns_none(self, clubs_data, competitions_data): + club = clubs_data[0] # 15 points + competition = competitions_data[0] # Future Comp, 25 places + assert server.validate_purchase(club, competition, 5) is None + + +class TestValidatePurchaseSadPath: + def test_zero_places_is_rejected(self, clubs_data, competitions_data): + error = server.validate_purchase(clubs_data[0], competitions_data[0], 0) + assert error == "Number of places must be at least 1." + + def test_negative_places_is_rejected(self, clubs_data, competitions_data): + error = server.validate_purchase(clubs_data[0], competitions_data[0], -3) + assert error == "Number of places must be at least 1." + + def test_past_competition_is_rejected(self, clubs_data, competitions_data): + past_competition = competitions_data[1] # Past Comp + error = server.validate_purchase(clubs_data[0], past_competition, 2) + assert error == "This competition has already taken place." + + def test_more_than_twelve_places_is_rejected(self, clubs_data, competitions_data): + error = server.validate_purchase(clubs_data[0], competitions_data[0], 13) + assert "Cannot book more than 12" in error + + def test_full_competition_is_rejected(self, clubs_data, competitions_data): + full_competition = competitions_data[2] # Full Comp, 0 place + error = server.validate_purchase(clubs_data[0], full_competition, 1) + assert error == "Competition is full." + + def test_not_enough_places_is_rejected(self, clubs_data, competitions_data): + competition = dict(competitions_data[0], numberOfPlaces="3") + error = server.validate_purchase(clubs_data[0], competition, 5) + assert "Not enough places available" in error + + def test_not_enough_points_is_rejected(self, clubs_data, competitions_data): + club = clubs_data[1] # Test Club B, 4 points + error = server.validate_purchase(club, competitions_data[0], 6) + assert error == "Not enough points available." + + +class TestValidatePurchaseCumulativeLimit: + def test_cumulative_over_twelve_is_rejected(self, clubs_data, competitions_data): + club = clubs_data[0] + competition = competitions_data[0] + server.record_purchase(club, competition, 8) + error = server.validate_purchase(club, competition, 5) + assert "you can only book 4 more" in error + + def test_no_more_places_allowed_when_limit_reached(self, clubs_data, competitions_data): + club = clubs_data[0] + competition = competitions_data[0] + server.record_purchase(club, competition, 12) + error = server.validate_purchase(club, competition, 1) + assert "Cannot book more than 12 places per competition for your club" in error