diff --git a/.DS_Store b/.DS_Store index 04a508e6f..ad8edd258 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 2cba99d87..6026fc595 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ bin include lib .Python -tests/ .envrc -__pycache__ \ No newline at end of file +__pycache__ +.coverage +.DS_Store \ No newline at end of file diff --git a/Locust_Test_Report.html b/Locust_Test_Report.html new file mode 100644 index 000000000..43c1411d3 --- /dev/null +++ b/Locust_Test_Report.html @@ -0,0 +1,156 @@ + + + + + + + + + + + Locust + + + + +
+ + + + + \ No newline at end of file diff --git a/README.md b/README.md index 61307d2cd..c9075ea33 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,120 @@ -# gudlift-registration +# GUDLFT - Réservation de Compétitions -1. Why +## 📖 Description +Application Flask pour les secrétaires de clubs permettant de réserver des places de compétitions en utilisant des points. - This is a proof of concept (POC) project to show a light-weight version of our competition booking platform. The aim is the keep things as light as possible, and use feedback from the users to iterate. +**Fonctionnalités principales :** +- Connexion sécurisée par email +- Réservation de places (max 12 par club) +- Déduction automatique des points +- Tableau public des points clubs -2. Getting Started +## Fonctionnalités Implémentées - This project uses the following technologies: +### Phase 1 - Authentification & Réservations +- [x] Connexion secrétaires via email/mot de passe +- [x] Liste compétitions à venir +- [x] Formulaire réservation `/book/mpétition>/` +- [x] Achat places `/purchasePlaces` +- [x] Déconnexion `/logout` - * Python v3.x+ +### Phase 2 - Transparence & Performance +- [x] Tableau points public `/points` (lecture seule) +- [x] Tests Locust : 6 utilisateurs simultanés + - GET pages : 5-6ms (< 5s ✅) + - POST achat : 14ms (< 2s ✅) +- [x] 100% Couverture pour le code server.py - * [Flask](https://flask.palletsprojects.com/en/1.1.x/) +### Contraintes Métier +- [x] Max 12 places par club/compétition +- [x] Nombre de points requis pour la réservation +- [x] Pas de réservation pour les compétitions passées - Whereas Django does a lot of things for us out of the box, Flask allows us to add only what we need. - +## 🛠️ Installation et démarrage - * [Virtual environment](https://virtualenv.pypa.io/en/stable/installation.html) +### Prérequis +- Python 3.8 ou plus récent +- `pip` installé - This ensures you'll be able to install the correct packages without interfering with Python on your machine. +### Installation des dépendances - Before you begin, please ensure you have this installed globally. +Dans votre terminal, positionnez-vous dans le dossier du projet puis exécutez : +pip install -r requirements.txt -3. Installation +Cette commande installe les bibliothèques nécessaires. - - After cloning, change into the directory and type virtualenv .. This will then set up a a virtual python environment within that directory. +### Lancement de l’application - - Next, type source bin/activate. You should see that your command prompt has changed to the name of the folder. This means that you can install packages in here without affecting affecting files outside. To deactivate, type deactivate +Pour démarrer l’application Flask localement, tapez : - - Rather than hunting around for the packages you need, you can install in one step. Type pip install -r requirements.txt. This will install all the packages listed in the respective file. If you install a package, make sure others know by updating the requirements.txt file. An easy way to do this is pip freeze > requirements.txt +flask --app server.py run -p 5000 - - Flask requires that you set an environmental variable to the python file. However you do that, you'll want to set the file to be server.py. Check [here](https://flask.palletsprojects.com/en/1.1.x/quickstart/#a-minimal-application) for more details - - You should now be ready to test the application. In the directory, type either flask run or python -m flask run. The app should respond with an address you should be able to go to using your browser. +L’application sera accessible ensuite à l’adresse : +`http://127.0.0.1:5000` -4. Current Setup +--- - The app is powered by [JSON files](https://www.tutorialspoint.com/json/json_quick_guide.htm). This is to get around having a DB until we actually need one. The main ones are: - - * competitions.json - list of competitions - * clubs.json - list of clubs with relevant information. You can look here to see what email addresses the app will accept for login. +## 🧪 Tests automatisés -5. Testing +### Lancement des tests unitaires et d’intégration - You are free to use whatever testing framework you like-the main thing is that you can show what tests you are using. +Les tests sont organisés dans le dossier `tests/`. Pour exécuter tous les tests, utilisez : - We also like to show how well we're testing, so there's a module called - [coverage](https://coverage.readthedocs.io/en/coverage-5.1/) you should add to your project. +coverage run -m pytest + +Cela lance tous les tests tout en mesurant la couverture du code. + +### Visualiser le rapport de couverture + +Pour obtenir un rapport détaillé de la couverture de code : + +coverage report -m + + +L’objectif est d’avoir un taux minimum de 60 % de couverture, mais ici la couverture est à 100 % sur `server.py`. + +--- + +## 🚀 Tests de performance avec Locust + +### Description + +Locust simule des utilisateurs réels pour tester la performance sous charge. Ici, 6 utilisateurs effectuent les actions de consultation et réservation. + +### Lancement des tests Locust + +Dans un nouveau terminal, lancez Locust avec : + +locust -f locustfile.py --host=http://localhost:5000 --users 6 --spawn-rate 1 --run-time 30s + + +Ensuite, ouvrez un navigateur à l’adresse : +`http://localhost:8089` + +Cliquez sur start avec 6 utilisateurs pour commencer les tests. + +### Résultat attendu + +Les temps de réponse doivent être : +- Inférieurs à 5 secondes pour le chargement des pages +- Inférieurs à 2 secondes pour les achats de places + +Notre rapport `Locust_Test_Report.html` contient les résultats détaillés. + +--- + +## Structure du projet + +python_testing/ +├── server.py # Application Flask principale +├── tests/ +│ ├── test_unit.py # Tests unitaires +│ ├── test_integration.py # Tests d’intégration +│ └── conftest.py # Configuration pytest +├── locustfile.py # Scénarios de tests de performance Locust +├── Locust_Test_Report.html # Rapport de test de performance généré par Locust +└── README.md # Ce fichier diff --git a/clubs.json b/clubs.json index 1d7ad1ffe..5184686ee 100644 --- a/clubs.json +++ b/clubs.json @@ -1,16 +1,19 @@ -{"clubs":[ +{ + "clubs": [ { - "name":"Simply Lift", - "email":"john@simplylift.co", - "points":"13" + "name": "Simply Lift", + "email": "john@simplylift.co", + "points": "13" }, { - "name":"Iron Temple", - "email": "admin@irontemple.com", - "points":"4" + "name": "Iron Temple", + "email": "admin@irontemple.com", + "points": "11" }, - { "name":"She Lifts", - "email": "kate@shelifts.co.uk", - "points":"12" + { + "name": "She Lifts", + "email": "kate@shelifts.co.uk", + "points": "22" } -]} \ No newline at end of file + ] +} \ No newline at end of file diff --git a/competitions.json b/competitions.json index 039fc61bd..7a7b37fdf 100644 --- a/competitions.json +++ b/competitions.json @@ -1,14 +1,24 @@ { - "competitions": [ - { - "name": "Spring Festival", - "date": "2020-03-27 10:00:00", - "numberOfPlaces": "25" - }, - { - "name": "Fall Classic", - "date": "2020-10-22 13:30:00", - "numberOfPlaces": "13" - } - ] + "competitions": [ + { + "name": "Spring Festival", + "date": "2020-03-27 10:00:00", + "numberOfPlaces": "25" + }, + { + "name": "Fall Classic", + "date": "2020-10-22 13:30:00", + "numberOfPlaces": "13" + }, + { + "name": "Winter Gala", + "date": "2025-12-15 09:00:00", + "numberOfPlaces": "30" + }, + { + "name": "New Year's Championship", + "date": "2026-01-02 11:00:00", + "numberOfPlaces": "10" + } + ] } \ No newline at end of file diff --git a/locustfile.py b/locustfile.py new file mode 100644 index 000000000..f15287525 --- /dev/null +++ b/locustfile.py @@ -0,0 +1,24 @@ +from locust import HttpUser, task, between + +class ClubSecretary(HttpUser): + wait_time = between(1, 3) # Pause 1-3s entre actions + + @task + def visit_welcome(self): + self.client.get("/") # Page d'accueil + + @task + def book_page(self): + self.client.get("/book/Winter Gala/Simply Lift") # Page réservation + + @task + def points_page(self): + self.client.get("/points") # Tableau points + + @task + def purchase_places(self): + self.client.post("/purchasePlaces", { + "competition": "Winter Gala", + "club": "Simply Lift", + "places": "1" + }) \ No newline at end of file diff --git a/server.py b/server.py index 4084baeac..d4c57b6c0 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 datetime import datetime + def loadClubs(): @@ -8,52 +10,129 @@ def loadClubs(): return listOfClubs + + def loadCompetitions(): with open('competitions.json') as comps: listOfCompetitions = json.load(comps)['competitions'] return listOfCompetitions + +def saveClubs(clubs_list): + """Sauvegarde la liste des clubs mise à jour dans clubs.json""" + with open('clubs.json', 'w') as f: + json.dump({'clubs': clubs_list}, f, indent=2) + + + +def saveCompetitions(competitions_list): + """Sauvegarde la liste des compétitions mise à jour dans competitions.json""" + with open('competitions.json', 'w') as f: + json.dump({'competitions': competitions_list}, f, indent=2) + + + + 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']) def showSummary(): + clubs = loadClubs() # Recharge pour état actuel club = [club for club in clubs if club['email'] == request.form['email']][0] + competitions = loadCompetitions() # Recharge pour état actuel return render_template('welcome.html',club=club,competitions=competitions) @app.route('/book//') def book(competition,club): - foundClub = [c for c in clubs if c['name'] == club][0] - foundCompetition = [c for c in competitions if c['name'] == competition][0] + """ Route pour afficher le formulaire de réservation d'une compétition pour un club spécifique.""" + + clubs = loadClubs() # Recharge pour état actuel + competitions = loadCompetitions() # Recharge pour état actuel + + foundClub = next((c for c in clubs if c['name'] == club), None) + foundCompetition = next((c for c in competitions if c['name'] == competition), None) + 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('welcome.html', club=None, competitions=competitions) + @app.route('/purchasePlaces',methods=['POST']) def purchasePlaces(): + # Chargement des données + competitions = loadCompetitions() + clubs = loadClubs() + 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) + # Vérifier la date de la compétition + competition_date = datetime.strptime(competition['date'], '%Y-%m-%d %H:%M:%S') + now = datetime.now() + if competition_date < now: + flash("Cannot book places for past competitions") + return render_template('welcome.html', + club=[c for c in loadClubs() if c['name'] == request.form['club']][0], + competitions=loadCompetitions()) + + # Vérifier la limite de 12 places max + if placesRequired > 12: + flash("Cannot book more than 12 places per competition per club") + return render_template('welcome.html', + club=[c for c in loadClubs() if c['name'] == request.form['club']][0], + competitions=loadCompetitions()) + + # Vérification du nombre de points restants avant réservation + if placesRequired > int(club['points']): + flash("Not enough points") + return render_template('welcome.html', + club=[c for c in loadClubs() if c['name'] == request.form['club']][0], + competitions=loadCompetitions()) + + # Vérifier que la compétition a assez de places disponibles + if placesRequired > int(competition['numberOfPlaces']): + flash("Not enough places available in this competition") + return render_template('welcome.html', + club=[c for c in loadClubs() if c['name'] == request.form['club']][0], + competitions=loadCompetitions()) + + # Déduire les places disponibles + competition['numberOfPlaces'] = str(int(competition['numberOfPlaces']) - placesRequired) + # Déduire les points du club + club['points'] = str(int(club['points']) - placesRequired) + + # Sauvegarder les changements dans les fichiers JSON + saveCompetitions(competitions) + saveClubs(clubs) + + flash('Great-booking complete!') + # Toujours recharger clubs et competitions avant d'envoyer au template + return render_template('welcome.html', + club=[c for c in loadClubs() if c['name'] == request.form['club']][0], + competitions=loadCompetitions()) -# TODO: Add route for points display +@app.route('/points') +def show_points(): + """Affiche le tableau des points de tous les clubs (lecture seule)""" + clubs = loadClubs() # Recharge pour état actuel + return render_template('points.html', clubs=clubs) @app.route('/logout') def logout(): - return redirect(url_for('index')) \ No newline at end of file + return redirect(url_for('index')) diff --git a/templates/index.html b/templates/index.html index 926526b7d..cd0eac2de 100644 --- a/templates/index.html +++ b/templates/index.html @@ -12,5 +12,6 @@

