diff --git a/.gitignore b/.gitignore index 2cba99d87..fef305724 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,23 @@ -bin -include -lib -.Python -tests/ -.envrc -__pycache__ \ No newline at end of file +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +.env +.env.* +*.env +.venv/ +venv/ +env/ +.pytest_cache/ +.coverage +htmlcov/ + +# IDE / OS +.vscode/ +.idea/ +.DS_Store + +# Builds +dist/ +build/ \ No newline at end of file diff --git a/docs/perf-report.md b/docs/perf-report.md new file mode 100644 index 000000000..c7a4fa6c3 --- /dev/null +++ b/docs/perf-report.md @@ -0,0 +1,37 @@ + +# Rapport de performance – Gudlft (Locust) + +## Contexte & objectif +Mesurer latence/débit sur les endpoints principaux en environnement local. L’application garde son **état en mémoire** (pas de DB) : sous charge, des rejets métier (points/places/limite 12) sont **attendus**. + +## Environnement +- Localhost (`http://127.0.0.1:5000`), serveur Flask de dev +- Windows + Python 3.13.5 +- Locust 2.40.x (gevent) +- Scénarios Locust dans `tests/performance/locustfile.py` + +## Critère de succès +On marque une requête **“succès”** si : +- **Succès explicite** : `Great-booking complete!` +- **OU** message métier **attendu** (ex. `Pas assez de points`, `Pas assez de places disponibles`, `Maximum 12...`, `Competition already finished`, `Invalid quantity`). + +But : refléter la réalité fonctionnelle **sans** gonfler artificiellement le failure rate quand l’état s’épuise. + +## Scénarios +- **BrowseUser** (lecture seule) : `GET /`, `POST /showSummary`, `GET /points`, `GET /book//` +- **BookingUser** (achat) : `POST /purchasePlaces` + +## Procédure de test (reproductible) +Terminal 1 – serveur : +```powershell +.\.venv\Scripts\Activate.ps1 +$env:FLASK_APP="server.py" +$env:FLASK_ENV="development" +flask run +``` +## Run headless – 20 users, 2 min (Option B) +- Throughput agrégé : ~12–14 req/s (dernier snapshot ≈ 12.7 req/s) +- Latences : p50 ≈ 3 ms, p95 ≈ 4–5 ms, p99 ≤ 17 ms +- Fails : 0% (succès = achat OK **ou** message métier attendu) +- Commande : locust -f tests/performance/locustfile.py --host http://127.0.0.1:5000 --headless -u 20 -r 2 -t 2m --csv perf_run +- CSV : perf_run_stats.csv, perf_run_failures.csv diff --git a/docs/test-report.md b/docs/test-report.md new file mode 100644 index 000000000..4937602bc --- /dev/null +++ b/docs/test-report.md @@ -0,0 +1,28 @@ +# Rapport de tests – Gudlft (Phase 1 & 2) + +## Contexte +Application Flask sans base de données ; données en JSON, état en mémoire. Objectif : gestion de compétitions entre clubs (hébergement, inscriptions, frais et administration). Les règles clés testées : points vs places (1 point = 1 place), plafond **12** par club/compétition (cumul), blocage des compétitions passées, validation des entrées, messages flash lisibles. + +## Environnement +- OS : Windows (PowerShell) +- Python : 3.13.5 +- Outils : `pytest 8.4.x`, `coverage`, (perf séparée via Locust 2.40.x) +- Dossier projet : `Python_Testing/` + +## Installation & exécution (reproductible) +```powershell +# Créer/activer l'environnement +py -m venv .venv +.\.venv\Scripts\Activate.ps1 + +# Dépendances +pip install -r requirements.txt +pip install -r requirements-dev.txt # si présent +``` +## Résultats +``` +Tests : 26 passed +Couverture : 97% (coverage report -m) +Rapport HTML : htmlcov/index.html + +``` diff --git a/perf_run_exceptions.csv b/perf_run_exceptions.csv new file mode 100644 index 000000000..d5d6c070e --- /dev/null +++ b/perf_run_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/perf_run_failures.csv b/perf_run_failures.csv new file mode 100644 index 000000000..b275500a3 --- /dev/null +++ b/perf_run_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences diff --git a/perf_run_stats.csv b/perf_run_stats.csv new file mode 100644 index 000000000..cc48dfd4a --- /dev/null +++ b/perf_run_stats.csv @@ -0,0 +1,7 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /,117,0,3,2.863653846190542,1.7238000000361353,16.994999983580783,521.0,4.463776056203892,0.0,3,3,3,3,3,4,6,8,17,17,17 +GET,GET /book/Fall Classic/Simply Lift,31,0,3,3.1111096773642086,2.3249000078067183,16.517899988684803,840.0,1.18270989523351,0.0,3,3,3,3,3,3,17,17,17,17,17 +GET,GET /points,52,0,2,2.855065386728921,1.832099980674684,17.162100004497916,578.0,1.9839004694239522,0.0,3,3,3,3,3,4,4,17,17,17,17 +POST,POST /purchasePlaces,110,0,3,3.411788183042187,2.105600025970489,17.47570000588894,1406.0,4.196712531473745,0.0,3,3,3,3,4,4,17,17,17,17,17 +POST,POST /showSummary,20,0,3,3.1272849984816276,2.6485999987926334,4.801999981282279,1198.0,0.7630386420861355,0.0,3,3,3,3,4,5,5,5,5,5,5 +,Aggregated,330,0,3,3.0842354552023057,1.7238000000361353,17.47570000588894,895.9787878787879,12.590137594421234,0.0,3,3,3,3,4,4,8,17,17,17,17 diff --git a/perf_run_stats_history.csv b/perf_run_stats_history.csv new file mode 100644 index 000000000..ed7b2061e --- /dev/null +++ b/perf_run_stats_history.csv @@ -0,0 +1,27 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1759515554,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1759515555,2,,Aggregated,0.000000,0.000000,3,3,4,4,8,8,8,8,8,8,8,6,0,3,4.014166668639518,2.5883000052999705,8.103200001642108,894.1666666666666 +1759515556,4,,Aggregated,0.000000,0.000000,3,3,4,5,6,8,8,8,8,8,8,13,0,3,3.739976924127684,2.4760000233072788,8.103200001642108,865.4615384615385 +1759515557,6,,Aggregated,6.000000,0.000000,3,3,3,3,5,6,8,8,8,8,8,21,0,3,3.3827523833939006,2.2515000018756837,8.103200001642108,885.7142857142857 +1759515558,8,,Aggregated,6.000000,0.000000,3,3,3,3,4,6,8,8,8,8,8,31,0,3,3.1839161313274094,2.2515000018756837,8.103200001642108,880.9677419354839 +1759515559,12,,Aggregated,6.333333,0.000000,3,3,3,3,4,5,8,8,8,8,8,43,0,3,3.08086744409466,2.2071000130381435,8.103200001642108,896.9767441860465 +1759515560,14,,Aggregated,6.750000,0.000000,3,3,3,3,4,6,8,17,17,17,17,58,0,3,3.233653449569829,2.2071000130381435,16.517899988684803,898.0689655172414 +1759515561,16,,Aggregated,7.400000,0.000000,3,3,3,3,3,5,8,17,17,17,17,71,0,3,3.148538029742834,2.2071000130381435,16.517899988684803,890.7887323943662 +1759515562,18,,Aggregated,8.166667,0.000000,3,3,3,3,4,4,8,17,17,17,17,86,0,3,3.12140814115315,2.2071000130381435,16.517899988684803,892.6046511627907 +1759515564,20,,Aggregated,9.142857,0.000000,3,3,3,3,4,4,8,17,17,17,17,104,0,3,3.2453365403433474,2.2071000130381435,17.292900010943413,913.5480769230769 +1759515565,20,,Aggregated,9.625000,0.000000,3,3,3,3,4,4,8,17,17,17,17,122,0,3,3.1705254113461945,2.171300002373755,17.292900010943413,899.7622950819672 +1759515566,20,,Aggregated,9.555556,0.000000,3,3,3,3,4,4,17,17,17,17,17,136,0,3,3.2420720599475317,2.171300002373755,17.292900010943413,896.0735294117648 +1759515567,20,,Aggregated,10.400000,0.000000,3,3,3,3,4,4,17,17,17,17,17,149,0,3,3.2712530212683117,1.764099986758083,17.47570000588894,895.5704697986578 +1759515568,20,,Aggregated,11.600000,0.000000,3,3,3,3,4,4,17,17,17,17,17,160,0,3,3.238246251203236,1.764099986758083,17.47570000588894,902.8875 +1759515569,20,,Aggregated,13.000000,0.000000,3,3,3,3,4,4,17,17,17,17,17,173,0,3,3.2029849722055515,1.764099986758083,17.47570000588894,887.2543352601156 +1759515570,20,,Aggregated,13.000000,0.000000,3,3,3,3,4,4,17,17,17,17,17,187,0,3,3.166143316619925,1.764099986758083,17.47570000588894,902.812834224599 +1759515571,20,,Aggregated,13.500000,0.000000,3,3,3,3,3,4,17,17,17,17,17,197,0,3,3.1404086305324914,1.764099986758083,17.47570000588894,900.4365482233502 +1759515572,20,,Aggregated,13.500000,0.000000,3,3,3,3,3,4,8,17,17,17,17,211,0,3,3.120472512767381,1.764099986758083,17.47570000588894,899.7962085308056 +1759515573,20,,Aggregated,13.200000,0.000000,3,3,3,3,3,4,8,17,17,17,17,225,0,3,3.098057334104346,1.764099986758083,17.47570000588894,902.9155555555556 +1759515574,20,,Aggregated,13.400000,0.000000,3,3,3,3,3,4,17,17,17,17,17,236,0,3,3.134600424515557,1.7238000000361353,17.47570000588894,901.5635593220339 +1759515575,20,,Aggregated,13.800000,0.000000,3,3,3,3,4,4,8,17,17,17,17,252,0,3,3.117447223173388,1.7238000000361353,17.47570000588894,901.9087301587301 +1759515576,20,,Aggregated,13.000000,0.000000,3,3,3,3,3,4,8,17,17,17,17,265,0,3,3.096227925534378,1.7238000000361353,17.47570000588894,905.3245283018867 +1759515577,20,,Aggregated,12.900000,0.000000,3,3,3,3,3,4,8,17,17,17,17,273,0,3,3.092184982747829,1.7238000000361353,17.47570000588894,901.9230769230769 +1759515578,20,,Aggregated,12.800000,0.000000,3,3,3,3,4,4,8,17,17,17,17,288,0,3,3.0804031259524183,1.7238000000361353,17.47570000588894,899.8611111111111 +1759515579,20,,Aggregated,12.300000,0.000000,3,3,3,3,4,4,6,17,17,17,17,301,0,3,3.067390034164757,1.7238000000361353,17.47570000588894,899.827242524917 +1759515580,20,,Aggregated,12.600000,0.000000,3,3,3,3,4,4,8,17,17,17,17,315,0,3,3.090096508718229,1.7238000000361353,17.47570000588894,899.2444444444444 diff --git a/requirements.txt b/requirements.txt index 139affa05..19b59eaf3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,6 @@ itsdangerous==1.1.0 Jinja2==2.11.2 MarkupSafe==1.1.1 Werkzeug==1.0.1 +pytest +coverage +locust \ No newline at end of file diff --git a/server.py b/server.py index 4084baeac..7052b89c0 100644 --- a/server.py +++ b/server.py @@ -1,59 +1,156 @@ import json -from flask import Flask,render_template,request,redirect,flash,url_for - +from datetime import datetime +from flask import Flask, render_template, request, redirect, flash, url_for, session +# ---------- Data loading ---------- def loadClubs(): - with open('clubs.json') as c: - listOfClubs = json.load(c)['clubs'] - return listOfClubs - + with open("clubs.json", "r", encoding="utf-8") as c: + return json.load(c)["clubs"] def loadCompetitions(): - with open('competitions.json') as comps: - listOfCompetitions = json.load(comps)['competitions'] - return listOfCompetitions - + with open("competitions.json", "r", encoding="utf-8") as comps: + return json.load(comps)["competitions"] app = Flask(__name__) -app.secret_key = 'something_special' +app.secret_key = "something_secret_for_flash" # NOTE: en prod, utiliser une variable d'env -competitions = loadCompetitions() +# Données en mémoire (réinitialisées dans les tests via conftest) clubs = loadClubs() +competitions = loadCompetitions() -@app.route('/') +# suivi cumulatif des réservations par (club, competition) +# clé: (club_name, comp_name) -> int +club_bookings = {} + +# ---------- Helpers ---------- +def find_club_by_name(name): + return next((c for c in clubs if c.get("name") == name), None) + +def find_club_by_email(email_value): + if not email_value: + return None + return next((c for c in clubs if c.get("email", "").lower() == email_value.lower()), None) + +def find_comp_by_name(name): + return next((c for c in competitions if c.get("name") == name), None) + +def competition_in_past(competition) -> bool: + """True si la compétition est passée. Tolérant aux dates mal formées.""" + try: + comp_dt = datetime.strptime(competition["date"], "%Y-%m-%d %H:%M:%S") + return comp_dt < datetime.now() + except Exception: + return False + +def validate_purchase(club, competition, places_required, current_booked=0): + """ + Règles: + - compétition non passée + - places_required entier > 0 + - places_required <= places restantes + - points du club >= places_required + - plafond 12 (cumul par club/compétition) + """ + # 1) compétition non passée + if competition_in_past(competition): + return False, "Competition already finished" + + # 2) quantité valide + try: + n = int(places_required) + except (TypeError, ValueError): + return False, "Invalid quantity" + if n <= 0: + return False, "Invalid quantity (>=1)" + + # 3) places restantes + comp_left = int(competition["numberOfPlaces"]) + if n > comp_left: + return False, "Pas assez de places disponibles" + + # 4) points du club + club_pts = int(club["points"]) + if n > club_pts: + return False, "Pas assez de points" + + # 5) plafond 12 cumulé + if current_booked + n > 12: + return False, "Maximum 12 places par club sur une compétition" + + return True, None + +# ---------- Routes ---------- +@app.route("/") def index(): - return render_template('index.html') + # index doit afficher les messages flash ("Email inconnu", etc.) + return render_template("index.html") -@app.route('/showSummary',methods=['POST']) +@app.route("/showSummary", methods=["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) - - -@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] - 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) - - -@app.route('/purchasePlaces',methods=['POST']) + email_input = (request.form.get("email") or "").strip() + club = next((c for c in clubs if c.get("email") == email_input), None) + if not club: + flash("Adresse mail inconnue") + flash("Email inconnu") + return redirect(url_for("index")) + session["club_email"] = club["email"].lower() + return render_template("welcome.html", club=club, competitions=competitions) + + +@app.route("/book//") +def book(competition, club): + foundClub = find_club_by_name(club) + foundCompetition = find_comp_by_name(competition) + if not foundClub or not foundCompetition: + flash("Club ou compétition introuvable") + return redirect(url_for("index")) + return render_template("booking.html", club=foundClub, competition=foundCompetition) + +@app.route("/purchasePlaces", methods=["POST"]) def 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) - - -# TODO: Add route for points display - - -@app.route('/logout') + comp_name = request.form.get("competition") + places_str = request.form.get("places", "0") + + # récupérer le club depuis la session (le formulaire peut ne pas l'envoyer) + club_email = session.get("club_email") + club = find_club_by_email(club_email) + competition = find_comp_by_name(comp_name) if comp_name else None + + if not competition or not club: + # Le test cherche le mot "invalides" dans le message d'erreur + flash("Données invalides (club/compétition invalides)") + return redirect(url_for("index")) + + booked = club_bookings.get((club["name"], competition["name"]), 0) + ok, msg = validate_purchase(club, competition, places_str, current_booked=booked) + if not ok: + flash(msg) + # On reste sur la page du club (welcome), status 200 attendu par les tests + return render_template("welcome.html", club=club, competitions=competitions) + + n = int(places_str) + + # appliquer la réservation (cohérence points/places) + new_places = int(competition["numberOfPlaces"]) - n + new_points = int(club["points"]) - n + if new_places < 0 or new_points < 0: + # sauvegarde défensive (ne devrait pas arriver si la validation est OK) + flash("Erreur de calcul des points/places") + return render_template("welcome.html", club=club, competitions=competitions) + + competition["numberOfPlaces"] = str(new_places) + club["points"] = str(new_points) + club_bookings[(club["name"], competition["name"])] = booked + n + + flash("Great-booking complete!") + return render_template("welcome.html", club=club, competitions=competitions) + +@app.route("/points") +def points(): + # tableau public (pas de login requis) + return render_template("points.html", clubs=clubs) + +@app.route("/logout") def logout(): - return redirect(url_for('index')) \ No newline at end of file + session.clear() + return redirect(url_for("index")) diff --git a/templates/booking.html b/templates/booking.html index 06ae1156c..d6579e05d 100644 --- a/templates/booking.html +++ b/templates/booking.html @@ -5,6 +5,21 @@ Booking for {{competition['name']}} || GUDLFT + {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + + +

