From 93e42528f79e0d8e202fde1964d52bc1ff9038a9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:23:15 +0000 Subject: [PATCH 1/6] feat: Add authentication tests for email signup and login This commit introduces a test suite for the email-based authentication endpoints (`/auth/signup/email` and `/auth/login/email`). Key changes include: 1. **Test Implementation (`backend/tests/auth/test_auth_routes.py`):** * Comprehensive tests covering successful registration and login. * Tests for error scenarios: existing email, incorrect password, non-existent user. * Parameterized tests for invalid input validation (missing fields, short passwords, invalid email formats) returning 422 errors. 2. **Mocking (`backend/tests/conftest.py`):** * MongoDB Mocking: Implemented an auto-use pytest fixture (`mock_db`) using `mongomock-motor` to simulate MongoDB interactions. This fixture patches `app.auth.service.get_database` to ensure `AuthService` uses the mock database during tests. * Firebase Mocking: Added a session-scoped fixture (`mock_firebase_admin`) to mock `firebase_admin.initialize_app`, `credentials.Certificate`, and `auth`. This prevents actual Firebase SDK initialization, which is not needed for these tests and might fail in CI environments. 3. **Test Configuration (`backend/pytest.ini`):** * Created `pytest.ini` to define test-specific environment variables (e.g., `SECRET_KEY`, `TESTING=True`). * Configured `asyncio_mode` and test discovery patterns. 4. **Dependencies (`backend/requirements.txt`):** * Added `pytest`, `pytest-asyncio`, `httpx`, `mongomock-motor`, and `pytest-env` to support the testing framework and environment configuration. All 13 tests for the authentication routes are passing. The test setup ensures that tests are isolated and do not depend on external services like a live MongoDB instance or Firebase. --- backend/pytest.ini | 13 ++ backend/requirements.txt | 5 + backend/tests/auth/test_auth_routes.py | 240 +++++++++++++++++++++++++ backend/tests/conftest.py | 75 ++++++++ 4 files changed, 333 insertions(+) create mode 100644 backend/pytest.ini create mode 100644 backend/tests/auth/test_auth_routes.py create mode 100644 backend/tests/conftest.py diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 00000000..606976f3 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,13 @@ +[pytest] +env = + SECRET_KEY=test_secret_key_for_pytest_1234567890abcdef + TESTING=True # Example: if the app ever needs to know it's in test mode + +asyncio_mode = auto + +python_files = test_*.py tests_*.py *_test.py *_tests.py +python_classes = Test* Tests* +python_functions = test_* + +# Optional: Add default command line options if desired +# addopts = --verbose diff --git a/backend/requirements.txt b/backend/requirements.txt index 31e9a808..d969e6dc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,8 @@ firebase-admin==6.9.0 python-dotenv==1.0.0 bcrypt==4.0.1 email-validator==2.2.0 +pytest +pytest-asyncio +httpx +mongomock-motor +pytest-env diff --git a/backend/tests/auth/test_auth_routes.py b/backend/tests/auth/test_auth_routes.py new file mode 100644 index 00000000..a2f47b30 --- /dev/null +++ b/backend/tests/auth/test_auth_routes.py @@ -0,0 +1,240 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from fastapi import FastAPI, status +from main import app # Assuming your FastAPI app instance is here +from app.config import settings # To potentially override settings if needed, or check values +from app.auth.security import verify_password, get_password_hash # For checking hashed password if necessary +from datetime import datetime +from bson import ObjectId + +# It's good practice to set a specific test secret key if not relying on external env vars +# For now, we assume 'your-super-secret-jwt-key-change-this-in-production' from config.py is used, +# or an environment variable overrides it. +# Ensure settings.secret_key is sufficiently long/random for HS256, the default is. + +# Helper to get the mock_db if direct interaction is needed (though often not preferred) +# from app.database import get_database + +@pytest.mark.asyncio +async def test_signup_with_email_success(mock_db): # mock_db fixture is auto-used + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + signup_data = { + "email": "testuser@example.com", + "password": "securepassword123", + "name": "Test User" + } + response = await ac.post("/auth/signup/email", json=signup_data) + print(f"Response text for test_signup_with_email_success: {response.text}") # Print response text + assert response.status_code == status.HTTP_200_OK # Or the actual success code used by the app + response_data = response.json() + assert "access_token" in response_data + assert "refresh_token" in response_data + assert "user" in response_data + assert response_data["user"]["email"] == signup_data["email"] + assert response_data["user"]["name"] == signup_data["name"] + assert "_id" in response_data["user"] # Changed 'id' to '_id' + + # Verify user creation in the mock database + # db = get_database() # This will be the mock_db instance due to the fixture + # Directly using mock_db fixture passed to test function + created_user = await mock_db.users.find_one({"email": signup_data["email"]}) + assert created_user is not None + assert created_user["name"] == signup_data["name"] + assert verify_password(signup_data["password"], created_user["hashed_password"]) + + # Verify refresh token creation + refresh_token_record = await mock_db.refresh_tokens.find_one({"user_id": created_user["_id"]}) + assert refresh_token_record is not None + assert not refresh_token_record["revoked"] + assert response_data["refresh_token"] == refresh_token_record["token"] + +@pytest.mark.asyncio +async def test_signup_with_existing_email(mock_db): + # Pre-populate with a user + existing_email = "existing@example.com" + await mock_db.users.insert_one({ + "email": existing_email, + "hashed_password": "hashedpassword", + "name": "Existing User", + "created_at": "sometime" # Simplified for mock + }) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + signup_data = { + "email": existing_email, + "password": "newpassword123", + "name": "New User" + } + response = await ac.post("/auth/signup/email", json=signup_data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + response_data = response.json() + assert "detail" in response_data + assert "User with this email already exists" in response_data["detail"] + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload_modifier, affected_field, description", + [ + (lambda p: p.pop("email"), "email", "missing_email"), + (lambda p: p.pop("password"), "password", "missing_password"), + (lambda p: p.pop("name"), "name", "missing_name"), + (lambda p: p.update({"password": "short"}), "password", "short_password"), + (lambda p: p.update({"email": "invalidemail"}), "email", "invalid_email"), + ] +) +async def test_signup_invalid_input_refined(mock_db, payload_modifier, affected_field, description): + base_payload = { + "email": "testuser@example.com", + "password": "securepassword123", + "name": "Test User" + } + payload_modifier(base_payload) # Modify the payload based on the current test case + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + response = await ac.post("/auth/signup/email", json=base_payload) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + response_data = response.json() + assert "detail" in response_data + + error_found = False + for error_item in response_data["detail"]: + # Check if the 'loc' array (location of the error) contains the affected_field + if affected_field in error_item.get("loc", []): + error_type = error_item.get("type", "") + # Specific checks for error types for Pydantic v2 + if description == "short_password" and error_type == "string_too_short": + error_found = True + break + elif description == "invalid_email" and error_type == "value_error": # Simpler check, msg gives more detail + error_found = True + break + elif "missing" in description and error_type == "missing": + error_found = True + break + assert error_found, f"Validation error for '{description}' (field: {affected_field}) not found or not specific enough in {response_data['detail']}" + + +@pytest.mark.asyncio +async def test_login_with_email_success(mock_db): + user_email = "loginuser@example.com" + user_password = "loginpassword123" + hashed_password = get_password_hash(user_password) + + # Pre-populate user in mock_db + # Ensure _id is an ObjectId if other parts of the code expect it, + # though for mock_db string usually works fine unless there's specific BSON type checking. + # For consistency with how AuthService creates user_id for refresh tokens (ObjectId(user_id)), + # let's store _id as ObjectId here. + user_obj_id = ObjectId() + await mock_db.users.insert_one({ + "_id": user_obj_id, + "email": user_email, + "hashed_password": hashed_password, + "name": "Login User", + "avatar": None, + "currency": "USD", + "created_at": datetime.utcnow(), # Ensure datetime is used + "auth_provider": "email", + "firebase_uid": None + }) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + login_data = { + "email": user_email, + "password": user_password + } + response = await ac.post("/auth/login/email", json=login_data) + + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert "access_token" in response_data + assert "refresh_token" in response_data + assert "user" in response_data + assert response_data["user"]["email"] == user_email + assert response_data["user"]["_id"] == str(user_obj_id) # Changed 'id' to '_id' + + # Verify refresh token creation for this user + # Refresh token service stores user_id as ObjectId + refresh_token_record = await mock_db.refresh_tokens.find_one({"user_id": user_obj_id}) + assert refresh_token_record is not None + assert not refresh_token_record["revoked"] + assert response_data["refresh_token"] == refresh_token_record["token"] + +@pytest.mark.asyncio +async def test_login_with_incorrect_password(mock_db): + user_email = "wrongpass@example.com" + correct_password = "correctpassword" + incorrect_password = "incorrectpassword" + + await mock_db.users.insert_one({ + "_id": ObjectId(), + "email": user_email, + "hashed_password": get_password_hash(correct_password), + "name": "Wrong Pass User", + "created_at": datetime.utcnow() + }) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + login_data = { + "email": user_email, + "password": incorrect_password + } + response = await ac.post("/auth/login/email", json=login_data) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + response_data = response.json() + assert "detail" in response_data + assert "Incorrect email or password" in response_data["detail"] + +@pytest.mark.asyncio +async def test_login_with_non_existent_email(mock_db): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + login_data = { + "email": "nosuchuser@example.com", + "password": "anypassword" + } + response = await ac.post("/auth/login/email", json=login_data) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + response_data = response.json() + assert "detail" in response_data + assert "Incorrect email or password" in response_data["detail"] # Same message for both cases + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload_modifier, affected_field, description", + [ + (lambda p: p.pop("email"), "email", "missing_email"), + (lambda p: p.pop("password"), "password", "missing_password"), + (lambda p: p.update({"email": "invalidemailformat"}), "email", "invalid_email_format"), + ] +) +async def test_login_invalid_input(mock_db, payload_modifier, affected_field, description): + base_payload = { + "email": "validuser@example.com", + "password": "validpassword123" + } + # It doesn't matter if the user exists or not for input validation, + # as validation happens before DB lookup for these kinds of errors. + payload_modifier(base_payload) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + response = await ac.post("/auth/login/email", json=base_payload) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + response_data = response.json() + assert "detail" in response_data + + error_found = False + for error_item in response_data["detail"]: + if affected_field in error_item.get("loc", []): + error_type = error_item.get("type", "") + if description == "invalid_email_format" and error_type == "value_error": # Simpler check + error_found = True + break + elif "missing" in description and error_type == "missing": + error_found = True + break + assert error_found, f"Validation error for '{description}' (field: {affected_field}) not found in {response_data['detail']}" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..7d369a85 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,75 @@ +import pytest +import pytest_asyncio +from unittest.mock import patch, MagicMock +import firebase_admin # Added +import os # Added +from mongomock_motor import AsyncMongoMockClient + +@pytest.fixture(scope="session", autouse=True) +def mock_firebase_admin(request): + # Check if we're in a test session that might use firebase, + # otherwise this mock might be too broad. + # For now, apply session-wide for simplicity as auth_service imports firebase_admin. + + # Mock firebase_admin.credentials.Certificate + # Create a mock object that can be called and returns another mock + mock_certificate = MagicMock() + # When firebase_admin.credentials.Certificate(path) is called, it returns a dummy object + mock_certificate.return_value = MagicMock() + + # Mock firebase_admin.initialize_app + mock_initialize_app = MagicMock() + + # Mock firebase_admin.auth for verify_id_token if Google login tests were being added + mock_firebase_auth = MagicMock() + mock_firebase_auth.verify_id_token.return_value = { + "uid": "test_firebase_uid", + "email": "firebaseuser@example.com", + "name": "Firebase User", + "picture": None + } # Dummy decoded token + + patches = [ + patch("firebase_admin.credentials.Certificate", mock_certificate), + patch("firebase_admin.initialize_app", mock_initialize_app), + patch("firebase_admin.auth", mock_firebase_auth) # Mock auth module + ] + + for p in patches: + p.start() + request.addfinalizer(p.stop) + + # Also, to prevent the "Firebase service account not found" print, + # we can temporarily set one of the expected firebase env vars + # so the code thinks it's configured, but initialize_app being mocked means nothing happens. + # This is optional and depends on whether the print is problematic. + # with patch.dict(os.environ, {"FIREBASE_PROJECT_ID": "test-project"}, clear=True): + # yield + + # If not using the os.environ patch, just yield: + yield + +@pytest_asyncio.fixture(scope="function", autouse=True) +async def mock_db(): + print("mock_db fixture: Creating AsyncMongoMockClient") + mock_mongo_client = AsyncMongoMockClient() + print(f"mock_db fixture: mock_mongo_client type: {type(mock_mongo_client)}") + mock_database_instance = mock_mongo_client["test_db"] + print(f"mock_db fixture: mock_database_instance type: {type(mock_database_instance)}, is None: {mock_database_instance is None}") + + # Ensure we are patching the correct target + # 'app.database.get_database' is where the function is defined. + # 'app.auth.service.get_database' is where it's imported and looked up by AuthService. + # Patching where it's looked up can be more robust. + + with patch("app.auth.service.get_database", return_value=mock_database_instance) as mock_get_database_function: + print(f"mock_db fixture: Patching app.auth.service.get_database. Patched object: {mock_get_database_function}") + print(f"mock_db fixture: Patched return_value: {mock_get_database_function.return_value}, type: {type(mock_get_database_function.return_value)}") + yield mock_database_instance # yield the same instance for direct use if needed + print("mock_db fixture: Restoring app.auth.service.get_database") + + # Optional: clear all collections in the mock_database after each test + # This ensures test isolation. + # mongomock doesn't have a straightforward way to list all collections like a real DB, + # so we might need to clear known collections if necessary, or rely on new client per test. + # For now, a new AsyncMongoMockClient per function scope should provide good isolation. From 537121769a6402de854241672ba2654a3cd16cea Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 19 Jun 2025 21:58:36 +0530 Subject: [PATCH 2/6] feat: Add GitHub Actions workflow to run backend tests --- .github/workflows/run-tests.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/run-tests.yml diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 00000000..7bc56a82 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,30 @@ +name: Run Tests + +on: + pull_request: + branches: [ main, master, feature/*] + push: + branches: [ main, master ] + +jobs: + test-backend: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + cd backend + pip install -r requirements.txt + + - name: Run tests + run: | + cd backend + pytest tests/ From 42f70173229350e7918b5aa954799e5879d9a094 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 19 Jun 2025 22:03:30 +0530 Subject: [PATCH 3/6] fix(tests): update test command to run specific auth test file --- .github/workflows/run-tests.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 7bc56a82..c289a14b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -26,5 +26,6 @@ jobs: - name: Run tests run: | - cd backend - pytest tests/ + cd $GITHUB_WORKSPACE + export PYTHONPATH=$PYTHONPATH:./backend + pytest backend/tests/auth/test_auth_routes.py From e0716308ac58ace2a0a8ecd76d4c233e4b6c04a5 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 19 Jun 2025 22:11:32 +0530 Subject: [PATCH 4/6] fix(tests): update test command to run all backend tests --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c289a14b..8b6144a5 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -28,4 +28,4 @@ jobs: run: | cd $GITHUB_WORKSPACE export PYTHONPATH=$PYTHONPATH:./backend - pytest backend/tests/auth/test_auth_routes.py + pytest backend/tests/ \ No newline at end of file From 609d074e012fc6218187130eb066df0de004e17c Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 19 Jun 2025 22:12:20 +0530 Subject: [PATCH 5/6] fix: remove duplicate entry for .vscode in .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 50e6309d..552a3680 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ service-account*.json # IDE .vscode/settings.json .idea/ +.vscode/ # Logs *.log From c91c10520ff8105c7c7cd2156daa1de37699e7e3 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 19 Jun 2025 22:14:31 +0530 Subject: [PATCH 6/6] fix: update Python setup action to version 5 in GitHub Actions workflow --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 8b6144a5..22ce281b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.12'