From 32569eff4a867b20f232b16c9782a1a6b1a5dda1 Mon Sep 17 00:00:00 2001 From: devogs Date: Mon, 8 Sep 2025 11:40:42 +0200 Subject: [PATCH 01/17] Install dependencies and setup requirements and venv --- .gitignore | 5 +++-- pytest.ini | 3 +++ requirements.txt | 47 +++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 pytest.ini diff --git a/.gitignore b/.gitignore index 2cba99d87..0dcd0c11b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ bin include lib .Python -tests/ .envrc -__pycache__ \ No newline at end of file +__pycache__ +venv/ +.coverage \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..b0e5a945f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +filterwarnings = + ignore::DeprecationWarning \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 139affa05..994cd7ba6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,41 @@ -click==7.1.2 -Flask==1.1.2 -itsdangerous==1.1.0 -Jinja2==2.11.2 -MarkupSafe==1.1.1 -Werkzeug==1.0.1 +bidict==0.23.1 +blinker==1.9.0 +Brotli==1.1.0 +certifi==2025.8.3 +charset-normalizer==3.4.3 +click==8.2.1 +ConfigArgParse==1.7.1 +coverage==7.10.6 +Flask==3.1.2 +flask-cors==6.0.1 +Flask-Login==0.6.3 +gevent==25.5.1 +geventhttpclient==2.3.4 +greenlet==3.2.4 +h11==0.16.0 +idna==3.10 +iniconfig==2.1.0 +itsdangerous==2.2.0 +Jinja2==3.1.6 +locust==2.40.0 +locust-cloud==1.26.3 +MarkupSafe==3.0.2 +msgpack==1.1.1 +packaging==25.0 +platformdirs==4.4.0 +pluggy==1.6.0 +psutil==7.0.0 +Pygments==2.19.2 +pytest==8.4.1 +python-engineio==4.12.2 +python-socketio==5.13.0 +pyzmq==27.0.2 +requests==2.32.5 +setuptools==80.9.0 +simple-websocket==1.1.0 +urllib3==2.5.0 +websocket-client==1.8.0 +Werkzeug==3.1.3 +wsproto==1.2.0 +zope.event==5.1.1 +zope.interface==7.2 From d4e2057fe947b628faf29e24c37c58342416468d Mon Sep 17 00:00:00 2001 From: devogs Date: Mon, 8 Sep 2025 12:13:07 +0200 Subject: [PATCH 02/17] Create test to check missing email resiliency and flash message is present --- tests/test_login.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/test_login.py diff --git a/tests/test_login.py b/tests/test_login.py new file mode 100644 index 000000000..e5b620518 --- /dev/null +++ b/tests/test_login.py @@ -0,0 +1,40 @@ +import pytest +from server import app, clubs, competitions +from unittest.mock import patch, MagicMock + + +@pytest.fixture +def client(): + """Configures the Flask test client and yields it for testing.""" + app.config['TESTING'] = True + with app.test_client() as client: + yield client + +def test_unknown_email_flashes_message(client): + """Test that an unknown email results in a flash message being stored.""" + # Mock the 'clubs' data so that the email is not found + with patch('server.clubs', []): + response = client.post('/showSummary', data={'email': 'unknown@test.com'}, follow_redirects=False) + + # Check the flash messages stored in the session + with client.session_transaction() as sess: + flashed_messages = dict(sess.get('_flashes', [])) + + assert response.status_code == 302 + assert response.location == '/' + assert "Sorry, that email was not found." in flashed_messages.values() + +def test_existing_email_login_is_successful(client): + """Test that a user with an existing email can log in and view the welcome page.""" + # Mock the 'clubs' data to include a known user + with patch('server.clubs', [{'name': 'Test Club', 'email': 'test@test.com', 'points': '10'}]): + response = client.post('/showSummary', data={'email': 'test@test.com'}) + + # Assertions + assert response.status_code == 200 + assert b'Welcome' in response.data + + # Check that the email was stored in the session + with client.session_transaction() as sess: + assert 'club_email' in sess + assert sess['club_email'] == 'test@test.com' \ No newline at end of file From 72c2c6e466d8d31e1085810fa27d1c6b271a0fbe Mon Sep 17 00:00:00 2001 From: devogs Date: Mon, 8 Sep 2025 12:15:26 +0200 Subject: [PATCH 03/17] Add an __init__.py file to treat the parent directory as a package --- tests/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/__init__.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb From 946b053cc76a44f418f9035b5227e102ae32f448 Mon Sep 17 00:00:00 2001 From: devogs Date: Mon, 8 Sep 2025 12:24:59 +0200 Subject: [PATCH 04/17] Fix: email missing resiliency and flash message in index --- server.py | 16 +++++++++++++--- templates/index.html | 13 +++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/server.py b/server.py index 4084baeac..5d303539d 100644 --- a/server.py +++ b/server.py @@ -1,5 +1,5 @@ import json -from flask import Flask,render_template,request,redirect,flash,url_for +from flask import Flask,render_template,request,redirect,flash,url_for,session def loadClubs(): @@ -24,10 +24,20 @@ 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) + user_email = request.form['email'] + found_clubs = [club for club in clubs if club['email'] == user_email] + + if found_clubs: + club = found_clubs[0] + session['club_email'] = club['email'] + return render_template('welcome.html',club=club,competitions=competitions) + else: + flash("Sorry, that email was not found.") + session.pop('club_email', None) + return redirect(url_for('index')) @app.route('/book//') diff --git a/templates/index.html b/templates/index.html index 926526b7d..cfa154e7b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,6 +3,11 @@ GUDLFT Registration +

