diff --git a/app/__init__.py b/app/__init__.py index 50b5bd6..dea58e1 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,25 +3,32 @@ import os from flask import Flask +from flask_pymongo import PyMongo from app.config import Config +from app.extensions import mongo 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 app.config.from_mapping(test_config) - # Import routes - from app.routes import \ - register_routes # pylint: disable=import-outside-toplevel + # Connect Pymongo to our specific app instance + mongo.init_app(app) + + # Import blueprints inside the factory + 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(app) + # Register routes with app instance + register_legacy_routes(app) + app.register_blueprint(auth_bp) return app diff --git a/app/config.py b/app/config.py index 72da9b0..8feaca9 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/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/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..b404e4c --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,7 @@ +"""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/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py new file mode 100644 index 0000000..9cd7c3c --- /dev/null +++ b/app/routes/auth_routes.py @@ -0,0 +1,76 @@ +# pylint: disable=cyclic-import +"""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 app.extensions import mongo + +auth_bp = Blueprint("auth_bp", __name__, url_prefix="/auth") + + +@auth_bp.route("/register", methods=["POST"]) +def register_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. + """ + + # VALIDATION the incoming data/request payload + try: + data = request.get_json() + if not data: + return jsonify({"message": "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 + + # 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 + + # 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, + ) 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. diff --git a/openapi.yml b/openapi.yml index f285cb3..5b0a1f7 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 registration 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 diff --git a/requirements.txt b/requirements.txt index f6881b0..09cdefe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,7 @@ pymongo python-dotenv mongomock black -isort \ No newline at end of file +isort +flask_pymongo +flask-bcrypt +email-validator \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 4982b70..e761a4c 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 @@ -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,17 @@ def test_app(): "COLLECTION_NAME": "test_books", } ) + # 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"]] + yield app @@ -105,3 +116,21 @@ 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({}) diff --git a/tests/test_api_security.py b/tests/test_api_security.py index 5377885..9b765e7 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 -------------------------- @@ -40,11 +39,13 @@ 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.legacy_routes.insert_book_to_mongo", lambda book, collection: None + ) monkeypatch.setattr( - "app.routes.insert_book_to_mongo", lambda book, collection: None + "app.routes.legacy_routes.append_hostname", lambda book, host: book ) - monkeypatch.setattr("app.routes.append_hostname", lambda book, host: book) # Hit the endpoint without Authorization header response = client.post("/books", json=DUMMY_PAYLOAD) @@ -70,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.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.legacy_routes.append_hostname", lambda book, host: book ) - monkeypatch.setattr("app.routes.append_hostname", lambda book, host: book) # Act response = client.post("/books", json=DUMMY_PAYLOAD, headers=HEADERS["VALID"]) @@ -127,8 +133,10 @@ 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 to replace the real get_book_collection with a function + monkeypatch.setattr( + "app.routes.legacy_routes.get_book_collection", lambda: mock_collection + ) # ACT response = client.put( @@ -205,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.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 a1fbab0..1025abb 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 = [ @@ -79,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.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"} @@ -174,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.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 @@ -187,8 +194,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() @@ -218,7 +225,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 = [ @@ -245,7 +252,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 @@ -254,7 +261,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 @@ -382,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.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}") @@ -461,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, "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}") @@ -480,7 +491,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 @@ -495,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, "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 @@ -524,13 +537,16 @@ 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"} @@ -561,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.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"} @@ -575,7 +593,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"} @@ -588,7 +606,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"} @@ -629,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() @@ -639,12 +657,14 @@ 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 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 @@ -657,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) @@ -680,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.get_book_collection", lambda: mock_collection) + monkeypatch.setattr( + "app.routes.legacy_routes.get_book_collection", lambda: mock_collection + ) # ACT response = client.put( @@ -711,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.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"] @@ -726,7 +750,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() @@ -768,21 +792,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 +821,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..16a0b21 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 ------------------------------------- @@ -43,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.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) @@ -127,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, "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( @@ -140,7 +144,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}") @@ -194,9 +198,11 @@ 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 --- @@ -206,9 +212,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 new file mode 100644 index 0000000..2bed9fc --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,151 @@ +"""Tests for auth/JWT upgrade""" + +import bcrypt +import pytest + +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 response 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() + + +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, + ) + + 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, + 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() + + +@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"] + + +@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 "message" in data, "The error response should contain a 'message' key" 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)