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