Welcome to the GUDLFT Registration Portal!

@@ -12,5 +17,13 @@

Welcome to the GUDLFT Registration Portal!

+ + {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +

{{ message }}

+ {% endfor %} + {% endif %} + {% endwith %} \ No newline at end of file From 1bd89f7a86477d775652ce5b2716928106c14694 Mon Sep 17 00:00:00 2001 From: devogs Date: Mon, 8 Sep 2025 12:42:38 +0200 Subject: [PATCH 05/17] FEAT: Add TDD test for purchase flow resiliency --- tests/test_purchase.py | 50 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/test_purchase.py diff --git a/tests/test_purchase.py b/tests/test_purchase.py new file mode 100644 index 000000000..13d38354e --- /dev/null +++ b/tests/test_purchase.py @@ -0,0 +1,50 @@ +import pytest +from server import app +from unittest.mock import patch, MagicMock + + +@pytest.fixture +def client(): + app.config['TESTING'] = True + with app.test_client() as client: + yield client + + +def test_purchase_with_insufficient_points(client): + """Test that a purchase is blocked when the club has insufficient points.""" + with patch('server.clubs', [{'name': 'Test Club', 'email': 'test@test.com', 'points': '10'}]): + with patch('server.competitions', [{'name': 'Test Competition', 'numberOfPlaces': '20'}]): + with patch('server.flash') as mock_flash: + response = client.post('/purchasePlaces', data={ + 'club': 'Test Club', + 'competition': 'Test Competition', + 'places': '11' + }, follow_redirects=False) + + mock_flash.assert_called_once_with("You do not have enough points to book 11 places. You currently have 10 points.") + assert response.status_code == 302 + assert response.location == '/book/Test%20Competition/Test%20Club' + + +def test_purchase_with_sufficient_points_and_places_without_save(client): + """Test a successful purchase with enough points and places without relying on save functions.""" + # Mock the lists to set up the test scenario + mock_clubs = [{'name': 'Test Club', 'email': 'test@test.com', 'points': '10'}] + mock_competitions = [{'name': 'Test Competition', 'numberOfPlaces': '20'}] + + with patch('server.clubs', mock_clubs): + with patch('server.competitions', mock_competitions): + with patch('server.flash') as mock_flash: + response = client.post('/purchasePlaces', data={ + 'club': 'Test Club', + 'competition': 'Test Competition', + 'places': '5' + }, follow_redirects=False) + + # Assertions for the happy path + mock_flash.assert_called_once_with("Great-booking complete!") + assert response.status_code == 200 + assert b'Welcome' in response.data + # Verify that the points and places were updated in the mock data + assert mock_clubs[0]['points'] == '5' + assert mock_competitions[0]['numberOfPlaces'] == 15 \ No newline at end of file From eed96062f0b4a4490504623c982600f31924e202 Mon Sep 17 00:00:00 2001 From: devogs Date: Mon, 8 Sep 2025 12:43:22 +0200 Subject: [PATCH 06/17] FIX: Implement purchase logic to prevent over-booking and improve UX --- server.py | 20 ++++++++++++++++---- templates/booking.html | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/server.py b/server.py index 5d303539d..23881016e 100644 --- a/server.py +++ b/server.py @@ -27,8 +27,7 @@ def index(): @app.route('/showSummary',methods=['POST']) def showSummary(): - user_email = request.form['email'] - found_clubs = [club for club in clubs if club['email'] == user_email] + found_clubs = [club for club in clubs if club['email'] == request.form['email']] if found_clubs: club = found_clubs[0] @@ -56,11 +55,24 @@ 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 + + # Check if club has enough points + if int(club['points']) < placesRequired: + flash(f"You do not have enough points to book {placesRequired} places. You currently have {club['points']} points.") + return redirect(url_for('book', competition=competition['name'], club=club['name'])) + + # Check if competition has enough places + if int(competition['numberOfPlaces']) < placesRequired: + flash(f"Not enough places available in this competition. Only {competition['numberOfPlaces']} places left.") + return redirect(url_for('book', competition=competition['name'], club=club['name'])) + + # Proceed with purchase only if all conditions are met + competition['numberOfPlaces'] = int(competition['numberOfPlaces']) - placesRequired + club['points'] = str(int(club['points']) - placesRequired) + flash('Great-booking complete!') return render_template('welcome.html', club=club, competitions=competitions) - # TODO: Add route for points display diff --git a/templates/booking.html b/templates/booking.html index 06ae1156c..823067c41 100644 --- a/templates/booking.html +++ b/templates/booking.html @@ -3,6 +3,11 @@ Booking for {{competition['name']}} || GUDLFT +

{{competition['name']}}

@@ -10,8 +15,18 @@

{{competition['name']}}

- +
+ + {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} \ No newline at end of file From 8e52a4781542ee8f63df7b44bf537fe4cd5198e6 Mon Sep 17 00:00:00 2001 From: devogs Date: Mon, 8 Sep 2025 12:52:01 +0200 Subject: [PATCH 07/17] FEAT: Add TDD test for 12-place booking limit --- tests/test_purchase.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_purchase.py b/tests/test_purchase.py index 13d38354e..8024fbb98 100644 --- a/tests/test_purchase.py +++ b/tests/test_purchase.py @@ -47,4 +47,20 @@ def test_purchase_with_sufficient_points_and_places_without_save(client): assert b'Welcome' in response.data # Verify that the points and places were updated in the mock data assert mock_clubs[0]['points'] == '5' - assert mock_competitions[0]['numberOfPlaces'] == 15 \ No newline at end of file + assert mock_competitions[0]['numberOfPlaces'] == 15 + + +def test_purchase_more_than_max_places(client): + """Test that a club cannot book more than 12 places per competition.""" + with patch('server.clubs', [{'name': 'Test Club', 'email': 'test@test.com', 'points': '20'}]): + with patch('server.competitions', [{'name': 'Test Competition', 'numberOfPlaces': '20'}]): + with patch('server.flash') as mock_flash: + response = client.post('/purchasePlaces', data={ + 'club': 'Test Club', + 'competition': 'Test Competition', + 'places': '13' + }, follow_redirects=False) + + mock_flash.assert_called_once_with("You cannot book more than 12 places per competition.") + assert response.status_code == 302 + assert response.location == '/book/Test%20Competition/Test%20Club' From 17f337bcab3d73397b4122151eb5fb53b3ac1b6e Mon Sep 17 00:00:00 2001 From: devogs Date: Mon, 8 Sep 2025 12:52:30 +0200 Subject: [PATCH 08/17] FIX: Implement 12-place booking limit --- server.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server.py b/server.py index 23881016e..5debb0fc9 100644 --- a/server.py +++ b/server.py @@ -56,6 +56,11 @@ def purchasePlaces(): club = [c for c in clubs if c['name'] == request.form['club']][0] placesRequired = int(request.form['places']) + # FIX for Bug 3: Block bookings over 12 places + if placesRequired > 12: + flash("You cannot book more than 12 places per competition.") + return redirect(url_for('book', competition=competition['name'], club=club['name'])) + # Check if club has enough points if int(club['points']) < placesRequired: flash(f"You do not have enough points to book {placesRequired} places. You currently have {club['points']} points.") From 5d913a8265561dc2d00f11ef2823969677fd2121 Mon Sep 17 00:00:00 2001 From: devogs Date: Mon, 8 Sep 2025 13:05:02 +0200 Subject: [PATCH 09/17] FIX: Add test for booking places in past competitions --- tests/test_purchase.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/tests/test_purchase.py b/tests/test_purchase.py index 8024fbb98..4677c3cbd 100644 --- a/tests/test_purchase.py +++ b/tests/test_purchase.py @@ -1,5 +1,6 @@ import pytest from server import app +from datetime import datetime from unittest.mock import patch, MagicMock @@ -13,7 +14,7 @@ def client(): def test_purchase_with_insufficient_points(client): """Test that a purchase is blocked when the club has insufficient points.""" with patch('server.clubs', [{'name': 'Test Club', 'email': 'test@test.com', 'points': '10'}]): - with patch('server.competitions', [{'name': 'Test Competition', 'numberOfPlaces': '20'}]): + with patch('server.competitions', [{'name': 'Test Competition', 'numberOfPlaces': '20', 'date': '2025-12-31 09:00:00'}]): with patch('server.flash') as mock_flash: response = client.post('/purchasePlaces', data={ 'club': 'Test Club', @@ -30,7 +31,7 @@ def test_purchase_with_sufficient_points_and_places_without_save(client): """Test a successful purchase with enough points and places without relying on save functions.""" # Mock the lists to set up the test scenario mock_clubs = [{'name': 'Test Club', 'email': 'test@test.com', 'points': '10'}] - mock_competitions = [{'name': 'Test Competition', 'numberOfPlaces': '20'}] + mock_competitions = [{'name': 'Test Competition', 'numberOfPlaces': '20', 'date': '2025-12-31 09:00:00'}] with patch('server.clubs', mock_clubs): with patch('server.competitions', mock_competitions): @@ -46,14 +47,14 @@ def test_purchase_with_sufficient_points_and_places_without_save(client): assert response.status_code == 200 assert b'Welcome' in response.data # Verify that the points and places were updated in the mock data - assert mock_clubs[0]['points'] == '5' - assert mock_competitions[0]['numberOfPlaces'] == 15 + assert int(mock_clubs[0]['points']) == 5 + assert int(mock_competitions[0]['numberOfPlaces']) == 15 def test_purchase_more_than_max_places(client): """Test that a club cannot book more than 12 places per competition.""" with patch('server.clubs', [{'name': 'Test Club', 'email': 'test@test.com', 'points': '20'}]): - with patch('server.competitions', [{'name': 'Test Competition', 'numberOfPlaces': '20'}]): + with patch('server.competitions', [{'name': 'Test Competition', 'numberOfPlaces': '20', 'date': '2025-12-31 09:00:00'}]): with patch('server.flash') as mock_flash: response = client.post('/purchasePlaces', data={ 'club': 'Test Club', @@ -64,3 +65,25 @@ def test_purchase_more_than_max_places(client): mock_flash.assert_called_once_with("You cannot book more than 12 places per competition.") assert response.status_code == 302 assert response.location == '/book/Test%20Competition/Test%20Club' + + +def test_purchase_on_past_competition(client): + """Test that a purchase is blocked for a past competition.""" + # Patch the datetime class imported in server.py and give it a mock 'now' method. + from datetime import datetime as original_datetime + with patch('server.datetime') as mock_datetime: + mock_datetime.now = MagicMock(return_value=original_datetime(2025, 1, 1, 10, 0, 0)) + mock_datetime.strptime.side_effect = original_datetime.strptime + + with patch('server.clubs', [{'name': 'Test Club', 'email': 'test@test.com', 'points': '20'}]): + with patch('server.competitions', [{'name': 'Test Competition', 'numberOfPlaces': '20', 'date': '2024-12-31 09:00:00'}]): + with patch('server.flash') as mock_flash: + response = client.post('/purchasePlaces', data={ + 'club': 'Test Club', + 'competition': 'Test Competition', + 'places': '5' + }, follow_redirects=False) + + mock_flash.assert_called_once_with("Booking for past competitions is not allowed.") + assert response.status_code == 302 + assert response.location == '/book/Test%20Competition/Test%20Club' \ No newline at end of file From eeed8e2fbb161088dc64d11f32bc0e3f25553aa5 Mon Sep 17 00:00:00 2001 From: devogs Date: Mon, 8 Sep 2025 13:05:52 +0200 Subject: [PATCH 10/17] FEAT: Added datetime logic to prevent past bookings --- competitions.json | 5 +++++ server.py | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/competitions.json b/competitions.json index 039fc61bd..55b4038b6 100644 --- a/competitions.json +++ b/competitions.json @@ -9,6 +9,11 @@ "name": "Fall Classic", "date": "2020-10-22 13:30:00", "numberOfPlaces": "13" + }, + { + "name": "Winter Classic", + "date": "2025-12-25 13:30:00", + "numberOfPlaces": "18" } ] } \ No newline at end of file diff --git a/server.py b/server.py index 5debb0fc9..740983f63 100644 --- a/server.py +++ b/server.py @@ -1,4 +1,5 @@ import json +from datetime import datetime from flask import Flask,render_template,request,redirect,flash,url_for,session @@ -56,6 +57,12 @@ def purchasePlaces(): club = [c for c in clubs if c['name'] == request.form['club']][0] placesRequired = int(request.form['places']) + # FIX for Bug 4: Block bookings for past competitions + competition_date = datetime.strptime(competition['date'], "%Y-%m-%d %H:%M:%S") + if competition_date < datetime.now(): + flash("Booking for past competitions is not allowed.") + return redirect(url_for('book', competition=competition['name'], club=club['name'])) + # FIX for Bug 3: Block bookings over 12 places if placesRequired > 12: flash("You cannot book more than 12 places per competition.") From d5e1768705dd8b783d8c3ef1cbc738eead8528f0 Mon Sep 17 00:00:00 2001 From: devogs Date: Tue, 9 Sep 2025 18:44:49 +0200 Subject: [PATCH 11/17] FIX: Add test for point updates are not reflected --- tests/test_purchase.py | 154 +++++++++++++++++++++++------------------ 1 file changed, 88 insertions(+), 66 deletions(-) diff --git a/tests/test_purchase.py b/tests/test_purchase.py index 4677c3cbd..b341ebf75 100644 --- a/tests/test_purchase.py +++ b/tests/test_purchase.py @@ -2,6 +2,19 @@ from server import app from datetime import datetime from unittest.mock import patch, MagicMock +import json + + +# Mock data to be used by the tests +MOCK_CLUBS_DATA = [ + {'name': 'Test Club', 'email': 'test@test.com', 'points': '10'}, + {'name': 'Another Club', 'email': 'another@test.com', 'points': '20'} +] + +MOCK_COMPETITIONS_DATA = [ + {'name': 'Test Competition', 'numberOfPlaces': '20', 'date': '2025-12-31 09:00:00'}, + {'name': 'Past Competition', 'numberOfPlaces': '10', 'date': '2024-01-01 09:00:00'} +] @pytest.fixture @@ -11,79 +24,88 @@ def client(): yield client -def test_purchase_with_insufficient_points(client): +@patch('server.clubs', MOCK_CLUBS_DATA) +@patch('server.competitions', MOCK_COMPETITIONS_DATA) +@patch('server.flash') +def test_purchase_with_insufficient_points(mock_flash, client): """Test that a purchase is blocked when the club has insufficient points.""" - with patch('server.clubs', [{'name': 'Test Club', 'email': 'test@test.com', 'points': '10'}]): - with patch('server.competitions', [{'name': 'Test Competition', 'numberOfPlaces': '20', 'date': '2025-12-31 09:00:00'}]): - with patch('server.flash') as mock_flash: - response = client.post('/purchasePlaces', data={ - 'club': 'Test Club', - 'competition': 'Test Competition', - 'places': '11' - }, follow_redirects=False) - - mock_flash.assert_called_once_with("You do not have enough points to book 11 places. You currently have 10 points.") - assert response.status_code == 302 - assert response.location == '/book/Test%20Competition/Test%20Club' - - -def test_purchase_with_sufficient_points_and_places_without_save(client): - """Test a successful purchase with enough points and places without relying on save functions.""" - # Mock the lists to set up the test scenario - mock_clubs = [{'name': 'Test Club', 'email': 'test@test.com', 'points': '10'}] - mock_competitions = [{'name': 'Test Competition', 'numberOfPlaces': '20', 'date': '2025-12-31 09:00:00'}] - - with patch('server.clubs', mock_clubs): - with patch('server.competitions', mock_competitions): - with patch('server.flash') as mock_flash: - response = client.post('/purchasePlaces', data={ - 'club': 'Test Club', - 'competition': 'Test Competition', - 'places': '5' - }, follow_redirects=False) - - # Assertions for the happy path - mock_flash.assert_called_once_with("Great-booking complete!") - assert response.status_code == 200 - assert b'Welcome' in response.data - # Verify that the points and places were updated in the mock data - assert int(mock_clubs[0]['points']) == 5 - assert int(mock_competitions[0]['numberOfPlaces']) == 15 - - -def test_purchase_more_than_max_places(client): + response = client.post('/purchasePlaces', data={ + 'club': 'Test Club', + 'competition': 'Test Competition', + 'places': '11' + }, follow_redirects=False) + + mock_flash.assert_called_once_with("You do not have enough points to book 11 places. You currently have 10 points.") + assert response.status_code == 302 + assert response.location == '/book/Test%20Competition/Test%20Club' + + +@patch('server.clubs', MOCK_CLUBS_DATA) +@patch('server.competitions', MOCK_COMPETITIONS_DATA) +@patch('server.flash') +def test_purchase_more_than_max_places(mock_flash, client): """Test that a club cannot book more than 12 places per competition.""" - with patch('server.clubs', [{'name': 'Test Club', 'email': 'test@test.com', 'points': '20'}]): - with patch('server.competitions', [{'name': 'Test Competition', 'numberOfPlaces': '20', 'date': '2025-12-31 09:00:00'}]): - with patch('server.flash') as mock_flash: - response = client.post('/purchasePlaces', data={ - 'club': 'Test Club', - 'competition': 'Test Competition', - 'places': '13' - }, follow_redirects=False) + response = client.post('/purchasePlaces', data={ + 'club': 'Test Club', + 'competition': 'Test Competition', + 'places': '13' + }, follow_redirects=False) - mock_flash.assert_called_once_with("You cannot book more than 12 places per competition.") - assert response.status_code == 302 - assert response.location == '/book/Test%20Competition/Test%20Club' + mock_flash.assert_called_once_with("You cannot book more than 12 places per competition.") + assert response.status_code == 302 + assert response.location == '/book/Test%20Competition/Test%20Club' -def test_purchase_on_past_competition(client): +@patch('server.clubs', MOCK_CLUBS_DATA) +@patch('server.competitions', MOCK_COMPETITIONS_DATA) +@patch('server.flash') +def test_purchase_on_past_competition(mock_flash, client): """Test that a purchase is blocked for a past competition.""" - # Patch the datetime class imported in server.py and give it a mock 'now' method. from datetime import datetime as original_datetime with patch('server.datetime') as mock_datetime: - mock_datetime.now = MagicMock(return_value=original_datetime(2025, 1, 1, 10, 0, 0)) + mock_datetime.now.return_value = original_datetime(2025, 1, 1, 10, 0, 0) mock_datetime.strptime.side_effect = original_datetime.strptime - with patch('server.clubs', [{'name': 'Test Club', 'email': 'test@test.com', 'points': '20'}]): - with patch('server.competitions', [{'name': 'Test Competition', 'numberOfPlaces': '20', 'date': '2024-12-31 09:00:00'}]): - with patch('server.flash') as mock_flash: - response = client.post('/purchasePlaces', data={ - 'club': 'Test Club', - 'competition': 'Test Competition', - 'places': '5' - }, follow_redirects=False) - - mock_flash.assert_called_once_with("Booking for past competitions is not allowed.") - assert response.status_code == 302 - assert response.location == '/book/Test%20Competition/Test%20Club' \ No newline at end of file + response = client.post('/purchasePlaces', data={ + 'club': 'Test Club', + 'competition': 'Past Competition', + 'places': '5' + }, follow_redirects=False) + + mock_flash.assert_called_once_with("Booking for past competitions is not allowed.") + assert response.status_code == 302 + assert response.location == '/book/Past%20Competition/Test%20Club' + + +@patch('server.saveClubs') +@patch('server.saveCompetitions') +@patch('server.clubs', MOCK_CLUBS_DATA) +@patch('server.competitions', MOCK_COMPETITIONS_DATA) +@patch('server.flash') +def test_saving_on_successful_purchase(mock_flash, mock_save_competitions, mock_save_clubs, client): + """Test that the save functions are called on a successful purchase.""" + client.post('/purchasePlaces', data={ + 'club': 'Test Club', + 'competition': 'Test Competition', + 'places': '5' + }) + + mock_save_clubs.assert_called_once() + mock_save_competitions.assert_called_once() + mock_flash.assert_called_once_with('Great-booking complete!') + + +@patch('server.saveClubs') +@patch('server.saveCompetitions') +@patch('server.clubs', MOCK_CLUBS_DATA) +@patch('server.competitions', MOCK_COMPETITIONS_DATA) +def test_no_saving_on_failed_purchase(mock_save_competitions, mock_save_clubs, client): + """Test that the save functions are NOT called on a failed purchase.""" + client.post('/purchasePlaces', data={ + 'club': 'Test Club', + 'competition': 'Test Competition', + 'places': '11' + }) + + mock_save_clubs.assert_not_called() + mock_save_competitions.assert_not_called() \ No newline at end of file From 8d6a8cfd17447f7bdcaec72019e357c68ccc3af0 Mon Sep 17 00:00:00 2001 From: devogs Date: Tue, 9 Sep 2025 18:45:16 +0200 Subject: [PATCH 12/17] FEAT: Add data persistence and comprehensive purchase validation --- server.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server.py b/server.py index 740983f63..17465ef0c 100644 --- a/server.py +++ b/server.py @@ -14,6 +14,14 @@ def loadCompetitions(): listOfCompetitions = json.load(comps)['competitions'] return listOfCompetitions +def saveClubs(clubs_data): + with open('clubs.json', 'w') as c: + json.dump({"clubs": clubs_data}, c, indent=4) + +def saveCompetitions(competitions_data): + with open('competitions.json', 'w') as comps: + json.dump({"competitions": competitions_data}, comps, indent=4) + app = Flask(__name__) app.secret_key = 'something_special' @@ -82,6 +90,9 @@ def purchasePlaces(): competition['numberOfPlaces'] = int(competition['numberOfPlaces']) - placesRequired club['points'] = str(int(club['points']) - placesRequired) + saveClubs(clubs) + saveCompetitions(competitions) + flash('Great-booking complete!') return render_template('welcome.html', club=club, competitions=competitions) From 2b99fd392bfcd82b2ef038c8b86cf12c7cf52e6d Mon Sep 17 00:00:00 2001 From: devogs Date: Tue, 9 Sep 2025 19:09:54 +0200 Subject: [PATCH 13/17] FEAT: Add tests for points display board --- tests/test_board.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/test_board.py diff --git a/tests/test_board.py b/tests/test_board.py new file mode 100644 index 000000000..2606a60da --- /dev/null +++ b/tests/test_board.py @@ -0,0 +1,43 @@ +import pytest +from server import app +from unittest.mock import patch + + +@pytest.fixture +def client(): + app.config['TESTING'] = True + with app.test_client() as client: + yield client + + +# Mock data to simulate the clubs with different point values +MOCK_CLUBS_DATA = [ + {'name': 'Club Alpha', 'email': 'alpha@test.com', 'points': '5'}, + {'name': 'Club Beta', 'email': 'beta@test.com', 'points': '20'}, + {'name': 'Club Gamma', 'email': 'gamma@test.com', 'points': '10'} +] + + +@patch('server.clubs', MOCK_CLUBS_DATA) +def test_points_display_board(client): + """ + Test that the points display board loads and correctly sorts clubs by points. + """ + response = client.get('/pointsDisplay') + + # Assert that the page loads successfully + assert response.status_code == 200 + assert b"Club Points Board" in response.data + + # Assert that the clubs are displayed in the correct sorted order (descending) + data = response.data.decode('utf-8') + assert "Club Beta" in data + assert "Club Gamma" in data + assert "Club Alpha" in data + + # A more robust check for order: find the index of each club name in the response. + beta_index = data.find("Club Beta") + gamma_index = data.find("Club Gamma") + alpha_index = data.find("Club Alpha") + + assert beta_index < gamma_index < alpha_index \ No newline at end of file From c79158183539910f5cb888b21079f1b3d300eb15 Mon Sep 17 00:00:00 2001 From: devogs Date: Tue, 9 Sep 2025 19:11:57 +0200 Subject: [PATCH 14/17] FEAT: Implement club points display board --- server.py | 42 +++++++++++++++++++++++++++++------------- templates/points.html | 28 ++++++++++++++++++++++++++++ templates/welcome.html | 1 + 3 files changed, 58 insertions(+), 13 deletions(-) create mode 100644 templates/points.html diff --git a/server.py b/server.py index 17465ef0c..f05e32b42 100644 --- a/server.py +++ b/server.py @@ -8,7 +8,6 @@ def loadClubs(): listOfClubs = json.load(c)['clubs'] return listOfClubs - def loadCompetitions(): with open('competitions.json') as comps: listOfCompetitions = json.load(comps)['competitions'] @@ -34,18 +33,31 @@ def index(): return render_template('index.html') -@app.route('/showSummary',methods=['POST']) +@app.route('/showSummary',methods=['GET', 'POST']) def showSummary(): - found_clubs = [club for club in clubs if club['email'] == request.form['email']] - - if found_clubs: - club = found_clubs[0] - session['club_email'] = club['email'] - return render_template('welcome.html',club=club,competitions=competitions) - else: - flash("Sorry, that email was not found.") - session.pop('club_email', None) - return redirect(url_for('index')) + if request.method == 'POST': + found_clubs = [club for club in clubs if club['email'] == request.form['email']] + if found_clubs: + club = found_clubs[0] + session['club_email'] = club['email'] + return render_template('welcome.html',club=club,competitions=competitions) + else: + flash("Sorry, that email was not found.") + session.pop('club_email', None) + return redirect(url_for('index')) + + elif 'club_email' in session: + club_email = session['club_email'] + found_clubs = [c for c in clubs if c['email'] == club_email] + if found_clubs: + club = found_clubs[0] + return render_template('welcome.html', club=club, competitions=competitions) + else: + session.pop('club_email', None) + flash("Your club's data was not found. Please log in again.") + return redirect(url_for('index')) + + return redirect(url_for('index')) @app.route('/book//') @@ -96,7 +108,11 @@ def purchasePlaces(): flash('Great-booking complete!') return render_template('welcome.html', club=club, competitions=competitions) -# TODO: Add route for points display +@app.route('/pointsDisplay') +def pointsDisplay(): + # Sort clubs by points in descending order + sorted_clubs = sorted(clubs, key=lambda c: int(c['points']), reverse=True) + return render_template('points.html', clubs=sorted_clubs) @app.route('/logout') diff --git a/templates/points.html b/templates/points.html new file mode 100644 index 000000000..b809772d9 --- /dev/null +++ b/templates/points.html @@ -0,0 +1,28 @@ + + + + + Points Display + + +

Club Points Board

+ + + + + + + + + {% for club in clubs %} + + + + + {% endfor %} + +
ClubPoints
{{ club.name }}{{ club.points }}
+
+ Back to Welcome + + \ No newline at end of file diff --git a/templates/welcome.html b/templates/welcome.html index ff6b261a2..124bb5322 100644 --- a/templates/welcome.html +++ b/templates/welcome.html @@ -6,6 +6,7 @@

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

Logout + View Club Points {% with messages = get_flashed_messages()%} {% if messages %} From 34a1d83384d6ab16f51a7d8e81bc023359b31411 Mon Sep 17 00:00:00 2001 From: devogs Date: Tue, 9 Sep 2025 19:23:32 +0200 Subject: [PATCH 15/17] FEAT: Add test for clubs should not be able to book more than the competition places available --- tests/test_purchase.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_purchase.py b/tests/test_purchase.py index b341ebf75..e674f5d42 100644 --- a/tests/test_purchase.py +++ b/tests/test_purchase.py @@ -108,4 +108,20 @@ def test_no_saving_on_failed_purchase(mock_save_competitions, mock_save_clubs, c }) mock_save_clubs.assert_not_called() - mock_save_competitions.assert_not_called() \ No newline at end of file + mock_save_competitions.assert_not_called() + + +def test_purchase_more_places_than_available(client): + """Test that a club cannot book more places than are available in a competition.""" + with patch('server.clubs', [{'name': 'Test Club', 'email': 'test@test.com', 'points': '20'}]): + with patch('server.competitions', [{'name': 'Test Competition', 'numberOfPlaces': '5', 'date': '2025-12-31 09:00:00'}]): + with patch('server.flash') as mock_flash: + response = client.post('/purchasePlaces', data={ + 'club': 'Test Club', + 'competition': 'Test Competition', + 'places': '6' + }, follow_redirects=False) + + mock_flash.assert_called_once_with("Not enough places available in this competition. Only 5 places left.") + assert response.status_code == 302 + assert response.location == '/book/Test%20Competition/Test%20Club' From d4fbcf4e4667402096b95572525eb52466369122 Mon Sep 17 00:00:00 2001 From: devogs Date: Tue, 9 Sep 2025 19:24:06 +0200 Subject: [PATCH 16/17] FIX: Add a fix so clubs are not able to book more than the competition places available --- server.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/server.py b/server.py index f05e32b42..79ae68ac2 100644 --- a/server.py +++ b/server.py @@ -88,17 +88,17 @@ def purchasePlaces(): flash("You cannot book more than 12 places per competition.") return redirect(url_for('book', competition=competition['name'], club=club['name'])) - # Check if club has enough points - if int(club['points']) < placesRequired: - flash(f"You do not have enough points to book {placesRequired} places. You currently have {club['points']} points.") - return redirect(url_for('book', competition=competition['name'], club=club['name'])) - # Check if competition has enough places if int(competition['numberOfPlaces']) < placesRequired: flash(f"Not enough places available in this competition. Only {competition['numberOfPlaces']} places left.") return redirect(url_for('book', competition=competition['name'], club=club['name'])) - - # Proceed with purchase only if all conditions are met + + # Check if club has enough points + if int(club['points']) < placesRequired: + flash(f"You do not have enough points to book {placesRequired} places. You currently have {club['points']} points.") + return redirect(url_for('book', competition=competition['name'], club=club['name'])) + + # All checks passed, proceed with purchase competition['numberOfPlaces'] = int(competition['numberOfPlaces']) - placesRequired club['points'] = str(int(club['points']) - placesRequired) From a0b053832dedee6c099a922377d71f1d7f156e04 Mon Sep 17 00:00:00 2001 From: devogs Date: Tue, 9 Sep 2025 19:47:32 +0200 Subject: [PATCH 17/17] Add integration test --- tests/test_integration.py | 77 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/test_integration.py diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 000000000..aefe47ce5 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,77 @@ +import pytest +from server import app, loadClubs, loadCompetitions, saveClubs, saveCompetitions +from datetime import datetime + + +@pytest.fixture +def client(): + app.config['TESTING'] = True + with app.test_client() as client: + yield client + + +def test_successful_purchase_workflow(client): + """ + Tests the complete workflow of a successful purchase from login to saving data. + """ + # 1. Capture the initial state of the clubs and competitions. + clubs_initial = loadClubs() + competitions_initial = loadCompetitions() + + # Find a valid club and competition with a future date + if not clubs_initial or not competitions_initial: + pytest.skip("Test requires non-empty clubs.json and competitions.json files.") + + club = clubs_initial[0] + competition = None + for c in competitions_initial: + competition_date = datetime.strptime(c['date'], "%Y-%m-%d %H:%M:%S") + if competition_date > datetime.now(): + competition = c + break + + if not competition: + pytest.skip("No future competitions available to test successful purchase.") + + # Get initial values from the valid data + club_name = club['name'] + competition_name = competition['name'] + + places_to_book = 1 + + club_initial_points = int(club['points']) + competition_initial_places = int(competition['numberOfPlaces']) + club_email = club['email'] + + try: + # 2. Simulate the login POST request + response_login = client.post('/showSummary', data={'email': club_email}) + assert response_login.status_code == 200 + assert b"Welcome" in response_login.data + + # 3. Simulate the purchase POST request + response_purchase = client.post('/purchasePlaces', data={ + 'club': club_name, + 'competition': competition_name, + 'places': str(places_to_book) + }) + assert response_purchase.status_code == 200 + assert b"Great-booking complete!" in response_purchase.data + + # 4. Verify the changes were saved to the JSON files. + clubs_final = loadClubs() + competitions_final = loadCompetitions() + + club_final_points = int([c for c in clubs_final if c['name'] == club_name][0]['points']) + competition_final_places = int([c for c in competitions_final if c['name'] == competition_name][0]['numberOfPlaces']) + + expected_points = club_initial_points - places_to_book + expected_places = competition_initial_places - places_to_book + + assert club_final_points == expected_points + assert competition_final_places == expected_places + + finally: + # 5. Clean up the JSON files to their original state. + saveClubs(clubs_initial) + saveCompetitions(competitions_initial) \ No newline at end of file