diff --git a/.gitignore b/.gitignore index 2cba99d87..6b74e86ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ -bin -include -lib +bin/ +include/ +lib/ .Python -tests/ -.envrc -__pycache__ \ No newline at end of file + +__pycache__/ +*.pyc +.coverage +htmlcov/ +venv/ \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..a73aec021 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = --cov=server --cov-report=html --cov-report=term-missing \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 139affa05..820ddef85 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,48 @@ -click==7.1.2 -Flask==1.1.2 -itsdangerous==1.1.0 -Jinja2==2.11.2 -MarkupSafe==1.1.1 -Werkzeug==1.0.1 +attrs==25.4.0 +bidict==0.23.1 +blinker==1.9.0 +brotli==1.2.0 +certifi==2026.2.25 +charset-normalizer==3.4.5 +click==8.3.1 +ConfigArgParse==1.7.5 +coverage==7.13.4 +Flask==3.1.3 +flask-cors==6.0.2 +Flask-Login==0.6.3 +gevent==25.9.1 +geventhttpclient==2.3.9 +greenlet==3.3.2 +h11==0.16.0 +idna==3.11 +iniconfig==2.3.0 +itsdangerous==2.2.0 +Jinja2==3.1.6 +locust==2.43.3 +MarkupSafe==3.0.3 +msgpack==1.1.2 +outcome==1.3.0.post0 +packaging==26.0 +pluggy==1.6.0 +psutil==7.2.2 +Pygments==2.19.2 +PySocks==1.7.1 +pytest==9.0.2 +pytest-cov==7.0.0 +python-engineio==4.13.1 +python-socketio==5.16.1 +pyzmq==27.1.0 +requests==2.32.5 +selenium==4.41.0 +simple-websocket==1.1.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +trio==0.33.0 +trio-websocket==0.12.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +websocket-client==1.9.0 +Werkzeug==3.1.6 +wsproto==1.3.2 +zope.event==6.1 +zope.interface==8.2 diff --git a/server.py b/server.py index 4084baeac..31847a329 100644 --- a/server.py +++ b/server.py @@ -24,10 +24,13 @@ def loadCompetitions(): def index(): return render_template('index.html') -@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('/showSummary', methods=['POST']) +def show_summary(): + club = next((club for club in clubs if club['email'] == request.form['email']), None) + if not club: + flash("Sorry, that email was not found.") + return render_template('index.html'), 200 + return render_template('welcome.html', club=club, competitions=competitions) @app.route('/book//') @@ -41,12 +44,17 @@ def book(competition,club): return render_template('welcome.html', club=club, competitions=competitions) -@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 +@app.route('/purchasePlaces', methods=['POST']) +def purchase_places(): + competition = next((c for c in competitions if c['name'] == request.form['competition']), None) + club = next((c for c in clubs if c['name'] == request.form['club']), None) + + if not competition or not club: + flash('Something went wrong - please try again.') + return render_template('welcome.html', club=club, competitions=competitions), 200 + + places_required = int(request.form['places']) + competition['numberOfPlaces'] = int(competition['numberOfPlaces']) - places_required flash('Great-booking complete!') return render_template('welcome.html', club=club, competitions=competitions) @@ -56,4 +64,8 @@ def purchasePlaces(): @app.route('/logout') def logout(): - return redirect(url_for('index')) \ No newline at end of file + return redirect(url_for('index')) + +# Needs this to run app +if __name__ == '__main__': + app.run(debug=True) \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 926526b7d..23c3879ad 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,6 +6,17 @@

Welcome to the GUDLFT Registration Portal!

