From dabb81f1db4df71da41fa9d89cb74b3748f6d731 Mon Sep 17 00:00:00 2001 From: Moon3233 Date: Fri, 3 Jul 2026 16:37:53 +0200 Subject: [PATCH 1/7] =?UTF-8?q?fix=20:=20=C3=A9viter=20le=20plantage=20lor?= =?UTF-8?q?s=20d'une=20connexion=20avec=20un=20email=20inconnu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lorsqu'un secrétaire saisissait un email absent de clubs.json, l'application levait une IndexError et renvoyait une erreur HTTP 500. La route /showSummary vérifie désormais si un club correspond à l'email soumis. En cas d'échec, un message d'erreur est affiché via flash et l'utilisateur est redirigé vers la page de connexion. Affichage des messages flash ajouté sur index.html pour rendre l'erreur visible à l'utilisateur. --- lib64 | 1 + pyvenv.cfg | 5 +++++ server.py | 6 +++++- templates/index.html | 9 +++++++++ 4 files changed, 20 insertions(+), 1 deletion(-) create mode 120000 lib64 create mode 100644 pyvenv.cfg diff --git a/lib64 b/lib64 new file mode 120000 index 000000000..7951405f8 --- /dev/null +++ b/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/pyvenv.cfg b/pyvenv.cfg new file mode 100644 index 000000000..82b31b3e5 --- /dev/null +++ b/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /usr/bin +include-system-site-packages = false +version = 3.12.3 +executable = /usr/bin/python3.12 +command = /usr/bin/python3 -m venv /home/moon/Documents/Openclassrooms/Projet 11/Python_Testing diff --git a/server.py b/server.py index 4084baeac..b3bf75579 100644 --- a/server.py +++ b/server.py @@ -26,7 +26,11 @@ def index(): @app.route('/showSummary',methods=['POST']) def showSummary(): - club = [club for club in clubs if club['email'] == request.form['email']][0] + 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')) + club = matching_clubs[0] return render_template('welcome.html',club=club,competitions=competitions) diff --git a/templates/index.html b/templates/index.html index 926526b7d..49fab871b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,6 +6,15 @@

Welcome to the GUDLFT Registration Portal!

+ {% with messages = get_flashed_messages() %} + {% if messages %} + + {% endif %} + {% endwith %} Please enter your secretary email to continue:
From c4472bf789e9bcc14da4010c577de81baba7105a Mon Sep 17 00:00:00 2001 From: Moon3233 Date: Fri, 3 Jul 2026 18:00:16 +0200 Subject: [PATCH 2/7] =?UTF-8?q?fix=20:=20impl=C3=A9menter=20une=20d=C3=A9c?= =?UTF-8?q?onnexion=20effective=20avec=20gestion=20de=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La route /logout ne faisait qu'une redirection sans supprimer d'état de connexion, car aucune session n'était créée à l'authentification. Les routes restaient accessible en GET par URL sans être connecté. Corrections apportées : - Stockage de l'email du club en session à la connexion (session['club_email']) - Pattern POST-redirect-GET sur /showSummary et /purchasePlaces - Protection des routes sensibles via le décorateur @login_required - Vérification métier require_own_club() (un secrétaire ne réserve que pour son club) - Gestion GET /purchasePlaces pour éviter l'erreur 405 au retour arrière - Logout via session.clear() pour invalider la session côté serveur - Commentaires ajoutés sur les helpers et le flux d'authentification --- server.py | 116 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 99 insertions(+), 17 deletions(-) diff --git a/server.py b/server.py index b3bf75579..c508eb673 100644 --- a/server.py +++ b/server.py @@ -1,5 +1,7 @@ import json -from flask import Flask,render_template,request,redirect,flash,url_for +from functools import wraps + +from flask import Flask, render_template, request, redirect, flash, url_for, session def loadClubs(): @@ -14,45 +16,123 @@ def loadCompetitions(): return listOfCompetitions +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 + + +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(): - 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')) - club = matching_clubs[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=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 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) + return render_template('booking.html', club=foundClub, competition=foundCompetition) + + 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] + + redirect_response = require_own_club(club, logged_in_club) + if redirect_response: + return redirect_response + placesRequired = int(request.form['places']) - competition['numberOfPlaces'] = int(competition['numberOfPlaces'])-placesRequired + competition['numberOfPlaces'] = int(competition['numberOfPlaces']) - placesRequired flash('Great-booking complete!') - return render_template('welcome.html', club=club, competitions=competitions) + # POST-redirect-GET : l'URL ne reste plus bloquée sur /purchasePlaces + return redirect(url_for('showSummary')) # TODO: Add route for points display @@ -60,4 +140,6 @@ def purchasePlaces(): @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')) From 7efc0e596591f9b9b0794f603807fb5a5684dfc0 Mon Sep 17 00:00:00 2001 From: Moon3233 Date: Fri, 3 Jul 2026 18:42:04 +0200 Subject: [PATCH 3/7] =?UTF-8?q?fix=20:=20valider=20les=20r=C3=A9servations?= =?UTF-8?q?=20et=20limiter=20=C3=A0=2012=20places=20cumul=C3=A9es=20par=20?= =?UTF-8?q?club?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La route purchasePlaces acceptait toute réservation sans contrôle : surréservation, dépassement de la limite d'équité (12 places), points non déduits, et possibilité de contourner la limite via plusieurs réservations successives (ex. 12 + X places). Ajout de validate_purchase() pour vérifier le nombre de places (1 à 12 par transaction), la disponibilité en compétition, le solde de points du club et le total cumulé déjà réservé par club/compétition (club_competition_bookings). Les points et places sont déduits après validation, avec messages flash pour chaque cas invalide et confirmation indiquant le nombre de places achetées. La page de réservation affiche désormais la limite dynamique, les points restants et le nombre de places déjà réservées pour la compétition. --- server.py | 94 ++++++++++++++++++++++++++++++++++++++++-- templates/booking.html | 8 +++- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/server.py b/server.py index c508eb673..3aa482a12 100644 --- a/server.py +++ b/server.py @@ -46,6 +46,61 @@ def wrapped_view(*args, **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 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. @@ -101,7 +156,25 @@ def book(competition, club, logged_in_club): return redirect_response if foundClub and foundCompetition: - return render_template('booking.html', club=foundClub, competition=foundCompetition) + 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')) @@ -128,9 +201,22 @@ def _process_purchase(logged_in_club): if redirect_response: return redirect_response - placesRequired = int(request.form['places']) - competition['numberOfPlaces'] = int(competition['numberOfPlaces']) - placesRequired - flash('Great-booking complete!') + 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')) 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.