Welcome to the GUDLFT Registration Portal!

+

Points leaderboard

\ No newline at end of file diff --git a/templates/points.html b/templates/points.html new file mode 100644 index 000000000..d64489a87 --- /dev/null +++ b/templates/points.html @@ -0,0 +1,19 @@ + + + + + Clubs Points + + +

Clubs and their Points

+ + + {% for club in clubs %} + + + + + {% endfor %} +
Club NamePoints
{{club['name']}}{{club['points']}}
+ + \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..7ba64600b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,48 @@ +import json +import pytest +import sys +import os + +# Ajoute le répertoire parent pour server +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +@pytest.fixture(autouse=True, scope='function') +def reset_json_files(): + """Reset clubs.json et competitions.json avant chaque test""" + + original_clubs = { + "clubs": [ + {"name": "Simply Lift", "email": "john@simplylift.co", "points": "13"}, + {"name": "Iron Temple", "email": "admin@irontemple.com", "points": "11"}, + {"name": "She Lifts", "email": "kate@shelifts.co.uk", "points": "22"} + ] + } + + original_competitions = { + "competitions": [ + {"name": "Spring Festival", "date": "2020-03-27 10:00:00", "numberOfPlaces": "25"}, + {"name": "Fall Classic", "date": "2020-10-22 13:30:00", "numberOfPlaces": "13"}, + {"name": "Winter Gala", "date": "2025-12-15 09:00:00", "numberOfPlaces": "30"}, + {"name": "New Year's Championship", "date": "2026-01-02 11:00:00", "numberOfPlaces": "10"} + ] + } + + # Supprime les fichiers existants d'abord + import os + if os.path.exists('clubs.json'): + os.remove('clubs.json') + if os.path.exists('competitions.json'): + os.remove('competitions.json') + + # Écriture des originaux + with open('clubs.json', 'w') as f: + json.dump(original_clubs, f, indent=2) + with open('competitions.json', 'w') as f: + json.dump(original_competitions, f, indent=2) + +@pytest.fixture +def client(): + from server import app + app.config['TESTING'] = True + with app.test_client() as client: + yield client diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 000000000..48dc2fc08 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,157 @@ +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # permet d'importer server.py qui est à la racine du projet + +import pytest +from server import app + +@pytest.fixture +def client(): + # Création d'un client test Flask pour simuler les requêtes HTTP + with app.test_client() as client: + yield client + +def test_index(client): + # Test basique pour vérifier que la page d'accueil répond bien + response = client.get('/') + assert response.status_code == 200 + assert b'Welcome' in response.data or b'Bienvenue' in response.data or b'' in response.data + +def test_purchase_places_deducts_club_points(client): + """Teste que les points du club sont déduits après réservation""" + + # Étape 1: Connexion avec email valide (Simply Lift a 13 points) + summary_response = client.post('/showSummary', data={'email': 'john@simplylift.co'}) + assert summary_response.status_code == 200 + + # Étape 2: Réservation de 2 places pour Spring Festival + data = { + 'competition': 'Winter Gala', + 'club': 'Simply Lift', + 'places': '2' + } + response = client.post('/purchasePlaces', data=data, follow_redirects=True) + assert response.status_code == 200 + assert b'Great-booking complete!' in response.data + + # Étape 3: Vérifier que les points ont été déduits (13 - 2 = 11) + assert b'Points available: 11' in response.data + +def test_cannot_book_past_competitions(client): + """Teste qu'on ne peut pas réserver pour des compétitions passées""" + + # Connexion Simply Lift + summary_response = client.post('/showSummary', data={'email': 'john@simplylift.co'}) + assert summary_response.status_code == 200 + + # Tentative de réservation pour Spring Festival (2020 - PASSÉ) + data_past = { + 'competition': 'Spring Festival', + 'club': 'Simply Lift', + 'places': '2' + } + response_past = client.post('/purchasePlaces', data=data_past, follow_redirects=True) + assert response_past.status_code == 200 + assert b"Cannot book places for past competitions" in response_past.data + assert b'Great-booking complete!' not in response_past.data + + # Vérifier que les points n'ont PAS été déduits (toujours 13) + assert b'Points available: 13' in response_past.data + +def test_cannot_book_more_than_12_places(client): + """Teste qu'on ne peut pas réserver plus de 12 places par réservation""" + + # Connexion Simply Lift (13 points) + summary_response = client.post('/showSummary', data={'email': 'john@simplylift.co'}) + assert summary_response.status_code == 200 + + # Tentative de réservation de 13 places (AU-DESSUS DE 12) + data_excess = { + 'competition': 'Winter Gala', # Compétition future + 'club': 'Simply Lift', + 'places': '13' + } + response_excess = client.post('/purchasePlaces', data=data_excess, follow_redirects=True) + assert response_excess.status_code == 200 + assert b"Cannot book more than 12 places per competition per club" in response_excess.data + assert b'Great-booking complete!' not in response_excess.data + + # Vérifier que les points n'ont PAS été déduits (toujours 13) + assert b'Points available: 13' in response_excess.data + +def test_cannot_spend_more_points_than_available(client): + """Teste qu'un club ne peut pas réserver plus de places que ses points disponibles""" + + # Connexion club Simply Lift (13 points) + summary_response = client.post('/showSummary', data={'email': 'john@simplylift.co'}) + assert summary_response.status_code == 200 + + # Tentative de réservation de 12 places (12 <= 12 max, MAIS 12 > 11 points) + data_too_many = { + 'competition': 'Winter Gala', # compétition future (30+ places) + 'club': 'Iron Temple', + 'places': '12' # 12 <= 12 max → passe → 12 > 11 points → "Not enough points" + } + + response_too_many = client.post('/purchasePlaces', data=data_too_many, follow_redirects=True) + assert response_too_many.status_code == 200 + assert b"Not enough points" in response_too_many.data + assert b'Great-booking complete!' not in response_too_many.data + + # Vérifier que les points restent inchangés + assert b'Points available: 11' in response_too_many.data + +def test_cannot_book_more_places_than_available(client): + """Teste qu'on ne peut pas réserver plus de places que disponibles dans la compétition""" + + # Connexion She Lifts (22 points) + summary_response = client.post('/showSummary', data={'email': 'kate@shelifts.co.uk'}) + assert summary_response.status_code == 200 + + # Test de depassement + data_excess = { + 'competition': "New Year's Championship", # 10 places + 'club': 'She Lifts', + 'places': '11' +} + + response_excess = client.post('/purchasePlaces', data=data_excess, follow_redirects=True) + assert response_excess.status_code == 200 + + # Doit afficher une erreur places insuffisantes + assert b"Not enough places available in this competition" in response_excess.data + assert b'Great-booking complete!' not in response_excess.data + + # Points n'ont pas été déduits + assert b'Points available: 22' in response_excess.data + +def test_points_page_displays_all_clubs(client): + """Teste que la page points affiche tous les clubs et leurs points""" + response = client.get('/points') + assert response.status_code == 200 + assert b'Simply Lift' in response.data + assert b'Iron Temple' in response.data + assert b'She Lifts' in response.data + assert b'13' in response.data # Simply Lift points + +def test_book_route_success(): + """Test route /book/ valide → booking.html""" + client = app.test_client() + response = client.get('/book/Winter Gala/Simply Lift') + assert response.status_code == 200 + assert b"Winter Gala" in response.data + assert b"Places available" in response.data + +def test_book_route_not_found(): + """Test route /book/ invalide → welcome.html """ + client = app.test_client() + response = client.get('/book/Winter Gala/ClubInexistant') + assert response.status_code == 200 + assert b"Something went wrong" in response.data + +def test_logout_redirect(): + """Test que la route logout redirige vers l'index""" + client = app.test_client() + response = client.get('/logout', follow_redirects=False) + assert response.status_code == 302 # redirection + assert response.headers['Location'] == '/' # Redirige vers index diff --git a/tests/test_unit.py b/tests/test_unit.py new file mode 100644 index 000000000..8b36acae4 --- /dev/null +++ b/tests/test_unit.py @@ -0,0 +1,106 @@ +# Tests unitaires pour la fonction purchasePlaces() + +import pytest +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # Permet d'importer server + +from unittest.mock import patch, MagicMock +from datetime import datetime +from server import loadClubs, loadCompetitions, app, purchasePlaces # Import app et purchasePlaces + +@pytest.fixture +def mock_data(): + """Données de test de base""" + clubs = [{"name": "Simply Lift", "email": "john@simplylift.co", "points": "13"}] + competitions = [{"name": "Winter Gala", "date": "2025-12-15 09:00:00", "numberOfPlaces": "30"}] + return clubs, competitions + +def test_purchase_places_reussite(mock_data): + """Test réservation 5 places """ + clubs, competitions = mock_data + + with app.test_request_context(method='POST', data={ + 'competition': 'Winter Gala', + 'club': 'Simply Lift', + 'places': '5' + }): + with patch('server.loadClubs', return_value=clubs), \ + patch('server.loadCompetitions', return_value=competitions): + + purchasePlaces() + + # Vérifications : déductions effectuées + assert int(competitions[0]['numberOfPlaces']) == 25 # 30-5 + assert int(clubs[0]['points']) == 8 # 13-5 + +def test_purchase_places_competition_passee(mock_data): + """Test : bloque si compétition passée""" + clubs, competitions = mock_data + competitions[0]['date'] = "2020-01-01 10:00:00" # Date passée (AVANT 2025) + + with app.test_request_context(method='POST', data={ + 'competition': 'Winter Gala', + 'club': 'Simply Lift', + 'places': '5' + }): + with patch('server.loadClubs', return_value=clubs), \ + patch('server.loadCompetitions', return_value=competitions): + + purchasePlaces() + + # Pas de modification des données + assert competitions[0]['numberOfPlaces'] == "30" + +def test_purchase_places_points_insuffisants(mock_data): + """Test : bloque si pas assez de points""" + clubs, competitions = mock_data + clubs[0]['points'] = "3" # Seulement 3 points + + with app.test_request_context(method='POST', data={ + 'competition': 'Winter Gala', + 'club': 'Simply Lift', + 'places': '5' + }): + with patch('server.loadClubs', return_value=clubs), \ + patch('server.loadCompetitions', return_value=competitions): + + purchasePlaces() + + # Points inchangés + assert clubs[0]['points'] == "3" + +def test_purchase_places_places_insuffisants(mock_data): + """Test : bloque si pas assez de places en compétition""" + clubs, competitions = mock_data + competitions[0]['numberOfPlaces'] = "3" # Seulement 3 places + + with app.test_request_context(method='POST', data={ + 'competition': 'Winter Gala', + 'club': 'Simply Lift', + 'places': '5' + }): + with patch('server.loadClubs', return_value=clubs), \ + patch('server.loadCompetitions', return_value=competitions): + + purchasePlaces() + + # Places inchangées + assert competitions[0]['numberOfPlaces'] == "3" + +def test_purchase_places_limite_12(mock_data): + """Test : bloque au-delà de 12 places par réservation""" + clubs, competitions = mock_data + + with app.test_request_context(method='POST', data={ + 'competition': 'Winter Gala', + 'club': 'Simply Lift', + 'places': '13' + }): + with patch('server.loadClubs', return_value=clubs), \ + patch('server.loadCompetitions', return_value=competitions): + + purchasePlaces() + + # Pas de déduction + assert competitions[0]['numberOfPlaces'] == "30"