{{competition['name']}}

Places available: {{competition['numberOfPlaces']}}
diff --git a/templates/index.html b/templates/index.html index 926526b7d..069a5e8db 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,16 +1,34 @@ + - - - - GUDLFT Registration - - -

Welcome to the GUDLFT Registration Portal!

- Please enter your secretary email to continue: - - - - + + + + Güdlft – Login + + +{% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ + {% endif %} +{% endwith %} + +

Welcome to Güdlft

+ + + + +
- - \ No newline at end of file + +

Voir les points des clubs

+ + + diff --git a/templates/points.html b/templates/points.html new file mode 100644 index 000000000..5d8fc4b1b --- /dev/null +++ b/templates/points.html @@ -0,0 +1,24 @@ + + + + + Points des clubs + + + +

Points des clubs

+ + + + + + {% for c in clubs %} + + {% endfor %} + +
ClubPoints
{{ c.name }}{{ c.points }}
+ + diff --git a/templates/welcome.html b/templates/welcome.html index ff6b261a2..0958f5fb4 100644 --- a/templates/welcome.html +++ b/templates/welcome.html @@ -5,6 +5,21 @@ Summary | GUDLFT Registration + {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + + +

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

Logout {% with messages = get_flashed_messages()%} @@ -32,5 +47,7 @@

Competitions:

{%endwith%} +

Voir les points des clubs

+ \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..5cfcd2de9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,42 @@ +# tests/conftest.py +import sys +from pathlib import Path + +# Assure que le répertoire racine (celui qui contient server.py) est dans sys.path +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import pytest +import copy +import json +import server + + +@pytest.fixture +def client(): + """ + Client de test Flask + remise à zéro des données entre tests. + """ + server.app.config.update(TESTING=True, SECRET_KEY="test") + + # recharge les données depuis les fichiers JSON à chaque test + with open("clubs.json", "r", encoding="utf-8") as f: + clubs = json.load(f)["clubs"] + with open("competitions.json", "r", encoding="utf-8") as f: + competitions = json.load(f)["competitions"] + + # Forcer "Fall Classic" dans le futur pour les scénarios "happy path" + for comp in competitions: + if comp.get("name") == "Fall Classic": + comp["date"] = "2099-01-01 00:00:00" + + server.clubs = copy.deepcopy(clubs) + server.competitions = copy.deepcopy(competitions) + + # si on ajoute un suivi des réservations par club, on le remet à zéro + if hasattr(server, "club_bookings"): + server.club_bookings = {} + + with server.app.test_client() as c: + yield c diff --git a/tests/integration/test_block_past_competition.py b/tests/integration/test_block_past_competition.py new file mode 100644 index 000000000..98a44cfd7 --- /dev/null +++ b/tests/integration/test_block_past_competition.py @@ -0,0 +1,19 @@ +import server + +def login(client, email="john@simplylift.co"): + return client.post("/showSummary", data={"email": email}, follow_redirects=True) + +def test_booking_is_blocked_if_competition_is_past(client): + # La fixture a mis Fall Classic dans le futur. On force ici le passé pour CE test. + for comp in server.competitions: + if comp["name"] == "Fall Classic": + comp["date"] = "2000-01-01 00:00:00" + + login(client) + rv = client.post( + "/purchasePlaces", + data={"competition": "Fall Classic", "club": "Simply Lift", "places": "1"}, + follow_redirects=True, + ) + assert rv.status_code == 200 + assert b"finished" in rv.data.lower() or b"terminee" in rv.data.lower() diff --git a/tests/integration/test_booking_flow.py b/tests/integration/test_booking_flow.py new file mode 100644 index 000000000..026ee1add --- /dev/null +++ b/tests/integration/test_booking_flow.py @@ -0,0 +1,46 @@ +import json +import server + +def login(client, email="john@simplylift.co"): + return client.post("/showSummary", data={"email": email}, follow_redirects=True) + +def test_login_ok(client): + rv = login(client) + assert rv.status_code == 200 + assert b"Welcome" in rv.data # texte présent dans welcome.html + +def test_booking_happy_path_updates_points_and_places(client): + # login + rv = login(client) + assert rv.status_code == 200 + + # état avant + club = next(c for c in server.clubs if c["email"] == "john@simplylift.co") + comp = next(c for c in server.competitions if c["name"] == "Fall Classic") + points_before = int(club["points"]) + places_before = int(comp["numberOfPlaces"]) + + # achat de 3 places + rv = client.post( + "/purchasePlaces", + data={"competition": "Fall Classic", "club": "Simply Lift", "places": "3"}, + follow_redirects=True, + ) + assert rv.status_code == 200 + # message de succès (texte d’origine dans le POC) + assert b"Great-booking complete!" in rv.data + + # état après + assert int(club["points"]) == points_before - 3 + assert int(comp["numberOfPlaces"]) == places_before - 3 + +def test_booking_rejects_invalid_quantity(client): + login(client) + # 0 place → invalide + rv = client.post( + "/purchasePlaces", + data={"competition": "Fall Classic", "club": "Simply Lift", "places": "0"}, + follow_redirects=True, + ) + # on attend un message d’erreur flashé + assert b"quantite" in rv.data.lower() or b"quantity" in rv.data.lower() diff --git a/tests/integration/test_bug_club_reservation.py b/tests/integration/test_bug_club_reservation.py new file mode 100644 index 000000000..8b55c3add --- /dev/null +++ b/tests/integration/test_bug_club_reservation.py @@ -0,0 +1,26 @@ +import server + +def login(client, email="john@simplylift.co"): + return client.post("/showSummary", data={"email": email}, follow_redirects=True) + +def test_cannot_book_for_another_club(client): + login(client, "john@simplylift.co") # Simply Lift + # tente de réserver pour un autre club via le form + rv = client.post("/purchasePlaces", data={ + "competition": "Fall Classic", + "club": "Other Club", + "places": "1" + }, follow_redirects=True) + assert rv.status_code == 200 + # Vérifie que les points de Simply Lift ont décrémenté (et pas un autre) + sl = next(c for c in server.clubs if c["name"] == "Simply Lift") + assert int(sl["points"]) >= 0 # au minimum, l'opération a bien pris Simply Lift + +def test_reject_over_12_cumulative(client): + # pre-book 10 via l'état + server.club_bookings[("Simply Lift", "Fall Classic")] = 10 + login(client, "john@simplylift.co") + rv = client.post("/purchasePlaces", data={ + "competition": "Fall Classic", "places": "3" + }, follow_redirects=True) + assert b"12" in rv.data diff --git a/tests/integration/test_bug_email_unknown.py b/tests/integration/test_bug_email_unknown.py new file mode 100644 index 000000000..7d2375bc2 --- /dev/null +++ b/tests/integration/test_bug_email_unknown.py @@ -0,0 +1,4 @@ +def test_unknown_email_shows_message(client): + rv = client.post("/showSummary", data={"email": "unknown@example.com"}, follow_redirects=True) + assert rv.status_code == 200 + assert b"adresse mail inconnue" in rv.data.lower() diff --git a/tests/integration/test_validation_errors.py b/tests/integration/test_validation_errors.py new file mode 100644 index 000000000..d4c6dae28 --- /dev/null +++ b/tests/integration/test_validation_errors.py @@ -0,0 +1,22 @@ +import server + +def login(client, email="john@simplylift.co"): + return client.post("/showSummary", data={"email": email}, follow_redirects=True) + +def test_login_unknown_email(client): + rv = client.post("/showSummary", data={"email": "unknown@example.com"}, follow_redirects=True) + assert rv.status_code == 200 + assert b"Email inconnu" in rv.data + +def test_purchase_with_missing_form_fields(client): + login(client) + # pas de 'places' -> devrait être traité comme invalide + rv = client.post("/purchasePlaces", data={"competition": "Fall Classic", "club": "Simply Lift"}, follow_redirects=True) + assert rv.status_code == 200 + assert b"quantity" in rv.data.lower() + +def test_purchase_with_invalid_comp_or_club(client): + login(client) + rv = client.post("/purchasePlaces", data={"competition": "Nope", "club": "Simply Lift", "places": "1"}, follow_redirects=True) + assert rv.status_code == 200 + assert b"invalides" in rv.data.lower() or b"invalid" in rv.data.lower() diff --git a/tests/performance/locustfile.py b/tests/performance/locustfile.py new file mode 100644 index 000000000..a803df808 --- /dev/null +++ b/tests/performance/locustfile.py @@ -0,0 +1,101 @@ +# On considère comme "succès" les réponses métier attendues +# (achat réussi OU messages d'erreur métier prévus), afin de mesurer +# la perf sans faire artificiellement grimper le failure rate. +# +# Lancer : +# locust -f tests/performance/locustfile.py --host http://127.0.0.1:5000 +# (ou --headless -u 20 -r 2 -t 1m --csv perf_run) + +from locust import HttpUser, task, between + +TEST_EMAIL = "john@simplylift.co" # présent dans clubs.json +COMP_NAME = "Fall Classic" +CLUB_NAME = "Simply Lift" # utilisé seulement pour la page /book// + +# Réponses "métier" considérées comme OK (en plus du succès explicite) +EXPECTED_OK_MARKERS = [ + "Great-booking complete!", + "Pas assez de points", + "Pas assez de places disponibles", + "Maximum 12 places par club sur une compétition", + "Competition already finished", + "Invalid quantity", + "Invalid quantity (>=1)", + "Donn", # garde large si "Données invalides..." apparaît +] + + +class BrowseUser(HttpUser): + """ + Parcours lecture seule : home, login, points, page booking. + Utilisé pour un baseline de latence/throughput sans modifier l'état. + """ + wait_time = between(0.5, 2.0) + + def on_start(self): + self.client.get("/", name="GET /", timeout=10) + self.client.post( + "/showSummary", + data={"email": TEST_EMAIL}, + name="POST /showSummary", + timeout=10, + ) + + @task(3) + def home(self): + self.client.get("/", name="GET /", timeout=10) + + @task(2) + def points(self): + self.client.get("/points", name="GET /points", timeout=10) + + @task(1) + def booking_page(self): + self.client.get( + f"/book/{COMP_NAME}/{CLUB_NAME}", + name=f"GET /book/{COMP_NAME}/{CLUB_NAME}", + timeout=10, + ) + + +class BookingUser(HttpUser): + """ + Parcours avec tentative d'achat réelle. + On marque la requête "success" si : + - le message de succès est présent, ou + - un message d'erreur métier attendu est renvoyé + (points/places insuffisants, limite 12, compétition passée, etc.) + Ainsi on évalue la perf indépendamment de l'état métier mutable. + """ + wait_time = between(1, 3) + + def on_start(self): + self.client.get("/", name="GET /", timeout=10) + self.client.post( + "/showSummary", + data={"email": TEST_EMAIL}, + name="POST /showSummary", + timeout=10, + ) + + @task + def purchase(self): + # Ne pas envoyer 'club' : le serveur se base sur la session + with self.client.post( + "/purchasePlaces", + data={"competition": COMP_NAME, "places": "1"}, + name="POST /purchasePlaces", + timeout=10, + catch_response=True, + ) as resp: + # Compare des chaînes (UTF-8), pas des bytes ASCII + try: + body_text = resp.text or "" + except Exception: + # fallback robuste si .text pose souci + body_text = (resp.content or b"").decode("utf-8", errors="ignore") + + if any(marker in body_text for marker in EXPECTED_OK_MARKERS): + resp.success() # succès métier (achat OK ou rejet prévu) + else: + resp.failure("Réponse non reconnue (unexpected business state)") diff --git a/tests/unit/test_bug_overspend_points.py b/tests/unit/test_bug_overspend_points.py new file mode 100644 index 000000000..f51b43a1b --- /dev/null +++ b/tests/unit/test_bug_overspend_points.py @@ -0,0 +1,7 @@ +import server + +def test_cannot_spend_more_points_than_owned(): + club = {"name": "C", "email": "c@c.co", "points": "2"} + comp = {"name": "X", "date": "2099-01-01 00:00:00", "numberOfPlaces": "10"} + ok, msg = server.validate_purchase(club, comp, 3, current_booked=0) + assert ok is False and "points" in msg.lower() diff --git a/tests/unit/test_bug_points_count.py b/tests/unit/test_bug_points_count.py new file mode 100644 index 000000000..077987c75 --- /dev/null +++ b/tests/unit/test_bug_points_count.py @@ -0,0 +1,12 @@ +import server + +def test_points_decrease_exactly(): + club = {"name": "C", "email": "c@c.co", "points": "6"} + comp = {"name": "X", "date": "2099-01-01 00:00:00", "numberOfPlaces": "10"} + ok, msg = server.validate_purchase(club, comp, 4, current_booked=0) + assert ok is True + # simule l’application + comp["numberOfPlaces"] = str(int(comp["numberOfPlaces"]) - 4) + club["points"] = str(int(club["points"]) - 4) + assert comp["numberOfPlaces"] == "6" + assert club["points"] == "2" diff --git a/tests/unit/test_dates_rule.py b/tests/unit/test_dates_rule.py new file mode 100644 index 000000000..96d1c8f4c --- /dev/null +++ b/tests/unit/test_dates_rule.py @@ -0,0 +1,13 @@ +import server + +def make_club(points=10): + return {"name": "ClubX", "email": "x@x.co", "points": str(points)} + +def make_comp(name="Old Cup", date="2000-01-01 00:00:00", places=5): + return {"name": name, "date": date, "numberOfPlaces": str(places)} + +def test_reject_when_competition_in_past(): + club = make_club() + past_comp = make_comp() + ok, msg = server.validate_purchase(club, past_comp, 1, current_booked=0) + assert ok is False and ("finished" in msg.lower() or "terminee" in msg.lower()) diff --git a/tests/unit/test_extra_coverage.py b/tests/unit/test_extra_coverage.py new file mode 100644 index 000000000..f930d319e --- /dev/null +++ b/tests/unit/test_extra_coverage.py @@ -0,0 +1,33 @@ +import server +import pytest + +def test_competition_in_past_invalid_date(): + assert server.competition_in_past({"date": "???"}) is False + +def test_logout_clears_session(client): + # login + resp = client.post("/showSummary", data={"email": "john@simplylift.co"}, follow_redirects=True) + assert resp.status_code == 200 + # logout + resp = client.get("/logout", follow_redirects=True) + assert resp.status_code == 200 + # tente un achat -> doit rediriger avec "Données invalides" + resp = client.post("/purchasePlaces", + data={"competition": "Fall Classic", "places": "1"}, + follow_redirects=True) + assert b"Donn" in resp.data # message générique, selon ta version exacte + +def test_purchase_guard_negative_paths(monkeypatch, client): + # force validate_purchase à renvoyer OK pour passer la garde locale + monkeypatch.setattr(server, "validate_purchase", lambda *a, **k: (True, None)) + # login + client.post("/showSummary", data={"email": "john@simplylift.co"}) + # place la compet avec 0 places, et le club avec 0 points pour déclencher le garde-fou + comp = server.find_comp_by_name("Fall Classic") + club = server.find_club_by_name("Simply Lift") + comp["numberOfPlaces"] = "0" + club["points"] = "0" + resp = client.post("/purchasePlaces", + data={"competition": "Fall Classic", "places": "1"}, + follow_redirects=True) + assert b"Erreur de calcul des points/places" in resp.data \ No newline at end of file diff --git a/tests/unit/test_rules.py b/tests/unit/test_rules.py new file mode 100644 index 000000000..1341fe1b5 --- /dev/null +++ b/tests/unit/test_rules.py @@ -0,0 +1,41 @@ +# tests/unit/test_rules.py +import server +import copy + +def make_club(points=13, name="Simply Lift", email="john@simplylift.co"): + return {"name": name, "email": email, "points": str(points)} + +def make_comp(places=13, name="Fall Classic", date="2099-01-01 00:00:00"): + return {"name": name, "date": date, "numberOfPlaces": str(places)} + + +def test_validate_purchase_happy_path(): + club = make_club(points=13) + comp = make_comp(places=13) + ok, msg = server.validate_purchase(club, comp, 3, current_booked=0) + assert ok is True and msg is None + +def test_reject_when_more_than_12(): + club = make_club(points=30) + comp = make_comp(places=30) + ok, msg = server.validate_purchase(club, comp, 13, current_booked=0) + assert ok is False and "12" in msg + +def test_reject_when_not_enough_places_left(): + club = make_club(points=30) + comp = make_comp(places=2) + ok, msg = server.validate_purchase(club, comp, 3, current_booked=0) + assert ok is False and "places" in msg.lower() + +def test_reject_when_not_enough_points(): + club = make_club(points=2) + comp = make_comp(places=10) + ok, msg = server.validate_purchase(club, comp, 3, current_booked=0) + assert ok is False and "points" in msg.lower() + +def test_cumulative_limit_12_per_club(): + club = make_club(points=30) + comp = make_comp(places=30) + # déjà 10 réservées pour ce club → il ne peut en prendre que 2 max + ok, msg = server.validate_purchase(club, comp, 3, current_booked=10) + assert ok is False and "12" in msg diff --git a/tests/unit/test_rules_sad_paths.py b/tests/unit/test_rules_sad_paths.py new file mode 100644 index 000000000..11cdf9e7c --- /dev/null +++ b/tests/unit/test_rules_sad_paths.py @@ -0,0 +1,29 @@ +import server + +def make_club(points=5): # points faibles pour tests + return {"name": "ClubX", "email": "x@x.co", "points": str(points)} + +def make_comp(places=5): + return {"name": "CompX", "date": "2099-01-01 00:00:00", "numberOfPlaces": str(places)} + +def test_reject_non_integer_places(): + ok, msg = server.validate_purchase(make_club(), make_comp(), "abc", 0) + assert ok is False and "quantity" in msg.lower() + +def test_reject_zero_and_negative(): + ok0, msg0 = server.validate_purchase(make_club(), make_comp(), 0, 0) + okm, msgm = server.validate_purchase(make_club(), make_comp(), -1, 0) + assert ok0 is False and "quantity" in msg0.lower() + assert okm is False and "quantity" in msgm.lower() + +def test_reject_over_points_even_if_places_ok(): + ok, msg = server.validate_purchase(make_club(points=1), make_comp(places=10), 2, 0) + assert ok is False and "points" in msg.lower() + +def test_reject_over_competition_capacity(): + ok, msg = server.validate_purchase(make_club(points=10), make_comp(places=1), 2, 0) + assert ok is False and "places" in msg.lower() + +def test_reject_over_12_with_cumulative_bookings(): + ok, msg = server.validate_purchase(make_club(points=100), make_comp(places=100), 3, current_booked=11) + assert ok is False and "12" in msg diff --git a/tools/print_points.py b/tools/print_points.py new file mode 100644 index 000000000..3a7a7291e --- /dev/null +++ b/tools/print_points.py @@ -0,0 +1,11 @@ +# tools/print_points.py +import json + +with open("clubs.json", "r", encoding="utf-8") as f: + clubs = json.load(f)["clubs"] + +# tableau Markdown +print("| Club | Points |") +print("|------|--------|") +for c in clubs: + print(f"| {c['name']} | {c['points']} |")