- + + From a0591a30389f34db03ed6987fd3b72f7edb86d3d Mon Sep 17 00:00:00 2001 From: Moon3233 Date: Fri, 3 Jul 2026 18:47:45 +0200 Subject: [PATCH 4/7] chore: ignorer l'environnement virtuel local Ajout de lib64 et pyvenv.cfg au .gitignore pour ne pas versionner le venv. Co-authored-by: Cursor --- .gitignore | 2 ++ lib64 | 1 - pyvenv.cfg | 5 ----- 3 files changed, 2 insertions(+), 6 deletions(-) delete mode 120000 lib64 delete mode 100644 pyvenv.cfg diff --git a/.gitignore b/.gitignore index 2cba99d87..e1bcac7f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ bin include lib +lib64 +pyvenv.cfg .Python tests/ .envrc diff --git a/lib64 b/lib64 deleted file mode 120000 index 7951405f8..000000000 --- a/lib64 +++ /dev/null @@ -1 +0,0 @@ -lib \ No newline at end of file diff --git a/pyvenv.cfg b/pyvenv.cfg deleted file mode 100644 index 82b31b3e5..000000000 --- a/pyvenv.cfg +++ /dev/null @@ -1,5 +0,0 @@ -home = /usr/bin -include-system-site-packages = false -version = 3.12.3 -executable = /usr/bin/python3.12 -command = /usr/bin/python3 -m venv /home/moon/Documents/Openclassrooms/Projet 11/Python_Testing From 6b39cd53305c9178b4fe5ff5d51ccdeedb57030d Mon Sep 17 00:00:00 2001 From: Moon3233 Date: Fri, 3 Jul 2026 19:04:14 +0200 Subject: [PATCH 5/7] =?UTF-8?q?fix=20:=20n'afficher=20que=20les=20comp?= =?UTF-8?q?=C3=A9titions=20=C3=A0=20venir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- competitions.json | 22 +++++++++++++++++++++- server.py | 28 +++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) 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/server.py b/server.py index 3aa482a12..fe6e5f18b 100644 --- a/server.py +++ b/server.py @@ -1,4 +1,5 @@ import json +from datetime import datetime from functools import wraps from flask import Flask, render_template, request, redirect, flash, url_for, session @@ -16,6 +17,20 @@ 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] @@ -72,6 +87,9 @@ def validate_purchase(club, competition, places_required): 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.' @@ -141,7 +159,11 @@ def showSummary(): @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=competitions) + 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) @@ -155,6 +177,10 @@ def book(competition, club, 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: already_booked = get_places_already_booked(foundClub, foundCompetition) max_places = min( From b67deb0f1cf72a0ed874b4da696cc0dd802913c9 Mon Sep 17 00:00:00 2001 From: Moon3233 Date: Wed, 8 Jul 2026 16:04:59 +0200 Subject: [PATCH 6/7] feat : ajouter le tableau public des points des clubs (Phase 2) --- server.py | 6 +- templates/clubs_points.html | 167 ++++++++++++++++++++++++++++++++++++ templates/index.html | 1 + templates/welcome.html | 4 +- 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 templates/clubs_points.html diff --git a/server.py b/server.py index fe6e5f18b..0dcd8e260 100644 --- a/server.py +++ b/server.py @@ -247,7 +247,11 @@ def _process_purchase(logged_in_club): 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') 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 49fab871b..1e3f85f9f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -21,5 +21,6 @@

Welcome to the GUDLFT Registration Portal!

+

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 %} From e1257583d010302e555e3b0db33ae727027aad3b Mon Sep 17 00:00:00 2001 From: Moon3233 Date: Thu, 16 Jul 2026 09:28:33 +0200 Subject: [PATCH 7/7] test : ajouter la suite de tests pytest (99% de couverture) --- .coveragerc | 8 +++ .gitignore | 6 +- pytest.ini | 5 ++ tests/__init__.py | 1 + tests/conftest.py | 61 ++++++++++++++++++ tests/functional/__init__.py | 1 + tests/functional/test_user_journey.py | 44 +++++++++++++ tests/integration/__init__.py | 1 + tests/integration/test_auth.py | 53 ++++++++++++++++ tests/integration/test_booking.py | 91 +++++++++++++++++++++++++++ tests/unit/__init__.py | 1 + tests/unit/test_dates.py | 24 +++++++ tests/unit/test_helpers.py | 33 ++++++++++ tests/unit/test_validation.py | 59 +++++++++++++++++ 14 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 .coveragerc create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/functional/__init__.py create mode 100644 tests/functional/test_user_journey.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_auth.py create mode 100644 tests/integration/test_booking.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_dates.py create mode 100644 tests/unit/test_helpers.py create mode 100644 tests/unit/test_validation.py 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 e1bcac7f2..83d7423cf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ 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/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/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