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/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/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 diff --git a/server.py b/server.py index 4084baeac..79ae68ac2 100644 --- a/server.py +++ b/server.py @@ -1,5 +1,6 @@ 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 def loadClubs(): @@ -7,12 +8,19 @@ def loadClubs(): listOfClubs = json.load(c)['clubs'] return listOfClubs - def loadCompetitions(): with open('competitions.json') as comps: 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' @@ -24,10 +32,32 @@ def loadCompetitions(): def index(): return render_template('index.html') -@app.route('/showSummary',methods=['POST']) + +@app.route('/showSummary',methods=['GET', 'POST']) def showSummary(): - club = [club for club in clubs if club['email'] == request.form['email']][0] - return render_template('welcome.html',club=club,competitions=competitions) + if request.method == 'POST': + 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//') @@ -46,12 +76,43 @@ 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 + + # 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.") + 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'])) + + # 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) + + saveClubs(clubs) + saveCompetitions(competitions) + 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/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 %} + + {% endif %} + {% endwith %} \ No newline at end of file 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 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 %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb 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 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 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 diff --git a/tests/test_purchase.py b/tests/test_purchase.py new file mode 100644 index 000000000..e674f5d42 --- /dev/null +++ b/tests/test_purchase.py @@ -0,0 +1,127 @@ +import pytest +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 +def client(): + app.config['TESTING'] = True + with app.test_client() as client: + yield 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.""" + 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.""" + 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' + + +@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.""" + from datetime import datetime as original_datetime + with patch('server.datetime') as mock_datetime: + mock_datetime.now.return_value = original_datetime(2025, 1, 1, 10, 0, 0) + mock_datetime.strptime.side_effect = original_datetime.strptime + + 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() + + +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'