diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 00000000..22ce281b --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,31 @@ +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@v5 + 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 $GITHUB_WORKSPACE + export PYTHONPATH=$PYTHONPATH:./backend + pytest backend/tests/ \ No newline at end of file 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 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.