diff --git a/Makefile b/Makefile index b18984d..beb09c1 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ PIP = $(VENV_DIR)/bin/pip .DEFAULT_GOAL := help # Phony Targets -.PHONY: run clean test help lint db-seed db-clean db-setup +.PHONY: run clean test help lint db-seed db-clean db-setup seed-users # ============================================================================== # CORE COMMANDS - For everyday development @@ -30,6 +30,7 @@ help: ## Show help @echo " make db-setup Reset the database to a clean, seeded state. (Runs db-clean then db-seed)" @echo " make db-seed Populate the database with initial data." @echo " make db-clean Delete all book data from the database." + @echo " make seed-users Populate the database with initial user data." install: $(PIP) @@ -86,3 +87,8 @@ db-seed: install db-clean: install @echo "--> ⚠️ Deleting all books from the database..." PATH=$(VENV_DIR)/bin:$$PATH PYTHONPATH=. $(PYTHON) -m scripts.delete_books + + +seed-users: install + @echo "--- Seeding the database with user data ---" + PATH=$(VENV_DIR)/bin:$$PATH PYTHONPATH=. $(PYTHON) -m scripts.seed_users diff --git a/README.md b/README.md index 0b0996a..c6d8fcb 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,9 @@ To use the API, you first need to populate the database with some initial data. | Command | Description | |----------------|-----------------------------------------------------------------------------| | `make db-setup`| **(Recommended)** Resets the database. Runs `db-clean` and then `db-seed`. | -| `make db-seed` | Populates the database with the contents of `scripts/books.json`. | +| `make db-seed` | Populates the database with the contents of `scripts/test_data/books.json`. | | `make db-clean`| Deletes all documents from the 'books' collection. Useful for starting fresh. | +| `make seed-users`| *** THIS IS WIP right now: The user data is required for the JWT authentication system. *** Populates the database with initial user data for authentication from `scripts/test_data/sample_user_data.json`. | To perform a full database reset, run: ```bash diff --git a/scripts/seed_users.py b/scripts/seed_users.py new file mode 100644 index 0000000..ae6b3a1 --- /dev/null +++ b/scripts/seed_users.py @@ -0,0 +1,85 @@ +"""Seeding user_data script""" + +import os +import json + +import bcrypt + +from app import create_app +from app.extensions import mongo + + +def seed_users(users_to_seed: list) -> str: + """ + Processes a list of user data, hashes their passwords, + and inserts them into the database. Skips existing users. + + This function MUST be run within an active Flask application context. + + Args: + users_to_seed: A list of dicts, each with 'email' and 'password'. + + Returns: + A string summarizing the result. + """ + count = 0 + + for user_data in users_to_seed: + email = user_data["email"] + + # Check if data already exists + if mongo.db.users.find_one({"email": email}): + print(f"Skipping existing user: {email}") + continue + + # hash the password + hashed_password = bcrypt.hashpw( + user_data["password"].encode("utf-8"), bcrypt.gensalt() + ) + + # insert to new user + mongo.db.users.insert_one( + {"email": email, "password_hash": hashed_password.decode("utf-8")} + ) + count += 1 + print(f"Created user: {email}") + + return f"Successfully seeded {count} users" + + +def main(): + """ + Main execution function to run the seeding process. + handles app context, data loading, and calls the core seeding logic. + """ + # Create the DEVELOPMENT app when run from the command line + app = create_app() + with app.app_context(): + + # 1. Get the directory where THIS script (seed_users.py) lives. + script_dir = os.path.dirname(__file__) + + # 2. Build the full, absolute path to the JSON file. + user_data_path = os.path.join(script_dir, "test_data/sample_user_data.json") + + try: + # You can define your default users here or import from another file + with open(user_data_path, "r", encoding="utf-8") as user_file: + default_users = json.load(user_file) + + print("--- Starting user seeding ---") + + message = seed_users(default_users) + print(f"--- {message} ---") + print("--- Seeding complete ---") + + except FileNotFoundError: + print(f"Error: Data file not found at '{user_data_path}'.") + except json.JSONDecodeError: + print( + f"Error: Could not decode JSON from '{user_data_path}'. Check for syntax errors." + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_data/sample_user_data.json b/scripts/test_data/sample_user_data.json new file mode 100644 index 0000000..a32e40f --- /dev/null +++ b/scripts/test_data/sample_user_data.json @@ -0,0 +1,5 @@ +[ + {"email": "test.admin@example.com", "password": "AdminPassword123"}, + {"email": "test.user@example.com", "password": "UserPassword456"}, + {"email": "miss.s.rai@outlook.com", "password": "DAYSend1991"} +] \ No newline at end of file diff --git a/tests/test_create_books.py b/tests/scripts/test_create_books.py similarity index 60% rename from tests/test_create_books.py rename to tests/scripts/test_create_books.py index 5238590..14ee1d2 100644 --- a/tests/test_create_books.py +++ b/tests/scripts/test_create_books.py @@ -1,23 +1,9 @@ # pylint: disable=missing-docstring,line-too-long, too-many-arguments, too-many-positional-arguments -import runpy -import sys + from unittest.mock import MagicMock, patch from scripts.create_books import main, populate_books, run_population - -# --------------------- Helper functions ------------------------------ -def run_create_books_script_cleanup(): - """ - Safely re-runs the 'scripts.create_books' module as a script. - - Removes 'scripts.create_books' from sys.modules to avoid re-import conflicts, - then executes it using runpy as if run from the command line (__main__ context). - """ - sys.modules.pop("scripts.create_books", None) - runpy.run_module("scripts.create_books", run_name="__main__") - - # ------------------------- Test Suite ------------------------------- @@ -137,96 +123,77 @@ def test_run_population_handles_no_inserted_list_books( ) -@patch("scripts.create_books.create_app") -@patch("scripts.create_books.run_population") -def test_main_creates_app_context_and_calls_run_population( - mock_run_population, mock_create_app, capsys -): - - # Arrange - # Mock Flask app object that has a working app_context manager - mock_app = MagicMock() - mock_create_app.return_value = mock_app - mock_run_population.return_value = "Success from mock" - expected_output = "Success from mock\n" - - # Act - main() - captured = capsys.readouterr() - - # Assert - # 1. Did we set up the environment correctly? - mock_create_app.assert_called_once() - mock_app.app_context.return_value.__enter__.assert_called_once() - - # 2. Did we call the core logic? - mock_run_population.assert_called_once() - - # 3. Did we print the result? - assert captured.out == expected_output +def test_main_orchestrates_and_outputs(capsys): + """ + Verifies that main() correctly: + 1. Creates the Flask app. + 2. Enters the app context. + 3. Calls run_population(). + 4. Prints the result returned by run_population(). + """ + with patch("scripts.create_books.create_app") as mock_create_app, patch( + "scripts.create_books.run_population" + ) as mock_run_population: -@patch("app.datastore.mongo_helper.upsert_book_from_file") -@patch("utils.db_helpers.load_books_json") # PATCHING AT THE SOURCE -@patch("app.datastore.mongo_db.get_book_collection") # PATCHING AT THE SOURCE -def test_script_entry_point_calls_main( - mock_get_collection, mock_load_json, mock_upsert_book, sample_book_data -): - # Arrange test_book sample and return values of MOCKS - test_books = sample_book_data + # Arrange: mock the app and its context manager + mock_app = MagicMock() + mock_create_app.return_value = mock_app + mock_run_population.return_value = "Success from mock" - mock_load_json.return_value = test_books - # Mock mongodb collection object - mock_collection_obj = MagicMock() - mock_get_collection.return_value = mock_collection_obj + expected_output = "Success from mock\n" - # Act: Run the script's main entry point. - run_create_books_script_cleanup() + # Act + main() + captured = capsys.readouterr() - # Assert: Verify mocked dependencies were called correctly. - mock_get_collection.assert_called_once() - mock_load_json.assert_called_once() + # Assert orchestration + mock_create_app.assert_called_once() + mock_app.app_context.return_value.__enter__.assert_called_once() + mock_run_population.assert_called_once() - assert mock_upsert_book.call_count == len(test_books) - mock_upsert_book.assert_any_call(test_books[0], mock_collection_obj) - mock_upsert_book.assert_any_call(test_books[1], mock_collection_obj) + # Assert output + assert captured.out == expected_output def test_run_population_should_insert_new_book_when_id_does_not_exist( - monkeypatch, mock_books_collection, sample_book_data + mock_books_collection, sample_book_data, test_app ): # Arrange - mock_db_collection_func = MagicMock(return_value=mock_books_collection) - monkeypatch.setattr( - "scripts.create_books.get_book_collection", mock_db_collection_func - ) + assert mock_books_collection.count_documents({}) == 0 - mock_load_data = MagicMock(return_value=sample_book_data) - monkeypatch.setattr("scripts.create_books.load_books_json", mock_load_data) + with test_app.app_context(): + # Use 'with patch' to safely contain the mocks + with patch( + "scripts.create_books.get_book_collection" + ) as mock_get_collection, patch( + "scripts.create_books.load_books_json" + ) as mock_load_data: - assert mock_books_collection.count_documents({}) == 0 + # Configure mocks inside the 'with' block + mock_get_collection.return_value = mock_books_collection + mock_load_data.return_value = sample_book_data - # Act - # Call the function with the new list_of_books and the MONGODB COLLECTION instance - result_message = run_population() + # Act + result_message = run_population() - # Assert - mock_db_collection_func.assert_called_once() - mock_load_data.assert_called_once() + # Assert + mock_get_collection.assert_called_once() + mock_load_data.assert_called_once() - # Check for specific book to be sure the data is right - book_a_from_db = mock_books_collection.find_one( - {"id": "550e8400-e29b-41d4-a716-446655440000"} - ) - assert book_a_from_db is not None - assert book_a_from_db["title"] == "To Kill a Mockingbird" + # Check for specific book to be sure the data is right + book_a_from_db = mock_books_collection.find_one( + {"id": "550e8400-e29b-41d4-a716-446655440000"} + ) + assert book_a_from_db is not None + assert book_a_from_db["title"] == "To Kill a Mockingbird" - # Verify that the function returned the correct status message - assert result_message == "Inserted 2 books" + # Verify that the function returned the correct status message + assert result_message == "Inserted 2 books" def test_run_population_correctly_upserts_a_batch_of_books( - mock_books_collection, monkeypatch + mock_books_collection, test_app ): """ BEHAVIORAL TEST: Verifies that run_population correctly handles a mix @@ -281,44 +248,50 @@ def test_run_population_correctly_upserts_a_batch_of_books( }, ] - # Monkeypatch the helper functions to isolate test - # - make get_book_collection return our mockDB - # - make load_books_json return our hard-coded new data - monkeypatch.setattr( - "scripts.create_books.get_book_collection", lambda: mock_books_collection - ) - monkeypatch.setattr( - "scripts.create_books.load_books_json", lambda: new_book_data_from_file - ) - # Sanity check: confim the database starts with exactly one document assert mock_books_collection.count_documents({}) == 1 - # Act - run_population() - - # Assert - # Check final state of database, total count == 2 - assert ( - mock_books_collection.count_documents({}) == 2 - ), "The total document count should be 2" - - # Retrieve the book we expected to be replaced and verify its contents - updated_book = mock_books_collection.find_one({"id": common_id}) - - assert updated_book is not None, "The updated book was not found in the database" - assert updated_book["title"] == "The Age of Surveillance Capitalism" - assert updated_book["author"] == "Shoshana Zuboff" - assert "version" not in updated_book - - # Retrieve the book we expected to be INSERTED and verify it exists. - inserted_book = mock_books_collection.find_one({"id": new_book_id}) - assert inserted_book is not None - assert inserted_book["title"] == "Brave New World" + with test_app.app_context(): + # Replace monkeypatch with the robust 'with patch' context manager + with patch( + "scripts.create_books.get_book_collection" + ) as mock_get_collection, patch( + "scripts.create_books.load_books_json" + ) as mock_load_json: + + # --- ARRANGE (Mock Setup) --- + # Configure mocks inside the 'with' block + mock_get_collection.return_value = mock_books_collection + mock_load_json.return_value = new_book_data_from_file + + # Act + run_population() + + # Assert + mock_get_collection.assert_called_once() + mock_load_json.assert_called_once() + assert ( + mock_books_collection.count_documents({}) == 2 + ), "The total document count should be 2" + + # Retrieve the book we expected to be replaced and verify its contents + updated_book = mock_books_collection.find_one({"id": common_id}) + + assert ( + updated_book is not None + ), "The updated book was not found in the database" + assert updated_book["title"] == "The Age of Surveillance Capitalism" + assert updated_book["author"] == "Shoshana Zuboff" + assert "version" not in updated_book + + # Retrieve the book we expected to be INSERTED and verify it exists. + inserted_book = mock_books_collection.find_one({"id": new_book_id}) + assert inserted_book is not None + assert inserted_book["title"] == "Brave New World" def test_upsert_book_to_mongo_replaces_document_when_id_exists( - mock_books_collection, monkeypatch + mock_books_collection, test_app ): # --- ARRANGE --- common_id = "550e8400-e29b-41d4-a716-446655440000" @@ -340,7 +313,7 @@ def test_upsert_book_to_mongo_replaces_document_when_id_exists( mock_books_collection.insert_one(old_book_version) # Define new version of book - new_book = [ + new_book_data = [ { "id": common_id, "title": "The Age of Surveillance Capitalism", @@ -355,24 +328,34 @@ def test_upsert_book_to_mongo_replaces_document_when_id_exists( } ] - # Monkeypatch the helper functions to isolate test - monkeypatch.setattr( - "scripts.create_books.get_book_collection", lambda: mock_books_collection - ) - monkeypatch.setattr("scripts.create_books.load_books_json", lambda: new_book) - # Sanity check: confim the database starts with exactly one document assert mock_books_collection.count_documents({}) == 1 - # Act - run_population() - - # ASSERT - assert mock_books_collection.count_documents({}) == 1 - - # Fetch the document and verify its contents are new - updated_book = mock_books_collection.find_one({"id": common_id}) - - assert updated_book is not None, "The updated book was not found in the database" - assert updated_book["title"] == "The Age of Surveillance Capitalism" - assert updated_book["author"] == "Shoshana Zuboff" + with test_app.app_context(): + with patch( + "scripts.create_books.get_book_collection" + ) as mock_get_collection, patch( + "scripts.create_books.load_books_json" + ) as mock_load_json: + + # Arrange + # Configure the mocks inside the 'with' block using .return_value + mock_get_collection.return_value = mock_books_collection + mock_load_json.return_value = new_book_data + + # Act + run_population() + + # ASSERT + mock_get_collection.assert_called_once() + mock_load_json.assert_called_once() + assert mock_books_collection.count_documents({}) == 1 + + # Fetch the document and verify its contents are new + updated_book = mock_books_collection.find_one({"id": common_id}) + + assert ( + updated_book is not None + ), "The updated book was not found in the database" + assert updated_book["title"] == "The Age of Surveillance Capitalism" + assert updated_book["author"] == "Shoshana Zuboff" diff --git a/tests/test_delete_books.py b/tests/scripts/test_delete_books.py similarity index 100% rename from tests/test_delete_books.py rename to tests/scripts/test_delete_books.py diff --git a/tests/scripts/test_seed_users.py b/tests/scripts/test_seed_users.py new file mode 100644 index 0000000..3e36e26 --- /dev/null +++ b/tests/scripts/test_seed_users.py @@ -0,0 +1,156 @@ +"""Test file for seeding database with user data""" + +import json +from unittest.mock import mock_open, patch + +import bcrypt + +from app.extensions import mongo +from scripts.seed_users import main as seed_users_main +from scripts.seed_users import seed_users + + +def test_seed_users_successfully(test_app): + """ + GIVEN an empty database and a list of user data + WHEN the seed_users function is called + THEN the users should be created in the database with hashed passwords. + """ + # Arrange + # define the user data we want to seed the database with + sample_users = [ + {"email": "test.admin@example.com", "password": "AdminPassword123"}, + {"email": "test.user@example.com", "password": "UserPassword456"}, + ] + + # Enter application context and + # Ensure the database is clean before the test + with test_app.app_context(): + mongo.db.users.delete_many({}) + + # Act: call the function we are testing + result_message = seed_users(sample_users) + + # Check the database state directly + assert mongo.db.users.count_documents({}) == 2 + admin_user = mongo.db.users.find_one({"email": "test.admin@example.com"}) + assert admin_user is not None + + # Verify the password was hashed + assert admin_user["password_hash"] != "AdminPassword123" + assert bcrypt.checkpw( + b"AdminPassword123", admin_user["password_hash"].encode("utf-8") + ) + assert "Successfully seeded 2 users" in result_message + + +def test_seed_users_skips_if_user_already_exists(test_app, capsys): + """ + GIVEN a database that already contains one user + WHEN the seed_users function is called with a list containing that existing user and a new one + THEN it should skip the existing user, insert the new one, and print a skip message. + """ + # Arrange + users_to_attempt_seeding = [ + {"email": "existing.user@example.com", "password": "Password123"}, + {"email": "new.user@example.com", "password": "Password456"}, + ] + + with test_app.app_context(): + # start with a clean state + mongo.db.users.delete_many({}) + + mongo.db.users.insert_one( + { + "email": "existing.user@example.com", + "password_hash": "some-pre-existing-hash", + } + ) + # ACT + result_message = seed_users(users_to_attempt_seeding) + + # Assert + final_count = mongo.db.users.count_documents({}) + assert final_count == 2 + # Check the return message from the function + assert "Successfully seeded 1 users" in result_message + + # Check the print + captured = capsys.readouterr() + assert "Skipping existing user: existing.user@example.com" in captured.out + assert "Created user: new.user@example.com" in captured.out + + +def test_main_runs_seeding_process_successfully(capsys): + """ + GIVEN a successful file read + WHEN the main function is called + THEN it should call seed_users with the loaded data and print success messages. + """ + # Arrange + fake_json_data = '[{"email": "fake@user.com", "password": "fakepass"}]' + + # Create mock objects for all of main's dependencies + with patch("scripts.seed_users.create_app"), patch( + "scripts.seed_users.seed_users" + ) as mock_seed_users, patch("builtins.open", mock_open(read_data=fake_json_data)): + + # Act + seed_users_main() + + # Assert + expected_data = json.loads(fake_json_data) + mock_seed_users.assert_called_once_with(expected_data) + # Did it print the right message? + captured = capsys.readouterr() + assert "--- Starting user seeding ---" in captured.out + assert "--- Seeding complete ---" in captured.out + + +def test_main_throws_filenotfounderror(capsys): + """ + GIVEN the data file does not exist + WHEN the main function is called + THEN it should print a FileNotFoundError message and not call seed_users. + """ + # Arrange + with patch("scripts.seed_users.create_app"), patch( + "scripts.seed_users.seed_users" + ) as mock_seed_users, patch("builtins.open") as mock_file: + + mock_file.side_effect = FileNotFoundError + + # Act + seed_users_main() + + # Assert + captured = capsys.readouterr() + assert "Error: Data file not found at" in captured.out + + mock_seed_users.assert_not_called() + + +def test_main_throws_jsondecodeerror(capsys): + """ + GIVEN the data file contains invalid JSON + WHEN the main function is called + THEN it should print a JSONDecodeError message and not call seed_users. + """ + # Arrange + corrupted_json_data = ( + '{"email": "bad@user.com", "password": "badpass"' # Missing closing brace + ) + + with patch("scripts.seed_users.create_app"), patch( + "scripts.seed_users.seed_users" + ) as mock_seed_users, patch( + "builtins.open", mock_open(read_data=corrupted_json_data) + ): + + # Act + seed_users_main() + + # Assert + captured = capsys.readouterr() + assert "Error: Could not decode JSON from" in captured.out + mock_seed_users.assert_not_called()