From 9885558118e260644f5066837763729b15a833c2 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Tue, 5 Aug 2025 17:48:09 +0100 Subject: [PATCH 01/28] add Flask-PyMongo extension and init in create_app factory --- app/__init__.py | 10 +++++++++- app/config.py | 7 ++++--- requirements.txt | 3 ++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 50b5bd6..01a7c39 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,9 +3,14 @@ import os from flask import Flask +from flask_pymongo import PyMongo from app.config import Config +# Create PyMongo extension object outside the factory +# in the global scope, creating an "empty" extension object +# This way, we can import it in other files +mongo = PyMongo() def create_app(test_config=None): """Application factory pattern.""" @@ -18,7 +23,10 @@ def create_app(test_config=None): if test_config: # Override with test specifics app.config.from_mapping(test_config) - # Import routes + # Connect Pymongo to our specific app instance + mongo.init_app(app) + + # Import and register routes from app.routes import \ register_routes # pylint: disable=import-outside-toplevel diff --git a/app/config.py b/app/config.py index 72da9b0..74a33b3 100644 --- a/app/config.py +++ b/app/config.py @@ -1,10 +1,11 @@ # pylint: disable=too-few-public-methods """ -Application configuration module for Flask. +The central, organized place for the application's settings. + +Loads environment variables (and other sensitive values) from a .env file and +Defines the Config class to be used. -Loads environment variables from a .env file and defines the Config class -used to configure Flask and database connection settings. """ import os diff --git a/requirements.txt b/requirements.txt index f6881b0..92ea35b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ pymongo python-dotenv mongomock black -isort \ No newline at end of file +isort +flask_pymongo \ No newline at end of file From 7fd0265a4efbfc0be06e44d9fe7199824c409958 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Tue, 5 Aug 2025 18:02:45 +0100 Subject: [PATCH 02/28] Add users_db_setup fixture to clean users collection --- tests/conftest.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4982b70..bd10a59 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ import mongomock import pytest -from app import create_app +from app import create_app, mongo from app.datastore.mongo_db import get_book_collection @@ -105,3 +105,20 @@ def db_setup(test_app): # pylint: disable=redefined-outer-name with test_app.app_context(): collection = get_book_collection() collection.delete_many({}) + +# Fixture for tests/test_auth.py +@pytest.fixture(scope="function") +def users_db_setup(test_app): # pylint: disable=redefined-outer-name + """ + Sets up and tears down the 'users' collection for a test. + """ + with test_app.app_context(): + # Now, the 'mongo' variable is defined and linked to the test_app + users_collection = mongo.db.users + users_collection.delete_many({}) + + yield + + with test_app.app_context(): + users_collection = mongo.db.users + users_collection.delete_many({}) From b728891fe068ffdb0c7207b0d32bbdf0f872534c Mon Sep 17 00:00:00 2001 From: codesungrape Date: Tue, 5 Aug 2025 18:28:55 +0100 Subject: [PATCH 03/28] Add tests/test_auth.py with register and duplicate-email tests --- tests/test_auth.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/test_auth.py diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..f606787 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,53 @@ +"""Tests for auth/JWT upgrade""" + +import bcrypt +from app import mongo + + +def test_register_with_valid_data(client, users_db_setup): + """GIVEN a clean users collection + WHEN a POST request is sent to /auth/register with new user data + THEN the repsonse should be 201 CREATED and the user should exist in the DB""" + _ = users_db_setup # pylint: disable=unused-variable + + # Arrange + new_user_data = { + 'email': 'newuser@example.com', + 'password': 'a-secure-password' + } + # ACT + response = client.post('/auth/register', json=new_user_data) + + # Verify the user was actually created in the database + assert response.status_code == 201 + user_in_db = mongo.db.users.find_one({'email': 'newuser@example.com'}) + assert user_in_db is not None + assert 'password_hash' in user_in_db + # You can even check if the password hashes match! + assert bcrypt.checkpw(b'a-secure-password', user_in_db['password_hash'].encode('utf-8')) + + +def test_register_with_duplicate_email(client, users_db_setup): + """ + GIVEN a user already exists in the database + WHEN a POST request is sent to /auth/register with the same email + THEN the response should be 409 Conflict""" + _ = users_db_setup # pylint: disable=unused-variable + + # Arrange + existing_user_data = { + 'email': 'newuser@example.com', + 'password': 'a-secure-password' + } + client.post("/auth/register", json=existing_user_data) + # sanity-check + user_in_db = mongo.db.users.find_one({'email': 'newuser@example.com'}) + assert user_in_db is not None + assert 'password_hash' in user_in_db + + # Act: try to register with the same email again + response = client.post("/auth/register", json=existing_user_data) + + # Assert + assert response.status_code == 409 + assert 'email is already registered' in response.get_json()['message'].lower() From 18ab2fd4de738ac69965d08d9165e5cbe95163d0 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Tue, 5 Aug 2025 18:30:32 +0100 Subject: [PATCH 04/28] Run 'make format' command for formatting files --- app/__init__.py | 1 + app/config.py | 2 +- app/datastore/mongo_helper.py | 4 +++- tests/conftest.py | 5 +++-- tests/test_api_security.py | 3 +-- tests/test_app.py | 17 +++++++---------- tests/test_app_utils_helper.py | 8 ++------ tests/test_auth.py | 26 +++++++++++++------------- tests/test_data.py | 1 - tests/test_mongo_helper.py | 7 ++++--- 10 files changed, 35 insertions(+), 39 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 01a7c39..c94c51d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -12,6 +12,7 @@ # This way, we can import it in other files mongo = PyMongo() + def create_app(test_config=None): """Application factory pattern.""" diff --git a/app/config.py b/app/config.py index 74a33b3..8feaca9 100644 --- a/app/config.py +++ b/app/config.py @@ -3,7 +3,7 @@ """ The central, organized place for the application's settings. -Loads environment variables (and other sensitive values) from a .env file and +Loads environment variables (and other sensitive values) from a .env file and Defines the Config class to be used. """ diff --git a/app/datastore/mongo_helper.py b/app/datastore/mongo_helper.py index c2a889a..948d6bd 100644 --- a/app/datastore/mongo_helper.py +++ b/app/datastore/mongo_helper.py @@ -1,8 +1,8 @@ """Module containing pymongo helper functions.""" from bson.objectid import InvalidId, ObjectId -from pymongo.cursor import Cursor from pymongo.collection import Collection +from pymongo.cursor import Cursor def insert_book_to_mongo(book_data, collection): @@ -83,8 +83,10 @@ def delete_book_by_id(book_collection: Collection, book_id: str): return result + # ------ PUT helpers ------------ + def validate_book_put_payload(payload: dict): """ Validates the payload for a PUT request. diff --git a/tests/conftest.py b/tests/conftest.py index bd10a59..2d424e9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -106,9 +106,10 @@ def db_setup(test_app): # pylint: disable=redefined-outer-name collection = get_book_collection() collection.delete_many({}) + # Fixture for tests/test_auth.py @pytest.fixture(scope="function") -def users_db_setup(test_app): # pylint: disable=redefined-outer-name +def users_db_setup(test_app): # pylint: disable=redefined-outer-name """ Sets up and tears down the 'users' collection for a test. """ @@ -116,7 +117,7 @@ def users_db_setup(test_app): # pylint: disable=redefined-outer-name # Now, the 'mongo' variable is defined and linked to the test_app users_collection = mongo.db.users users_collection.delete_many({}) - + yield with test_app.app_context(): diff --git a/tests/test_api_security.py b/tests/test_api_security.py index 5377885..a9e04f8 100644 --- a/tests/test_api_security.py +++ b/tests/test_api_security.py @@ -8,8 +8,7 @@ import pytest from bson.objectid import ObjectId -from tests.test_data import HEADERS, DUMMY_PAYLOAD - +from tests.test_data import DUMMY_PAYLOAD, HEADERS # -------------- LOGGING -------------------------- diff --git a/tests/test_app.py b/tests/test_app.py index a1fbab0..aa87439 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -5,11 +5,9 @@ from bson.objectid import ObjectId from pymongo.errors import ConnectionFailure -from tests.test_data import HEADERS, DUMMY_PAYLOAD - from app import routes from app.datastore.mongo_db import get_book_collection - +from tests.test_data import DUMMY_PAYLOAD, HEADERS # Mock book database object books_database = [ @@ -768,21 +766,20 @@ def test_update_book_sent_with_missing_required_fields(client): expected_error = "Missing required fields: synopsis, title" assert response_data["error"] == expected_error + def test_update_book_fails_with_malformed_json_body(client): # --- ARRANGE --- malformed_json_string = '{"title": "A Test Book", }' headers_with_bad_body = { "Content-Type": "application/json", - "X-API-KEY": "test-key-123" + "X-API-KEY": "test-key-123", } # --- ACT --- # Use the `data` argument to send the raw, broken string. # If we used `json=`, the test client would fix it for us! response = client.put( - "/books/some_id", - data=malformed_json_string, - headers=headers_with_bad_body + "/books/some_id", data=malformed_json_string, headers=headers_with_bad_body ) # --- ASSERT --- assert response.status_code == 400 @@ -798,15 +795,15 @@ def test_update_book_fails_with_wrong_content_type(client): """ # --- ARRANGE --- headers_with_wrong_type = { - "Content-Type": "text/plain", # The wrong type - "X-API-KEY": "test-key-123" + "Content-Type": "text/plain", # The wrong type + "X-API-KEY": "test-key-123", } # --- ACT --- response = client.put( "/books/some_id", data="This is just plain text", - headers=headers_with_wrong_type + headers=headers_with_wrong_type, ) # --- ASSERT --- diff --git a/tests/test_app_utils_helper.py b/tests/test_app_utils_helper.py index 2f325bd..c667e3f 100644 --- a/tests/test_app_utils_helper.py +++ b/tests/test_app_utils_helper.py @@ -6,11 +6,9 @@ from bson.objectid import ObjectId from pymongo.errors import ServerSelectionTimeoutError -from tests.test_data import HEADERS, DUMMY_PAYLOAD - from app import create_app, routes from app.datastore.mongo_db import get_book_collection - +from tests.test_data import DUMMY_PAYLOAD, HEADERS # ------------------------ Tests for HELPER FUNCTIONS ------------------------------------- @@ -206,9 +204,7 @@ def test_append_host_to_links_in_put(monkeypatch, client): assert response.status_code == 200 mock_append_hostname.assert_called_once() - call_args, _ = ( # pylint: disable=unused-variable - mock_append_hostname.call_args - ) + call_args, _ = mock_append_hostname.call_args # pylint: disable=unused-variable book_arg = call_args[0] host_arg = call_args[1] diff --git a/tests/test_auth.py b/tests/test_auth.py index f606787..208a715 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,7 @@ """Tests for auth/JWT upgrade""" import bcrypt + from app import mongo @@ -11,20 +12,19 @@ def test_register_with_valid_data(client, users_db_setup): _ = users_db_setup # pylint: disable=unused-variable # Arrange - new_user_data = { - 'email': 'newuser@example.com', - 'password': 'a-secure-password' - } + new_user_data = {"email": "newuser@example.com", "password": "a-secure-password"} # ACT - response = client.post('/auth/register', json=new_user_data) + response = client.post("/auth/register", json=new_user_data) # Verify the user was actually created in the database assert response.status_code == 201 - user_in_db = mongo.db.users.find_one({'email': 'newuser@example.com'}) + user_in_db = mongo.db.users.find_one({"email": "newuser@example.com"}) assert user_in_db is not None - assert 'password_hash' in user_in_db + assert "password_hash" in user_in_db # You can even check if the password hashes match! - assert bcrypt.checkpw(b'a-secure-password', user_in_db['password_hash'].encode('utf-8')) + assert bcrypt.checkpw( + b"a-secure-password", user_in_db["password_hash"].encode("utf-8") + ) def test_register_with_duplicate_email(client, users_db_setup): @@ -36,18 +36,18 @@ def test_register_with_duplicate_email(client, users_db_setup): # Arrange existing_user_data = { - 'email': 'newuser@example.com', - 'password': 'a-secure-password' + "email": "newuser@example.com", + "password": "a-secure-password", } client.post("/auth/register", json=existing_user_data) # sanity-check - user_in_db = mongo.db.users.find_one({'email': 'newuser@example.com'}) + user_in_db = mongo.db.users.find_one({"email": "newuser@example.com"}) assert user_in_db is not None - assert 'password_hash' in user_in_db + assert "password_hash" in user_in_db # Act: try to register with the same email again response = client.post("/auth/register", json=existing_user_data) # Assert assert response.status_code == 409 - assert 'email is already registered' in response.get_json()['message'].lower() + assert "email is already registered" in response.get_json()["message"].lower() diff --git a/tests/test_data.py b/tests/test_data.py index 9c699a0..f7811ce 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -1,6 +1,5 @@ """Constants which couldnt be added to conftest""" - # A dictionary for headers to keep things clean HEADERS = { "VALID": {"X-API-KEY": "test-key-123"}, diff --git a/tests/test_mongo_helper.py b/tests/test_mongo_helper.py index 3bc68c0..a03386a 100644 --- a/tests/test_mongo_helper.py +++ b/tests/test_mongo_helper.py @@ -191,7 +191,7 @@ def test_validate_payload_fails_with_extra_fields(): "title": "Valid Title", "author": "Valid Author", "synopsis": "A valid synopsis.", - "rating": 5 # This is the unexpected, extra field + "rating": 5, # This is the unexpected, extra field } is_valid, error_dict = validate_book_put_payload(payload_with_extra_field) @@ -203,6 +203,7 @@ def test_validate_payload_fails_with_extra_fields(): expected_error_message = "Unexpected fields provided: rating" assert error_dict["error"] == expected_error_message + def test_validate_payload_fails_with_multiple_extra_fields(): """ GIVEN a payload with multiple extra fields @@ -213,8 +214,8 @@ def test_validate_payload_fails_with_multiple_extra_fields(): "title": "Valid Title", "author": "Valid Author", "synopsis": "A valid synopsis.", - "year": 2024, # Extra field - "isbn": "123-456" # Extra field + "year": 2024, # Extra field + "isbn": "123-456", # Extra field } is_valid, error_dict = validate_book_put_payload(payload_with_extra_fields) From 7a3c268ecb7047bbc8147d872e20787191fe8f97 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 6 Aug 2025 12:50:21 +0100 Subject: [PATCH 05/28] Use Flask Blueprint and restructure app layout Move app/routes.py to app/routes/legacy_routes.py and rename function to register_legacy_routes(). Add app/__init__.py to centralize route registration. Register Blueprint in __init__.py. Improves modularity and prepares for future structure. --- app/routes/__init__.py | 16 ++++++++++++++++ app/routes/auth_routes.py | 14 ++++++++++++++ app/{routes.py => routes/legacy_routes.py} | 2 +- 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 app/routes/__init__.py create mode 100644 app/routes/auth_routes.py rename app/{routes.py => routes/legacy_routes.py} (99%) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..94c4266 --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1,16 @@ +"""Make into package and house helper functions""" + +def register_routes(app): + """ + CENTRAL CONTROLLER + Imports and registers all the blueprint routes for the application. + This is the single entry point for all route registration. + """ + + # Register the OLD, non-Blueprint routes + from .legacy_routes import register_legacy_routes # pylint: disable=import-outside-toplevel + register_legacy_routes(app) + + # Register the NEW, Bluerprint-based routes + from .auth_routes import auth_bp # pylint: disable=import-outside-toplevel + app.register_blueprint(auth_bp) diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py new file mode 100644 index 0000000..e981fed --- /dev/null +++ b/app/routes/auth_routes.py @@ -0,0 +1,14 @@ +"""Routes for authorrization for the JWT upgrade""" + +from flask import Blueprint + + +auth_bp = Blueprint('auth', __name__, url_prefix="/books") + +@auth_bp.route("auth/register", methods=["POST"]) +def register_user(): + """Function that takes user information + Verfies it is not a duplicate email + SENDS it to mongoDB to be stored + """ + return "User registered", 201 diff --git a/app/routes.py b/app/routes/legacy_routes.py similarity index 99% rename from app/routes.py rename to app/routes/legacy_routes.py index 6a80d8c..d09dba3 100644 --- a/app/routes.py +++ b/app/routes/legacy_routes.py @@ -15,7 +15,7 @@ from app.utils.helper import append_hostname -def register_routes(app): # pylint: disable=too-many-statements +def register_legacy_routes(app): # pylint: disable=too-many-statements """ Register all Flask routes with the given app instance. From 4fc903fb37ed0caad128e8d6b65a510c7e9375eb Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 6 Aug 2025 13:38:47 +0100 Subject: [PATCH 06/28] Update tests to reflect post Flask Blueprint restructure --- tests/test_api_security.py | 18 +++++++------- tests/test_app.py | 44 +++++++++++++++++----------------- tests/test_app_utils_helper.py | 12 +++++----- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/tests/test_api_security.py b/tests/test_api_security.py index a9e04f8..ea97be5 100644 --- a/tests/test_api_security.py +++ b/tests/test_api_security.py @@ -39,11 +39,11 @@ def test_invalid_api_key_logs_attempt_for_post_route(client, caplog, method, pat def test_add_book_fails_with_missing_key(client, monkeypatch): # Mock external dependencies using monkeypatch - monkeypatch.setattr("app.routes.get_book_collection", lambda: None) + monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: None) monkeypatch.setattr( - "app.routes.insert_book_to_mongo", lambda book, collection: None + "app.routes.legacy_routes.insert_book_to_mongo", lambda book, collection: None ) - monkeypatch.setattr("app.routes.append_hostname", lambda book, host: book) + monkeypatch.setattr("app.routes.legacy_routes.append_hostname", lambda book, host: book) # Hit the endpoint without Authorization header response = client.post("/books", json=DUMMY_PAYLOAD) @@ -69,12 +69,12 @@ def test_add_book_succeeds_with_valid_key(client, monkeypatch): mock_collection.find_one.return_value = mock_book_from_db # Patch get_book_collection to return our fake collection - monkeypatch.setattr("app.routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) # Patch insert_book_to_mongo to return our fake insert result monkeypatch.setattr( - "app.routes.insert_book_to_mongo", lambda book, collection: mock_insert_result + "app.routes.legacy_routes.insert_book_to_mongo", lambda book, collection: mock_insert_result ) - monkeypatch.setattr("app.routes.append_hostname", lambda book, host: book) + monkeypatch.setattr("app.routes.legacy_routes.append_hostname", lambda book, host: book) # Act response = client.post("/books", json=DUMMY_PAYLOAD, headers=HEADERS["VALID"]) @@ -127,7 +127,7 @@ def test_update_book_succeeds_with_valid_api_key(monkeypatch, client): mock_collection.find_one.return_value = expected_book_from_db # monkeypatcj to replace the real get_book_collection with a function - monkeypatch.setattr("app.routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) # ACT response = client.put( @@ -204,10 +204,10 @@ def test_delete_book_succeeds_with_valid_api_key(client, monkeypatch): successful_db_result = {"_id": "some-id", "state": "active"} monkeypatch.setattr( - "app.routes.delete_book_by_id", lambda collection, book_id: successful_db_result + "app.routes.legacy_routes.delete_book_by_id", lambda collection, book_id: successful_db_result ) - monkeypatch.setattr("app.routes.get_book_collection", lambda: "a fake collection") + monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: "a fake collection") # Act valid_oid_string = "635c02a7a5f6e1e2b3f4d5e6" diff --git a/tests/test_app.py b/tests/test_app.py index aa87439..531d4f0 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -77,9 +77,9 @@ def test_add_book_creates_and_returns_new_book(client, _insert_book_to_db, monke mock_insert_helper = MagicMock(return_value=mock_insert_result) # Apply all the patches - monkeypatch.setattr("app.routes.get_book_collection", lambda: mock_collection) - monkeypatch.setattr("app.routes.insert_book_to_mongo", mock_insert_helper) - monkeypatch.setattr("app.routes.append_hostname", lambda book, host: book) + monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr("app.routes.legacy_routes.insert_book_to_mongo", mock_insert_helper) + monkeypatch.setattr("app.routes.legacy_routes.append_hostname", lambda book, host: book) # Define the valid headers, including the API key that matches conftest.py valid_headers = {"X-API-KEY": "test-key-123"} @@ -172,7 +172,7 @@ def test_500_response_is_json(client): error_message = "An unexpected error occurred" # Use patch to mock uuid module failing and throwing an exception - with patch("app.routes.insert_book_to_mongo", side_effect=Exception(error_message)): + with patch("app.routes.legacy_routes.insert_book_to_mongo", side_effect=Exception(error_message)): response = client.post("/books", json=test_book, headers=valid_headers) # ASSERT @@ -185,8 +185,8 @@ def test_500_response_is_json(client): # ------------------------ Tests for GET -------------------------------------------- -@patch("app.routes.format_books_for_api") -@patch("app.routes.fetch_active_books") +@patch("app.routes.legacy_routes.format_books_for_api") +@patch("app.routes.legacy_routes.fetch_active_books") def test_get_all_books_returns_all_books(mock_fetch, mock_format, client): mock_books_list = MagicMock() @@ -216,7 +216,7 @@ def test_get_all_books_returns_all_books(mock_fetch, mock_format, client): mock_format.assert_called_once_with(mock_books_list, "http://localhost") -@patch("app.routes.fetch_active_books") +@patch("app.routes.legacy_routes.fetch_active_books") def test_missing_fields_in_book_object_returned_by_database(mock_fetch, client): bad_raw_data = [ @@ -243,7 +243,7 @@ def test_missing_fields_in_book_object_returned_by_database(mock_fetch, client): mock_fetch.assert_called_once() -@patch("app.routes.fetch_active_books") +@patch("app.routes.legacy_routes.fetch_active_books") def test_get_all_books_returns_error_404_when_list_is_empty(mock_fetch, client): empty_data = [] mock_fetch.return_value = empty_data @@ -252,7 +252,7 @@ def test_get_all_books_returns_error_404_when_list_is_empty(mock_fetch, client): assert "No books found" in response.get_json()["error"] -@patch("app.routes.fetch_active_books") +@patch("app.routes.legacy_routes.fetch_active_books") def test_get_book_returns_404_when_books_is_none(mock_fetch, client): none_data = None mock_fetch.return_value = none_data @@ -380,7 +380,7 @@ def test_get_book_happy_path_unit_test(client, monkeypatch): mock_collection.find_one.return_value = fake_book_from_db # use monkeypatch to replace the get_book_collection - monkeypatch.setattr("app.routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) # ACT get_response = client.get(f"/books/{fake_book_id_str}") @@ -459,7 +459,7 @@ def test_get_book_not_found_returns_404(client, monkeypatch): # Mock the collection to return None (book not in DB) mock_collection = MagicMock() mock_collection.find_one.return_value = None - monkeypatch.setattr(routes, "get_book_collection", lambda: mock_collection) + monkeypatch.setattr(routes.legacy_routes, "get_book_collection", lambda: mock_collection) # ACT: Test GET request using invalid book ID response = client.get(f"/books/{valid_id_str}") @@ -478,7 +478,7 @@ def test_book_database_is_initialized_for_specific_book_route(client, monkeypatc valid_id = ObjectId() valid_id_str = str(valid_id) - monkeypatch.setattr(routes, "get_book_collection", lambda: None) + monkeypatch.setattr(routes.legacy_routes, "get_book_collection", lambda: None) response = client.get(f"/books/{valid_id_str}") assert response.status_code == 500 @@ -493,7 +493,7 @@ def test_get_book_returns_404_if_state_equals_deleted(client, monkeypatch): # Mock the collection to return None (book state deleted) mock_collection = MagicMock() mock_collection.find_one.return_value = None - monkeypatch.setattr(routes, "get_book_collection", lambda: mock_collection) + monkeypatch.setattr(routes.legacy_routes, "get_book_collection", lambda: mock_collection) response = client.get(f"/books/{valid_id_str}") assert response.status_code == 404 @@ -522,13 +522,13 @@ def test_book_is_soft_deleted_on_delete_request(client): This test verifies the integration between the Flask route and the data layer. """ - with patch("app.routes.delete_book_by_id") as mock_delete_helper: + with patch("app.routes.legacy_routes.delete_book_by_id") as mock_delete_helper: # Arrange # Configure the mock to simulate a successful deletion mock_delete_helper.return_value = {"_id": VALID_OID_STRING} # Mock get_book_collection to avoid a real DB connection - with patch("app.routes.get_book_collection", return_value="fake_collection"): + with patch("app.routes.legacy_routes.get_book_collection", return_value="fake_collection"): # --- Act --- # Send the DELETE request using a valid API header. headers = {"X-API-KEY": "test-key-123"} @@ -559,7 +559,7 @@ def test_delete_invalid_book_id(client): invalid_id = "1234-this-is-not-a-valid-id" # Mock get_book_collection to avoid a real DB connection - with patch("app.routes.get_book_collection", return_value="fake_collection"): + with patch("app.routes.legacy_routes.get_book_collection", return_value="fake_collection"): # --- Act --- # Send the DELETE request using a valid API header. headers = {"X-API-KEY": "test-key-123"} @@ -573,7 +573,7 @@ def test_delete_invalid_book_id(client): def test_book_database_is_initialized_for_delete_book_route(client): - with patch("app.routes.get_book_collection") as mock_get_collection: + with patch("app.routes.legacy_routes.get_book_collection") as mock_get_collection: mock_get_collection.return_value = None headers = {"X-API-KEY": "test-key-123"} @@ -586,7 +586,7 @@ def test_book_database_is_initialized_for_delete_book_route(client): def test_returns_404_if_helper_function_result_is_none(client): - with patch("app.routes.delete_book_by_id") as mock_delete_book: + with patch("app.routes.legacy_routes.delete_book_by_id") as mock_delete_book: mock_delete_book.return_value = None headers = {"X-API-KEY": "test-key-123"} @@ -637,7 +637,7 @@ def test_update_book_response_contains_all_required_fields(monkeypatch, client): mock_collection.find_one.return_value = book_doc_from_db # Patch the function that provides the database collection - monkeypatch.setattr("app.routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) # ACT # Send the PUT request to the endpoint @@ -678,7 +678,7 @@ def test_update_book_replaces_whole_object(monkeypatch, client): mock_collection.find_one.return_value = book_doc_after_put # Inject our mock into the application. - monkeypatch.setattr("app.routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) # ACT response = client.put( @@ -709,7 +709,7 @@ def test_update_book_sent_with_invalid_book_id(monkeypatch, client): # Simulate a failed replacement by setting matched_count to 0. mock_collection.replace_one.return_value.matched_count = 0 - monkeypatch.setattr("app.routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) response = client.put( f"/books/{non_existent_id}", json=DUMMY_PAYLOAD, headers=HEADERS["VALID"] @@ -724,7 +724,7 @@ def test_update_book_sent_with_invalid_book_id(monkeypatch, client): def test_book_database_is_initialized_for_update_book_route(monkeypatch, client): - monkeypatch.setattr("app.routes.get_book_collection", lambda: None) + monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: None) response = client.put("/books/123", json=DUMMY_PAYLOAD, headers=HEADERS["VALID"]) assert response.status_code == 500 response_data = response.get_json() diff --git a/tests/test_app_utils_helper.py b/tests/test_app_utils_helper.py index c667e3f..4ac0584 100644 --- a/tests/test_app_utils_helper.py +++ b/tests/test_app_utils_helper.py @@ -41,8 +41,8 @@ def test_add_book_response_contains_absolute_urls(client, monkeypatch): mock_insert_helper = MagicMock(return_value=mock_insert_result) # E. Apply all patches to isolate the route from the database - monkeypatch.setattr("app.routes.get_book_collection", lambda: mock_collection) - monkeypatch.setattr("app.routes.insert_book_to_mongo", mock_insert_helper) + monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr("app.routes.legacy_routes.insert_book_to_mongo", mock_insert_helper) # Act response = client.post("/books", json=test_book_payload, headers=valid_headers) @@ -125,7 +125,7 @@ def test_append_host_to_links_in_get_book(client, monkeypatch): fake_collection = MagicMock() fake_collection.find_one.return_value = book_from_db # Patch the function that the route uses to get the collection - monkeypatch.setattr(routes, "get_book_collection", lambda: fake_collection) + monkeypatch.setattr(routes.legacy_routes, "get_book_collection", lambda: fake_collection) # mock append_hostname helper, define its output mock_appender = MagicMock( @@ -138,7 +138,7 @@ def test_append_host_to_links_in_get_book(client, monkeypatch): }, } ) - monkeypatch.setattr(routes, "append_hostname", mock_appender) + monkeypatch.setattr(routes.legacy_routes, "append_hostname", mock_appender) # ACT response = client.get(f"/books/{book_id_str}") @@ -192,9 +192,9 @@ def test_append_host_to_links_in_put(monkeypatch, client): mock_collection = MagicMock() mock_collection.replace_one.return_value.matched_count = 1 mock_collection.find_one.return_value = book_doc_from_db - monkeypatch.setattr("app.routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) - with patch("app.routes.append_hostname") as mock_append_hostname: + with patch("app.routes.legacy_routes.append_hostname") as mock_append_hostname: mock_append_hostname.side_effect = lambda book, host: book # --- 2. ACT --- From 9719e03723ce5a2813f6cfc4e0fd423c7eafaa7d Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 6 Aug 2025 15:03:48 +0100 Subject: [PATCH 07/28] Patch mongo connection with mock for Flask-Pymongo --- tests/conftest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2d424e9..ee28d9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,7 @@ def stub_insert_book(): """Fixture that mocks insert_book_to_mongo() to prevent real DB writes during tests. Returns a mock with a fixed inserted_id.""" - with patch("app.routes.insert_book_to_mongo") as mock_insert_book: + with patch("app.routes.legacy_routes.insert_book_to_mongo") as mock_insert_book: mock_insert_book.return_value.inserted_id = "12345" yield mock_insert_book @@ -78,6 +78,11 @@ def test_app(): "COLLECTION_NAME": "test_books", } ) + # PATCH mongodb context to be mock db object + with app.app_context(): + mongo.cx = mongomock.MongoClient() + mongo.db = mongo.cx[app.config["DB_NAME"]] + yield app From 52809eb1375a4d5bb634b3984538bbe2d0ffc5e8 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 6 Aug 2025 15:09:42 +0100 Subject: [PATCH 08/28] Run 'make format' command for formatting --- app/routes/__init__.py | 8 ++++-- app/routes/auth_routes.py | 2 +- tests/conftest.py | 8 +++++- tests/test_api_security.py | 26 +++++++++++++----- tests/test_app.py | 50 ++++++++++++++++++++++++++-------- tests/test_app_utils_helper.py | 16 ++++++++--- 6 files changed, 83 insertions(+), 27 deletions(-) diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 94c4266..6d7f511 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,5 +1,6 @@ """Make into package and house helper functions""" + def register_routes(app): """ CENTRAL CONTROLLER @@ -8,9 +9,12 @@ def register_routes(app): """ # Register the OLD, non-Blueprint routes - from .legacy_routes import register_legacy_routes # pylint: disable=import-outside-toplevel + from .legacy_routes import \ + register_legacy_routes # pylint: disable=import-outside-toplevel + register_legacy_routes(app) # Register the NEW, Bluerprint-based routes - from .auth_routes import auth_bp # pylint: disable=import-outside-toplevel + from .auth_routes import auth_bp # pylint: disable=import-outside-toplevel + app.register_blueprint(auth_bp) diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py index e981fed..39fc144 100644 --- a/app/routes/auth_routes.py +++ b/app/routes/auth_routes.py @@ -2,8 +2,8 @@ from flask import Blueprint +auth_bp = Blueprint("auth", __name__, url_prefix="/books") -auth_bp = Blueprint('auth', __name__, url_prefix="/books") @auth_bp.route("auth/register", methods=["POST"]) def register_user(): diff --git a/tests/conftest.py b/tests/conftest.py index ee28d9d..e761a4c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -78,7 +78,13 @@ def test_app(): "COLLECTION_NAME": "test_books", } ) - # PATCH mongodb context to be mock db object + # The application now uses the Flask-PyMongo extension, + # which requires initialization via `init_app`. + # In the test environment, the connection to a real database fails, + # leaving `mongo.db` as None. + # Fix: Manually patch the global `mongo` object's connection with a `mongomock` client. + # This ensures all tests run against a fast, in-memory mock database AND + # are isolated from external services." with app.app_context(): mongo.cx = mongomock.MongoClient() mongo.db = mongo.cx[app.config["DB_NAME"]] diff --git a/tests/test_api_security.py b/tests/test_api_security.py index ea97be5..88862cd 100644 --- a/tests/test_api_security.py +++ b/tests/test_api_security.py @@ -43,7 +43,9 @@ def test_add_book_fails_with_missing_key(client, monkeypatch): monkeypatch.setattr( "app.routes.legacy_routes.insert_book_to_mongo", lambda book, collection: None ) - monkeypatch.setattr("app.routes.legacy_routes.append_hostname", lambda book, host: book) + monkeypatch.setattr( + "app.routes.legacy_routes.append_hostname", lambda book, host: book + ) # Hit the endpoint without Authorization header response = client.post("/books", json=DUMMY_PAYLOAD) @@ -69,12 +71,17 @@ def test_add_book_succeeds_with_valid_key(client, monkeypatch): mock_collection.find_one.return_value = mock_book_from_db # Patch get_book_collection to return our fake collection - monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr( + "app.routes.legacy_routes.get_book_collection", lambda: mock_collection + ) # Patch insert_book_to_mongo to return our fake insert result monkeypatch.setattr( - "app.routes.legacy_routes.insert_book_to_mongo", lambda book, collection: mock_insert_result + "app.routes.legacy_routes.insert_book_to_mongo", + lambda book, collection: mock_insert_result, + ) + monkeypatch.setattr( + "app.routes.legacy_routes.append_hostname", lambda book, host: book ) - monkeypatch.setattr("app.routes.legacy_routes.append_hostname", lambda book, host: book) # Act response = client.post("/books", json=DUMMY_PAYLOAD, headers=HEADERS["VALID"]) @@ -127,7 +134,9 @@ def test_update_book_succeeds_with_valid_api_key(monkeypatch, client): mock_collection.find_one.return_value = expected_book_from_db # monkeypatcj to replace the real get_book_collection with a function - monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr( + "app.routes.legacy_routes.get_book_collection", lambda: mock_collection + ) # ACT response = client.put( @@ -204,10 +213,13 @@ def test_delete_book_succeeds_with_valid_api_key(client, monkeypatch): successful_db_result = {"_id": "some-id", "state": "active"} monkeypatch.setattr( - "app.routes.legacy_routes.delete_book_by_id", lambda collection, book_id: successful_db_result + "app.routes.legacy_routes.delete_book_by_id", + lambda collection, book_id: successful_db_result, ) - monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: "a fake collection") + monkeypatch.setattr( + "app.routes.legacy_routes.get_book_collection", lambda: "a fake collection" + ) # Act valid_oid_string = "635c02a7a5f6e1e2b3f4d5e6" diff --git a/tests/test_app.py b/tests/test_app.py index 531d4f0..cc7c0ae 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -77,9 +77,15 @@ def test_add_book_creates_and_returns_new_book(client, _insert_book_to_db, monke mock_insert_helper = MagicMock(return_value=mock_insert_result) # Apply all the patches - monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) - monkeypatch.setattr("app.routes.legacy_routes.insert_book_to_mongo", mock_insert_helper) - monkeypatch.setattr("app.routes.legacy_routes.append_hostname", lambda book, host: book) + monkeypatch.setattr( + "app.routes.legacy_routes.get_book_collection", lambda: mock_collection + ) + monkeypatch.setattr( + "app.routes.legacy_routes.insert_book_to_mongo", mock_insert_helper + ) + monkeypatch.setattr( + "app.routes.legacy_routes.append_hostname", lambda book, host: book + ) # Define the valid headers, including the API key that matches conftest.py valid_headers = {"X-API-KEY": "test-key-123"} @@ -172,7 +178,10 @@ def test_500_response_is_json(client): error_message = "An unexpected error occurred" # Use patch to mock uuid module failing and throwing an exception - with patch("app.routes.legacy_routes.insert_book_to_mongo", side_effect=Exception(error_message)): + with patch( + "app.routes.legacy_routes.insert_book_to_mongo", + side_effect=Exception(error_message), + ): response = client.post("/books", json=test_book, headers=valid_headers) # ASSERT @@ -380,7 +389,9 @@ def test_get_book_happy_path_unit_test(client, monkeypatch): mock_collection.find_one.return_value = fake_book_from_db # use monkeypatch to replace the get_book_collection - monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr( + "app.routes.legacy_routes.get_book_collection", lambda: mock_collection + ) # ACT get_response = client.get(f"/books/{fake_book_id_str}") @@ -459,7 +470,9 @@ def test_get_book_not_found_returns_404(client, monkeypatch): # Mock the collection to return None (book not in DB) mock_collection = MagicMock() mock_collection.find_one.return_value = None - monkeypatch.setattr(routes.legacy_routes, "get_book_collection", lambda: mock_collection) + monkeypatch.setattr( + routes.legacy_routes, "get_book_collection", lambda: mock_collection + ) # ACT: Test GET request using invalid book ID response = client.get(f"/books/{valid_id_str}") @@ -493,7 +506,9 @@ def test_get_book_returns_404_if_state_equals_deleted(client, monkeypatch): # Mock the collection to return None (book state deleted) mock_collection = MagicMock() mock_collection.find_one.return_value = None - monkeypatch.setattr(routes.legacy_routes, "get_book_collection", lambda: mock_collection) + monkeypatch.setattr( + routes.legacy_routes, "get_book_collection", lambda: mock_collection + ) response = client.get(f"/books/{valid_id_str}") assert response.status_code == 404 @@ -528,7 +543,10 @@ def test_book_is_soft_deleted_on_delete_request(client): mock_delete_helper.return_value = {"_id": VALID_OID_STRING} # Mock get_book_collection to avoid a real DB connection - with patch("app.routes.legacy_routes.get_book_collection", return_value="fake_collection"): + with patch( + "app.routes.legacy_routes.get_book_collection", + return_value="fake_collection", + ): # --- Act --- # Send the DELETE request using a valid API header. headers = {"X-API-KEY": "test-key-123"} @@ -559,7 +577,9 @@ def test_delete_invalid_book_id(client): invalid_id = "1234-this-is-not-a-valid-id" # Mock get_book_collection to avoid a real DB connection - with patch("app.routes.legacy_routes.get_book_collection", return_value="fake_collection"): + with patch( + "app.routes.legacy_routes.get_book_collection", return_value="fake_collection" + ): # --- Act --- # Send the DELETE request using a valid API header. headers = {"X-API-KEY": "test-key-123"} @@ -637,7 +657,9 @@ def test_update_book_response_contains_all_required_fields(monkeypatch, client): mock_collection.find_one.return_value = book_doc_from_db # Patch the function that provides the database collection - monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr( + "app.routes.legacy_routes.get_book_collection", lambda: mock_collection + ) # ACT # Send the PUT request to the endpoint @@ -678,7 +700,9 @@ def test_update_book_replaces_whole_object(monkeypatch, client): mock_collection.find_one.return_value = book_doc_after_put # Inject our mock into the application. - monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr( + "app.routes.legacy_routes.get_book_collection", lambda: mock_collection + ) # ACT response = client.put( @@ -709,7 +733,9 @@ def test_update_book_sent_with_invalid_book_id(monkeypatch, client): # Simulate a failed replacement by setting matched_count to 0. mock_collection.replace_one.return_value.matched_count = 0 - monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr( + "app.routes.legacy_routes.get_book_collection", lambda: mock_collection + ) response = client.put( f"/books/{non_existent_id}", json=DUMMY_PAYLOAD, headers=HEADERS["VALID"] diff --git a/tests/test_app_utils_helper.py b/tests/test_app_utils_helper.py index 4ac0584..16a0b21 100644 --- a/tests/test_app_utils_helper.py +++ b/tests/test_app_utils_helper.py @@ -41,8 +41,12 @@ def test_add_book_response_contains_absolute_urls(client, monkeypatch): mock_insert_helper = MagicMock(return_value=mock_insert_result) # E. Apply all patches to isolate the route from the database - monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) - monkeypatch.setattr("app.routes.legacy_routes.insert_book_to_mongo", mock_insert_helper) + monkeypatch.setattr( + "app.routes.legacy_routes.get_book_collection", lambda: mock_collection + ) + monkeypatch.setattr( + "app.routes.legacy_routes.insert_book_to_mongo", mock_insert_helper + ) # Act response = client.post("/books", json=test_book_payload, headers=valid_headers) @@ -125,7 +129,9 @@ def test_append_host_to_links_in_get_book(client, monkeypatch): fake_collection = MagicMock() fake_collection.find_one.return_value = book_from_db # Patch the function that the route uses to get the collection - monkeypatch.setattr(routes.legacy_routes, "get_book_collection", lambda: fake_collection) + monkeypatch.setattr( + routes.legacy_routes, "get_book_collection", lambda: fake_collection + ) # mock append_hostname helper, define its output mock_appender = MagicMock( @@ -192,7 +198,9 @@ def test_append_host_to_links_in_put(monkeypatch, client): mock_collection = MagicMock() mock_collection.replace_one.return_value.matched_count = 1 mock_collection.find_one.return_value = book_doc_from_db - monkeypatch.setattr("app.routes.legacy_routes.get_book_collection", lambda: mock_collection) + monkeypatch.setattr( + "app.routes.legacy_routes.get_book_collection", lambda: mock_collection + ) with patch("app.routes.legacy_routes.append_hostname") as mock_append_hostname: mock_append_hostname.side_effect = lambda book, host: book From 84e3d3029493ab22d030ae8d0059191650aef6f9 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 6 Aug 2025 17:32:20 +0100 Subject: [PATCH 09/28] Add flask-bcrypt dependency to requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 92ea35b..f0ae8a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ python-dotenv mongomock black isort -flask_pymongo \ No newline at end of file +flask_pymongo +flask-bcrypt \ No newline at end of file From c5e262c629bd90628b0630ab65a5202a05248a0e Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 6 Aug 2025 17:40:29 +0100 Subject: [PATCH 10/28] Add user registration logic with validation and hashing Includes duplicate email check, password hashing using bcrypt, and database insertion. Previously written registration tests now pass. --- app/routes/auth_routes.py | 61 ++++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py index 39fc144..f2d44cc 100644 --- a/app/routes/auth_routes.py +++ b/app/routes/auth_routes.py @@ -1,14 +1,61 @@ """Routes for authorrization for the JWT upgrade""" -from flask import Blueprint +from flask import Blueprint, request, jsonify +import bcrypt +from werkzeug.exceptions import BadRequest +# import mongo instance from main app package +from app import mongo -auth_bp = Blueprint("auth", __name__, url_prefix="/books") +auth_bp = Blueprint("auth_bp", __name__, url_prefix="/auth") -@auth_bp.route("auth/register", methods=["POST"]) +@auth_bp.route("/register", methods=["POST"]) def register_user(): - """Function that takes user information - Verfies it is not a duplicate email - SENDS it to mongoDB to be stored """ - return "User registered", 201 + Registers a new user. + Takes a JSON payload with "email" and "password". + It verfies it is not a duplicate email, + Hashes the password and stores the new user in the database. + """ + # VALIDATION the incoming data/request payload + try: + data = request.get_json() + if not data: + return jsonify({"error": "Request body cannot be empty"}), 400 + + email = data.get("email") + password = data.get("password") + + if not email or not password: + return jsonify({"message": "Email and password are required"}), 400 + + except BadRequest: + return jsonify({"message": "Invalid JSON format"}), 400 + + # Check for Duplicate User + # Easy access with Flask_PyMongo's 'mongo' + if mongo.db.users.find_one({"email": email}): + return jsonify({"message": "Email is already registered"}), 409 + + + # Password Hashing + # Generate a salt and hash the password + # result is a byte object representing the final hash + hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) + + # Database Insertion + user_id = mongo.db.users.insert_one({ + "email": email, + # The hash is stored as a string in the DB + "password_hash": hashed_password.decode('utf-8') + }).inserted_id + print(user_id) + + # Prepare response + return jsonify({ + "message": "User registered successfully", + "user": { + "id": str(user_id), + "email": email + } + }), 201 From 3ff6916ebf54fa25dae9d2b7533f22c4b1b52d4f Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 6 Aug 2025 18:07:36 +0100 Subject: [PATCH 11/28] Add edge case tests for empty/invalid JSON --- tests/test_auth.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_auth.py b/tests/test_auth.py index 208a715..e886611 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -51,3 +51,42 @@ def test_register_with_duplicate_email(client, users_db_setup): # Assert assert response.status_code == 409 assert "email is already registered" in response.get_json()["message"].lower() + +def test_register_fails_with_empty_json(client, users_db_setup): + """ + When a POST is sent with an empty JSON, + it returns a 400 and an error message + """ + _ = users_db_setup # pylint: disable=unused-variable + + # Arrange + json_body = "" + + # Act + response = client.post( + "/auth/register", + json=json_body, + content_type="application/json" + ) + + assert response.status_code == 400 + assert "request body cannot be empty" in response.get_json()["error"].lower() + +def test_request_fails_with_invalid_json(client, users_db_setup): + """ + When a POST is sent with an empty JSON, + it returns a 400 and an error message + """ + _ = users_db_setup # pylint: disable=unused-variable + + # Arrange + invalid_json_string = "this is not json" + + # Act + response = client.post( + "/auth/register", data=invalid_json_string, + content_type="application/json" + ) + + assert response.status_code == 400 + assert "invalid json format" in response.get_json()["message"].lower() From b26742dce15391c7b044d517ab227b5e11de35b0 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 6 Aug 2025 18:44:23 +0100 Subject: [PATCH 12/28] Change all errors to use 'message' for consistency --- app/routes/auth_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py index f2d44cc..e3ea077 100644 --- a/app/routes/auth_routes.py +++ b/app/routes/auth_routes.py @@ -21,7 +21,7 @@ def register_user(): try: data = request.get_json() if not data: - return jsonify({"error": "Request body cannot be empty"}), 400 + return jsonify({"message": "Request body cannot be empty"}), 400 email = data.get("email") password = data.get("password") From dee4d854cfbb774b7fea711b5cd170bd2043a098 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 6 Aug 2025 18:46:00 +0100 Subject: [PATCH 13/28] Add test for missing fields with parametrize to avoid DRY --- tests/test_auth.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index e886611..36f6fe0 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,7 @@ """Tests for auth/JWT upgrade""" import bcrypt +import pytest from app import mongo @@ -66,11 +67,10 @@ def test_register_fails_with_empty_json(client, users_db_setup): response = client.post( "/auth/register", json=json_body, - content_type="application/json" ) assert response.status_code == 400 - assert "request body cannot be empty" in response.get_json()["error"].lower() + assert "request body cannot be empty" in response.get_json()["message"].lower() def test_request_fails_with_invalid_json(client, users_db_setup): """ @@ -90,3 +90,27 @@ def test_request_fails_with_invalid_json(client, users_db_setup): assert response.status_code == 400 assert "invalid json format" in response.get_json()["message"].lower() + + +@pytest.mark.parametrize( + "payload, expected_message", # Define the names of the variables for the test + [ + ({"password": "a-password"}, "Email and password are required"), # 1st test + ({"email": "test@example.com"}, "Email and password are required"), # 2nd test + ({}, "Request body cannot be empty") # 3rd test + ] +) +def test_request_fails_with_missing_fields(client, users_db_setup, payload, expected_message): + """ + GIVEN a payload that is missing a required field (email or password) + WHEN a POST request is sent to /auth/register + THEN the response should be 400 Bad Request with an appropriate error message. + """ + _ = users_db_setup # pylint: disable=unused-variable + + # Act + response = client.post("/auth/register", json=payload) + + assert response.status_code == 400 + response_data = response.get_json() + assert expected_message in response_data["message"] From ee0b1bfa974defae75dfccfcce3dc67f51993358 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Wed, 6 Aug 2025 18:47:21 +0100 Subject: [PATCH 14/28] Run formatting makefile command --- app/routes/auth_routes.py | 36 ++++++++++++++++++++---------------- tests/test_auth.py | 21 ++++++++++++--------- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py index e3ea077..9d46397 100644 --- a/app/routes/auth_routes.py +++ b/app/routes/auth_routes.py @@ -1,8 +1,9 @@ """Routes for authorrization for the JWT upgrade""" -from flask import Blueprint, request, jsonify import bcrypt +from flask import Blueprint, jsonify, request from werkzeug.exceptions import BadRequest + # import mongo instance from main app package from app import mongo @@ -12,10 +13,10 @@ @auth_bp.route("/register", methods=["POST"]) def register_user(): """ - Registers a new user. + Registers a new user. Takes a JSON payload with "email" and "password". It verfies it is not a duplicate email, - Hashes the password and stores the new user in the database. + Hashes the password and stores the new user in the database. """ # VALIDATION the incoming data/request payload try: @@ -37,25 +38,28 @@ def register_user(): if mongo.db.users.find_one({"email": email}): return jsonify({"message": "Email is already registered"}), 409 - # Password Hashing # Generate a salt and hash the password # result is a byte object representing the final hash hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) # Database Insertion - user_id = mongo.db.users.insert_one({ - "email": email, - # The hash is stored as a string in the DB - "password_hash": hashed_password.decode('utf-8') - }).inserted_id + user_id = mongo.db.users.insert_one( + { + "email": email, + # The hash is stored as a string in the DB + "password_hash": hashed_password.decode("utf-8"), + } + ).inserted_id print(user_id) # Prepare response - return jsonify({ - "message": "User registered successfully", - "user": { - "id": str(user_id), - "email": email - } - }), 201 + return ( + jsonify( + { + "message": "User registered successfully", + "user": {"id": str(user_id), "email": email}, + } + ), + 201, + ) diff --git a/tests/test_auth.py b/tests/test_auth.py index 36f6fe0..5888e78 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -53,6 +53,7 @@ def test_register_with_duplicate_email(client, users_db_setup): assert response.status_code == 409 assert "email is already registered" in response.get_json()["message"].lower() + def test_register_fails_with_empty_json(client, users_db_setup): """ When a POST is sent with an empty JSON, @@ -65,13 +66,14 @@ def test_register_fails_with_empty_json(client, users_db_setup): # Act response = client.post( - "/auth/register", + "/auth/register", json=json_body, ) assert response.status_code == 400 assert "request body cannot be empty" in response.get_json()["message"].lower() + def test_request_fails_with_invalid_json(client, users_db_setup): """ When a POST is sent with an empty JSON, @@ -84,8 +86,7 @@ def test_request_fails_with_invalid_json(client, users_db_setup): # Act response = client.post( - "/auth/register", data=invalid_json_string, - content_type="application/json" + "/auth/register", data=invalid_json_string, content_type="application/json" ) assert response.status_code == 400 @@ -93,14 +94,16 @@ def test_request_fails_with_invalid_json(client, users_db_setup): @pytest.mark.parametrize( - "payload, expected_message", # Define the names of the variables for the test + "payload, expected_message", # Define the names of the variables for the test [ - ({"password": "a-password"}, "Email and password are required"), # 1st test - ({"email": "test@example.com"}, "Email and password are required"), # 2nd test - ({}, "Request body cannot be empty") # 3rd test - ] + ({"password": "a-password"}, "Email and password are required"), # 1st test + ({"email": "test@example.com"}, "Email and password are required"), # 2nd test + ({}, "Request body cannot be empty"), # 3rd test + ], ) -def test_request_fails_with_missing_fields(client, users_db_setup, payload, expected_message): +def test_request_fails_with_missing_fields( + client, users_db_setup, payload, expected_message +): """ GIVEN a payload that is missing a required field (email or password) WHEN a POST request is sent to /auth/register From 23a7c7415b70f10b36fac30c98d8010db45c7b97 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Thu, 7 Aug 2025 09:03:59 +0100 Subject: [PATCH 15/28] Rename variable to avoid Pylint duplicate code err --- tests/test_api_security.py | 2 +- tests/test_app.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_api_security.py b/tests/test_api_security.py index 88862cd..9b765e7 100644 --- a/tests/test_api_security.py +++ b/tests/test_api_security.py @@ -133,7 +133,7 @@ def test_update_book_succeeds_with_valid_api_key(monkeypatch, client): } mock_collection.find_one.return_value = expected_book_from_db - # monkeypatcj to replace the real get_book_collection with a function + # monkeypatch to replace the real get_book_collection with a function monkeypatch.setattr( "app.routes.legacy_routes.get_book_collection", lambda: mock_collection ) diff --git a/tests/test_app.py b/tests/test_app.py index cc7c0ae..1025abb 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -647,9 +647,9 @@ def test_update_book_response_contains_all_required_fields(monkeypatch, client): WHEN the response is received THEN it should be a 200 OK and the JSON body must contain all required fields. """ - test_book_id = str(ObjectId()) + test_book_obj_id = str(ObjectId()) - book_doc_from_db = {"_id": ObjectId(test_book_id), **DUMMY_PAYLOAD} + book_doc_from_db = {"_id": ObjectId(test_book_obj_id), **DUMMY_PAYLOAD} # Create and configure our mock collection mock_collection = MagicMock() @@ -664,7 +664,7 @@ def test_update_book_response_contains_all_required_fields(monkeypatch, client): # ACT # Send the PUT request to the endpoint response = client.put( - f"/books/{test_book_id}", json=DUMMY_PAYLOAD, headers=HEADERS["VALID"] + f"/books/{test_book_obj_id}", json=DUMMY_PAYLOAD, headers=HEADERS["VALID"] ) # Assert @@ -677,7 +677,7 @@ def test_update_book_response_contains_all_required_fields(monkeypatch, client): field in response_data ), f"Required field '{field}' is missing from the response" - assert response_data["id"] == test_book_id + assert response_data["id"] == test_book_obj_id assert isinstance(response_data["links"], dict) From 06051c744bc4495cad048d869e25a8f4781fb7f4 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Thu, 7 Aug 2025 14:36:46 +0100 Subject: [PATCH 16/28] Refactor/ app/__init__.py handles imports and registration --- app/__init__.py | 12 ++++++------ app/routes/__init__.py | 20 -------------------- 2 files changed, 6 insertions(+), 26 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index c94c51d..a2c6664 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -17,8 +17,6 @@ def create_app(test_config=None): """Application factory pattern.""" app = Flask(__name__) - - # 1. Load the default configuration from the Config object. app.config.from_object(Config) if test_config: # Override with test specifics @@ -27,10 +25,12 @@ def create_app(test_config=None): # Connect Pymongo to our specific app instance mongo.init_app(app) - # Import and register routes - from app.routes import \ - register_routes # pylint: disable=import-outside-toplevel + # Import blueprints inside the factory + from app.routes.legacy_routes import register_legacy_routes # pylint: disable=import-outside-toplevel + from app.routes.auth_routes import auth_bp # pylint: disable=import-outside-toplevel - register_routes(app) + # Register routes with app instance + register_legacy_routes(app) + app.register_blueprint(auth_bp) return app diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 6d7f511..e69de29 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,20 +0,0 @@ -"""Make into package and house helper functions""" - - -def register_routes(app): - """ - CENTRAL CONTROLLER - Imports and registers all the blueprint routes for the application. - This is the single entry point for all route registration. - """ - - # Register the OLD, non-Blueprint routes - from .legacy_routes import \ - register_legacy_routes # pylint: disable=import-outside-toplevel - - register_legacy_routes(app) - - # Register the NEW, Bluerprint-based routes - from .auth_routes import auth_bp # pylint: disable=import-outside-toplevel - - app.register_blueprint(auth_bp) From 6819bcd0ff19fb6e984a6ff5c914f91f4caaee59 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Thu, 7 Aug 2025 14:37:49 +0100 Subject: [PATCH 17/28] Run formatting and fic spelling/typos --- app/__init__.py | 6 ++++-- app/routes/auth_routes.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index a2c6664..547ebcd 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -26,8 +26,10 @@ def create_app(test_config=None): mongo.init_app(app) # Import blueprints inside the factory - from app.routes.legacy_routes import register_legacy_routes # pylint: disable=import-outside-toplevel - from app.routes.auth_routes import auth_bp # pylint: disable=import-outside-toplevel + from app.routes.auth_routes import \ + auth_bp # pylint: disable=import-outside-toplevel + from app.routes.legacy_routes import \ + register_legacy_routes # pylint: disable=import-outside-toplevel # Register routes with app instance register_legacy_routes(app) diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py index 9d46397..395f6bf 100644 --- a/app/routes/auth_routes.py +++ b/app/routes/auth_routes.py @@ -1,4 +1,4 @@ -"""Routes for authorrization for the JWT upgrade""" +"""Routes for authorization for the JWT upgrade""" import bcrypt from flask import Blueprint, jsonify, request From e8df69e3c4d81db6a0dea931e58e7ed9a328062d Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 8 Aug 2025 13:05:45 +0100 Subject: [PATCH 18/28] Break circular import cycle with an extensions module --- app/__init__.py | 6 +----- app/extensions.py | 8 ++++++++ app/routes/auth_routes.py | 6 +++--- 3 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 app/extensions.py diff --git a/app/__init__.py b/app/__init__.py index 547ebcd..dea58e1 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -6,11 +6,7 @@ from flask_pymongo import PyMongo from app.config import Config - -# Create PyMongo extension object outside the factory -# in the global scope, creating an "empty" extension object -# This way, we can import it in other files -mongo = PyMongo() +from app.extensions import mongo def create_app(test_config=None): diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..8d4fd40 --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,8 @@ +"""Module for Flask extensions.""" + +from flask_pymongo import PyMongo + + +# Createempty PyMongo extension object globally +# This way, we can import it in other files and avoid a code smell: tighly-coupled, cyclic error +mongo = PyMongo() diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py index 395f6bf..76217d9 100644 --- a/app/routes/auth_routes.py +++ b/app/routes/auth_routes.py @@ -1,11 +1,10 @@ +# pylint: disable=cyclic-import """Routes for authorization for the JWT upgrade""" import bcrypt from flask import Blueprint, jsonify, request from werkzeug.exceptions import BadRequest - -# import mongo instance from main app package -from app import mongo +from app.extensions import mongo auth_bp = Blueprint("auth_bp", __name__, url_prefix="/auth") @@ -18,6 +17,7 @@ def register_user(): It verfies it is not a duplicate email, Hashes the password and stores the new user in the database. """ + # VALIDATION the incoming data/request payload try: data = request.get_json() From 62a77e83e16ad86b2ae0f06fc003b26d24cbc698 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 8 Aug 2025 13:47:58 +0100 Subject: [PATCH 19/28] Update openapi.yml for /auth/register endpoint. --- openapi.yml | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/openapi.yml b/openapi.yml index f285cb3..c785c14 100644 --- a/openapi.yml +++ b/openapi.yml @@ -28,6 +28,9 @@ tags: description: Find out more url: example.com + - name: Authentication + description: Operations related to user registraion and login + # -------------------------------------------- # Components components: @@ -108,6 +111,57 @@ components: type: array items: $ref: '#/components/schemas/BookOutput' + + # ----- AUTH schemas ------------- + + # Schema for the data client POSTs to register a user + UserRegistrationInput: + type: object + properties: + email: + type: string + format: email + description: The user's email address. + example: "newuser@example.com" + password: + type: string + format: password # This format is a hint for UI tools to obscure the input + description: The user's desired password. + minLength: 8 # It's good practice to suggest a minimum length + example: "a-very-secure-password" + required: + - email + - password + + # Schema for the User object as returned by the server on success + UserOutput: + type: object + properties: + id: + type: string + description: The unique 24-character hexadecimal identifier for the user (MongoDB ObjectId). + readOnly: true + example: "635f3a7e3a8e3bcfc8e6a1e0" + email: + type: string + format: email + readOnly: true + example: "newuser@example.com" + + # Schema for the successful registration response + RegistrationSuccess: + type: object + properties: + message: + type: string + example: "User registered successfully" + user: + $ref: '#/components/schemas/UserOutput' + + + + + # ------ ERROR schemas ---------- # Generic Error schema StandardError: @@ -144,6 +198,16 @@ components: required: - error + # Schema for a simple message response (useful for errors) + MessageError: + type: object + properties: + message: + type: string + description: A brief error message. + required: + - message + # API Error: Reusable responses for common errors responses: BadRequest: @@ -426,3 +490,50 @@ paths: error: "Book not found" '500': $ref: '#/components/responses/InternalServerError' +# -------------------------------------------- + /auth/register: +# -------------------------------------------- + post: + tags: + - Authentication + summary: Register a new user + description: >- + Creates a new user account. The server will hash the password and store the user details, returning a unique ID for the new user. + operationId: registerUser + requestBody: + description: User's email and password for registration. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserRegistrationInput' + responses: + '201': + description: User registered successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrationSuccess' + '400': + description: Bad Request. The request body is missing, not valid JSON, or is missing required fields. + content: + application/json: + schema: + $ref: '#/components/schemas/MessageError' + examples: + missingFields: + summary: Missing Fields + value: + message: "Email and password are required" + emptyBody: + summary: Empty Body + value: + message: "Request body cannot be empty" + '409': + description: Conflict. The provided email is already registered. + content: + application/json: + schema: + $ref: '#/components/schemas/MessageError' # Reuse the error schema again! + example: + message: "Email is already registered" \ No newline at end of file From b0a9a43eb7254b812617d9cdf904b789475e06dc Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 8 Aug 2025 14:07:40 +0100 Subject: [PATCH 20/28] Update openapi.yml Fix spelling error Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- openapi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openapi.yml b/openapi.yml index c785c14..5b0a1f7 100644 --- a/openapi.yml +++ b/openapi.yml @@ -29,7 +29,7 @@ tags: url: example.com - name: Authentication - description: Operations related to user registraion and login + description: Operations related to user registration and login # -------------------------------------------- # Components From 7fb13a8466f7c0f0df6c3c41a51d9fc274e8ba96 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 8 Aug 2025 14:08:09 +0100 Subject: [PATCH 21/28] Update app/extensions.py Fix spelling error 2 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/extensions.py b/app/extensions.py index 8d4fd40..e94b02b 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -3,6 +3,6 @@ from flask_pymongo import PyMongo -# Createempty PyMongo extension object globally +# Create empty PyMongo extension object globally # This way, we can import it in other files and avoid a code smell: tighly-coupled, cyclic error mongo = PyMongo() From 889a975479924755f78c9155dd4b649d21133ea9 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 8 Aug 2025 14:08:21 +0100 Subject: [PATCH 22/28] Update app/extensions.py Fix spelling error 3 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/extensions.py b/app/extensions.py index e94b02b..d7dddf0 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -4,5 +4,5 @@ # Create empty PyMongo extension object globally -# This way, we can import it in other files and avoid a code smell: tighly-coupled, cyclic error +# This way, we can import it in other files and avoid a code smell: tightly-coupled, cyclic error mongo = PyMongo() From 8d04e6d8fa2507af9733abfc10bcc4b382f3bd50 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 8 Aug 2025 14:08:30 +0100 Subject: [PATCH 23/28] Update tests/test_auth.py Fix spelling error 4 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 5888e78..98ee4ae 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -9,7 +9,7 @@ def test_register_with_valid_data(client, users_db_setup): """GIVEN a clean users collection WHEN a POST request is sent to /auth/register with new user data - THEN the repsonse should be 201 CREATED and the user should exist in the DB""" + THEN the response should be 201 CREATED and the user should exist in the DB""" _ = users_db_setup # pylint: disable=unused-variable # Arrange From a008464d4df5ebe1dd3589496c99c6a0bc200fa2 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 8 Aug 2025 13:48:30 +0100 Subject: [PATCH 24/28] Run formatting --- app/extensions.py | 5 ++--- app/routes/auth_routes.py | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/extensions.py b/app/extensions.py index d7dddf0..b404e4c 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -2,7 +2,6 @@ from flask_pymongo import PyMongo - -# Create empty PyMongo extension object globally -# This way, we can import it in other files and avoid a code smell: tightly-coupled, cyclic error +# Createempty PyMongo extension object globally +# This way, we can import it in other files and avoid a code smell: tighly-coupled, cyclic error mongo = PyMongo() diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py index 76217d9..52ea74f 100644 --- a/app/routes/auth_routes.py +++ b/app/routes/auth_routes.py @@ -4,6 +4,7 @@ import bcrypt from flask import Blueprint, jsonify, request from werkzeug.exceptions import BadRequest + from app.extensions import mongo auth_bp = Blueprint("auth_bp", __name__, url_prefix="/auth") From d17ce4fb154b6168ff7d756df6f78d896f99f61b Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 8 Aug 2025 15:31:33 +0100 Subject: [PATCH 25/28] Add failing test for invalid email formats --- tests/test_auth.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_auth.py b/tests/test_auth.py index 98ee4ae..57cf298 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -117,3 +117,36 @@ def test_request_fails_with_missing_fields( assert response.status_code == 400 response_data = response.get_json() assert expected_message in response_data["message"] + +@pytest.mark.parametrize("invalid_email", [ + "not-an-email", # Just a string + "test@.com", # Missing domain name + "test@domain.", # Missing top-level domain + "test@domaincom", # Missing dot in domain + "test @ domain.com" # Contains spaces +]) +def test_register_fails_with_invalid_email(client, users_db_setup, invalid_email): + """ + GIVEN a Flask application client + WHEN a POST request is made to /auth/register with an invalid email format + THEN the response status code should be 400 (Bad Request) + AND the response JSON should contain an appropriate error message. + """ + _ = users_db_setup # pylint: disable=unused-variable + + # Arrange + new_user_data = { + "email": invalid_email, + "password": "a-secure-password" + } + # ACT + response = client.post("/auth/register", json=new_user_data) + + # assert + assert response.status_code == 400 + data = response.get_json() + assert isinstance(data, dict), "Expected JSON body" + assert "message" in data + assert "invalid email address" in response.get_json()["message"].lower() + msg = data["message"].lower() + assert ("invalid" in msg) or ("email" in msg) From e779edee9b2a533f17c7357d7c2037c1d0b2cdc3 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 8 Aug 2025 15:44:38 +0100 Subject: [PATCH 26/28] Update test to test behavior vs the msg --- tests/test_auth.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 57cf298..c2d689f 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -147,6 +147,4 @@ def test_register_fails_with_invalid_email(client, users_db_setup, invalid_email data = response.get_json() assert isinstance(data, dict), "Expected JSON body" assert "message" in data - assert "invalid email address" in response.get_json()["message"].lower() - msg = data["message"].lower() - assert ("invalid" in msg) or ("email" in msg) + assert "message" in data, "The error response should contain a 'message' key" From a931f257fb03041a45c497c0fb63856abd141631 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 8 Aug 2025 15:47:58 +0100 Subject: [PATCH 27/28] Install email-validator lib, use in register_user() --- app/routes/auth_routes.py | 11 +++++++++++ requirements.txt | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py index 52ea74f..a92443e 100644 --- a/app/routes/auth_routes.py +++ b/app/routes/auth_routes.py @@ -5,6 +5,8 @@ from flask import Blueprint, jsonify, request from werkzeug.exceptions import BadRequest +from email_validator import validate_email, EmailNotValidError + from app.extensions import mongo auth_bp = Blueprint("auth_bp", __name__, url_prefix="/auth") @@ -31,6 +33,15 @@ def register_user(): if not email or not password: return jsonify({"message": "Email and password are required"}), 400 + # email-validator + try: + valid = validate_email(email, check_deliverability=False) + + # use the normalized email for all subsequent operations + email = valid.normalized + except EmailNotValidError as e: + return jsonify({"message": str(e)}), 400 + except BadRequest: return jsonify({"message": "Invalid JSON format"}), 400 diff --git a/requirements.txt b/requirements.txt index f0ae8a6..09cdefe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ mongomock black isort flask_pymongo -flask-bcrypt \ No newline at end of file +flask-bcrypt +email-validator \ No newline at end of file From 6fe38433f966484226a154f2d698ac3d755b9321 Mon Sep 17 00:00:00 2001 From: codesungrape Date: Fri, 8 Aug 2025 15:58:09 +0100 Subject: [PATCH 28/28] Run formatting --- app/routes/auth_routes.py | 3 +-- tests/test_auth.py | 23 ++++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py index a92443e..9cd7c3c 100644 --- a/app/routes/auth_routes.py +++ b/app/routes/auth_routes.py @@ -2,11 +2,10 @@ """Routes for authorization for the JWT upgrade""" import bcrypt +from email_validator import EmailNotValidError, validate_email from flask import Blueprint, jsonify, request from werkzeug.exceptions import BadRequest -from email_validator import validate_email, EmailNotValidError - from app.extensions import mongo auth_bp = Blueprint("auth_bp", __name__, url_prefix="/auth") diff --git a/tests/test_auth.py b/tests/test_auth.py index c2d689f..2bed9fc 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -118,13 +118,17 @@ def test_request_fails_with_missing_fields( response_data = response.get_json() assert expected_message in response_data["message"] -@pytest.mark.parametrize("invalid_email", [ - "not-an-email", # Just a string - "test@.com", # Missing domain name - "test@domain.", # Missing top-level domain - "test@domaincom", # Missing dot in domain - "test @ domain.com" # Contains spaces -]) + +@pytest.mark.parametrize( + "invalid_email", + [ + "not-an-email", # Just a string + "test@.com", # Missing domain name + "test@domain.", # Missing top-level domain + "test@domaincom", # Missing dot in domain + "test @ domain.com", # Contains spaces + ], +) def test_register_fails_with_invalid_email(client, users_db_setup, invalid_email): """ GIVEN a Flask application client @@ -135,10 +139,7 @@ def test_register_fails_with_invalid_email(client, users_db_setup, invalid_email _ = users_db_setup # pylint: disable=unused-variable # Arrange - new_user_data = { - "email": invalid_email, - "password": "a-secure-password" - } + new_user_data = {"email": invalid_email, "password": "a-secure-password"} # ACT response = client.post("/auth/register", json=new_user_data)