Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
@@ -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/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ service-account*.json
# IDE
.vscode/settings.json
.idea/
.vscode/

# Logs
*.log
Expand Down
13 changes: 13 additions & 0 deletions backend/pytest.ini
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
240 changes: 240 additions & 0 deletions backend/tests/auth/test_auth_routes.py
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"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 = "[email protected]"
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": "[email protected]",
"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 = "[email protected]"
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 = "[email protected]"
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": "[email protected]",
"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": "[email protected]",
"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']}"
75 changes: 75 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"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.