diff --git a/.DS_Store b/.DS_Store index 04a508e6f..88899d37e 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..3dbfbb408 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = tests/* \ No newline at end of file diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..e09bfe059 --- /dev/null +++ b/.flake8 @@ -0,0 +1,10 @@ +[flake8] +exclude = + .pytest_cache, + __pycache__, + bin, + htmlcov, + include, + lib +format = html +htmldir = flake8-report \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2cba99d87..13fea6c5a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ bin include lib .Python -tests/ .envrc -__pycache__ \ No newline at end of file +__pycache__ +.pytest_cache/ +.coverage +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 61307d2cd..b4c6817b3 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,236 @@ -# gudlift-registration +# GUDLFT Registration -1. Why +## ๐Ÿ“Œ About +This is a **proof of concept (POC)** project for a lightweight competition booking platform. The goal is to keep things as simple as possible and iterate based on user feedback. - This is a proof of concept (POC) project to show a light-weight version of our competition booking platform. The aim is the keep things as light as possible, and use feedback from the users to iterate. +--- -2. Getting Started +## โš™๏ธ Prerequisites - This project uses the following technologies: +- **Python 3.10+** (recommended: 3.11 or 3.12) +- **pip** (Python package manager, included with Python 3) +- **Git** (for cloning the repository) - * Python v3.x+ +--- - * [Flask](https://flask.palletsprojects.com/en/1.1.x/) +## ๐Ÿš€ Getting Started - Whereas Django does a lot of things for us out of the box, Flask allows us to add only what we need. - +### 1๏ธโƒฃ Clone the Repository - * [Virtual environment](https://virtualenv.pypa.io/en/stable/installation.html) +```bash +git clone https://github.com/Q1009/Python_Testing.git +cd Python_Testing +``` - This ensures you'll be able to install the correct packages without interfering with Python on your machine. +--- - Before you begin, please ensure you have this installed globally. +## ๐Ÿ› ๏ธ Installation +### Create and Activate a Virtual Environment -3. Installation +A virtual environment isolates dependencies for this project, preventing conflicts with other Python projects on your system. - - After cloning, change into the directory and type virtualenv .. This will then set up a a virtual python environment within that directory. +```bash +# Create a virtual environment in the project directory using virtualenv +virtualenv . - - Next, type source bin/activate. You should see that your command prompt has changed to the name of the folder. This means that you can install packages in here without affecting affecting files outside. To deactivate, type deactivate +# Activate the virtual environment +# On macOS/Linux: +source bin/activate - - Rather than hunting around for the packages you need, you can install in one step. Type pip install -r requirements.txt. This will install all the packages listed in the respective file. If you install a package, make sure others know by updating the requirements.txt file. An easy way to do this is pip freeze > requirements.txt +# On Windows (Command Prompt): +Scripts\activate - - Flask requires that you set an environmental variable to the python file. However you do that, you'll want to set the file to be server.py. Check [here](https://flask.palletsprojects.com/en/1.1.x/quickstart/#a-minimal-application) for more details +# On Windows (PowerShell): +.\Scripts\activate +``` - - You should now be ready to test the application. In the directory, type either flask run or python -m flask run. The app should respond with an address you should be able to go to using your browser. +Your terminal prompt should now indicate the virtual environment is active. -4. Current Setup +> **โš ๏ธ Note:** Always activate the virtual environment before running the app or tests. - The app is powered by [JSON files](https://www.tutorialspoint.com/json/json_quick_guide.htm). This is to get around having a DB until we actually need one. The main ones are: - - * competitions.json - list of competitions - * clubs.json - list of clubs with relevant information. You can look here to see what email addresses the app will accept for login. +--- -5. Testing +### Install Dependencies - You are free to use whatever testing framework you like-the main thing is that you can show what tests you are using. +With the virtual environment active, install all required packages: - We also like to show how well we're testing, so there's a module called - [coverage](https://coverage.readthedocs.io/en/coverage-5.1/) you should add to your project. +```bash +pip install --upgrade pip # Optional: upgrade pip to the latest version +pip install -r requirements.txt +``` +> **๐Ÿ’ก Tip:** If you add a new package, update `requirements.txt` with: +> ```bash +> pip freeze > requirements.txt +> ``` + +--- + +## โ–ถ๏ธ Running the Application + +Set the Flask environment variable and start the development server: + +```bash +# On macOS/Linux: +export FLASK_APP=server.py +flask run + +# On Windows (Command Prompt): +set FLASK_APP=server.py +flask run + +# On Windows (PowerShell): +$env:FLASK_APP = "server.py" +flask run +``` + +The app should start on `http://127.0.0.1:5000/`. Open this address in your browser to access the **GudLift Registration Portal**. + +--- + +## ๐Ÿ“‚ Project Structure + +The application uses **JSON files** for data storage (no database required): + +- `competitions.json` โ€“ List of available competitions +- `clubs.json` โ€“ List of clubs with their email and points + +> **๐Ÿ” Tip:** Check these files to see valid login emails and available competitions. + +--- + +## ๐Ÿงช Running Tests + +This project includes **unit tests**, **integration tests**, and **functional tests**. + +### Run All Tests + +```bash +# Install pytest if not already installed +pip install pytest + +# Run all tests +pytest +``` +### ๐Ÿ“Š Testing Results + +Here's an example of pytest testing results: + +![Pytest Test Results](./tests/test_results_screenshot.png) + + +### Run Specific Test Suites + +```bash +# Run only unit tests +pytest tests/unit/ + +# Run only integration tests +pytest tests/integration/ + +# Run only functional tests +pytest tests/functional/ + +# Run tests with verbose output +pytest -v +``` + +### Run Tests with Coverage + +To check test coverage (how much of your code is tested): + +```bash +# Install coverage if not already installed +pip install coverage + +# Run tests with coverage and generate an HTML report +pytest --cov=. --cov-report html + +#Open the report in your browser +open htmlcov/index.html # macOS +start htmlcov/index.html # Windows +xdg-open htmlcov/index.html # Linux + +``` + +### ๐Ÿ“Š Coverage Test Results + +Here's an example of coverage test results: + +![Coverage Test Results](./tests/coverage_test_results_screenshot.png) + +--- + +## ๐Ÿš€ Performance Testing with Locust + +[Locust](https://locust.io/) is used for **load testing** to simulate multiple users and check how the application performs under stress. + +### Install Locust + +```bash +# Install locust if not already installed +pip install locust +``` + +### Run Locust Tests + +```bash +locust -f tests/performance/locustfile.py +``` + +### Using the Locust Web Interface + +1. Open `http://localhost:8089` in your browser. +2. Set the **Number of total users** (e.g., 10). +3. Set the **Spawn rate** (users per second, e.g., 1). +4. Click **Start swarming**. +5. Monitor: + - **Response times** (in ms) + - **Request rate** (RPS) + - **Failure rate** + - **Number of users** + +> **๐Ÿ’ก Tip:** Stop the test with `Ctrl+C` in the terminal or click **Stop** in the web interface. + +### ๐Ÿ“Š Locust Performance Test Results + +Here's an example of performance test results with Locust: + +![Locust Performance Test Results](./tests/performance_test_results_screenshot.png) + +--- + +## ๐Ÿ” Code Quality & Linting + +This project uses **Flake8** to ensure code compliance with [PEP 8](https://peps.python.org/pep-0008/) style guidelines. + +### Generate an HTML Report + +With the virtual environment active, to generate and visualize Flake8 results in a browser: + +```bash +# Generate a detailed HTML report +flake8 --format=html --htmldir=flake8_report + +# Open the report in your browser +open flake8_report/index.html # macOS +start flake8_report/index.html # Windows +xdg-open flake8_report/index.html # Linux +``` + +### ๐Ÿ“Š Flake8 Report + +Here's an example of Flake8 html report: + +![Locust Performance Test Results](./tests/flake8_report_screenshot.png) + +--- + +## ๐Ÿ“ Notes + +- The virtual environment files (`bin/`, `Scripts/`, `lib/`, etc.) are **not committed** to Git (add them to `.gitignore`). +- Always **deactivate** the virtual environment when done: + ```bash + deactivate \ No newline at end of file diff --git a/clubs.json b/clubs.json index 1d7ad1ffe..7f9e77958 100644 --- a/clubs.json +++ b/clubs.json @@ -12,5 +12,65 @@ { "name":"She Lifts", "email": "kate@shelifts.co.uk", "points":"12" + }, + { + "name": "Power House Gym", + "email": "contact@powerhousegym.com", + "points": "8" + }, + { + "name": "Fit Nation", + "email": "info@fitnation.org", + "points": "15" + }, + { + "name": "Stronghold Fitness", + "email": "hello@strongholdfit.com", + "points": "6" + }, + { + "name": "Elite Training", + "email": "train@elitetraining.net", + "points": "10" + }, + { + "name": "Viking Strength", + "email": "admin@vikingstrength.io", + "points": "5" + }, + { + "name": "Zenith Club", + "email": "members@zenithclub.co", + "points": "11" + }, + { + "name": "Titan Fitness", + "email": "support@titanfitness.com", + "points": "7" + }, + { + "name": "Apex Athletics", + "email": "contact@apexathletics.com", + "points": "14" + }, + { + "name": "Olympus Gym", + "email": "info@olympusgym.net", + "points": "9" + }, + { + "name": "Iron Will", + "email": "admin@ironwill.fit", + "points": "13" + }, + { + "name": "Champion's Club", + "email": "members@championsclub.org", + "points": "16" + }, + { + "name": "Phoenix Rising", + "email": "hello@phoenixrising.gym", + "points": "4" } ]} \ No newline at end of file diff --git a/competitions.json b/competitions.json index 039fc61bd..2b7b6af54 100644 --- a/competitions.json +++ b/competitions.json @@ -2,13 +2,53 @@ "competitions": [ { "name": "Spring Festival", - "date": "2020-03-27 10:00:00", - "numberOfPlaces": "25" + "date": "2026-03-27 10:00:00", + "number_of_places": "25" }, { "name": "Fall Classic", - "date": "2020-10-22 13:30:00", - "numberOfPlaces": "13" + "date": "2026-10-22 13:30:00", + "number_of_places": "13" + }, + { + "name": "Summer Showdown", + "date": "2026-08-15 09:00:00", + "number_of_places": "20" + }, + { + "name": "Autumn Challenge", + "date": "2026-09-12 14:00:00", + "number_of_places": "18" + }, + { + "name": "Winter Open", + "date": "2026-11-05 11:00:00", + "number_of_places": "22" + }, + { + "name": "New Year's Cup", + "date": "2027-01-10 10:00:00", + "number_of_places": "15" + }, + { + "name": "Valentine's Clash", + "date": "2027-02-14 15:00:00", + "number_of_places": "12" + }, + { + "name": "Spring Cup", + "date": "2027-03-20 12:00:00", + "number_of_places": "20" + }, + { + "name": "Summer Slam", + "date": "2027-06-18 13:00:00", + "number_of_places": "25" + }, + { + "name": "Grand Finale", + "date": "2027-08-22 10:00:00", + "number_of_places": "30" } ] } \ No newline at end of file diff --git a/flake8_report/back.svg b/flake8_report/back.svg new file mode 100644 index 000000000..ce80d2e6d --- /dev/null +++ b/flake8_report/back.svg @@ -0,0 +1,73 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/flake8_report/file.svg b/flake8_report/file.svg new file mode 100644 index 000000000..98706cfe5 --- /dev/null +++ b/flake8_report/file.svg @@ -0,0 +1,64 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/flake8_report/index.html b/flake8_report/index.html new file mode 100644 index 000000000..d34838bd3 --- /dev/null +++ b/flake8_report/index.html @@ -0,0 +1,30 @@ + + + + flake8 violations + + + + +
+
+

flake8 violations

+

Generated on 2026-08-03 19:37 + with Installed plugins: flake8-html: 0.4.3, mccabe: 0.7.0, pycodestyle: 2.14.0, pyflakes: 3.4.0 +

+ +
+ + \ No newline at end of file diff --git a/flake8_report/styles.css b/flake8_report/styles.css new file mode 100644 index 000000000..6e0e447a6 --- /dev/null +++ b/flake8_report/styles.css @@ -0,0 +1,327 @@ +html { + font-family: sans-serif; + font-size: 90%; +} + +#masthead { + position: fixed; + left: 0; + top: 0; + right: 0; + height: 40%; +} + +h1, h2 { + font-family: sans-serif; + font-weight: normal; +} + +h1 { + color: white; + font-size: 36px; + margin-top: 1em; +} + +h1 img { + margin-right: 0.3em; +} + +h2 { + margin-top: 0; +} + +h1 a { + color: white; +} + +#versions { + color: rgba(255, 255, 255, 0.7); +} + +#page { + position: relative; + max-width: 960px; + margin: 0 auto; +} + +#index { + background-color: white; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.8); + padding: 0; + margin: 0; +} + +#index li { + list-style: none; + margin: 0; + padding: 1px 0; +} + +#index li + li { + border-top: solid silver 1px; +} + +.details p { + margin-left: 3em; + color: #888; +} + +#index a { + display: block; + padding: 0.8em 1em; + cursor: pointer; +} + +#index #all-good { + padding: 1.4em 1em 0.8em; +} + +#all-good .count .tick { + font-size: 2em; +} + +#all-good .count { + float: left; +} + +#all-good h2, +#all-good p { + margin-left: 50px; +} + +#index a:hover { + background-color: #eee; +} + +.count { + display: inline-block; + border-radius: 50%; + text-align: center; + width: 2.5em; + line-height: 2.5em; + height: 2.5em; + color: white; + margin-right: 1em; +} + +.sev-1 { + background-color: #a00; +} +.sev-2 { + background-color: #b80; +} +.sev-3 { + background-color: #28c; +} +.sev-4 { + background-color: #383; +} + +a { + text-decoration: none; +} + +#doc { + background-color: white; + margin: 1em 0; + padding: 1em; + padding-left: 1.2em; + position: relative; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.8); +} + +#doc pre { + margin: 0; + padding: 0.07em; +} + +.violations { + position: absolute; + margin: 1.2em 0 0 3em; + padding: 0.5em 1em; + font-size: 14px; + background-color: white; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.4); + display: none; +} + +.violations .count { + font-size: 70%; +} + +.violations li { + padding: 0.1em 0.3em; + list-style: none; +} + +.line-violations::before { + display: block; + content: ""; + position: absolute; + left: -1em; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: red; +} + +.code:hover .violations { + display: block; +} + +tt { + white-space: pre-wrap; + font-family: Consolas, monospace; + font-size: 10pt; +} + +tt i { + color: silver; + display: inline-block; + text-align: right; + width: 3em; + box-sizing: border-box; + height: 100%; + border-right: solid #eee 1px; + padding-right: 0.2em; +} + +.le { + background-color: #ffe8e8; + cursor: pointer; +} + +.le:hover { + background-color: #fcc; +} + +.details { + clear: both; +} + +#index .details { + border-top-style: none; + margin: 1em; +} + +ul.details { + margin-left: 0; + padding-left: 0; +} + +#index .details li { + list-style: none; + border-top-style: none; + margin: 0.3em 0; + padding: 0; +} + +#srclink { + float: right; + font-size: 36px; + margin: 0; +} + +#srclink a { + color: white; +} + +#index .details a { + padding: 0; + color: inherit; +} + +.le { + background-color: #ffe8e8; + cursor: pointer; +} + +.le.sev-1 { + background-color: #f88; +} +.le.sev-2 { + background-color: #fda; +} +.le.sev-3 { + background-color: #adf; +} + +img { + height: 1.2em; + vertical-align: -0.35em; +} + +pre { line-height: 125%; } +td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.hll { background-color: #ffffcc } +.c { color: #3D7B7B; font-style: italic } /* Comment */ +.err { border: 1px solid #F00 } /* Error */ +.k { color: #008000; font-weight: bold } /* Keyword */ +.o { color: #666 } /* Operator */ +.ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */ +.cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */ +.cp { color: #9C6500 } /* Comment.Preproc */ +.cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */ +.c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */ +.cs { color: #3D7B7B; font-style: italic } /* Comment.Special */ +.gd { color: #A00000 } /* Generic.Deleted */ +.ge { font-style: italic } /* Generic.Emph */ +.ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */ +.gr { color: #E40000 } /* Generic.Error */ +.gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.gi { color: #008400 } /* Generic.Inserted */ +.go { color: #717171 } /* Generic.Output */ +.gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.gs { font-weight: bold } /* Generic.Strong */ +.gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.gt { color: #04D } /* Generic.Traceback */ +.kc { color: #008000; font-weight: bold } /* Keyword.Constant */ +.kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ +.kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ +.kp { color: #008000 } /* Keyword.Pseudo */ +.kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ +.kt { color: #B00040 } /* Keyword.Type */ +.m { color: #666 } /* Literal.Number */ +.s { color: #BA2121 } /* Literal.String */ +.na { color: #687822 } /* Name.Attribute */ +.nb { color: #008000 } /* Name.Builtin */ +.nc { color: #00F; font-weight: bold } /* Name.Class */ +.no { color: #800 } /* Name.Constant */ +.nd { color: #A2F } /* Name.Decorator */ +.ni { color: #717171; font-weight: bold } /* Name.Entity */ +.ne { color: #CB3F38; font-weight: bold } /* Name.Exception */ +.nf { color: #00F } /* Name.Function */ +.nl { color: #767600 } /* Name.Label */ +.nn { color: #00F; font-weight: bold } /* Name.Namespace */ +.nt { color: #008000; font-weight: bold } /* Name.Tag */ +.nv { color: #19177C } /* Name.Variable */ +.ow { color: #A2F; font-weight: bold } /* Operator.Word */ +.w { color: #BBB } /* Text.Whitespace */ +.mb { color: #666 } /* Literal.Number.Bin */ +.mf { color: #666 } /* Literal.Number.Float */ +.mh { color: #666 } /* Literal.Number.Hex */ +.mi { color: #666 } /* Literal.Number.Integer */ +.mo { color: #666 } /* Literal.Number.Oct */ +.sa { color: #BA2121 } /* Literal.String.Affix */ +.sb { color: #BA2121 } /* Literal.String.Backtick */ +.sc { color: #BA2121 } /* Literal.String.Char */ +.dl { color: #BA2121 } /* Literal.String.Delimiter */ +.sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ +.s2 { color: #BA2121 } /* Literal.String.Double */ +.se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */ +.sh { color: #BA2121 } /* Literal.String.Heredoc */ +.si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */ +.sx { color: #008000 } /* Literal.String.Other */ +.sr { color: #A45A77 } /* Literal.String.Regex */ +.s1 { color: #BA2121 } /* Literal.String.Single */ +.ss { color: #19177C } /* Literal.String.Symbol */ +.bp { color: #008000 } /* Name.Builtin.Pseudo */ +.fm { color: #00F } /* Name.Function.Magic */ +.vc { color: #19177C } /* Name.Variable.Class */ +.vg { color: #19177C } /* Name.Variable.Global */ +.vi { color: #19177C } /* Name.Variable.Instance */ +.vm { color: #19177C } /* Name.Variable.Magic */ +.il { color: #666 } /* Literal.Number.Integer.Long */ \ No newline at end of file diff --git a/flake8_report/tests.unit.tempo.report.html b/flake8_report/tests.unit.tempo.report.html new file mode 100644 index 000000000..dbf858fa0 --- /dev/null +++ b/flake8_report/tests.unit.tempo.report.html @@ -0,0 +1,243 @@ + + + + flake8 violations: tests/unit/tempo.py + + + + + +
+
+ +

+ + ⬅ + tests/unit/tempo.py + +

+ + +
+ + \ No newline at end of file diff --git a/flake8_report/tests.unit.tempo.source.html b/flake8_report/tests.unit.tempo.source.html new file mode 100644 index 000000000..fa563851b --- /dev/null +++ b/flake8_report/tests.unit.tempo.source.html @@ -0,0 +1,1739 @@ + + + + tests/unit/tempo.py - flake8 annotated source + + + + +
+
+

+ + ⬅ + tests/unit/tempo.py source + +

+ +
+
1 import json +
+
2 from datetime import datetime +
+
3 from unittest.mock import mock_open, patch +
+
4 from utils import ( +
+
5 load_clubs, +
+
6 load_competitions, +
+
7 get_club_by_email, +
+
8 lower_case_email, +
+
9 strip_white_space, +
+
10 get_competition_by_name, +
+
11 get_club_by_name, +
+
12 get_club_points, +
+
13 get_competition_places, +
+
14 is_competition_bookable, +
+
15 is_booking_valid, +
+
16 update_club_points, +
+
17 update_competition_places, +
+
18 get_booking_key, +
+
19 get_logged_club, +
+
20 ) +
+
21   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
22 class TestLoadClubs: +
+
23 def test_returns_all_clubs(self, mock_clubs): +
+
24 """Case 1 โ€” valid file with multiple clubs: returns all elements.""" +
+
25 data = json.dumps({"clubs": mock_clubs}) +
+
26 with patch("builtins.open", mock_open(read_data=data)): +
+
27 result = load_clubs() +
+
28 assert len(result) == 3 +
+
29   +
+
30 def test_each_club_has_required_keys(self, mock_clubs): +
+
31 """Case 2 โ€” each club contains the name, email, and points keys.""" +
+
32 data = json.dumps({"clubs": mock_clubs}) +
+
33 with patch("builtins.open", mock_open(read_data=data)): +
+
34 result = load_clubs() +
+
35 for club in result: +
+
36 assert "name" in club +
+
37 assert "email" in club +
+
38 assert "points" in club +
+
39   +
+
40 def test_returns_single_club(self): +
+
+
    + +
  • + + E501 + + Line too long (84 > 79 characters)
  • + +
41 """Case 3 โ€” valid file with a single club: returns list with one element.""" +
+
42 club = [{"name": "Simply Lift", +
+
43 "email": "john@simplylift.co", "points": "13"}] +
+
44 data = json.dumps({"clubs": club}) +
+
45 with patch("builtins.open", mock_open(read_data=data)): +
+
46 result = load_clubs() +
+
47 assert len(result) == 1 +
+
48 assert result[0]["name"] == "Simply Lift" +
+
49   +
+
50 def test_returns_empty_list_when_clubs_is_empty(self): +
+
51 """Case 4 โ€” 'clubs' key present but empty list: returns []. """ +
+
52 data = json.dumps({"clubs": []}) +
+
53 with patch("builtins.open", mock_open(read_data=data)): +
+
54 result = load_clubs() +
+
55 assert result == [] +
+
56   +
+
57 def test_raises_file_not_found_when_file_missing(self): +
+
58 """Case 5 โ€” file not found: returns None.""" +
+
59 with patch("builtins.open", side_effect=FileNotFoundError): +
+
60 assert load_clubs() is None +
+
61   +
+
62 def test_raises_json_decode_error_on_invalid_json(self): +
+
63 """Case 6 โ€” invalid JSON content: returns None.""" +
+
64 with patch("builtins.open", mock_open(read_data="not valid json {")): +
+
65 assert load_clubs() is None +
+
66   +
+
67 def test_raises_key_error_when_clubs_key_missing(self): +
+
68 """Case 7 โ€” 'clubs' key missing from JSON: returns None.""" +
+
69 data = json.dumps({"wrong_key": []}) +
+
70 with patch("builtins.open", mock_open(read_data=data)): +
+
71 assert load_clubs() is None +
+
72   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
73 class TestLoadCompetitions: +
+
74 def test_returns_all_competitions(self, mock_competitions): +
+
75 """Case 1 โ€” valid file with multiple competitions: all elements.""" +
+
76 data = json.dumps({"competitions": mock_competitions}) +
+
77 with patch("builtins.open", mock_open(read_data=data)): +
+
78 result = load_competitions() +
+
79 assert len(result) == 2 +
+
80   +
+
81 def test_each_competition_has_required_keys(self, mock_competitions): +
+
82 """Case 2 โ€” each competition has name, date, number_of_places keys.""" +
+
83 data = json.dumps({"competitions": mock_competitions}) +
+
84 with patch("builtins.open", mock_open(read_data=data)): +
+
85 result = load_competitions() +
+
86 for competition in result: +
+
87 assert "name" in competition +
+
88 assert "date" in competition +
+
89 assert "number_of_places" in competition +
+
90   +
+
91 def test_returns_single_competition(self): +
+
+
    + +
  • + + E501 + + Line too long (81 > 79 characters)
  • + +
92 """Case 3 โ€” valid file with single competition: list with one element.""" +
+
93 competition = [{ +
+
94 "name": "Spring Festival", +
+
95 "date": "2025-03-27 10:00:00", +
+
96 "number_of_places": "25" +
+
97 }] +
+
98 data = json.dumps({"competitions": competition}) +
+
99 with patch("builtins.open", mock_open(read_data=data)): +
+
100 result = load_competitions() +
+
101 assert len(result) == 1 +
+
102 assert result[0]["name"] == "Spring Festival" +
+
103   +
+
104 def test_returns_empty_list_when_competitions_is_empty(self): +
+
105 """Case 4 โ€” 'competitions' key present but empty list: []. """ +
+
106 data = json.dumps({"competitions": []}) +
+
107 with patch("builtins.open", mock_open(read_data=data)): +
+
108 result = load_competitions() +
+
109 assert result == [] +
+
110   +
+
111 def test_raises_file_not_found_when_file_missing(self): +
+
112 """Case 5 โ€” file not found: returns None.""" +
+
113 with patch("builtins.open", side_effect=FileNotFoundError): +
+
114 assert load_competitions() is None +
+
115   +
+
116 def test_raises_json_decode_error_on_invalid_json(self): +
+
117 """Case 6 โ€” invalid JSON content: returns None.""" +
+
118 with patch("builtins.open", mock_open(read_data="not valid json {")): +
+
119 assert load_competitions() is None +
+
120   +
+
121 def test_raises_key_error_when_competitions_key_missing(self): +
+
122 """Case 7 โ€” 'competitions' key missing from JSON: returns None.""" +
+
123 data = json.dumps({"wrong_key": []}) +
+
124 with patch("builtins.open", mock_open(read_data=data)): +
+
125 assert load_competitions() is None +
+
126   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
127 class TestGetClubByEmail: +
+
128 def test_get_club_with_valid_email(self, mock_clubs): +
+
129 """Case 1 โ€” valid email: returns the corresponding club.""" +
+
130 valid_email = "john@simplylift.co" +
+
131 expected_club = { +
+
132 "name": "Simply Lift", +
+
133 "email": "john@simplylift.co", +
+
134 "points": "13" +
+
135 } +
+
136 assert get_club_by_email(valid_email, mock_clubs) == expected_club +
+
137   +
+
138 def test_get_club_with_invalid_email(self, mock_clubs): +
+
139 """Case 2 โ€” invalid email: returns None.""" +
+
140 invalid_email = "invalid@simplylift.co" +
+
141 assert get_club_by_email(invalid_email, mock_clubs) is None +
+
142   +
+
143 def test_get_club_with_empty_clubs_list(self): +
+
144 """Case 3 โ€” empty clubs list: returns None.""" +
+
145 empty_clubs = [] +
+
146 email = "john@example.com" +
+
147 assert get_club_by_email(email, empty_clubs) is None +
+
148   +
+
149 def test_get_club_with_multiple_clubs_same_email(self): +
+
150 """Case 4 โ€” multiple clubs same email: returns first club found.""" +
+
151 clubs_with_duplicate_email = [ +
+
152 {"name": "Club A", "email": "duplicate@simplylift.co", +
+
153 "points": "10"}, +
+
154 {"name": "Club B", "email": "duplicate@simplylift.co", +
+
155 "points": "20"} +
+
156 ] +
+
157 email = "duplicate@simplylift.co" +
+
158 result = get_club_by_email(email, clubs_with_duplicate_email) +
+
159 assert result == clubs_with_duplicate_email[0] +
+
160   +
+
161 def test_get_club_with_email_case_sensitivity(self, mock_clubs): +
+
162 """Case 5 โ€” email with different case: returns corresponding club.""" +
+
163 email = "John@SimplyLift.co" +
+
164 expected = { +
+
165 "name": "Simply Lift", +
+
166 "email": "john@simplylift.co", +
+
167 "points": "13" +
+
168 } +
+
169 assert get_club_by_email(email, mock_clubs) == expected +
+
170   +
+
171 def test_get_club_with_email_with_white_space(self, mock_clubs): +
+
172 """Case 6 โ€” email with spaces: returns corresponding club.""" +
+
173 email = " john@simplylift.co " +
+
174 expected = { +
+
175 "name": "Simply Lift", +
+
176 "email": "john@simplylift.co", +
+
177 "points": "13" +
+
178 } +
+
179 assert get_club_by_email(email, mock_clubs) == expected +
+
180   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
181 class TestLowerCaseEmail: +
+
182 def test_lower_case_email(self): +
+
183 """Case 1 โ€” email with uppercase: returns email in lower_case.""" +
+
184 assert lower_case_email("John@SimplyLift.co") == "john@simplylift.co" +
+
185   +
+
186 def test_lower_case_email_already_lower_case(self): +
+
187 """Case 2 โ€” email already lower_case: returns same email.""" +
+
188 assert lower_case_email("john@simplylift.co") == "john@simplylift.co" +
+
189   +
+
190 def test_lower_case_email_with_white_space(self): +
+
191 """Case 3 โ€” email with spaces: lower_case with spaces.""" +
+
192 assert ( +
+
193 lower_case_email(" John@SimplyLift.co ") +
+
194 == " john@simplylift.co " +
+
195 ) +
+
196   +
+
197 def test_lower_case_email_empty_string(self): +
+
198 """Case 4 โ€” empty email: returns an empty string.""" +
+
199 assert lower_case_email("") == "" +
+
200   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
201 class TestStripWhiteSpace: +
+
202 def test_strip_white_space(self): +
+
203 """Case 1 โ€” email with spaces before/after: without spaces.""" +
+
+
    + +
  • + + E501 + + Line too long (82 > 79 characters)
  • + +
204 assert strip_white_space(" john@simplylift.co ") == "john@simplylift.co" +
+
205   +
+
206 def test_strip_white_space_no_spaces(self): +
+
207 """Case 2 โ€” email without spaces: returns same email.""" +
+
208 assert strip_white_space("john@simplylift.co") == "john@simplylift.co" +
+
209   +
+
210 def test_strip_white_space_only_spaces(self): +
+
211 """Case 3 โ€” email with only spaces: returns empty string.""" +
+
212 assert strip_white_space(" ") == "" +
+
213   +
+
214 def test_strip_white_space_empty_string(self): +
+
215 """Case 4 โ€” empty email: returns an empty string.""" +
+
216 assert strip_white_space("") == "" +
+
217   +
+
218 def test_strip_white_space_with_tabs_and_newlines(self): +
+
219 """Case 5 โ€” email with tabs/newlines: without tabs/newlines.""" +
+
220 assert strip_white_space("\n\t john@simplylift.co \n\t") == ( +
+
221 "john@simplylift.co" +
+
222 ) +
+
223   +
+
224 def test_strip_white_space_with_internal_spaces(self): +
+
225 """Case 6 โ€” email with internal spaces: only trims edges.""" +
+
226 assert ( +
+
227 strip_white_space(" john @ simplylift . co ") +
+
228 == "john @ simplylift . co" +
+
229 ) +
+
230   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
231 class TestGetClubByName: +
+
232 def test_get_club_with_valid_name(self, mock_clubs): +
+
233 """Case 1 โ€” valid club name: returns the corresponding club.""" +
+
234 expected = { +
+
235 "name": "Simply Lift", +
+
236 "email": "john@simplylift.co", +
+
237 "points": "13" +
+
238 } +
+
239 assert get_club_by_name("Simply Lift", mock_clubs) == expected +
+
240   +
+
241 def test_get_club_with_invalid_name(self, mock_clubs): +
+
242 """Case 2 โ€” invalid club name: returns None.""" +
+
243 assert get_club_by_name("Nonexistent Club", mock_clubs) is None +
+
244   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
245 class TestGetCompetitionByName: +
+
246 def test_get_competition_with_valid_name(self, mock_competitions): +
+
247 """Case 1 โ€” valid competition name: returns the competition.""" +
+
248 expected = { +
+
249 "name": "Spring Festival", +
+
250 "date": "2025-03-27 10:00:00", +
+
251 "number_of_places": "25" +
+
252 } +
+
253 assert ( +
+
254 get_competition_by_name("Spring Festival", mock_competitions) +
+
255 == expected +
+
256 ) +
+
257   +
+
258 def test_get_competition_with_invalid_name(self, mock_competitions): +
+
259 """Case 2 โ€” invalid competition name: returns None.""" +
+
260 assert ( +
+
+
    + +
  • + + E501 + + Line too long (81 > 79 characters)
  • + +
261 get_competition_by_name("Nonexistent Competition", mock_competitions) +
+
262 is None +
+
263 ) +
+
264   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
265 class TestGetClubPoints: +
+
266 def test_get_club_points_with_valid_club(self, mock_clubs): +
+
267 """Case 1 โ€” valid club: returns the number of points.""" +
+
268 club = { +
+
269 "name": "Simply Lift", +
+
270 "email": "john@simplylift.co", +
+
271 "points": "13" +
+
272 } +
+
273 assert get_club_points(club) == 13 +
+
274   +
+
275 def test_get_club_points_with_invalid_club(self, mock_clubs): +
+
276 """Case 2 โ€” invalid club: returns None.""" +
+
277 club = {"name": "Invalid Club", "email": "invalid@club.co"} +
+
278 assert get_club_points(club) is None +
+
279   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
280 class TestGetCompetitionPlaces: +
+
+
    + +
  • + + E501 + + Line too long (84 > 79 characters)
  • + +
281 def test_get_competition_places_with_valid_competition(self, mock_competitions): +
+
282 """Case 1 โ€” valid competition: returns available places.""" +
+
283 competition = { +
+
284 "name": "Spring Festival", +
+
285 "date": "2025-03-27 10:00:00", +
+
286 "number_of_places": "25" +
+
287 } +
+
288 assert get_competition_places(competition) == 25 +
+
289   +
+
290 def test_get_competition_places_with_invalid_competition(self, +
+
+
    + +
  • + + E128 + + Continuation line under-indented for visual indent
  • + +
291 mock_competitions): +
+
292 """Case 2 โ€” invalid competition: returns None.""" +
+
293 competition = {"name": "Invalid", "date": "2025-01-01 00:00:00"} +
+
294 assert get_competition_places(competition) is None +
+
295   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
296 class TestIsCompetitionBookable: +
+
297 def test_returns_true_for_future_competition_with_places(self): +
+
298 """Case 1 โ€” future date and places > 0: returns True.""" +
+
299 now = datetime(2026, 6, 22, 12, 0, 0) +
+
300 competition = { +
+
301 "name": "Future Open", +
+
302 "date": "2026-06-23 10:00:00", +
+
303 "number_of_places": "5", +
+
304 } +
+
305 assert is_competition_bookable(competition, now=now) is True +
+
306   +
+
307 def test_returns_false_for_past_competition(self): +
+
308 """Case 2 โ€” past date: returns False.""" +
+
309 now = datetime(2026, 6, 22, 12, 0, 0) +
+
310 competition = { +
+
311 "name": "Past Open", +
+
312 "date": "2026-06-21 10:00:00", +
+
313 "number_of_places": "5", +
+
314 } +
+
315 assert is_competition_bookable(competition, now=now) is False +
+
316   +
+
317 def test_returns_false_when_no_places_available(self): +
+
318 """Case 3 โ€” no places available: returns False.""" +
+
319 now = datetime(2026, 6, 22, 12, 0, 0) +
+
320 competition = { +
+
321 "name": "No Places", +
+
322 "date": "2026-06-23 10:00:00", +
+
323 "number_of_places": "0", +
+
324 } +
+
325 assert is_competition_bookable(competition, now=now) is False +
+
326   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
327 class TestIsBookingValid: +
+
328 def test_validate_booking_with_valid_points_and_places(self): +
+
329 """Case 1 โ€” enough points and places: returns empty list.""" +
+
330 assert is_booking_valid(10, 5, 3) == [] +
+
331   +
+
332 def test_validate_booking_with_insufficient_club_points(self): +
+
333 """Case 2 โ€” insufficient club points: returns error message.""" +
+
334 result = is_booking_valid(2, 5, 3) +
+
335 expected = [ +
+
336 "Not enough points available in your club to book " +
+
337 "the requested number of places." +
+
338 ] +
+
339 assert result == expected +
+
340   +
+
341 def test_validate_booking_with_insufficient_competition_places(self): +
+
342 """Case 3 โ€” insufficient competition places: returns error message.""" +
+
343 result = is_booking_valid(10, 2, 3) +
+
344 expected = ["Not enough places available in this competition."] +
+
345 assert result == expected +
+
346   +
+
347 def test_validate_booking_with_insufficient_club_points_and_places(self): +
+
348 """Case 4 โ€” insufficient points and places: returns both errors.""" +
+
349 result = is_booking_valid(2, 2, 3) +
+
350 expected = [ +
+
351 "Not enough places available in this competition.", +
+
352 "Not enough points available in your club to book " +
+
353 "the requested number of places.", +
+
354 ] +
+
355 assert result == expected +
+
356   +
+
357 def test_validate_booking_with_zero_places_requested(self): +
+
358 """Case 5 โ€” request to book zero places: returns error message.""" +
+
359 result = is_booking_valid(10, 5, 0) +
+
360 expected = ["You need to book at least one place."] +
+
361 assert result == expected +
+
362   +
+
363 def test_validate_booking_with_negative_places_requested(self): +
+
364 """Case 6 โ€” request negative places: returns error message.""" +
+
365 result = is_booking_valid(10, 5, -1) +
+
366 expected = ["You cannot book a negative number of places."] +
+
367 assert result == expected +
+
368   +
+
369 def test_validate_booking_with_places_exceeding_max_value(self): +
+
370 """Case 7 โ€” request more than 12 places: returns error message.""" +
+
371 result = is_booking_valid(15, 25, 13) +
+
372 expected = ["You cannot book more than 12 places per competition."] +
+
373 assert result == expected +
+
374   +
+
375 def test_validate_booking_with_cumulative_places_exceeding_twelve(self): +
+
376 """Case 8 โ€” cumulative > 12: returns error.""" +
+
377 result = is_booking_valid(20, 20, 3, places_already_booked=10) +
+
378 expected = ["You cannot book more than 12 places per competition."] +
+
379 assert result == expected +
+
380   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
381 class TestUpdateClubPoints: +
+
382 def test_update_club_points_with_valid_deduction(self): +
+
383 """Case 1 โ€” valid deduction: updates club points.""" +
+
384 club = {"name": "Simply Lift", +
+
385 "email": "john@simplylift.com", "points": "15"} +
+
386 assert update_club_points(club, 5) is True +
+
387 assert club["points"] == "10" +
+
388   +
+
389 def test_update_club_points_with_deduction_exceeding_current_points(self): +
+
390 """Case 2 โ€” deduction exceeds current: no update, returns False.""" +
+
391 club = {"name": "Simply Lift", +
+
392 "email": "john@simplylift.com", "points": "5"} +
+
393 assert update_club_points(club, 10) is False +
+
394 assert club["points"] == "5" +
+
395   +
+
396 def test_update_club_points_with_invalid_club(self): +
+
397 """Case 3 โ€” invalid club: no update, returns False.""" +
+
398 assert update_club_points("Not a dict", 5) is False +
+
399   +
+
400 def test_update_club_points_with_non_integer_points(self): +
+
401 """Case 4 โ€” non-integer points: no update, returns False.""" +
+
402 club = {"name": "Simply Lift", +
+
403 "email": "john@simplylift.com", "points": "not a number"} +
+
404 assert update_club_points(club, 5) is False +
+
405 assert club["points"] == "not a number" +
+
406   +
+
407 def test_update_club_points_with_negative_deduction(self): +
+
408 """Case 5 โ€” negative deduction: no update, returns False.""" +
+
409 club = {"name": "Simply Lift", +
+
410 "email": "john@simplylift.com", "points": "15"} +
+
411 assert update_club_points(club, -5) is False +
+
412 assert club["points"] == "15" +
+
413   +
+
414 def test_update_club_points_with_zero_deduction(self): +
+
415 """Case 6 โ€” zero deduction: no change, returns True.""" +
+
416 club = {"name": "Simply Lift", +
+
417 "email": "john@simplylift.com", "points": "15"} +
+
418 assert update_club_points(club, 0) is True +
+
419 assert club["points"] == "15" +
+
420   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
421 class TestUpdateCompetitionPlaces: +
+
422 def test_update_competition_places_with_valid_deduction(self): +
+
423 """Case 1 โ€” valid deduction: updates competition places.""" +
+
424 competition = { +
+
425 "name": "Fall Classic", +
+
426 "date": "2026-10-22 13:30:00", +
+
427 "number_of_places": "13" +
+
428 } +
+
429 assert update_competition_places(competition, 5) is True +
+
430 assert competition["number_of_places"] == "8" +
+
431   +
+
432 def test_update_competition_places_with_deduction_exceeding_places(self): +
+
433 """Case 2 โ€” deduction exceeds places: no update, returns False.""" +
+
434 competition = { +
+
435 "name": "Spring Festival", +
+
436 "date": "2025-03-27 10:00:00", +
+
437 "number_of_places": "5" +
+
438 } +
+
439 assert update_competition_places(competition, 10) is False +
+
440 assert competition["number_of_places"] == "5" +
+
441   +
+
442 def test_update_competition_places_with_invalid_competition(self): +
+
443 """Case 3 โ€” invalid competition: no update, returns False.""" +
+
444 assert update_competition_places("Not a dict", 5) is False +
+
445   +
+
446 def test_update_competition_places_with_non_integer_places(self): +
+
447 """Case 4 โ€” non-integer places: no update, returns False.""" +
+
448 competition = { +
+
449 "name": "Fall Classic", +
+
450 "date": "2026-10-22 13:30:00", +
+
451 "number_of_places": "not a number" +
+
452 } +
+
453 assert update_competition_places(competition, 5) is False +
+
454 assert competition["number_of_places"] == "not a number" +
+
455   +
+
456 def test_update_competition_places_with_negative_deduction(self): +
+
457 """Case 5 โ€” negative deduction: no update, returns False.""" +
+
458 competition = { +
+
459 "name": "Fall Classic", +
+
460 "date": "2026-10-22 13:30:00", +
+
461 "number_of_places": "13" +
+
462 } +
+
463 assert update_competition_places(competition, -5) is False +
+
464 assert competition["number_of_places"] == "13" +
+
465   +
+
466 def test_update_competition_places_with_zero_deduction(self): +
+
467 """Case 6 โ€” zero deduction: no change, returns True.""" +
+
468 competition = { +
+
469 "name": "Fall Classic", +
+
470 "date": "2026-10-22 13:30:00", +
+
471 "number_of_places": "13" +
+
472 } +
+
473 assert update_competition_places(competition, 0) is True +
+
474 assert competition["number_of_places"] == "13" +
+
475   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
476 class TestGetBookingKey: +
+
477 def test_get_booking_key_with_valid_club_and_competition(self, +
+
478 mock_clubs, +
+
+
    + +
  • + + E501 + + Line too long (80 > 79 characters)
  • + +
479 mock_competitions): +
+
480 """Case 1 โ€” valid club and competition: returns booking key.""" +
+
481 club = mock_clubs[0] +
+
482 competition = mock_competitions[0] +
+
483 expected = f"{club['name']}::{competition['name']}" +
+
484 assert get_booking_key(club['name'], competition['name']) == expected +
+
485   +
+
+
    + +
  • + + E302 + + Expected 2 blank lines, found 1
  • + +
486 class TestGetLoggedClub: +
+
+
    + +
  • + + E501 + + Line too long (83 > 79 characters)
  • + +
487 def test_get_logged_club_with_valid_session(self, request_session, mock_clubs): +
+
488 """Case 1 โ€” valid session: returns corresponding club.""" +
+
489 with request_session(mock_clubs[0]['email']): +
+
490 assert get_logged_club() == mock_clubs[0] +
+
491   +
+
492 def test_get_logged_club_with_no_session(self, request_session): +
+
493 """Case 2 โ€” no session: returns None.""" +
+
494 with request_session(): +
+
495 assert get_logged_club() is None +
+
496   +
+
497 def test_get_logged_club_with_invalid_session_data(self, request_session): +
+
498 """Case 3 โ€” invalid session: returns None.""" +
+
499 with request_session("invalid_email@example.com"): +
+
+
    + +
  • + + W292 + + No newline at end of file
  • + +
500 assert get_logged_club() is None +
+ +
+
+ + \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..5c883ba74 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +testpaths = tests +filterwarnings = + ignore:ast\.Str is deprecated and will be removed in Python 3\.14; use ast\.Constant instead:DeprecationWarning + ignore:Constant\.__init__ got an unexpected keyword argument 's'\. Support for arbitrary keyword arguments is deprecated and will be removed in Python 3\.15\.:DeprecationWarning + ignore:Attribute s is deprecated and will be removed in Python 3\.14; use value instead:DeprecationWarning + ignore:Constant\.__init__ missing 1 required positional argument.*:DeprecationWarning + ignore:datetime\.datetime\.utcfromtimestamp\(\) is deprecated and scheduled for removal in a future version\..*:DeprecationWarning \ No newline at end of file diff --git a/pyvenv.cfg b/pyvenv.cfg new file mode 100644 index 000000000..5d632cc2b --- /dev/null +++ b/pyvenv.cfg @@ -0,0 +1,11 @@ +home = /Library/Frameworks/Python.framework/Versions/3.13/bin +implementation = CPython +version_info = 3.13.3.final.0 +version = 3.13.3 +executable = /Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13 +command = /Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13 -m virtualenv /Users/quentintellier/Documents/2 - Python Projects/P11_2 - GUฬˆDLFT/Python_Testing +virtualenv = 21.2.4 +include-system-site-packages = false +base-prefix = /Library/Frameworks/Python.framework/Versions/3.13 +base-exec-prefix = /Library/Frameworks/Python.framework/Versions/3.13 +base-executable = /Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13 diff --git a/requirements.txt b/requirements.txt index 139affa05..6d633dee2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,56 @@ -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==26.1.0 +bidict==0.23.1 +blinker==1.9.0 +brotli==1.2.0 +certifi==2026.6.17 +charset-normalizer==3.4.9 +click==8.4.2 +ConfigArgParse==1.7.5 +coverage==7.14.1 +flake8==7.3.0 +flake8-html==0.4.3 +Flask==3.1.3 +flask-cors==6.0.5 +Flask-Login==0.6.3 +Flask-Testing==0.8.1 +gevent==25.9.1 +geventhttpclient==2.3.9 +greenlet==3.5.3 +h11==0.16.0 +idna==3.18 +iniconfig==2.3.0 +itsdangerous==2.2.0 +Jinja2==3.1.6 +locust==2.45.0 +MarkupSafe==3.0.3 +mccabe==0.7.0 +msgpack==1.2.1 +outcome==1.3.0.post0 +packaging==26.2 +pluggy==1.6.0 +psutil==7.2.2 +pycodestyle==2.14.0 +pyflakes==3.4.0 +Pygments==2.20.0 +PySocks==1.7.1 +pytest==9.0.3 +pytest-cov==7.1.0 +pytest-flask==1.3.0 +pytest-mock==3.15.1 +python-engineio==4.13.3 +python-socketio==5.16.3 +pyzmq==27.1.0 +requests==2.34.2 +selenium==4.45.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.7.0 +websocket-client==1.9.0 +Werkzeug==3.1.8 +wsproto==1.3.2 +zope.event==6.2 +zope.interface==8.5 diff --git a/server.py b/server.py index 4084baeac..7fc5af6aa 100644 --- a/server.py +++ b/server.py @@ -1,59 +1,282 @@ -import json -from flask import Flask,render_template,request,redirect,flash,url_for +from flask import ( + Flask, + current_app, + flash, + redirect, + render_template, + request, session, + url_for, +) +from utils import ( + load_clubs, + load_competitions, + get_club_by_email, + get_competition_by_name, + get_club_by_name, + get_club_points, + get_competition_places, + is_competition_bookable, + is_booking_valid, + update_club_points, + update_competition_places, + get_booking_key, + require_login, + logout_and_redirect, + build_competitions_view, + clear_session_keeping_flashes, +) -def loadClubs(): - with open('clubs.json') as c: - listOfClubs = json.load(c)['clubs'] - return listOfClubs +def create_app(config=None, clubs=None, competitions=None): + """ + Create and configure the Flask application. + Args: + config (dict, optional): Configuration dictionary + to update the app config. + clubs (list, optional): List of clubs to use. + If None, loads from clubs.json. + competitions (list, optional): List of competitions to use. + If None, loads from competitions.json. -def loadCompetitions(): - with open('competitions.json') as comps: - listOfCompetitions = json.load(comps)['competitions'] - return listOfCompetitions + Returns: + Flask: Configured Flask application instance. + """ + app = Flask(__name__) + app.secret_key = 'something_special' + if config: + app.config.update(config) + app.config['COMPETITIONS'] = ( + competitions if competitions is not None + else load_competitions() + ) + app.config['CLUBS'] = ( + clubs if clubs is not None + else load_clubs() + ) + app.config.setdefault('BOOKINGS_BY_CLUB_COMPETITION', {}) -app = Flask(__name__) -app.secret_key = 'something_special' + def render_welcome(club, competitions): + """ + Render the welcome/dashboard template for a logged-in club. -competitions = loadCompetitions() -clubs = loadClubs() + Args: + club (dict): Club dictionary containing name, email, and points. + competitions (list): List of competition dictionaries. -@app.route('/') -def index(): - return render_template('index.html') + Returns: + Response: Rendered template response for the welcome page. + """ + return render_template( + 'welcome.html', + club=club, + competitions=build_competitions_view(competitions), + ) -@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('/') + def index(): + """ + Render the login/index page. + Clears any existing session data (except flashed messages) + before rendering. -@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) + Returns: + Response: Rendered template for the login page. + """ + clear_session_keeping_flashes() + return render_template('index.html') + @app.route('/dashboard') + def dashboard(): + """ + Render the dashboard for a logged-in club. -@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) + Requires an active login session. If not logged in, + redirects to the login page. + Returns: + Response: Rendered welcome template if logged in, + or redirect response to login page if not. + """ + club = require_login() + if club is None: + return logout_and_redirect() + available_competitions = current_app.config['COMPETITIONS'] + return render_welcome(club, available_competitions) -# TODO: Add route for points display + @app.route('/points_board') + def points_board(): + """ + Render the public points board showing all clubs and their points. + Returns: + Response: Rendered template for the points board + if clubs are loaded, or redirect to index with error + flash message if clubs data fails to load. + """ + available_clubs = current_app.config['CLUBS'] -@app.route('/logout') -def logout(): - return redirect(url_for('index')) \ No newline at end of file + if available_clubs is None: + flash("Error loading clubs data.") + return redirect(url_for('index')) + + return render_template('points_board.html', clubs=available_clubs) + + @app.route('/show_summary', methods=['POST']) + def show_summary(): + """ + Handle login form submission. + + Validates the submitted email against registered clubs. + On success, creates a session and redirects to the dashboard. + On failure, flashes an error and redirects to login. + + Returns: + Response: Redirect to dashboard if login succeeds, + or redirect to login with error message if login fails. + """ + available_clubs = current_app.config['CLUBS'] + available_competitions = current_app.config['COMPETITIONS'] + + if available_clubs is None or available_competitions is None: + flash("Error loading clubs or competitions data.") + return logout_and_redirect() + + club = get_club_by_email(request.form['email'], available_clubs) + if club: + session['club_email'] = club['email'] + session['club_name'] = club['name'] + return render_welcome(club, available_competitions) + + flash("Unfortunately, the email you entered was not found.") + return logout_and_redirect() + + @app.route('/book//') + def book(competition, club): + """ + Render the booking page for a specific competition and club. + + Args: + competition (str): Name of the competition to book. + club (str): Name of the club making the booking. + + Returns: + Response: Rendered booking template if all validations pass, + or redirect to welcome/dashboard with error message if + validation fails. + """ + available_clubs = current_app.config['CLUBS'] + available_competitions = current_app.config['COMPETITIONS'] + logged_club = require_login() + + if logged_club is None: + return logout_and_redirect() + + if available_clubs is None or available_competitions is None: + flash("Error loading clubs or competitions data.") + return logout_and_redirect() + + found_competition = get_competition_by_name( + competition, available_competitions) + found_club = get_club_by_name(club, available_clubs) + + if found_club is None or found_club['name'] != logged_club['name']: + flash("Invalid booking URL. Please check the club name.") + return render_welcome(logged_club, available_competitions) + + if found_competition is None: + flash("Invalid booking URL. Please check the competition name.") + return render_welcome(found_club, available_competitions) + + if not is_competition_bookable(found_competition): + flash("This competition is no longer open for booking.") + return render_welcome(found_club, available_competitions) + + return render_template( + 'booking.html', club=found_club, competition=found_competition) + + @app.route('/purchase_places', methods=['POST']) + def purchase_places(): + """ + Process a booking request to purchase places in a competition. + + Validates the request, updates club points and competition + places on success, and flashes appropriate messages for any errors. + + Returns: + Response: Redirect to welcome/dashboard with success + or error messages. + """ + available_clubs = current_app.config['CLUBS'] + available_competitions = current_app.config['COMPETITIONS'] + logged_club = require_login() + + if logged_club is None: + return logout_and_redirect() + + if available_clubs is None or available_competitions is None: + flash("Error loading clubs or competitions data.") + return logout_and_redirect() + + competition = get_competition_by_name( + request.form['competition'], available_competitions) + club = logged_club + places_required = int(request.form['places']) + booking_key = get_booking_key( + club['name'], request.form['competition']) + places_already_booked = ( + current_app.config['BOOKINGS_BY_CLUB_COMPETITION'].get( + booking_key, 0 + ) + ) + + if competition is None or club is None: + flash( + "Invalid booking request. Please check the club " + "and competition names.") + return logout_and_redirect() + + if not is_competition_bookable(competition): + flash("This competition is no longer open for booking.") + return render_welcome(club, available_competitions) + + validation_errors = is_booking_valid( + get_club_points(club), + get_competition_places(competition), + places_required, + places_already_booked=places_already_booked, + ) + + if validation_errors: + for error in validation_errors: + flash(error) + return render_welcome(club, available_competitions) + + update_club_points(club, places_required) + update_competition_places(competition, places_required) + current_app.config['BOOKINGS_BY_CLUB_COMPETITION'][booking_key] = ( + places_already_booked + places_required + ) + flash(f'Booking complete: {places_required} places purchased.') + return render_welcome(club, available_competitions) + + @app.route('/logout') + def logout(): + """ + Log out the current user. + + Clears the session and flashes a logout message. + + Returns: + Response: Redirect to the login page with + a logout confirmation message. + """ + flash("You have been logged out.") + return logout_and_redirect() + + return app + + +app = create_app() diff --git a/templates/booking.html b/templates/booking.html index 06ae1156c..1fd799f7c 100644 --- a/templates/booking.html +++ b/templates/booking.html @@ -1,3 +1,4 @@ + @@ -5,13 +6,42 @@ Booking for {{competition['name']}} || GUDLFT -

{{competition['name']}}

- Places available: {{competition['numberOfPlaces']}} -
- - - - -
+
+

GUDLFT Booking

+ {% if session.get('club_name') %} +

Logged in as {{ session.get('club_name') }}.

+ {% endif %} + +
+ +
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
+
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+
+ {% endif %} + {% endwith %} + +

{{ competition['name'] }}

+

Places available: {{ competition['number_of_places'] }}

+ + {% set max_places = [12, club['points']|int, competition['number_of_places']|int] | min %} +
+ + + + +
+
\ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 926526b7d..61f305952 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,3 +1,4 @@ + @@ -5,12 +6,38 @@ GUDLFT Registration -

Welcome to the GUDLFT Registration Portal!

- Please enter your secretary email to continue: -
- - - -
+
+

Welcome to the GUDLFT Registration Portal!

+ +
+ +
+ {% if session.get('club_name') %} +

Logged in as {{ session.get('club_name') }}.

+ {% endif %} + + {% with messages = get_flashed_messages() %} + {% if messages %} +
+
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+
+ {% endif %} + {% endwith %} + +

Please enter your secretary email to continue:

+
+ + + +
+
\ No newline at end of file diff --git a/templates/points_board.html b/templates/points_board.html new file mode 100644 index 000000000..6fceda3fd --- /dev/null +++ b/templates/points_board.html @@ -0,0 +1,43 @@ + + + + + + Public Points Board | GUDLFT Registration + + +
+

Public Points Board

+ +
+ +
+ + + + + + + + + + {% for club in clubs %} + + + + + {% endfor %} + +
Current club points
ClubAvailable Points
{{ club['name'] }}{{ club['points'] }}
+
+ + diff --git a/templates/welcome.html b/templates/welcome.html index ff6b261a2..807375589 100644 --- a/templates/welcome.html +++ b/templates/welcome.html @@ -1,3 +1,4 @@ + @@ -5,32 +6,47 @@ Summary | GUDLFT Registration -

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

Logout +
+

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

+ {% if session.get('club_name') %} +

Logged in as {{ session.get('club_name') }}.

+ {% endif %} + +
- {% with messages = get_flashed_messages()%} - {% if messages %} +
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
+
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+
+ {% endif %} + {% endwith %} + +

Points available: {{ club['points'] }}

+

Competitions:

    - {% for message in messages %} -
  • {{message}}
  • - {% endfor %} -
- {% endif%} - Points available: {{club['points']}} -

Competitions:

-
    - {% for comp in competitions%} -
  • - {{comp['name']}}
    - Date: {{comp['date']}}
    - Number of Places: {{comp['numberOfPlaces']}} - {%if comp['numberOfPlaces']|int >0%} - Book Places - {%endif%} -
  • -
    - {% endfor %} -
- {%endwith%} + {% for comp in competitions %} +
  • +

    {{ comp['name'] }}

    +

    Date: {{ comp['date'] }}

    +

    Number of Places: {{ comp['number_of_places'] }}

    + {% if comp['can_book'] %} + Book Places + {% endif %} +
  • + {% endfor %} + +
    \ No newline at end of file diff --git a/tests/.DS_Store b/tests/.DS_Store new file mode 100644 index 000000000..65b661378 Binary files /dev/null and b/tests/.DS_Store differ 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..5f15e675e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,94 @@ +import pytest +import multiprocessing as mp +from contextlib import contextmanager +from flask import session +from server import create_app + + +# LiveServerTestCase uses multiprocessing; on macOS/Python 3.13, spawn can fail +# to pickle Flask-Testing's local worker function. Prefer fork for test runs. +try: + mp.set_start_method("fork") +except RuntimeError: + # Start method already set by the test runner/interpreter. + pass +except ValueError: + # Fallback for platforms where fork is unavailable. + pass + + +@pytest.fixture +def app(mock_clubs, mock_competitions): + return create_app( + {"TESTING": True}, + clubs=mock_clubs, + competitions=mock_competitions, + ) + + +@pytest.fixture +def client(app): + """Flask test client with testing mode enabled.""" + with app.test_client() as client: + yield client + + +@pytest.fixture +def login_as_valid_user(client): + def _login(email='john@simplylift.co'): + return client.post( + '/show_summary', + data={'email': email}, + follow_redirects=True, + ) + + return _login + + +@pytest.fixture +def mock_clubs(): + 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" + }, + ] + + +@pytest.fixture +def mock_competitions(): + return [ + { + "name": "Spring Festival", + "date": "2025-03-27 10:00:00", + "number_of_places": "25" + }, + { + "name": "Fall Classic", + "date": "2026-10-22 13:30:00", + "number_of_places": "13" + }, + ] + + +@pytest.fixture +def request_session(app): + @contextmanager + def _request_session(club_email=None): + with app.test_request_context('/'): + if club_email is not None: + session['club_email'] = club_email + yield + + return _request_session diff --git a/tests/coverage_test_results_screenshot.png b/tests/coverage_test_results_screenshot.png new file mode 100644 index 000000000..97fcb0ad1 Binary files /dev/null and b/tests/coverage_test_results_screenshot.png differ diff --git a/tests/flake8_report_screenshot.png b/tests/flake8_report_screenshot.png new file mode 100644 index 000000000..f1d533d1a Binary files /dev/null and b/tests/flake8_report_screenshot.png differ diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/functional/geckodriver b/tests/functional/geckodriver new file mode 100755 index 000000000..c672e339d Binary files /dev/null and b/tests/functional/geckodriver differ diff --git a/tests/functional/test_authentication.py b/tests/functional/test_authentication.py new file mode 100644 index 000000000..2161ee61e --- /dev/null +++ b/tests/functional/test_authentication.py @@ -0,0 +1,47 @@ +from selenium import webdriver +from selenium.webdriver.common.by import By +from flask_testing import LiveServerTestCase +from server import create_app + + +class TestAuthentication(LiveServerTestCase): + def create_app(self): + return create_app() + + def setUp(self): + # Ensure you have the GeckoDriver installed and in your PATH + self.driver = webdriver.Firefox() + self.driver.get(self.get_server_url()) + + def tearDown(self): + self.driver.quit() + + def test_login_page_loads(self): + self.driver.get(self.get_server_url()) + assert self.driver.current_url in ( + 'http://127.0.0.1:5000/', 'http://localhost:5000/') + self.assertIn("Welcome to the GUDLFT Registration Portal!", + self.driver.page_source) + + def test_login_with_valid_credentials(self): + email_input = self.driver.find_element(By.ID, "email") + email_input.send_keys("john@simplylift.co") + login_button = self.driver.find_element(By.ID, "login") + login_button.click() + self.assertIn("Welcome, Simply Lift", self.driver.page_source) + + def test_login_with_invalid_credentials(self): + # 1) The user attempts to log in with an invalid identifier. + email_input = self.driver.find_element(By.ID, "email") + email_input.send_keys("unknown-user@gudlft.co") + self.driver.find_element(By.ID, "login").click() + + # 2) They are redirected to the homepage + # with an explicit error message. + self.assertIn("Welcome to the GUDLFT Registration Portal!", + self.driver.page_source + ) + self.assertIn( + "Unfortunately, the email you entered was not found.", + self.driver.page_source + ) diff --git a/tests/functional/test_book_places_above_12_single.py b/tests/functional/test_book_places_above_12_single.py new file mode 100644 index 000000000..796615851 --- /dev/null +++ b/tests/functional/test_book_places_above_12_single.py @@ -0,0 +1,55 @@ +from flask_testing import LiveServerTestCase +from selenium import webdriver +from selenium.webdriver.common.by import By +from server import create_app + + +class TestBookPlacesAbove12Single(LiveServerTestCase): + def create_app(self): + return create_app() + + def setUp(self): + # Ensure GeckoDriver is installed and in PATH. + self.driver = webdriver.Firefox() + self.driver.get(self.get_server_url()) + + def tearDown(self): + self.driver.quit() + + def test_user_cannot_book_thirteen_places_in_one_request(self): + # 1) The user logs in from the homepage. + email_input = self.driver.find_element(By.ID, "email") + email_input.send_keys("john@simplylift.co") + self.driver.find_element(By.ID, "login").click() + + self.assertIn("Welcome, Simply Lift", self.driver.page_source) + + # 2) The user opens a reservable upcoming competition (Fall Classic). + competition_item = self.driver.find_element( + By.XPATH, + "//li[p[normalize-space()='Fall Classic']]", + ) + self.assertIn("Number of Places: 13", competition_item.text) + competition_item.find_element(By.LINK_TEXT, "Book Places").click() + + self.assertIn("Places available: 13", self.driver.page_source) + places_input = self.driver.find_element(By.ID, "places") + self.assertEqual(places_input.get_attribute("max"), "12") + + # 3) The user tries to buy 13 places in one request. + # Force the invalid value through the form + # to validate server-side guard. + self.driver.execute_script( + "arguments[0].removeAttribute('max'); arguments[0].value = '13';", + places_input, + ) + self.driver.find_element( + By.CSS_SELECTOR, "button[type='submit']").click() + + # 4) The request is rejected and points/places remain unchanged. + self.assertIn( + "You cannot book more than 12 places per competition.", + self.driver.page_source + ) + self.assertIn("Points available: 13", self.driver.page_source) + self.assertIn("Number of Places: 13", self.driver.page_source) diff --git a/tests/functional/test_book_places_valid.py b/tests/functional/test_book_places_valid.py new file mode 100644 index 000000000..d6ec70c7d --- /dev/null +++ b/tests/functional/test_book_places_valid.py @@ -0,0 +1,57 @@ +from flask_testing import LiveServerTestCase +from selenium import webdriver +from selenium.webdriver.common.by import By +from server import create_app + + +class TestBookPlacesValid(LiveServerTestCase): + def create_app(self): + return create_app() + + def setUp(self): + # Ensure GeckoDriver is installed and in PATH. + self.driver = webdriver.Firefox() + self.driver.get(self.get_server_url()) + + def tearDown(self): + self.driver.quit() + + def test_user_can_book_five_places_and_view_updated_points_board(self): + # 1) The user logs in from the homepage. + email_input = self.driver.find_element(By.ID, "email") + email_input.send_keys("john@simplylift.co") + self.driver.find_element(By.ID, "login").click() + + self.assertIn("Welcome, Simply Lift", self.driver.page_source) + + # 2) The user opens a reservable upcoming competition (Fall Classic). + competition_item = self.driver.find_element( + By.XPATH, + "//li[p[normalize-space()='Fall Classic']]", + ) + self.assertIn("Number of Places: 13", competition_item.text) + competition_item.find_element(By.LINK_TEXT, "Book Places").click() + + self.assertIn("Places available: 13", self.driver.page_source) + places_input = self.driver.find_element(By.ID, "places") + self.assertEqual(places_input.get_attribute("max"), "12") + + # 3) The user buys 5 places. + places_input.send_keys("5") + self.driver.find_element( + By.CSS_SELECTOR, "button[type='submit']").click() + + self.assertIn("Booking complete: 5 places purchased.", + self.driver.page_source) + self.assertIn("Points available: 8", self.driver.page_source) + self.assertIn("Number of Places: 8", self.driver.page_source) + + # 4) The user opens the points board and checks the updated points. + self.driver.find_element(By.LINK_TEXT, "Points Board").click() + + self.assertIn("Public Points Board", self.driver.page_source) + simply_lift_row = self.driver.find_element( + By.XPATH, + "//tr[td[normalize-space()='Simply Lift']]", + ) + self.assertIn("8", simply_lift_row.text) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/test_booking.py b/tests/integration/test_booking.py new file mode 100644 index 000000000..692999975 --- /dev/null +++ b/tests/integration/test_booking.py @@ -0,0 +1,174 @@ +# from tests.conftest import client +from urllib.parse import quote +from datetime import datetime, timedelta + + +class TestBooking: + + valid_club_name = "Simply Lift" + valid_competition_name = "Fall Classic" + invalid_club_name = "Invalid Club" + invalid_competition_name = "Invalid Competition" + + def test_booking_with_valid_club_name_and_valid_competition_name( + self, client, login_as_valid_user): + """ + Case 1 โ€” booking request with a valid club name + and a valid competition name: access to the booking page. + """ + login_as_valid_user() + valid_club_name = quote(self.valid_club_name) + valid_competition_name = quote(self.valid_competition_name) + response = client.get( + f'/book/{valid_competition_name}/{valid_club_name}', + follow_redirects=True, + ) + assert response.status_code == 200 + assert b"Booking for" in response.data + + def test_booking_with_invalid_club_name_and_valid_competition_name( + self, client, login_as_valid_user): + """ + Case 2 โ€” booking request with an invalid club name + and a valid competition name: redirect to the homepage + with an error message. + """ + login_as_valid_user() + invalid_club_name = quote("Invalid Club") + valid_competition_name = quote(self.valid_competition_name) + response = client.get( + f'/book/{valid_competition_name}/{invalid_club_name}', + follow_redirects=True, + ) + assert response.status_code == 200 + assert b"Summary | GUDLFT Registration" in response.data + assert ( + b"Invalid booking URL. Please check the club name." + in response.data + ) + + def test_booking_with_valid_club_name_and_invalid_competition_name( + self, client, login_as_valid_user): + """ + Case 3 โ€” booking request with a valid club name + and an invalid competition name: redirect to the homepage + with an error message. + """ + login_as_valid_user() + invalid_competition_name = quote(self.invalid_competition_name) + valid_club_name = quote(self.valid_club_name) + response = client.get( + f'/book/{invalid_competition_name}/{valid_club_name}', + follow_redirects=True, + ) + assert response.status_code == 200 + assert b"Summary | GUDLFT Registration" in response.data + assert ( + b"Invalid booking URL. Please check the competition name." + in response.data + ) + + def test_booking_with_invalid_club_name_and_invalid_competition_name( + self, client, login_as_valid_user): + """ + Case 4 โ€” booking request with an invalid club name + and an invalid competition name: redirect to the homepage + with an error message. + """ + login_as_valid_user() + invalid_club_name = quote(self.invalid_club_name) + invalid_competition_name = quote(self.invalid_competition_name) + response = client.get( + f'/book/{invalid_competition_name}/{invalid_club_name}', + follow_redirects=True, + ) + assert response.status_code == 200 + assert b"Summary | GUDLFT Registration" in response.data + assert ( + b"Invalid booking URL. Please check the club name." + in response.data + ) + + def test_booking_with_empty_club_name_and_valid_competition_name( + self, client): + """ + Case 5 โ€” booking request with an empty club name and a + valid competition name: redirect to the homepage with + an error message. + """ + invalid_club_name = quote("") + valid_competition_name = quote(self.valid_competition_name) + response = client.get( + f'/book/{valid_competition_name}/{invalid_club_name}', + follow_redirects=True, + ) + assert response.status_code == 404 + + def test_booking_with_valid_club_name_and_empty_competition_name( + self, client): + """ + Case 6 โ€” booking request with a valid club name and an empty + competition name: redirect to the homepage with an error message. + """ + valid_club_name = quote(self.valid_club_name) + invalid_competition_name = quote("") + response = client.get( + f'/book/{invalid_competition_name}/{valid_club_name}', + follow_redirects=True, + ) + assert response.status_code == 404 + + def test_booking_with_empty_club_name_and_empty_competition_name( + self, client): + """ + Case 7 โ€” booking request with an empty club name and an + empty competition name: redirect to the homepage with + an error message. + """ + invalid_club_name = quote("") + invalid_competition_name = quote("") + response = client.get( + f'/book/{invalid_competition_name}/{invalid_club_name}', + follow_redirects=True, + ) + assert response.status_code == 404 + + def test_booking_with_past_competition_redirects_to_welcome( + self, app, client, login_as_valid_user): + """ + Case 8 โ€” past competition: direct access to /book + denied and return to welcome. + """ + login_as_valid_user() + app.config['COMPETITIONS'][0]['date'] = ( + datetime.now() - timedelta(days=1) + ).strftime('%Y-%m-%d %H:%M:%S') + + valid_club_name = quote(self.valid_club_name) + past_competition_name = quote(app.config['COMPETITIONS'][0]['name']) + + response = client.get( + f'/book/{past_competition_name}/{valid_club_name}', + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b"Summary | GUDLFT Registration" in response.data + assert ( + b"This competition is no longer open for booking." + in response.data + ) + + def test_booking_requires_login(self, client): + """Case 9 โ€” user not logged in: redirect to index with message.""" + valid_club_name = quote(self.valid_club_name) + valid_competition_name = quote(self.valid_competition_name) + + response = client.get( + f'/book/{valid_competition_name}/{valid_club_name}', + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b"Please log in first." in response.data + assert b"Welcome to the GUDLFT Registration Portal!" in response.data diff --git a/tests/integration/test_login.py b/tests/integration/test_login.py new file mode 100644 index 000000000..2e4543f68 --- /dev/null +++ b/tests/integration/test_login.py @@ -0,0 +1,74 @@ + +class TestLogin: + + def test_login_with_valid_email(self, client): + """Case 1 โ€” login with a valid email: access to the dashboard.""" + response = client.post( + '/show_summary', + data={'email': 'john@simplylift.co'}, + follow_redirects=True, + ) + assert response.status_code == 200 + assert b"Welcome" in response.data + + def test_login_with_invalid_email(self, client): + """Case 2 โ€” login with an invalid email: error message displayed.""" + response = client.post( + '/show_summary', + data={'email': 'invalid_email@example.com'}, + follow_redirects=True, + ) + assert response.status_code == 200 + assert ( + b"Unfortunately, the email you entered was not found." + in response.data + ) + + def test_login_with_empty_email(self, client): + """Case 3 โ€” login with an empty email: error message displayed.""" + response = client.post( + '/show_summary', + data={'email': ''}, + follow_redirects=True, + ) + assert response.status_code == 200 + assert ( + b"Unfortunately, the email you entered was not found." + in response.data + ) + + def test_login_with_white_space_email(self, client): + """Case 4 โ€” login with an email containing spaces: access granted.""" + response = client.post( + '/show_summary', + data={'email': ' john@simplylift.co '}, + follow_redirects=True, + ) + assert response.status_code == 200 + assert b"Welcome" in response.data + + def test_login_with_case_insensitive_email(self, client): + """Case 5 โ€” login with a different case email: access granted.""" + response = client.post( + '/show_summary', + data={'email': 'JOHN@SIMPLYLIFT.CO'}, + follow_redirects=True, + ) + assert response.status_code == 200 + assert b"Welcome" in response.data + + def test_login_with_special_characters_email(self, client): + """ + Case 6 โ€” login with an email containing special characters: + Error message displayed. + """ + response = client.post( + '/show_summary', + data={'email': 'john@simplylift.co!'}, + follow_redirects=True, + ) + assert response.status_code == 200 + assert ( + b"Unfortunately, the email you entered was not found." + in response.data + ) diff --git a/tests/integration/test_logout.py b/tests/integration/test_logout.py new file mode 100644 index 000000000..84e1526f7 --- /dev/null +++ b/tests/integration/test_logout.py @@ -0,0 +1,25 @@ + + +class TestLogout: + + def test_logout_redirects_to_homepage(self, client, login_as_valid_user): + """Case 1 โ€” logout: redirects to the homepage.""" + login_as_valid_user() + response = client.get('/logout', follow_redirects=True) + assert response.status_code == 200 + assert b"Welcome to the GUDLFT Registration Portal!" in response.data + + def test_logout_clears_session(self, client, login_as_valid_user): + """Case 2 โ€” logout: the session is cleared.""" + login_as_valid_user() + response = client.get('/logout', follow_redirects=True) + assert response.status_code == 200 + with client.session_transaction() as session: + assert 'club_email' not in session + + def test_logout_flash_message(self, client, login_as_valid_user): + """Case 3 โ€” logout: a flash message is displayed.""" + login_as_valid_user() + response = client.get('/logout', follow_redirects=True) + assert response.status_code == 200 + assert b"You have been logged out." in response.data diff --git a/tests/integration/test_points_board.py b/tests/integration/test_points_board.py new file mode 100644 index 000000000..72e286d23 --- /dev/null +++ b/tests/integration/test_points_board.py @@ -0,0 +1,50 @@ +class TestPointsBoard: + + def test_points_board_is_publicly_accessible(self, client): + response = client.get('/points_board', follow_redirects=True) + + assert response.status_code == 200 + assert b"Public Points Board" in response.data + assert b"Back to Login" in response.data + + def test_points_board_is_accessible_after_login( + self, client, login_as_valid_user): + login_as_valid_user() + response = client.get('/points_board', follow_redirects=True) + + assert response.status_code == 200 + assert b"Public Points Board" in response.data + assert b"Logout" in response.data + + def test_points_board_displays_club_names_and_points(self, client): + response = client.get('/points_board', follow_redirects=True) + + assert response.status_code == 200 + assert b"Simply Lift" in response.data + assert b"13" in response.data + assert b"Iron Temple" in response.data + assert b"4" in response.data + assert b"She Lifts" in response.data + assert b"12" in response.data + + def test_points_board_is_updated_after_booking( + self, client, login_as_valid_user): + # Simulate a booking to change the points of a club + login_as_valid_user() + response1 = client.post( + '/purchase_places', + data={ + 'competition': 'Fall Classic', + 'places': '3', + }, + follow_redirects=True, + ) + assert response1.status_code == 200 + + # Now check the points board to see if the points have been updated + response2 = client.get('/points_board', follow_redirects=True) + + assert response2.status_code == 200 + assert b"Simply Lift" in response2.data + # Points should be updated from 13 to 10 after booking 3 places + assert b"10" in response2.data diff --git a/tests/integration/test_purchase.py b/tests/integration/test_purchase.py new file mode 100644 index 000000000..b18000b1c --- /dev/null +++ b/tests/integration/test_purchase.py @@ -0,0 +1,247 @@ +class TestPurchase: + + def test_purchase_places_with_valid_request( + self, client, login_as_valid_user): + """ + Case 1 โ€” valid purchase: + Confirmation message displayed and points/places deducted. + """ + login_as_valid_user() + response = client.post( + '/purchase_places', + data={ + 'competition': 'Fall Classic', + 'places': '5', + }, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b"Booking complete: 5 places purchased." in response.data + assert b"Points available: 8" in response.data + assert b"Number of Places: 8" in response.data + + competition = next( + c for c in client.application.config['COMPETITIONS'] + if c['name'] == 'Fall Classic' + ) + club = next( + c for c in client.application.config['CLUBS'] + if c['name'] == 'Simply Lift' + ) + assert competition['number_of_places'] == '8' + assert club['points'] == '8' + + def test_purchase_places_when_competition_is_complete( + self, client, login_as_valid_user): + """ + Case 2 โ€” competition complete: + Message indicating that the competition is no longer open. + """ + login_as_valid_user() + competition = next( + c for c in client.application.config['COMPETITIONS'] + if c['name'] == 'Fall Classic' + ) + competition['number_of_places'] = '0' + + response = client.post( + '/purchase_places', + data={ + 'competition': 'Fall Classic', + 'places': '1', + }, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert ( + b"This competition is no longer open for booking." + in response.data + ) + + def test_purchase_places_more_than_available_places( + self, client, login_as_valid_user): + """ + Case 3 โ€” request exceeds available places: + Denial with explicit message. + """ + login_as_valid_user() + competition = next( + c for c in client.application.config['COMPETITIONS'] + if c['name'] == 'Fall Classic' + ) + competition['number_of_places'] = '3' + + response = client.post( + '/purchase_places', + data={ + 'competition': 'Fall Classic', + 'places': '5', + }, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert ( + b"Not enough places available in this competition." + in response.data + ) + assert competition['number_of_places'] == '3' + + def test_purchase_places_more_than_twelve( + self, client, login_as_valid_user): + """ + Case 4 โ€” request exceeds 12 places: + Denial to ensure fairness. + """ + # If done in multiple times, the club can book more than 12 places, + # but not in a single request. + login_as_valid_user() + response = client.post( + '/purchase_places', + data={ + 'competition': 'Fall Classic', + 'places': '13', + }, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert ( + b"You cannot book more than 12 places per competition." + in response.data + ) + + def test_purchase_places_more_than_club_points( + self, client, login_as_valid_user): + """ + Case 5 โ€” request exceeds club points: + Denial with explicit message. + """ + login_as_valid_user() + club = next( + c for c in client.application.config['CLUBS'] + if c['name'] == 'Simply Lift' + ) + club['points'] = '4' + response = client.post( + '/purchase_places', + data={ + 'competition': 'Fall Classic', + 'places': '5', + }, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert ( + b"Not enough points available in your club to book the " + b"requested number of places." + in response.data + ) + assert club['points'] == '4' + + def test_purchase_multiple_times_accumulates_points_and_places( + self, client, login_as_valid_user): + """ + Case 6 โ€” multiple purchases: + Points and places are correctly updated after several purchases. + """ + login_as_valid_user() + # Premier achat + response1 = client.post( + '/purchase_places', + data={ + 'competition': 'Fall Classic', + 'places': '3', + }, + follow_redirects=True, + ) + assert response1.status_code == 200 + assert b"Booking complete: 3 places purchased." in response1.data + assert b"Points available: 10" in response1.data + assert b"Number of Places: 10" in response1.data + + # Deuxiรจme achat + response2 = client.post( + '/purchase_places', + data={ + 'competition': 'Fall Classic', + 'places': '4', + }, + follow_redirects=True, + ) + assert response2.status_code == 200 + assert b"Booking complete: 4 places purchased." in response2.data + assert b"Points available: 6" in response2.data + assert b"Number of Places: 6" in response2.data + + def test_purchase_12_places_in_multiple_requests( + self, client, login_as_valid_user): + """ + Case 7 โ€” booking 12 places in multiple requests: + The club cannot book more than 12 places in total. + """ + login_as_valid_user() + # Premier achat de 6 places + response1 = client.post( + '/purchase_places', + data={ + 'competition': 'Fall Classic', + 'places': '6', + }, + follow_redirects=True, + ) + assert response1.status_code == 200 + assert b"Booking complete: 6 places purchased." in response1.data + assert b"Points available: 7" in response1.data + assert b"Number of Places: 7" in response1.data + + # Deuxiรจme achat de 6 places + response2 = client.post( + '/purchase_places', + data={ + 'competition': 'Fall Classic', + 'places': '6', + }, + follow_redirects=True, + ) + assert response2.status_code == 200 + assert b"Booking complete: 6 places purchased." in response2.data + assert b"Points available: 1" in response2.data + assert b"Number of Places: 1" in response2.data + + # Troisiรจme tentative d'achat de 1 place (total de 13 places) + response3 = client.post( + '/purchase_places', + data={ + 'competition': 'Fall Classic', + 'places': '1', + }, + follow_redirects=True, + ) + assert response3.status_code == 200 + assert ( + b"You cannot book more than 12 places per competition." + in response3.data + ) + assert b"Points available: 1" in response3.data + assert b"Number of Places: 1" in response3.data + + def test_purchase_requires_login(self, client): + """ + Case 8 โ€” user not logged in: + Purchase denied and redirect to index. + """ + response = client.post( + '/purchase_places', + data={ + 'competition': 'Fall Classic', + 'places': '1', + }, + follow_redirects=True, + ) + assert response.status_code == 200 + assert b"Please log in first." in response.data + assert b"Welcome to the GUDLFT Registration Portal!" in response.data diff --git a/tests/performance/Locust_2026-08-03_report.html b/tests/performance/Locust_2026-08-03_report.html new file mode 100644 index 000000000..6f4b43f72 --- /dev/null +++ b/tests/performance/Locust_2026-08-03_report.html @@ -0,0 +1,147 @@ + + + + + + + + + + + Locust + + + + +
    + + + + + \ No newline at end of file diff --git a/tests/performance/locustfile.py b/tests/performance/locustfile.py new file mode 100644 index 000000000..ae4a62e5e --- /dev/null +++ b/tests/performance/locustfile.py @@ -0,0 +1,262 @@ +""" +Performance test suite for the GudLift application using Locust. + +This script simulates a realistic load of secretary users interacting with the +GudLift competition booking system. It validates: +- Endpoint availability and HTTP response codes +- UI consistency (expected text in responses) +- Business rule enforcement (places, points, deadlines) +- Authentication and session management +""" +import json +import os +from urllib.parse import quote + +from locust import HttpUser, between, task + + +class ProjectPerformanceTest(HttpUser): + """ + Load test profile emulating a GudLift secretary user journey. + + Simulates a user who: + 1. Browses public pages (homepage, points board) + 2. Logs in with a valid club email + 3. Books places for competitions (with validation checks) + 4. Logs out + + Uses dynamic data from clubs.json and competitions.json to: + 1. Authenticate with all available club emails + 2. Book maximum possible places for competitions based on: + - Club's remaining points + - Competition's remaining places + - 12 places maximum per club per competition rule + + Task weights reflect expected usage frequency: + - authenticated_flow (6): Most common (booking places) + - homepage (5): Frequent navigation + - points_board (4): Regular checks + - logout (2): Less frequent + """ + + wait_time = between(1, 3) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.clubs = [] + self.competitions = [] + self._load_data() + + def _load_data(self): + """Load clubs and competitions from JSON files.""" + script_dir = os.path.dirname( + os.path.abspath(__file__) + ) + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(script_dir)) + ) + + # Load clubs + clubs_path = os.path.join( + project_root, "Python_Testing", "clubs.json") + with open(clubs_path, 'r') as f: + clubs_data = json.load(f) + self.clubs = clubs_data["clubs"] + + # Load competitions + comp_path = os.path.join( + project_root, "Python_Testing", "competitions.json") + with open(comp_path, 'r') as f: + comp_data = json.load(f) + self.competitions = comp_data["competitions"] + + # Prepare email list from all clubs + self.user_emails = [club["email"] for club in self.clubs] + + def on_start(self): + """ + Initialize the virtual user's session state. + Resets login status and counters for email/booking rotation. + """ + self.logged_in = False + self.email_idx = 0 + self.current_club = None + self.current_competition = None + + def _next_email(self): + """ + Return the next email in round-robin rotation. + Cycles through valid club emails for authentication. + """ + email = self.user_emails[self.email_idx % len(self.user_emails)] + self.email_idx += 1 + return email + + def _get_club_by_email(self, email): + """Find club data by email address.""" + for club in self.clubs: + if club["email"] == email: + return club + return None + + def _next_booking_target(self): + """ + Return the next (club, competition, places) tuple dynamically. + Calculates maximum places as min(club_points, competition_places, 12). + """ + # Get next club in rotation + email = self._next_email() + club = self._get_club_by_email(email) + if not club: + return None, None, 0 + + # Get next competition in rotation + comp_idx = self.email_idx % len(self.competitions) + competition = self.competitions[comp_idx] + + # Calculate maximum places the club can book: + # - Limited by club points (1 point = 1 place) + # - Limited by competition available places + # - Limited by 12 places max rule + club_points = int(club["points"]) + comp_places = int(competition["number_of_places"]) + + max_places = min(club_points, comp_places, 12) + + # Ensure at least 1 place if possible + max_places = max( + 1, max_places) if club_points > 0 and comp_places > 0 else 0 + + return club, competition, max_places + + @task(5) + def homepage(self): + """ + Load the application homepage. + Validates that the root endpoint returns a 200 response. + """ + self.client.get("/", name="GET /") + + @task(4) + def points_board(self): + """ + Load the points board page. + Validates HTTP 200 and presence of 'Points Board' in the response. + """ + with self.client.get( + "/points_board", + name="GET /points_board", + catch_response=True + ) as response: + if (response.status_code != 200 + or "Points Board" not in response.text): + response.failure("Points board unavailable") + + @task(6) + def authenticated_flow(self): + """Simulate a full secretary workflow: + login -> book places -> validate. + Tests: + - Successful login with valid credentials + - Competition booking form accessibility + - Place purchasing with business rule validation + - Response handling for success/error cases + """ + club, competition, places = self._next_booking_target() + + if not club or not competition or places <= 0: + return + + email = club["email"] + + # Login + with self.client.post( + "/show_summary", + data={"email": email}, + name="POST /show_summary (login)", + catch_response=True, + ) as login_response: + if login_response.status_code != 200: + login_response.failure("Login non-200") + return + if "Welcome," not in login_response.text: + login_response.failure("Login invalid") + return + + self.logged_in = True + self.current_club = club + self.current_competition = competition + + encoded_club = quote(club["name"], safe="") + encoded_comp = quote(competition["name"], safe="") + + # Access booking page + with self.client.get( + f"/book/{encoded_comp}/{encoded_club}", + name="GET /book//", + catch_response=True, + ) as booking_page_response: + if booking_page_response.status_code != 200: + booking_page_response.failure("Page booking non-200") + return + if "How many places?" not in booking_page_response.text: + booking_page_response.failure("Booking form absent") + return + + # Attempt to purchase calculated places + with self.client.post( + "/purchase_places", + data={ + "competition": competition["name"], + "club": club["name"], + "places": str(places) + }, + name="POST /purchase_places", + catch_response=True, + ) as purchase_response: + if purchase_response.status_code != 200: + purchase_response.failure("Purchase non-200") + return + + success_text = "Booking complete" + known_validation_errors = ( + "Not enough places", + "Not enough points", + "cannot book more than 12", + "no longer open for booking", + ) + + if success_text in purchase_response.text: + purchase_response.success() + elif any( + msg in purchase_response.text + for msg in known_validation_errors + ): + # This is expected if another user booked places concurrently + purchase_response.success() + else: + purchase_response.failure( + "Unexpected response on purchase_places") + + @task(2) + def logout(self): + """ + Terminate the user session. + Validates HTTP 200 and redirects to the login page. + """ + if not self.logged_in: + return + + with self.client.get( + "/logout", + name="GET /logout", + catch_response=True + ) as response: + if response.status_code != 200: + response.failure("Logout non-200") + return + if "Please enter your secretary email" not in response.text: + response.failure("Logout incomplete") + return + + self.logged_in = False diff --git a/tests/performance_test_results_screenshot.png b/tests/performance_test_results_screenshot.png new file mode 100644 index 000000000..dd1a52b64 Binary files /dev/null and b/tests/performance_test_results_screenshot.png differ diff --git a/tests/test_results_screenshot.png b/tests/test_results_screenshot.png new file mode 100644 index 000000000..a07ea71d7 Binary files /dev/null and b/tests/test_results_screenshot.png differ 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_utils.py b/tests/unit/test_utils.py new file mode 100644 index 000000000..dee88f17e --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,782 @@ +import json +from datetime import datetime +from unittest.mock import mock_open, patch +from utils import ( + load_clubs, + load_competitions, + get_club_by_email, + lower_case_email, + strip_white_space, + get_competition_by_name, + get_club_by_name, + get_club_points, + get_competition_places, + is_competition_bookable, + is_booking_valid, + update_club_points, + update_competition_places, + get_booking_key, + get_logged_club, +) + + +class TestLoadClubs: + + def test_returns_all_clubs(self, mock_clubs): + """Case 1 โ€” valid file with multiple clubs: returns all elements.""" + data = json.dumps({"clubs": mock_clubs}) + with patch("builtins.open", mock_open(read_data=data)): + result = load_clubs() + assert len(result) == 3 + + def test_each_club_has_required_keys(self, mock_clubs): + """Case 2 โ€” each club contains the name, email, and points keys.""" + data = json.dumps({"clubs": mock_clubs}) + with patch("builtins.open", mock_open(read_data=data)): + result = load_clubs() + for club in result: + assert "name" in club + assert "email" in club + assert "points" in club + + def test_returns_single_club(self): + """ + Case 3 โ€” valid file with a single club: + Returns a list with one element. + """ + club = [{"name": "Simply Lift", + "email": "john@simplylift.co", "points": "13"}] + data = json.dumps({"clubs": club}) + with patch("builtins.open", mock_open(read_data=data)): + result = load_clubs() + assert len(result) == 1 + assert result[0]["name"] == "Simply Lift" + + def test_returns_empty_list_when_clubs_is_empty(self): + """Case 4 โ€” 'clubs' key present but empty list: returns [].""" + data = json.dumps({"clubs": []}) + with patch("builtins.open", mock_open(read_data=data)): + result = load_clubs() + assert result == [] + + def test_raises_file_not_found_when_file_missing(self): + """Case 5 โ€” file not found: returns None.""" + with patch("builtins.open", side_effect=FileNotFoundError): + assert load_clubs() is None + + def test_raises_json_decode_error_on_invalid_json(self): + """Case 6 โ€” invalid JSON content: returns None.""" + with patch("builtins.open", mock_open(read_data="not valid json {")): + assert load_clubs() is None + + def test_raises_key_error_when_clubs_key_missing(self): + """Case 7 โ€” 'clubs' key missing from JSON: returns None.""" + data = json.dumps({"wrong_key": []}) + with patch("builtins.open", mock_open(read_data=data)): + assert load_clubs() is None + + +class TestLoadCompetitions: + + def test_returns_all_competitions(self, mock_competitions): + """ + Case 1 โ€” valid file with multiple competitions: + Returns all elements. + """ + data = json.dumps({"competitions": mock_competitions}) + with patch("builtins.open", mock_open(read_data=data)): + result = load_competitions() + assert len(result) == 2 + + def test_each_competition_has_required_keys(self, mock_competitions): + """ + Case 2 โ€” each competition contains the name, + date, and number_of_places keys. + """ + data = json.dumps({"competitions": mock_competitions}) + with patch("builtins.open", mock_open(read_data=data)): + result = load_competitions() + for competition in result: + assert "name" in competition + assert "date" in competition + assert "number_of_places" in competition + + def test_returns_single_competition(self): + """ + Case 3 โ€” valid file with a single competition: + Returns a list with one element. + """ + competition = [ + { + "name": "Spring Festival", + "date": "2025-03-27 10:00:00", + "number_of_places": "25" + } + ] + data = json.dumps({"competitions": competition}) + with patch("builtins.open", mock_open(read_data=data)): + result = load_competitions() + assert len(result) == 1 + assert result[0]["name"] == "Spring Festival" + + def test_returns_empty_list_when_competitions_is_empty(self): + """Case 4 โ€” 'competitions' key present but empty list: returns [].""" + data = json.dumps({"competitions": []}) + with patch("builtins.open", mock_open(read_data=data)): + result = load_competitions() + assert result == [] + + def test_raises_file_not_found_when_file_missing(self): + """Case 5 โ€” file not found: returns None.""" + with patch("builtins.open", side_effect=FileNotFoundError): + assert load_competitions() is None + + def test_raises_json_decode_error_on_invalid_json(self): + """Case 6 โ€” invalid JSON content: returns None.""" + with patch("builtins.open", mock_open(read_data="not valid json {")): + assert load_competitions() is None + + def test_raises_key_error_when_competitions_key_missing(self): + """Case 7 โ€” 'competitions' key missing from JSON: returns None.""" + data = json.dumps({"wrong_key": []}) + with patch("builtins.open", mock_open(read_data=data)): + assert load_competitions() is None + + +class TestGetClubByEmail: + + def test_get_club_with_valid_email(self, mock_clubs): + """Case 1 โ€” valid email: returns the corresponding club.""" + valid_email = "john@simplylift.co" + expected_club = {"name": "Simply Lift", + "email": "john@simplylift.co", "points": "13"} + assert get_club_by_email(valid_email, mock_clubs) == expected_club + + def test_get_club_with_invalid_email(self, mock_clubs): + """Case 2 โ€” invalid email: returns None.""" + invalid_email = "invalid@simplylift.co" + expected_result = None + assert get_club_by_email(invalid_email, mock_clubs) == expected_result + + def test_get_club_with_empty_clubs_list(self): + """Case 3 โ€” empty clubs list: returns None.""" + empty_clubs = [] + email = "john@example.com" + result = get_club_by_email(email, empty_clubs) + assert result is None + + def test_get_club_with_multiple_clubs_same_email(self): + """ + Case 4 โ€” multiple clubs with the same email: + Returns the first club found. + """ + clubs_with_duplicate_email = [ + { + "name": "Club A", + "email": "duplicate@simplylift.co", + "points": "10" + }, + { + "name": "Club B", + "email": "duplicate@simplylift.co", + "points": "20" + } + ] + email = "duplicate@simplylift.co" + result = get_club_by_email(email, clubs_with_duplicate_email) + assert result == clubs_with_duplicate_email[0] + + def test_get_club_with_email_case_sensitivity(self, mock_clubs): + """ + Case 5 โ€” email with different case: + Returns the corresponding club. + """ + email_with_different_case = "John@SimplyLift.co" + expected_club = { + "name": "Simply Lift", + "email": "john@simplylift.co", + "points": "13" + } + result = get_club_by_email(email_with_different_case, mock_clubs) + assert result == expected_club + + def test_get_club_with_email_with_white_space(self, mock_clubs): + """ + Case 6 โ€” email with spaces: + Returns the corresponding club. + """ + email_with_white_space = " john@simplylift.co " + expected_club = { + "name": "Simply Lift", + "email": "john@simplylift.co", + "points": "13" + } + result = get_club_by_email(email_with_white_space, mock_clubs) + assert result == expected_club + + +class TestLowerCaseEmail: + + def test_lower_case_email(self): + """ + Case 1 โ€” email with uppercase: + Returns the email in lower_case. + """ + email = "John@SimplyLift.co" + expected_email = "john@simplylift.co" + result = lower_case_email(email) + assert result == expected_email + + def test_lower_case_email_already_lower_case(self): + """ + Case 2 โ€” email already in lower_case: + Returns the same email. + """ + email = "john@simplylift.co" + expected_email = "john@simplylift.co" + result = lower_case_email(email) + assert result == expected_email + + def test_lower_case_email_with_white_space(self): + """ + Case 3 โ€” email with spaces: + Returns the email in lower_case with spaces. + """ + email = " John@SimplyLift.co " + expected_email = " john@simplylift.co " + result = lower_case_email(email) + assert result == expected_email + + def test_lower_case_email_empty_string(self): + """Case 4 โ€” empty email: returns an empty string.""" + email = "" + expected_email = "" + result = lower_case_email(email) + assert result == expected_email + + +class TestStripWhiteSpace: + + def test_strip_white_space(self): + """ + Case 1 โ€” email with spaces before and after: + Returns the email without spaces. + """ + email = " john@simplylift.co " + expected_email = "john@simplylift.co" + result = strip_white_space(email) + assert result == expected_email + + def test_strip_white_space_no_spaces(self): + """Case 2 โ€” email without spaces: returns the same email.""" + email = "john@simplylift.co" + expected_email = "john@simplylift.co" + result = strip_white_space(email) + assert result == expected_email + + def test_strip_white_space_only_spaces(self): + """Case 3 โ€” email with only spaces: returns an empty string.""" + email = " " + expected_email = "" + result = strip_white_space(email) + assert result == expected_email + + def test_strip_white_space_empty_string(self): + """Case 4 โ€” empty email: returns an empty string.""" + email = "" + expected_email = "" + result = strip_white_space(email) + assert result == expected_email + + def test_strip_white_space_with_tabs_and_newlines(self): + """ + Case 5 โ€” email with tabs and newlines: + Returns the email without tabs and newlines. + """ + email = "\n\t john@simplylift.co \n\t" + expected_email = "john@simplylift.co" + result = strip_white_space(email) + assert result == expected_email + + def test_strip_white_space_with_internal_spaces(self): + """ + Case 6 โ€” email with internal spaces: + Only removes spaces before and after. + """ + email = " john @ simplylift . co " + expected_email = "john @ simplylift . co" + result = strip_white_space(email) + assert result == expected_email + + +class TestGetClubByName: + + def test_get_club_with_valid_name(self, mock_clubs): + """ + Case 1 โ€” valid club name + Returns the corresponding club. + """ + name = "Simply Lift" + expected_club = { + "name": "Simply Lift", + "email": "john@simplylift.co", + "points": "13" + } + result = get_club_by_name(name, mock_clubs) + assert result == expected_club + + def test_get_club_with_invalid_name(self, mock_clubs): + """Case 2 โ€” invalid club name: returns None.""" + name = "Nonexistent Club" + expected_result = None + result = get_club_by_name(name, mock_clubs) + assert result == expected_result + + +class TestGetCompetitionByName: + + def test_get_competition_with_valid_name(self, mock_competitions): + """ + Case 1 โ€” valid competition name: + Returns the corresponding competition. + """ + name = "Spring Festival" + expected_competition = { + "name": "Spring Festival", + "date": "2025-03-27 10:00:00", + "number_of_places": "25" + } + result = get_competition_by_name(name, mock_competitions) + assert result == expected_competition + + def test_get_competition_with_invalid_name(self, mock_competitions): + """Case 2 โ€” invalid competition name: returns None.""" + name = "Nonexistent Competition" + expected_result = None + result = get_competition_by_name(name, mock_competitions) + assert result == expected_result + + +class TestGetClubPoints: + + def test_get_club_points_with_valid_club(self, mock_clubs): + """Case 1 โ€” valid club as parameter: returns the number of points.""" + valid_club = {"name": "Simply Lift", + "email": "john@simplylift.co", "points": "13"} + expected_points = 13 + result = get_club_points(valid_club) + assert result == expected_points + + def test_get_club_points_with_invalid_club(self, mock_clubs): + """Case 2 โ€” invalid club as parameter: returns None.""" + invalid_club = {"name": "Invalid Club", "email": "invalid@club.co"} + expected_points = None + result = get_club_points(invalid_club) + assert result == expected_points + + +class TestGetCompetitionPlaces: + + def test_get_competition_places_with_valid_competition( + self, mock_competitions): + """ + Case 1 โ€” valid competition as parameter: + Returns the number of available places. + """ + valid_competition = { + "name": "Spring Festival", + "date": "2025-03-27 10:00:00", + "number_of_places": "25" + } + expected_places = 25 + result = get_competition_places(valid_competition) + assert result == expected_places + + def test_get_competition_places_with_invalid_competition( + self, mock_competitions): + """Case 2 โ€” invalid competition as parameter: returns None.""" + invalid_competition = { + "name": "Invalid Competition", "date": "2025-01-01 00:00:00"} + expected_places = None + result = get_competition_places(invalid_competition) + assert result == expected_places + + +class TestIsCompetitionBookable: + + def test_returns_true_for_future_competition_with_places(self): + """Case 1 โ€” future date and places > 0: returns True.""" + now = datetime(2026, 6, 22, 12, 0, 0) + competition = { + "name": "Future Open", + "date": "2026-06-23 10:00:00", + "number_of_places": "5", + } + + assert is_competition_bookable(competition, now=now) is True + + def test_returns_false_for_past_competition(self): + """Case 2 โ€” past date: returns False.""" + now = datetime(2026, 6, 22, 12, 0, 0) + competition = { + "name": "Past Open", + "date": "2026-06-21 10:00:00", + "number_of_places": "5", + } + + assert is_competition_bookable(competition, now=now) is False + + def test_returns_false_when_no_places_available(self): + """Case 3 โ€” no places available: returns False.""" + now = datetime(2026, 6, 22, 12, 0, 0) + competition = { + "name": "No Places", + "date": "2026-06-23 10:00:00", + "number_of_places": "0", + } + + assert is_competition_bookable(competition, now=now) is False + + +class TestIsBookingValid: + + def test_validate_booking_with_valid_points_and_places(self): + """ + Case 1 โ€” club has enough points and places available: + Returns an empty list. + """ + club_points = 10 + competition_places = 5 + places_required = 3 + expected_errors = [] + result = is_booking_valid( + club_points, competition_places, places_required) + assert result == expected_errors + + def test_validate_booking_with_insufficient_club_points(self): + """ + Case 2 โ€” club has insufficient points: + Returns a list with an error message. + """ + club_points = 2 + competition_places = 5 + places_required = 3 + expected_errors = [ + "Not enough points available in your club to " + "book the requested number of places."] + result = is_booking_valid( + club_points, competition_places, places_required) + assert result == expected_errors + + def test_validate_booking_with_insufficient_competition_places(self): + """ + Case 3 โ€” insufficient available places: + Returns a list with an error message. + """ + club_points = 10 + competition_places = 2 + places_required = 3 + expected_errors = ["Not enough places available in this competition."] + result = is_booking_valid( + club_points, competition_places, places_required) + assert result == expected_errors + + def test_validate_booking_with_invalid_club_and_competition_points(self): + """ + Case 4 โ€” club has insufficient points and competition has insufficient + available places: + Returns a list with both error messages. + """ + club_points = 2 + competition_places = 2 + places_required = 3 + expected_errors = [ + "Not enough places available in this competition.", + "Not enough points available in your club " + "to book the requested number of places.", + ] + result = is_booking_valid( + club_points, competition_places, places_required) + assert result == expected_errors + + def test_validate_booking_with_zero_places_requested(self): + """ + Case 5 โ€” request to book zero places: + Returns a list with an error message. + """ + club_points = 10 + competition_places = 5 + places_required = 0 + expected_errors = ["You need to book at least one place."] + result = is_booking_valid( + club_points, competition_places, places_required) + assert result == expected_errors + + def test_validate_booking_with_negative_places_requested(self): + """ + Case 6 โ€” request to book a negative number of places + Returns a list with an error message. + """ + club_points = 10 + competition_places = 5 + places_required = -1 + expected_errors = ["You cannot book a negative number of places."] + result = is_booking_valid( + club_points, competition_places, places_required) + assert result == expected_errors + + def test_validate_booking_with_places_requested_exceeding_max_value(self): + """ + Case 7 โ€” request to book more than the maximum number of places + Returns a list with an error message. + """ + club_points = 15 + competition_places = 25 + places_required = 13 + expected_errors = [ + "You cannot book more than 12 places per competition."] + result = is_booking_valid( + club_points, competition_places, places_required) + assert result == expected_errors + + def test_validate_booking_with_cumulative_places_exceeding_twelve(self): + """ + Case 8 โ€” club/competition cumulative > 12 + Returns an error even if the unit request is <= 12. + """ + club_points = 20 + competition_places = 20 + places_required = 3 + places_already_booked = 10 + expected_errors = [ + "You cannot book more than 12 places per competition."] + result = is_booking_valid( + club_points, + competition_places, + places_required, + places_already_booked=places_already_booked, + ) + assert result == expected_errors + + +class TestUpdateClubPoints: + + def test_update_club_points_with_valid_deduction(self): + """ + Case 1 โ€” valid points deduction + Updates the club's points. + """ + club = { + "name": "Simply Lift", + "email": "john@simplylift.com", + "points": "15" + } + points_to_deduct = 5 + expected_points_after_deduction = "10" + result = update_club_points(club, points_to_deduct) + assert result is True + assert club["points"] == expected_points_after_deduction + + def test_update_club_points_with_deduction_exceeding_current_points(self): + """ + Case 2 โ€” deduction exceeds current points + Does not update points and returns False. + """ + club = { + "name": "Simply Lift", + "email": "john@simplylift.com", + "points": "5" + } + points_to_deduct = 10 + expected_points_after_deduction = "5" + result = update_club_points(club, points_to_deduct) + assert result is False + assert club["points"] == expected_points_after_deduction + + def test_update_club_points_with_invalid_club(self): + """ + Case 3 โ€” invalid club (not a dictionary) + Does not update points and returns False. + """ + invalid_club = "Not a club dictionary" + points_to_deduct = 5 + result = update_club_points(invalid_club, points_to_deduct) + assert result is False + + def test_update_club_points_with_non_integer_points(self): + """ + Case 4 โ€” club points not integers + Does not update points and returns False. + """ + club = { + "name": "Simply Lift", + "email": "john@simplylift.com", + "points": "not a number" + } + points_to_deduct = 5 + result = update_club_points(club, points_to_deduct) + assert result is False + assert club["points"] == "not a number" + + def test_update_club_points_with_negative_deduction(self): + """ + Case 5 โ€” negative points deduction + Does not update points and returns False. + """ + club = { + "name": "Simply Lift", + "email": "john@simplylift.com", + "points": "15" + } + points_to_deduct = -5 + expected_points_after_deduction = "15" + result = update_club_points(club, points_to_deduct) + assert result is False + assert club["points"] == expected_points_after_deduction + + def test_update_club_points_with_zero_deduction(self): + """ + Case 6 โ€” zero points deduction + Does not update points and returns True. + """ + club = { + "name": "Simply Lift", + "email": "john@simplylift.com", + "points": "15" + } + points_to_deduct = 0 + expected_points_after_deduction = "15" + result = update_club_points(club, points_to_deduct) + assert result is True + assert club["points"] == expected_points_after_deduction + + +class TestUpdateCompetitionPlaces: + + def test_update_competition_places_with_valid_deduction(self): + """ + Case 1 โ€” valid places deduction + Updates the competition's number of places. + """ + competition = { + "name": "Fall Classic", + "date": "2026-10-22 13:30:00", + "number_of_places": "13" + } + places_to_deduct = 5 + expected_places_after_deduction = "8" + result = update_competition_places(competition, places_to_deduct) + assert result is True + number_of_places = competition["number_of_places"] + assert number_of_places == expected_places_after_deduction + + def test_update_competition_places_with_deduction_exceeding_places(self): + """ + Case 2 โ€” deduction exceeds current places + Does not update places and returns False. + """ + competition = { + "name": "Spring Festival", + "date": "2025-03-27 10:00:00", + "number_of_places": "5" + } + places_to_deduct = 10 + expected_places_after_deduction = "5" + result = update_competition_places(competition, places_to_deduct) + assert result is False + number_of_places = competition["number_of_places"] + assert number_of_places == expected_places_after_deduction + + def test_update_competition_places_with_invalid_competition(self): + """ + Case 3 โ€” invalid competition (not a dictionary) + Does not update places and returns False. + """ + invalid_competition = "Not a competition dictionary" + places_to_deduct = 5 + result = update_competition_places( + invalid_competition, places_to_deduct) + assert result is False + + def test_update_competition_places_with_non_integer_places(self): + """ + Case 4 โ€” competition places not integers + Does not update places and returns False. + """ + competition = { + "name": "Fall Classic", + "date": "2026-10-22 13:30:00", + "number_of_places": "not a number" + } + places_to_deduct = 5 + result = update_competition_places(competition, places_to_deduct) + assert result is False + assert competition["number_of_places"] == "not a number" + + def test_update_competition_places_with_negative_deduction(self): + """ + Case 5 โ€” negative places deduction + Does not update places and returns False. + """ + competition = {"name": "Fall Classic", + "date": "2026-10-22 13:30:00", "number_of_places": "13"} + places_to_deduct = -5 + expected_places_after_deduction = "13" + result = update_competition_places(competition, places_to_deduct) + assert result is False + number_of_places = competition["number_of_places"] + assert number_of_places == expected_places_after_deduction + + def test_update_competition_places_with_zero_deduction(self): + """ + Case 6 โ€” zero places deduction + Does not update places and returns True. + """ + competition = {"name": "Fall Classic", + "date": "2026-10-22 13:30:00", "number_of_places": "13"} + places_to_deduct = 0 + expected_places_after_deduction = "13" + result = update_competition_places(competition, places_to_deduct) + assert result is True + number_of_places = competition["number_of_places"] + assert number_of_places == expected_places_after_deduction + + +class TestGetBookingKey: + + def test_get_booking_key_with_valid_club_and_competition( + self, mock_clubs, mock_competitions): + """ + Case 1 โ€” valid club and competition + Returns the booking keys. + """ + valid_club = mock_clubs[0] + valid_competition = mock_competitions[0] + expected_keys = f"{valid_club['name']}::{valid_competition['name']}" + result = get_booking_key(valid_club['name'], valid_competition['name']) + assert result == expected_keys + + +class TestGetLoggedClub: + + def test_get_logged_club_with_valid_session( + self, request_session, mock_clubs): + """ + Case 1 โ€” valid session with a club + Returns: the corresponding club name. + """ + with request_session(mock_clubs[0]['email']): + result = get_logged_club() + assert result == mock_clubs[0] + + def test_get_logged_club_with_no_session(self, request_session): + """Case 2 โ€” no session: returns None.""" + with request_session(): + result = get_logged_club() + assert result is None + + def test_get_logged_club_with_invalid_session_data(self, request_session): + """Case 3 โ€” invalid session data: returns None.""" + with request_session("invalid_email@example.com"): + result = get_logged_club() + assert result is None diff --git a/utils.py b/utils.py new file mode 100644 index 000000000..eaa442505 --- /dev/null +++ b/utils.py @@ -0,0 +1,387 @@ +import json +from datetime import datetime +from typing import Dict, List, Optional, Union +from flask import current_app, flash, redirect, session, url_for, Response + +DATE_FORMAT: str = '%Y-%m-%d %H:%M:%S' + +Club = Dict[str, Union[str, int]] +Competition = Dict[str, Union[str, int]] + + +def get_booking_key(club_name: str, competition_name: str) -> str: + """ + Generate a unique booking key for a club and competition combination. + + Args: + club_name: Name of the club. + competition_name: Name of the competition. + + Returns: + Unique key string in the format 'club_name::competition_name'. + """ + return f"{club_name}::{competition_name}" + + +def get_logged_club() -> Optional[Club]: + """ + Retrieve the currently logged-in club from the session. + + Uses the email stored in the session to find the corresponding club. + + Returns: + Dictionary representing the logged-in club if found, otherwise None. + """ + email = session.get('club_email') + if not email: + return None + return get_club_by_email(email, current_app.config['CLUBS']) + + +def require_login() -> Optional[Club]: + """ + Check if a user is logged in and return the club if so. + + If no user is logged in, flashes an error message and returns None. + + Returns: + Dictionary representing the logged-in club if session + is valid, otherwise None. + """ + club = get_logged_club() + if club is None: + flash("Please log in first.") + return None + return club + + +def clear_session_keeping_flashes() -> None: + """ + Clear the current session while preserving any flashed messages. + + This allows error or success messages to persist across redirects. + """ + flashed_messages = session.get('_flashes', []) + session.clear() + if flashed_messages: + session['_flashes'] = flashed_messages + + +def logout_and_redirect() -> Response: + """ + Clear the session and redirect to the index page. + + Preserves any flashed messages during the redirect. + + Returns: + Flask redirect response to the index route. + """ + clear_session_keeping_flashes() + return redirect(url_for('index')) + + +def build_competitions_view( + competitions: List[Competition]) -> List[Competition]: + """ + Build an enhanced view of competitions with booking availability. + + Adds a 'can_book' boolean to each competition indicating + if it's currently bookable. + + Args: + competitions: List of competition dictionaries. + + Returns: + List of competition dictionaries with added 'can_book' field. + """ + now = datetime.now() + competitions_view = [] + + for competition in competitions: + competition_view = dict(competition) + competition_view['can_book'] = is_competition_bookable( + competition, now=now) + competitions_view.append(competition_view) + + return competitions_view + + +def load_clubs() -> Optional[List[Club]]: + """ + Load clubs data from the clubs.json file. + + Returns: + List of club dictionaries if the file is + found and valid, otherwise None. + + Raises: + None. All exceptions are caught and result in returning None. + """ + try: + with open('clubs.json') as c: + list_of_clubs: List[Club] = json.load(c)['clubs'] + return list_of_clubs + except (OSError, json.JSONDecodeError, KeyError): + return None + + +def load_competitions() -> Optional[List[Competition]]: + """ + Load competitions data from the competitions.json file. + + Returns: + List of competition dictionaries if the file is + found and valid, otherwise None. + + Raises: + None. All exceptions are caught and result in returning None. + """ + try: + with open('competitions.json') as comps: + list_of_competitions: List[Competition] = json.load(comps)[ + 'competitions'] + return list_of_competitions + except (OSError, json.JSONDecodeError, KeyError): + return None + + +def get_club_by_email(email: str, clubs: List[Club]) -> Optional[Club]: + """ + Find a club by its email address from a list of clubs. + + The comparison is case-insensitive and ignores whitespace. + + Args: + email: Email address to search for. + clubs: List of club dictionaries to search in. + + Returns: + Club dictionary if found, otherwise None. + """ + email = lower_case_email(strip_white_space(email)) + for club in clubs: + if lower_case_email(strip_white_space(club['email'])) == email: + return club + return None + + +def lower_case_email(email: str) -> str: + """ + Convert an email address to lowercase. + + Args: + email: Email address to convert. + + Returns: + Lowercase version of the email. + """ + return email.lower() + + +def strip_white_space(email: str) -> str: + """ + Remove leading and trailing whitespace from an email address. + + Args: + email: Email address to clean. + + Returns: + Email address without leading or trailing whitespace. + """ + return email.strip() + + +def get_competition_by_name( + name: str, competitions: List[Competition]) -> Optional[Competition]: + """ + Find a competition by its name from a list of competitions. + + Args: + name: Name of the competition to find. + competitions: List of competition dictionaries to search in. + + Returns: + Competition dictionary if found, otherwise None. + """ + for competition in competitions: + if competition['name'] == name: + return competition + return None + + +def get_club_by_name(name: str, clubs: List[Club]) -> Optional[Club]: + """ + Find a club by its name from a list of clubs. + + Args: + name: Name of the club to find. + clubs: List of club dictionaries to search in. + + Returns: + Club dictionary if found, otherwise None. + """ + for club in clubs: + if club['name'] == name: + return club + return None + + +def get_club_points(club: Club) -> Optional[int]: + """ + Extract and convert the points value from a club dictionary. + + Args: + club: Club dictionary containing a 'points' key. + + Returns: + Integer value of the club's points if valid, otherwise None. + """ + if not isinstance(club, dict): + return None + try: + return int(club.get('points', None)) + except (TypeError, ValueError): + return None + + +def get_competition_places(competition: Competition) -> Optional[int]: + """ + Extract and convert the number_of_places value + from a competition dictionary. + + Args: + competition: Competition dictionary containing a + 'number_of_places' key. + + Returns: + Integer value of the available places if valid, otherwise None. + """ + if not isinstance(competition, dict): + return None + try: + return int(competition.get('number_of_places', None)) + except (TypeError, ValueError): + return None + + +def is_competition_bookable( + competition: Competition, now: Optional[datetime] = None) -> bool: + """ + Check if a competition is available for booking. + + A competition is bookable if: + - It has at least 1 available place + - Its date is in the future (or present) + + Args: + competition: Competition dictionary to check. + now: Reference datetime for comparison. + Uses current time if not provided. + + Returns: + True if the competition is bookable, False otherwise. + """ + if now is None: + now = datetime.now() + + competition_places = get_competition_places(competition) + if competition_places is None or competition_places <= 0: + return False + + try: + competition_date = datetime.strptime(competition['date'], DATE_FORMAT) + except (KeyError, TypeError, ValueError): + return False + + return competition_date >= now + + +def is_booking_valid( + club_points: int, + competition_places: int, + places_requested: int, + places_already_booked: int = 0 +) -> List[str]: + """ + Validate a booking request against available points and places. + + Args: + club_points: Available points of the club. + competition_places: Available places in the competition. + places_requested: Number of places the club wants to book. + places_already_booked: Number of places already booked by + this club for this competition. + + Returns: + List of error message strings. Empty list if the booking is valid. + """ + errors: List[str] = [] + + if places_requested <= 0: + if places_requested == 0: + errors.append("You need to book at least one place.") + else: + errors.append("You cannot book a negative number of places.") + + if places_requested > competition_places: + errors.append("Not enough places available in this competition.") + + if places_requested > club_points: + errors.append( + "Not enough points available in your club " + "to book the requested number of places.") + + if (places_requested > 12 + or (places_already_booked + places_requested) > 12): + errors.append("You cannot book more than 12 places per competition.") + + return errors + + +def update_club_points(club: Club, points_to_deduct: int) -> bool: + """ + Deduct points from a club after a successful booking. + + Args: + club: Club dictionary to update. + points_to_deduct: Number of points to subtract. + + Returns: + True if the update was successful, False otherwise. + """ + if not isinstance(club, dict): + return False + try: + current_points = int(club.get('points', 0)) + new_points = current_points - points_to_deduct + if new_points < 0 or points_to_deduct < 0: + return False + club['points'] = str(new_points) + return True + except (TypeError, ValueError): + return False + + +def update_competition_places( + competition: Competition, places_to_deduct: int) -> bool: + """ + Deduct places from a competition after a successful booking. + + Args: + competition: Competition dictionary to update. + places_to_deduct: Number of places to subtract. + + Returns: + True if the update was successful, False otherwise. + """ + if not isinstance(competition, dict): + return False + try: + current_places = int(competition.get('number_of_places', 0)) + new_places = current_places - places_to_deduct + if new_places < 0 or places_to_deduct < 0: + return False + competition['number_of_places'] = str(new_places) + return True + except (TypeError, ValueError): + return False