+ + {% with messages = get_flashed_messages() %} + {% if messages %} + + {% endif %} + {% endwith %} + Please enter your secretary email to continue:
diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..c396eec89 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,90 @@ +import pytest +import server +from server import app + + +# --- Mock data fixtures --- + +@pytest.fixture +def mock_clubs(): + """ + Returns a controlled list of mock clubs for testing. + Includes real-world clubs plus edge cases (zero points, one point) + to cover boundary conditions without touching clubs.json. + """ + return [ + {"name": "Simply Lift", "email": "john@simplylift.co", "points": "13"}, + {"name": "Iron Temple", "email": "admin@irontemple.com", "points": "4"}, + {"name": "She Lifts", "email": "kate@shelifts.co.uk", "points": "12"}, + # Edge cases + {"name": "Zero Points Club", "email": "zero@club.com", "points": "0"}, + {"name": "One Point Club", "email": "one@club.com", "points": "1"}, + ] + +@pytest.fixture +def mock_competitions(): + """ + Returns a controlled list of mock competitions for testing. + Includes future competitions, edge cases (full, almost full), + and past competitions to cover all validation scenarios + without touching competitions.json. + """ + return [ + # Future competitions — valid for booking + { + "name": "Future Festival", + "date": "2030-03-27 10:00:00", + "numberOfPlaces": "25" + }, + { + "name": "Future Classic", + "date": "2030-10-22 13:30:00", + "numberOfPlaces": "13" + }, + # Edge cases for place availability + { + "name": "Almost Full Competition", + "date": "2030-06-15 10:00:00", + "numberOfPlaces": "1" + }, + { + "name": "Full Competition", + "date": "2030-07-20 10:00:00", + "numberOfPlaces": "0" + }, + # Past competitions — visible but not bookable + { + "name": "Past Festival", + "date": "2020-03-27 10:00:00", + "numberOfPlaces": "25" + }, + { + "name": "Past Classic", + "date": "2020-10-22 13:30:00", + "numberOfPlaces": "13" + }, + ] + + +# --- App fixtures --- + +@pytest.fixture +def client(): + """ + Provides a basic Flask test client with TESTING mode enabled. + Uses the real clubs.json and competitions.json data. + Only use this fixture for tests that specifically need real data. + """ + app.config['TESTING'] = True + with app.test_client() as client: + yield client + +@pytest.fixture +def mock_client(client, mock_clubs, mock_competitions, monkeypatch): + """ + Flask test client with fully isolated mock data injected via monkeypatch. + Use this in all unit and integration tests to ensure test isolation. + """ + monkeypatch.setattr(server, 'clubs', mock_clubs) + monkeypatch.setattr(server, 'competitions', mock_competitions) + yield client \ No newline at end of file diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_booking.py b/tests/unit/test_booking.py new file mode 100644 index 000000000..39e4e9519 --- /dev/null +++ b/tests/unit/test_booking.py @@ -0,0 +1,92 @@ +import pytest +import server + + +class TestPurchasePlaces: + """ + Unit tests for the /purchasePlaces route. + + Issue #6: Competition places are not correctly deducted after booking. + Branch: fix/competition-places-not-deducted + + Verifies that places are correctly deducted from the competition's + available total after a successful booking, and that the type + consistency of numberOfPlaces is maintained after deduction. + """ + + # ----------------- + # HAPPY PATH + # ----------------- + + def test_booking_deducts_places_from_competition(self, mock_client): + """ + After a valid booking, the competition's numberOfPlaces + should be reduced by the number of places requested. + """ + response = mock_client.post('/purchasePlaces', data={ + 'competition': 'Future Festival', + 'club': 'Simply Lift', + 'places': '3', + }) + assert response.status_code == 200 + assert b'Great-booking complete!' in response.data + + def test_booking_reflects_updated_count_in_response(self, mock_client): + """ + Checks the in-memory state directly. + The updated number of places should be reflected + immediately in the response after booking. + """ + mock_client.post('/purchasePlaces', data={ + 'competition': 'Future Festival', + 'club': 'Simply Lift', + 'places': '3', + }) + competition = next( + (c for c in server.competitions if c['name'] == 'Future Festival'), None + ) + assert competition['numberOfPlaces'] == 22 + + # ----------------- + # SAD PATH + # ----------------- + + @pytest.mark.parametrize("competition,club", [ + ("Unknown Competition", "Simply Lift"), + ("Future Festival", "Unknown Club"), + ]) + def test_invalid_booking_data_returns_error(self, mock_client, competition, club): + """ + If either competition or club name is not found, + the app should return an error message without crashing. + """ + response = mock_client.post('/purchasePlaces', data={ + 'competition': competition, + 'club': club, + 'places': '3' + }) + assert response.status_code == 200 + assert b'Something went wrong' in response.data + + # ----------------- + # EDGE CASES + # ----------------- + + def test_places_value_remains_integer_after_deduction(self, mock_client): + """ + After deduction, numberOfPlaces should be stored + as an integer to maintain type consistency across bookings. + """ + mock_client.post("/purchasePlaces", data={ + 'competition': 'Future Festival', + 'club': 'Simply Lift', + 'places': '3', + }) + competition = next( + (c for c in server.competitions if c['name'] == 'Future Festival'), None + ) + + # Verify type consistency — numberOfPlaces must be int after deduction, + # not a string as it was originally stored in the JSON file + assert isinstance(competition['numberOfPlaces'], int) + assert competition['numberOfPlaces'] == 22 diff --git a/tests/unit/test_login.py b/tests/unit/test_login.py new file mode 100644 index 000000000..58760fb4a --- /dev/null +++ b/tests/unit/test_login.py @@ -0,0 +1,47 @@ +import pytest + +class TestLogin: + """ + Unit tests for the /showSummary route (login). + + Issue #2: Unknown email crashes the app with IndexError 500. + Branch: fix/login-unknown-email-crash + """ + + # ----------------- + # SAD PATH + # ----------------- + + @pytest.mark.parametrize("email", [ + "unknown@notfound.com", + "", + "notanemail", + ]) + def test_invalid_email_returns_error_message(self, mock_client, email): + """ + Any email that does not match a club in the database + should return 200 with a graceful error message — not crash. + Covers unknown, empty, and malformed email inputs. + """ + response = mock_client.post( + '/showSummary', + data={'email': email} + ) + assert response.status_code == 200 + assert b"Sorry, that email was not found." in response.data + + # ----------------- + # HAPPY PATH + # ----------------- + + def test_valid_email_loads_summary_page(self, mock_client): + """ + A valid email should load the summary/welcome page + and display the club's name. + """ + response = mock_client.post( + '/showSummary', + data={'email': 'john@simplylift.co'} + ) + assert response.status_code == 200 + assert b"Welcome" in response.data