Convert password reset and auth dependencies tests to async

- Refactored all `password reset` and `auth dependency` tests to utilize async patterns for compatibility with async database sessions.
- Enhanced test fixtures with `pytest-asyncio` to support asynchronous database operations.
- Improved user handling with async context management for `test_user` and `async_mock_user`.
- Introduced `await` syntax for route calls, token generation, and database transactions in test cases.
This commit is contained in:
Felipe Cardoso
2025-10-31 22:31:01 +01:00
parent 8a7a3b9521
commit 92a8699479
32 changed files with 708 additions and 437 deletions

0
backend/tests/api/dependencies/__init__.py Normal file → Executable file
View File

128
backend/tests/api/dependencies/test_auth_dependencies.py Normal file → Executable file
View File

@@ -1,5 +1,6 @@
# tests/api/dependencies/test_auth_dependencies.py
import pytest
import pytest_asyncio
import uuid
from unittest.mock import patch
from fastapi import HTTPException
@@ -10,7 +11,8 @@ from app.api.dependencies.auth import (
get_current_superuser,
get_optional_current_user
)
from app.core.auth import TokenExpiredError, TokenInvalidError
from app.core.auth import TokenExpiredError, TokenInvalidError, get_password_hash
from app.models.user import User
@pytest.fixture
@@ -19,24 +21,52 @@ def mock_token():
return "mock.jwt.token"
@pytest_asyncio.fixture
async def async_mock_user(async_test_db):
"""Async fixture to create and return a mock User instance."""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
mock_user = User(
id=uuid.uuid4(),
email="mockuser@example.com",
password_hash=get_password_hash("mockhashedpassword"),
first_name="Mock",
last_name="User",
phone_number="1234567890",
is_active=True,
is_superuser=False,
preferences=None,
)
session.add(mock_user)
await session.commit()
await session.refresh(mock_user)
return mock_user
class TestGetCurrentUser:
"""Tests for get_current_user dependency"""
def test_get_current_user_success(self, db_session, mock_user, mock_token):
@pytest.mark.asyncio
async def test_get_current_user_success(self, async_test_db, async_mock_user, mock_token):
"""Test successfully getting the current user"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to return user_id that matches our mock_user
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id
mock_get_data.return_value.user_id = async_mock_user.id
# Call the dependency
user = get_current_user(db=db_session, token=mock_token)
user = await get_current_user(db=session, token=mock_token)
# Verify the correct user was returned
assert user.id == mock_user.id
assert user.email == mock_user.email
assert user.id == async_mock_user.id
assert user.email == async_mock_user.email
def test_get_current_user_nonexistent(self, db_session, mock_token):
@pytest.mark.asyncio
async def test_get_current_user_nonexistent(self, async_test_db, mock_token):
"""Test when the token contains a user ID that doesn't exist"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to return a non-existent user ID
nonexistent_id = uuid.UUID("11111111-1111-1111-1111-111111111111")
@@ -45,50 +75,62 @@ class TestGetCurrentUser:
# Should raise HTTPException with 404 status
with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token)
await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 404
assert "User not found" in exc_info.value.detail
def test_get_current_user_inactive(self, db_session, mock_user, mock_token):
@pytest.mark.asyncio
async def test_get_current_user_inactive(self, async_test_db, async_mock_user, mock_token):
"""Test when the user is inactive"""
# Make the user inactive
mock_user.is_active = False
db_session.commit()
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Get the user in this session and make it inactive
from sqlalchemy import select
result = await session.execute(select(User).where(User.id == async_mock_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
# Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id
mock_get_data.return_value.user_id = async_mock_user.id
# Should raise HTTPException with 403 status
with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token)
await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 403
assert "Inactive user" in exc_info.value.detail
def test_get_current_user_expired_token(self, db_session, mock_token):
@pytest.mark.asyncio
async def test_get_current_user_expired_token(self, async_test_db, mock_token):
"""Test with an expired token"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenExpiredError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenExpiredError("Token expired")
# Should raise HTTPException with 401 status
with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token)
await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 401
assert "Token expired" in exc_info.value.detail
def test_get_current_user_invalid_token(self, db_session, mock_token):
@pytest.mark.asyncio
async def test_get_current_user_invalid_token(self, async_test_db, mock_token):
"""Test with an invalid token"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenInvalidError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenInvalidError("Invalid token")
# Should raise HTTPException with 401 status
with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token)
await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 401
assert "Could not validate credentials" in exc_info.value.detail
@@ -151,63 +193,81 @@ class TestGetCurrentSuperuser:
class TestGetOptionalCurrentUser:
"""Tests for get_optional_current_user dependency"""
def test_get_optional_current_user_with_token(self, db_session, mock_user, mock_token):
@pytest.mark.asyncio
async def test_get_optional_current_user_with_token(self, async_test_db, async_mock_user, mock_token):
"""Test getting optional user with a valid token"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id
mock_get_data.return_value.user_id = async_mock_user.id
# Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token)
user = await get_optional_current_user(db=session, token=mock_token)
# Should return the correct user
assert user is not None
assert user.id == mock_user.id
assert user.id == async_mock_user.id
def test_get_optional_current_user_no_token(self, db_session):
@pytest.mark.asyncio
async def test_get_optional_current_user_no_token(self, async_test_db):
"""Test getting optional user with no token"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Call the dependency with no token
user = get_optional_current_user(db=db_session, token=None)
user = await get_optional_current_user(db=session, token=None)
# Should return None
assert user is None
def test_get_optional_current_user_invalid_token(self, db_session, mock_token):
@pytest.mark.asyncio
async def test_get_optional_current_user_invalid_token(self, async_test_db, mock_token):
"""Test getting optional user with an invalid token"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenInvalidError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenInvalidError("Invalid token")
# Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token)
user = await get_optional_current_user(db=session, token=mock_token)
# Should return None, not raise an exception
assert user is None
def test_get_optional_current_user_expired_token(self, db_session, mock_token):
@pytest.mark.asyncio
async def test_get_optional_current_user_expired_token(self, async_test_db, mock_token):
"""Test getting optional user with an expired token"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenExpiredError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenExpiredError("Token expired")
# Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token)
user = await get_optional_current_user(db=session, token=mock_token)
# Should return None, not raise an exception
assert user is None
def test_get_optional_current_user_inactive(self, db_session, mock_user, mock_token):
@pytest.mark.asyncio
async def test_get_optional_current_user_inactive(self, async_test_db, async_mock_user, mock_token):
"""Test getting optional user when user is inactive"""
# Make the user inactive
mock_user.is_active = False
db_session.commit()
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Get the user in this session and make it inactive
from sqlalchemy import select
result = await session.execute(select(User).where(User.id == async_mock_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
# Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id
mock_get_data.return_value.user_id = async_mock_user.id
# Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token)
user = await get_optional_current_user(db=session, token=mock_token)
# Should return None for inactive users
assert user is None

0
backend/tests/api/routes/__init__.py Normal file → Executable file
View File

0
backend/tests/api/routes/test_auth.py Normal file → Executable file
View File

0
backend/tests/api/routes/test_health.py Normal file → Executable file
View File

0
backend/tests/api/routes/test_rate_limiting.py Normal file → Executable file
View File

0
backend/tests/api/routes/test_users.py Normal file → Executable file
View File

138
backend/tests/api/test_auth_dependencies.py Normal file → Executable file
View File

@@ -1,6 +1,8 @@
# tests/api/dependencies/test_auth_dependencies.py
import pytest
from unittest.mock import patch, MagicMock
import pytest_asyncio
import uuid
from unittest.mock import patch
from fastapi import HTTPException
from app.api.dependencies.auth import (
@@ -9,84 +11,126 @@ from app.api.dependencies.auth import (
get_current_superuser,
get_optional_current_user
)
from app.core.auth import TokenExpiredError, TokenInvalidError
from app.core.auth import TokenExpiredError, TokenInvalidError, get_password_hash
from app.models.user import User
@pytest.fixture
def mock_token():
"""Fixture providing a mock JWT token"""
return "mock.jwt.token"
@pytest_asyncio.fixture
async def async_mock_user(async_test_db):
"""Async fixture to create and return a mock User instance."""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
mock_user = User(
id=uuid.uuid4(),
email="mockuser@example.com",
password_hash=get_password_hash("mockhashedpassword"),
first_name="Mock",
last_name="User",
phone_number="1234567890",
is_active=True,
is_superuser=False,
preferences=None,
)
session.add(mock_user)
await session.commit()
await session.refresh(mock_user)
return mock_user
class TestGetCurrentUser:
"""Tests for get_current_user dependency"""
def test_get_current_user_success(self, db_session, mock_user, mock_token):
@pytest.mark.asyncio
async def test_get_current_user_success(self, async_test_db, async_mock_user, mock_token):
"""Test successfully getting the current user"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to return user_id that matches our mock_user
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id
mock_get_data.return_value.user_id = async_mock_user.id
# Call the dependency
user = get_current_user(db=db_session, token=mock_token)
user = await get_current_user(db=session, token=mock_token)
# Verify the correct user was returned
assert user.id == mock_user.id
assert user.email == mock_user.email
assert user.id == async_mock_user.id
assert user.email == async_mock_user.email
def test_get_current_user_nonexistent(self, db_session, mock_token):
@pytest.mark.asyncio
async def test_get_current_user_nonexistent(self, async_test_db, mock_token):
"""Test when the token contains a user ID that doesn't exist"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to return a non-existent user ID
# Use a real UUID object instead of a string
import uuid
nonexistent_id = uuid.UUID("11111111-1111-1111-1111-111111111111")
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = nonexistent_id # Using UUID object, not string
mock_get_data.return_value.user_id = nonexistent_id
# Should raise HTTPException with 404 status
with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token)
await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 404
assert "User not found" in exc_info.value.detail
def test_get_current_user_inactive(self, db_session, mock_user, mock_token):
@pytest.mark.asyncio
async def test_get_current_user_inactive(self, async_test_db, async_mock_user, mock_token):
"""Test when the user is inactive"""
# Make the user inactive
mock_user.is_active = False
db_session.commit()
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Get the user in this session and make it inactive
from sqlalchemy import select
result = await session.execute(select(User).where(User.id == async_mock_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
# Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id
mock_get_data.return_value.user_id = async_mock_user.id
# Should raise HTTPException with 403 status
with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token)
await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 403
assert "Inactive user" in exc_info.value.detail
def test_get_current_user_expired_token(self, db_session, mock_token):
@pytest.mark.asyncio
async def test_get_current_user_expired_token(self, async_test_db, mock_token):
"""Test with an expired token"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenExpiredError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenExpiredError("Token expired")
# Should raise HTTPException with 401 status
with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token)
await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 401
assert "Token expired" in exc_info.value.detail
def test_get_current_user_invalid_token(self, db_session, mock_token):
@pytest.mark.asyncio
async def test_get_current_user_invalid_token(self, async_test_db, mock_token):
"""Test with an invalid token"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenInvalidError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenInvalidError("Invalid token")
# Should raise HTTPException with 401 status
with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token)
await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 401
assert "Could not validate credentials" in exc_info.value.detail
@@ -149,63 +193,81 @@ class TestGetCurrentSuperuser:
class TestGetOptionalCurrentUser:
"""Tests for get_optional_current_user dependency"""
def test_get_optional_current_user_with_token(self, db_session, mock_user, mock_token):
@pytest.mark.asyncio
async def test_get_optional_current_user_with_token(self, async_test_db, async_mock_user, mock_token):
"""Test getting optional user with a valid token"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id
mock_get_data.return_value.user_id = async_mock_user.id
# Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token)
user = await get_optional_current_user(db=session, token=mock_token)
# Should return the correct user
assert user is not None
assert user.id == mock_user.id
assert user.id == async_mock_user.id
def test_get_optional_current_user_no_token(self, db_session):
@pytest.mark.asyncio
async def test_get_optional_current_user_no_token(self, async_test_db):
"""Test getting optional user with no token"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Call the dependency with no token
user = get_optional_current_user(db=db_session, token=None)
user = await get_optional_current_user(db=session, token=None)
# Should return None
assert user is None
def test_get_optional_current_user_invalid_token(self, db_session, mock_token):
@pytest.mark.asyncio
async def test_get_optional_current_user_invalid_token(self, async_test_db, mock_token):
"""Test getting optional user with an invalid token"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenInvalidError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenInvalidError("Invalid token")
# Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token)
user = await get_optional_current_user(db=session, token=mock_token)
# Should return None, not raise an exception
assert user is None
def test_get_optional_current_user_expired_token(self, db_session, mock_token):
@pytest.mark.asyncio
async def test_get_optional_current_user_expired_token(self, async_test_db, mock_token):
"""Test getting optional user with an expired token"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenExpiredError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenExpiredError("Token expired")
# Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token)
user = await get_optional_current_user(db=session, token=mock_token)
# Should return None, not raise an exception
assert user is None
def test_get_optional_current_user_inactive(self, db_session, mock_user, mock_token):
@pytest.mark.asyncio
async def test_get_optional_current_user_inactive(self, async_test_db, async_mock_user, mock_token):
"""Test getting optional user when user is inactive"""
# Make the user inactive
mock_user.is_active = False
db_session.commit()
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Get the user in this session and make it inactive
from sqlalchemy import select
result = await session.execute(select(User).where(User.id == async_mock_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
# Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id
mock_get_data.return_value.user_id = async_mock_user.id
# Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token)
user = await get_optional_current_user(db=session, token=mock_token)
# Should return None for inactive users
assert user is None

161
backend/tests/api/test_auth_endpoints.py Normal file → Executable file
View File

@@ -3,8 +3,10 @@
Tests for authentication endpoints.
"""
import pytest
import pytest_asyncio
from unittest.mock import patch, MagicMock
from fastapi import status
from sqlalchemy import select
from app.models.user import User
from app.schemas.users import UserCreate
@@ -21,9 +23,10 @@ def disable_rate_limit():
class TestRegisterEndpoint:
"""Tests for POST /auth/register endpoint."""
def test_register_success(self, client, test_db):
@pytest.mark.asyncio
async def test_register_success(self, client):
"""Test successful user registration."""
response = client.post(
response = await client.post(
"/api/v1/auth/register",
json={
"email": "newuser@example.com",
@@ -39,12 +42,13 @@ class TestRegisterEndpoint:
assert data["first_name"] == "New"
assert "password" not in data
def test_register_duplicate_email(self, client, test_user):
@pytest.mark.asyncio
async def test_register_duplicate_email(self, client, async_test_user):
"""Test registering with existing email."""
response = client.post(
response = await client.post(
"/api/v1/auth/register",
json={
"email": test_user.email,
"email": async_test_user.email,
"password": "SecurePassword123",
"first_name": "Duplicate",
"last_name": "User"
@@ -55,9 +59,10 @@ class TestRegisterEndpoint:
data = response.json()
assert data["success"] is False
def test_register_weak_password(self, client):
@pytest.mark.asyncio
async def test_register_weak_password(self, client):
"""Test registration with weak password."""
response = client.post(
response = await client.post(
"/api/v1/auth/register",
json={
"email": "weakpass@example.com",
@@ -69,12 +74,13 @@ class TestRegisterEndpoint:
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_register_unexpected_error(self, client, test_db):
@pytest.mark.asyncio
async def test_register_unexpected_error(self, client):
"""Test registration with unexpected error."""
with patch('app.services.auth_service.AuthService.create_user') as mock_create:
mock_create.side_effect = Exception("Unexpected error")
response = client.post(
response = await client.post(
"/api/v1/auth/register",
json={
"email": "error@example.com",
@@ -90,12 +96,13 @@ class TestRegisterEndpoint:
class TestLoginEndpoint:
"""Tests for POST /auth/login endpoint."""
def test_login_success(self, client, test_user):
@pytest.mark.asyncio
async def test_login_success(self, client, async_test_user):
"""Test successful login."""
response = client.post(
response = await client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"email": async_test_user.email,
"password": "TestPassword123"
}
)
@@ -106,21 +113,23 @@ class TestLoginEndpoint:
assert "refresh_token" in data
assert data["token_type"] == "bearer"
def test_login_wrong_password(self, client, test_user):
@pytest.mark.asyncio
async def test_login_wrong_password(self, client, async_test_user):
"""Test login with wrong password."""
response = client.post(
response = await client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"email": async_test_user.email,
"password": "WrongPassword123"
}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_login_nonexistent_user(self, client):
@pytest.mark.asyncio
async def test_login_nonexistent_user(self, client):
"""Test login with non-existent email."""
response = client.post(
response = await client.post(
"/api/v1/auth/login",
json={
"email": "nonexistent@example.com",
@@ -130,31 +139,37 @@ class TestLoginEndpoint:
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_login_inactive_user(self, client, test_user, test_db):
@pytest.mark.asyncio
async def test_login_inactive_user(self, client, async_test_user, async_test_db):
"""Test login with inactive user."""
test_user.is_active = False
test_db.add(test_user)
test_db.commit()
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Get the user in this session and make it inactive
result = await session.execute(select(User).where(User.id == async_test_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
response = client.post(
response = await client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"email": async_test_user.email,
"password": "TestPassword123"
}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_login_unexpected_error(self, client, test_user):
@pytest.mark.asyncio
async def test_login_unexpected_error(self, client, async_test_user):
"""Test login with unexpected error."""
with patch('app.services.auth_service.AuthService.authenticate_user') as mock_auth:
mock_auth.side_effect = Exception("Database error")
response = client.post(
response = await client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"email": async_test_user.email,
"password": "TestPassword123"
}
)
@@ -165,12 +180,13 @@ class TestLoginEndpoint:
class TestOAuthLoginEndpoint:
"""Tests for POST /auth/login/oauth endpoint."""
def test_oauth_login_success(self, client, test_user):
@pytest.mark.asyncio
async def test_oauth_login_success(self, client, async_test_user):
"""Test successful OAuth login."""
response = client.post(
response = await client.post(
"/api/v1/auth/login/oauth",
data={
"username": test_user.email,
"username": async_test_user.email,
"password": "TestPassword123"
}
)
@@ -180,43 +196,50 @@ class TestOAuthLoginEndpoint:
assert "access_token" in data
assert "refresh_token" in data
def test_oauth_login_wrong_credentials(self, client, test_user):
@pytest.mark.asyncio
async def test_oauth_login_wrong_credentials(self, client, async_test_user):
"""Test OAuth login with wrong credentials."""
response = client.post(
response = await client.post(
"/api/v1/auth/login/oauth",
data={
"username": test_user.email,
"username": async_test_user.email,
"password": "WrongPassword"
}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_oauth_login_inactive_user(self, client, test_user, test_db):
@pytest.mark.asyncio
async def test_oauth_login_inactive_user(self, client, async_test_user, async_test_db):
"""Test OAuth login with inactive user."""
test_user.is_active = False
test_db.add(test_user)
test_db.commit()
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Get the user in this session and make it inactive
result = await session.execute(select(User).where(User.id == async_test_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
response = client.post(
response = await client.post(
"/api/v1/auth/login/oauth",
data={
"username": test_user.email,
"username": async_test_user.email,
"password": "TestPassword123"
}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_oauth_login_unexpected_error(self, client, test_user):
@pytest.mark.asyncio
async def test_oauth_login_unexpected_error(self, client, async_test_user):
"""Test OAuth login with unexpected error."""
with patch('app.services.auth_service.AuthService.authenticate_user') as mock_auth:
mock_auth.side_effect = Exception("Unexpected error")
response = client.post(
response = await client.post(
"/api/v1/auth/login/oauth",
data={
"username": test_user.email,
"username": async_test_user.email,
"password": "TestPassword123"
}
)
@@ -227,20 +250,21 @@ class TestOAuthLoginEndpoint:
class TestRefreshTokenEndpoint:
"""Tests for POST /auth/refresh endpoint."""
def test_refresh_token_success(self, client, test_user):
@pytest.mark.asyncio
async def test_refresh_token_success(self, client, async_test_user):
"""Test successful token refresh."""
# First, login to get a refresh token
login_response = client.post(
login_response = await client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"email": async_test_user.email,
"password": "TestPassword123"
}
)
refresh_token = login_response.json()["refresh_token"]
# Now refresh the token
response = client.post(
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token}
)
@@ -250,36 +274,39 @@ class TestRefreshTokenEndpoint:
assert "access_token" in data
assert "refresh_token" in data
def test_refresh_token_expired(self, client):
@pytest.mark.asyncio
async def test_refresh_token_expired(self, client):
"""Test refresh with expired token."""
from app.core.auth import TokenExpiredError
with patch('app.services.auth_service.AuthService.refresh_tokens') as mock_refresh:
mock_refresh.side_effect = TokenExpiredError("Token expired")
response = client.post(
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": "some_token"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_refresh_token_invalid(self, client):
@pytest.mark.asyncio
async def test_refresh_token_invalid(self, client):
"""Test refresh with invalid token."""
response = client.post(
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": "invalid_token"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_refresh_token_unexpected_error(self, client, test_user):
@pytest.mark.asyncio
async def test_refresh_token_unexpected_error(self, client, async_test_user):
"""Test refresh with unexpected error."""
# Get a valid refresh token first
login_response = client.post(
login_response = await client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"email": async_test_user.email,
"password": "TestPassword123"
}
)
@@ -288,7 +315,7 @@ class TestRefreshTokenEndpoint:
with patch('app.services.auth_service.AuthService.refresh_tokens') as mock_refresh:
mock_refresh.side_effect = Exception("Unexpected error")
response = client.post(
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token}
)
@@ -299,48 +326,52 @@ class TestRefreshTokenEndpoint:
class TestGetCurrentUserEndpoint:
"""Tests for GET /auth/me endpoint."""
def test_get_current_user_success(self, client, test_user):
@pytest.mark.asyncio
async def test_get_current_user_success(self, client, async_test_user):
"""Test getting current user info."""
# First, login to get an access token
login_response = client.post(
login_response = await client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"email": async_test_user.email,
"password": "TestPassword123"
}
)
access_token = login_response.json()["access_token"]
# Get current user info
response = client.get(
response = await client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["email"] == test_user.email
assert data["first_name"] == test_user.first_name
assert data["email"] == async_test_user.email
assert data["first_name"] == async_test_user.first_name
def test_get_current_user_no_token(self, client):
@pytest.mark.asyncio
async def test_get_current_user_no_token(self, client):
"""Test getting current user without token."""
response = client.get("/api/v1/auth/me")
response = await client.get("/api/v1/auth/me")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_get_current_user_invalid_token(self, client):
@pytest.mark.asyncio
async def test_get_current_user_invalid_token(self, client):
"""Test getting current user with invalid token."""
response = client.get(
response = await client.get(
"/api/v1/auth/me",
headers={"Authorization": "Bearer invalid_token"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_get_current_user_expired_token(self, client):
@pytest.mark.asyncio
async def test_get_current_user_expired_token(self, client):
"""Test getting current user with expired token."""
# Use a clearly invalid/malformed token
response = client.get(
response = await client.get(
"/api/v1/auth/me",
headers={"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid"}
)

148
backend/tests/api/test_auth_password_reset.py Normal file → Executable file
View File

@@ -3,11 +3,14 @@
Tests for password reset endpoints.
"""
import pytest
import pytest_asyncio
from unittest.mock import patch, AsyncMock, MagicMock
from fastapi import status
from sqlalchemy import select
from app.schemas.users import PasswordResetRequest, PasswordResetConfirm
from app.utils.security import create_password_reset_token
from app.models.user import User
# Disable rate limiting for tests
@@ -22,14 +25,14 @@ class TestPasswordResetRequest:
"""Tests for POST /auth/password-reset/request endpoint."""
@pytest.mark.asyncio
async def test_password_reset_request_valid_email(self, client, test_user):
async def test_password_reset_request_valid_email(self, client, async_test_user):
"""Test password reset request with valid email."""
with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send:
mock_send.return_value = True
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": test_user.email}
json={"email": async_test_user.email}
)
assert response.status_code == status.HTTP_200_OK
@@ -40,15 +43,15 @@ class TestPasswordResetRequest:
# Verify email was sent
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args.kwargs["to_email"] == test_user.email
assert call_args.kwargs["user_name"] == test_user.first_name
assert call_args.kwargs["to_email"] == async_test_user.email
assert call_args.kwargs["user_name"] == async_test_user.first_name
assert "reset_token" in call_args.kwargs
@pytest.mark.asyncio
async def test_password_reset_request_nonexistent_email(self, client):
"""Test password reset request with non-existent email."""
with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send:
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": "nonexistent@example.com"}
)
@@ -62,17 +65,20 @@ class TestPasswordResetRequest:
mock_send.assert_not_called()
@pytest.mark.asyncio
async def test_password_reset_request_inactive_user(self, client, test_db, test_user):
async def test_password_reset_request_inactive_user(self, client, async_test_db, async_test_user):
"""Test password reset request with inactive user."""
# Deactivate user
test_user.is_active = False
test_db.add(test_user)
test_db.commit()
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await session.execute(select(User).where(User.id == async_test_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send:
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": test_user.email}
json={"email": async_test_user.email}
)
# Should still return success to prevent email enumeration
@@ -86,7 +92,7 @@ class TestPasswordResetRequest:
@pytest.mark.asyncio
async def test_password_reset_request_invalid_email_format(self, client):
"""Test password reset request with invalid email format."""
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": "not-an-email"}
)
@@ -96,7 +102,7 @@ class TestPasswordResetRequest:
@pytest.mark.asyncio
async def test_password_reset_request_missing_email(self, client):
"""Test password reset request without email."""
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/request",
json={}
)
@@ -104,14 +110,14 @@ class TestPasswordResetRequest:
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_password_reset_request_email_service_error(self, client, test_user):
async def test_password_reset_request_email_service_error(self, client, async_test_user):
"""Test password reset when email service fails."""
with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send:
mock_send.side_effect = Exception("SMTP Error")
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": test_user.email}
json={"email": async_test_user.email}
)
# Should still return success even if email fails
@@ -120,16 +126,16 @@ class TestPasswordResetRequest:
assert data["success"] is True
@pytest.mark.asyncio
async def test_password_reset_request_rate_limiting(self, client, test_user):
async def test_password_reset_request_rate_limiting(self, client, async_test_user):
"""Test that password reset requests are rate limited."""
with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send:
mock_send.return_value = True
# Make multiple requests quickly (3/minute limit)
for _ in range(3):
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": test_user.email}
json={"email": async_test_user.email}
)
assert response.status_code == status.HTTP_200_OK
@@ -137,13 +143,14 @@ class TestPasswordResetRequest:
class TestPasswordResetConfirm:
"""Tests for POST /auth/password-reset/confirm endpoint."""
def test_password_reset_confirm_valid_token(self, client, test_user, test_db):
@pytest.mark.asyncio
async def test_password_reset_confirm_valid_token(self, client, async_test_user, async_test_db):
"""Test password reset confirmation with valid token."""
# Generate valid token
token = create_password_reset_token(test_user.email)
token = create_password_reset_token(async_test_user.email)
new_password = "NewSecure123"
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
@@ -157,21 +164,25 @@ class TestPasswordResetConfirm:
assert "successfully" in data["message"].lower()
# Verify user can login with new password
test_db.refresh(test_user)
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await session.execute(select(User).where(User.id == async_test_user.id))
updated_user = result.scalar_one_or_none()
from app.core.auth import verify_password
assert verify_password(new_password, test_user.password_hash) is True
assert verify_password(new_password, updated_user.password_hash) is True
def test_password_reset_confirm_expired_token(self, client, test_user):
@pytest.mark.asyncio
async def test_password_reset_confirm_expired_token(self, client, async_test_user):
"""Test password reset confirmation with expired token."""
import time as time_module
# Create token that expires immediately
token = create_password_reset_token(test_user.email, expires_in=1)
token = create_password_reset_token(async_test_user.email, expires_in=1)
# Wait for token to expire
time_module.sleep(2)
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
@@ -186,9 +197,10 @@ class TestPasswordResetConfirm:
error_msg = data["errors"][0]["message"].lower() if "errors" in data else ""
assert "invalid" in error_msg or "expired" in error_msg
def test_password_reset_confirm_invalid_token(self, client):
@pytest.mark.asyncio
async def test_password_reset_confirm_invalid_token(self, client):
"""Test password reset confirmation with invalid token."""
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": "invalid_token_xyz",
@@ -202,13 +214,14 @@ class TestPasswordResetConfirm:
error_msg = data["errors"][0]["message"].lower() if "errors" in data else ""
assert "invalid" in error_msg or "expired" in error_msg
def test_password_reset_confirm_tampered_token(self, client, test_user):
@pytest.mark.asyncio
async def test_password_reset_confirm_tampered_token(self, client, async_test_user):
"""Test password reset confirmation with tampered token."""
import base64
import json
# Create valid token and tamper with it
token = create_password_reset_token(test_user.email)
token = create_password_reset_token(async_test_user.email)
decoded = base64.urlsafe_b64decode(token.encode('utf-8')).decode('utf-8')
token_data = json.loads(decoded)
token_data["payload"]["email"] = "hacker@example.com"
@@ -216,7 +229,7 @@ class TestPasswordResetConfirm:
# Re-encode tampered token
tampered = base64.urlsafe_b64encode(json.dumps(token_data).encode('utf-8')).decode('utf-8')
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": tampered,
@@ -226,12 +239,13 @@ class TestPasswordResetConfirm:
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_password_reset_confirm_nonexistent_user(self, client):
@pytest.mark.asyncio
async def test_password_reset_confirm_nonexistent_user(self, client):
"""Test password reset confirmation for non-existent user."""
# Create token for email that doesn't exist
token = create_password_reset_token("nonexistent@example.com")
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
@@ -245,16 +259,20 @@ class TestPasswordResetConfirm:
error_msg = data["errors"][0]["message"].lower() if "errors" in data else ""
assert "not found" in error_msg
def test_password_reset_confirm_inactive_user(self, client, test_user, test_db):
@pytest.mark.asyncio
async def test_password_reset_confirm_inactive_user(self, client, async_test_user, async_test_db):
"""Test password reset confirmation for inactive user."""
# Deactivate user
test_user.is_active = False
test_db.add(test_user)
test_db.commit()
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await session.execute(select(User).where(User.id == async_test_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
token = create_password_reset_token(test_user.email)
token = create_password_reset_token(async_test_user.email)
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
@@ -268,9 +286,10 @@ class TestPasswordResetConfirm:
error_msg = data["errors"][0]["message"].lower() if "errors" in data else ""
assert "inactive" in error_msg
def test_password_reset_confirm_weak_password(self, client, test_user):
@pytest.mark.asyncio
async def test_password_reset_confirm_weak_password(self, client, async_test_user):
"""Test password reset confirmation with weak password."""
token = create_password_reset_token(test_user.email)
token = create_password_reset_token(async_test_user.email)
# Test various weak passwords
weak_passwords = [
@@ -280,7 +299,7 @@ class TestPasswordResetConfirm:
]
for weak_password in weak_passwords:
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
@@ -290,10 +309,11 @@ class TestPasswordResetConfirm:
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_password_reset_confirm_missing_fields(self, client):
@pytest.mark.asyncio
async def test_password_reset_confirm_missing_fields(self, client):
"""Test password reset confirmation with missing fields."""
# Missing token
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={"new_password": "NewSecure123"}
)
@@ -301,20 +321,22 @@ class TestPasswordResetConfirm:
# Missing password
token = create_password_reset_token("test@example.com")
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={"token": token}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_password_reset_confirm_database_error(self, client, test_user, test_db):
@pytest.mark.asyncio
async def test_password_reset_confirm_database_error(self, client, async_test_user):
"""Test password reset confirmation with database error."""
token = create_password_reset_token(test_user.email)
token = create_password_reset_token(async_test_user.email)
with patch.object(test_db, 'commit') as mock_commit:
mock_commit.side_effect = Exception("Database error")
# Mock the password update to raise an exception
with patch('app.api.routes.auth.user_crud.update') as mock_update:
mock_update.side_effect = Exception("Database error")
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
@@ -328,18 +350,19 @@ class TestPasswordResetConfirm:
error_msg = data["errors"][0]["message"].lower() if "errors" in data else ""
assert "error" in error_msg or "resetting" in error_msg
def test_password_reset_full_flow(self, client, test_user, test_db):
@pytest.mark.asyncio
async def test_password_reset_full_flow(self, client, async_test_user, async_test_db):
"""Test complete password reset flow."""
original_password = test_user.password_hash
original_password = async_test_user.password_hash
new_password = "BrandNew123"
# Step 1: Request password reset
with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send:
mock_send.return_value = True
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": test_user.email}
json={"email": async_test_user.email}
)
assert response.status_code == status.HTTP_200_OK
@@ -349,7 +372,7 @@ class TestPasswordResetConfirm:
reset_token = call_args.kwargs["reset_token"]
# Step 2: Confirm password reset
response = client.post(
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": reset_token,
@@ -360,15 +383,18 @@ class TestPasswordResetConfirm:
assert response.status_code == status.HTTP_200_OK
# Step 3: Verify old password doesn't work
test_db.refresh(test_user)
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await session.execute(select(User).where(User.id == async_test_user.id))
updated_user = result.scalar_one_or_none()
from app.core.auth import verify_password
assert test_user.password_hash != original_password
assert updated_user.password_hash != original_password
# Step 4: Verify new password works
response = client.post(
response = await client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"email": async_test_user.email,
"password": new_password
}
)

0
backend/tests/api/test_security_headers.py Normal file → Executable file
View File

0
backend/tests/api/test_session_management.py Normal file → Executable file
View File

254
backend/tests/api/test_user_routes.py Normal file → Executable file
View File

@@ -4,10 +4,13 @@ Comprehensive tests for user management endpoints.
These tests focus on finding potential bugs, not just coverage.
"""
import pytest
import pytest_asyncio
from unittest.mock import patch
from fastapi import status
import uuid
from sqlalchemy import select
from app.models.user import User
from app.models.user import User
from app.schemas.users import UserUpdate
@@ -21,9 +24,9 @@ def disable_rate_limit():
yield
def get_auth_headers(client, email, password):
async def get_auth_headers(client, email, password):
"""Helper to get authentication headers."""
response = client.post(
response = await client.post(
"/api/v1/auth/login",
json={"email": email, "password": password}
)
@@ -34,11 +37,12 @@ def get_auth_headers(client, email, password):
class TestListUsers:
"""Tests for GET /users endpoint."""
def test_list_users_as_superuser(self, client, test_superuser):
@pytest.mark.asyncio
async def test_list_users_as_superuser(self, client, async_test_superuser):
"""Test listing users as superuser."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.get("/api/v1/users", headers=headers)
response = await client.get("/api/v1/users", headers=headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
@@ -46,15 +50,17 @@ class TestListUsers:
assert "pagination" in data
assert isinstance(data["data"], list)
def test_list_users_as_regular_user(self, client, test_user):
@pytest.mark.asyncio
async def test_list_users_as_regular_user(self, client, async_test_user):
"""Test that regular users cannot list users."""
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.get("/api/v1/users", headers=headers)
response = await client.get("/api/v1/users", headers=headers)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_list_users_pagination(self, client, test_superuser, test_db):
@pytest.mark.asyncio
async def test_list_users_pagination(self, client, async_test_superuser, test_db):
"""Test pagination works correctly."""
# Create multiple users
for i in range(15):
@@ -68,17 +74,18 @@ class TestListUsers:
test_db.add(user)
test_db.commit()
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
# Get first page
response = client.get("/api/v1/users?page=1&limit=5", headers=headers)
response = await client.get("/api/v1/users?page=1&limit=5", headers=headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data["data"]) == 5
assert data["pagination"]["page"] == 1
assert data["pagination"]["total"] >= 15
def test_list_users_filter_active(self, client, test_superuser, test_db):
@pytest.mark.asyncio
async def test_list_users_filter_active(self, client, async_test_superuser, test_db):
"""Test filtering by active status."""
# Create active and inactive users
active_user = User(
@@ -98,35 +105,37 @@ class TestListUsers:
test_db.add_all([active_user, inactive_user])
test_db.commit()
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
# Filter for active users
response = client.get("/api/v1/users?is_active=true", headers=headers)
response = await client.get("/api/v1/users?is_active=true", headers=headers)
data = response.json()
emails = [u["email"] for u in data["data"]]
assert "activefilter@example.com" in emails
assert "inactivefilter@example.com" not in emails
# Filter for inactive users
response = client.get("/api/v1/users?is_active=false", headers=headers)
response = await client.get("/api/v1/users?is_active=false", headers=headers)
data = response.json()
emails = [u["email"] for u in data["data"]]
assert "inactivefilter@example.com" in emails
assert "activefilter@example.com" not in emails
def test_list_users_sort_by_email(self, client, test_superuser):
@pytest.mark.asyncio
async def test_list_users_sort_by_email(self, client, async_test_superuser):
"""Test sorting users by email."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.get("/api/v1/users?sort_by=email&sort_order=asc", headers=headers)
response = await client.get("/api/v1/users?sort_by=email&sort_order=asc", headers=headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
emails = [u["email"] for u in data["data"]]
assert emails == sorted(emails)
def test_list_users_no_auth(self, client):
@pytest.mark.asyncio
async def test_list_users_no_auth(self, client):
"""Test that unauthenticated requests are rejected."""
response = client.get("/api/v1/users")
response = await client.get("/api/v1/users")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# Note: Removed test_list_users_unexpected_error because mocking at CRUD level
@@ -136,31 +145,34 @@ class TestListUsers:
class TestGetCurrentUserProfile:
"""Tests for GET /users/me endpoint."""
def test_get_own_profile(self, client, test_user):
@pytest.mark.asyncio
async def test_get_own_profile(self, client, async_test_user):
"""Test getting own profile."""
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.get("/api/v1/users/me", headers=headers)
response = await client.get("/api/v1/users/me", headers=headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["email"] == test_user.email
assert data["first_name"] == test_user.first_name
assert data["email"] == async_test_user.email
assert data["first_name"] == async_test_user.first_name
def test_get_profile_no_auth(self, client):
@pytest.mark.asyncio
async def test_get_profile_no_auth(self, client):
"""Test that unauthenticated requests are rejected."""
response = client.get("/api/v1/users/me")
response = await client.get("/api/v1/users/me")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestUpdateCurrentUser:
"""Tests for PATCH /users/me endpoint."""
def test_update_own_profile(self, client, test_user, test_db):
@pytest.mark.asyncio
async def test_update_own_profile(self, client, async_test_user, test_db):
"""Test updating own profile."""
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch(
response = await client.patch(
"/api/v1/users/me",
headers=headers,
json={"first_name": "Updated", "last_name": "Name"}
@@ -172,14 +184,15 @@ class TestUpdateCurrentUser:
assert data["last_name"] == "Name"
# Verify in database
test_db.refresh(test_user)
assert test_user.first_name == "Updated"
test_db.refresh(async_test_user)
assert async_test_user.first_name == "Updated"
def test_update_profile_phone_number(self, client, test_user, test_db):
@pytest.mark.asyncio
async def test_update_profile_phone_number(self, client, async_test_user, test_db):
"""Test updating phone number with validation."""
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch(
response = await client.patch(
"/api/v1/users/me",
headers=headers,
json={"phone_number": "+19876543210"}
@@ -189,11 +202,12 @@ class TestUpdateCurrentUser:
data = response.json()
assert data["phone_number"] == "+19876543210"
def test_update_profile_invalid_phone(self, client, test_user):
@pytest.mark.asyncio
async def test_update_profile_invalid_phone(self, client, async_test_user):
"""Test that invalid phone numbers are rejected."""
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch(
response = await client.patch(
"/api/v1/users/me",
headers=headers,
json={"phone_number": "invalid"}
@@ -201,13 +215,14 @@ class TestUpdateCurrentUser:
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_cannot_elevate_to_superuser(self, client, test_user):
@pytest.mark.asyncio
async def test_cannot_elevate_to_superuser(self, client, async_test_user):
"""Test that users cannot make themselves superuser."""
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
# Note: is_superuser is not in UserUpdate schema, but the endpoint checks for it
# This tests that even if someone tries to send it, it's rejected
response = client.patch(
response = await client.patch(
"/api/v1/users/me",
headers=headers,
json={"first_name": "Test", "is_superuser": True}
@@ -220,9 +235,10 @@ class TestUpdateCurrentUser:
# Verify user is still not a superuser
assert data["is_superuser"] is False
def test_update_profile_no_auth(self, client):
@pytest.mark.asyncio
async def test_update_profile_no_auth(self, client):
"""Test that unauthenticated requests are rejected."""
response = client.patch(
response = await client.patch(
"/api/v1/users/me",
json={"first_name": "Hacker"}
)
@@ -234,17 +250,19 @@ class TestUpdateCurrentUser:
class TestGetUserById:
"""Tests for GET /users/{user_id} endpoint."""
def test_get_own_profile_by_id(self, client, test_user):
@pytest.mark.asyncio
async def test_get_own_profile_by_id(self, client, async_test_user):
"""Test getting own profile by ID."""
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.get(f"/api/v1/users/{test_user.id}", headers=headers)
response = await client.get(f"/api/v1/users/{async_test_user.id}", headers=headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["email"] == test_user.email
assert data["email"] == async_test_user.email
def test_get_other_user_as_regular_user(self, client, test_user, test_db):
@pytest.mark.asyncio
async def test_get_other_user_as_regular_user(self, client, async_test_user, test_db):
"""Test that regular users cannot view other profiles."""
# Create another user
other_user = User(
@@ -258,36 +276,39 @@ class TestGetUserById:
test_db.commit()
test_db.refresh(other_user)
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.get(f"/api/v1/users/{other_user.id}", headers=headers)
response = await client.get(f"/api/v1/users/{other_user.id}", headers=headers)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_get_other_user_as_superuser(self, client, test_superuser, test_user):
@pytest.mark.asyncio
async def test_get_other_user_as_superuser(self, client, async_test_superuser, async_test_user):
"""Test that superusers can view other profiles."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.get(f"/api/v1/users/{test_user.id}", headers=headers)
response = await client.get(f"/api/v1/users/{async_test_user.id}", headers=headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["email"] == test_user.email
assert data["email"] == async_test_user.email
def test_get_nonexistent_user(self, client, test_superuser):
@pytest.mark.asyncio
async def test_get_nonexistent_user(self, client, async_test_superuser):
"""Test getting non-existent user."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
fake_id = uuid.uuid4()
response = client.get(f"/api/v1/users/{fake_id}", headers=headers)
response = await client.get(f"/api/v1/users/{fake_id}", headers=headers)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_get_user_invalid_uuid(self, client, test_superuser):
@pytest.mark.asyncio
async def test_get_user_invalid_uuid(self, client, async_test_superuser):
"""Test getting user with invalid UUID format."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.get("/api/v1/users/not-a-uuid", headers=headers)
response = await client.get("/api/v1/users/not-a-uuid", headers=headers)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@@ -295,12 +316,13 @@ class TestGetUserById:
class TestUpdateUserById:
"""Tests for PATCH /users/{user_id} endpoint."""
def test_update_own_profile_by_id(self, client, test_user, test_db):
@pytest.mark.asyncio
async def test_update_own_profile_by_id(self, client, async_test_user, test_db):
"""Test updating own profile by ID."""
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch(
f"/api/v1/users/{test_user.id}",
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers=headers,
json={"first_name": "SelfUpdated"}
)
@@ -309,7 +331,8 @@ class TestUpdateUserById:
data = response.json()
assert data["first_name"] == "SelfUpdated"
def test_update_other_user_as_regular_user(self, client, test_user, test_db):
@pytest.mark.asyncio
async def test_update_other_user_as_regular_user(self, client, async_test_user, test_db):
"""Test that regular users cannot update other profiles."""
# Create another user
other_user = User(
@@ -323,9 +346,9 @@ class TestUpdateUserById:
test_db.commit()
test_db.refresh(other_user)
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch(
response = await client.patch(
f"/api/v1/users/{other_user.id}",
headers=headers,
json={"first_name": "Hacked"}
@@ -337,12 +360,13 @@ class TestUpdateUserById:
test_db.refresh(other_user)
assert other_user.first_name == "Other"
def test_update_other_user_as_superuser(self, client, test_superuser, test_user, test_db):
@pytest.mark.asyncio
async def test_update_other_user_as_superuser(self, client, async_test_superuser, async_test_user, test_db):
"""Test that superusers can update other profiles."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.patch(
f"/api/v1/users/{test_user.id}",
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers=headers,
json={"first_name": "AdminUpdated"}
)
@@ -351,14 +375,15 @@ class TestUpdateUserById:
data = response.json()
assert data["first_name"] == "AdminUpdated"
def test_regular_user_cannot_modify_superuser_status(self, client, test_user):
@pytest.mark.asyncio
async def test_regular_user_cannot_modify_superuser_status(self, client, async_test_user):
"""Test that regular users cannot change superuser status even if they try."""
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
# is_superuser not in UserUpdate schema, so it gets ignored by Pydantic
# Just verify the user stays the same
response = client.patch(
f"/api/v1/users/{test_user.id}",
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers=headers,
json={"first_name": "Test"}
)
@@ -367,12 +392,13 @@ class TestUpdateUserById:
data = response.json()
assert data["is_superuser"] is False
def test_superuser_can_update_users(self, client, test_superuser, test_user, test_db):
@pytest.mark.asyncio
async def test_superuser_can_update_users(self, client, async_test_superuser, async_test_user, test_db):
"""Test that superusers can update other users."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.patch(
f"/api/v1/users/{test_user.id}",
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers=headers,
json={"first_name": "AdminChanged", "is_active": False}
)
@@ -382,12 +408,13 @@ class TestUpdateUserById:
assert data["first_name"] == "AdminChanged"
assert data["is_active"] is False
def test_update_nonexistent_user(self, client, test_superuser):
@pytest.mark.asyncio
async def test_update_nonexistent_user(self, client, async_test_superuser):
"""Test updating non-existent user."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
fake_id = uuid.uuid4()
response = client.patch(
response = await client.patch(
f"/api/v1/users/{fake_id}",
headers=headers,
json={"first_name": "Ghost"}
@@ -401,11 +428,12 @@ class TestUpdateUserById:
class TestChangePassword:
"""Tests for PATCH /users/me/password endpoint."""
def test_change_password_success(self, client, test_user, test_db):
@pytest.mark.asyncio
async def test_change_password_success(self, client, async_test_user, test_db):
"""Test successful password change."""
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch(
response = await client.patch(
"/api/v1/users/me/password",
headers=headers,
json={
@@ -419,20 +447,21 @@ class TestChangePassword:
assert data["success"] is True
# Verify can login with new password
login_response = client.post(
login_response = await client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"email": async_test_user.email,
"password": "NewPassword123"
}
)
assert login_response.status_code == status.HTTP_200_OK
def test_change_password_wrong_current(self, client, test_user):
@pytest.mark.asyncio
async def test_change_password_wrong_current(self, client, async_test_user):
"""Test that wrong current password is rejected."""
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch(
response = await client.patch(
"/api/v1/users/me/password",
headers=headers,
json={
@@ -443,11 +472,12 @@ class TestChangePassword:
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_change_password_weak_new_password(self, client, test_user):
@pytest.mark.asyncio
async def test_change_password_weak_new_password(self, client, async_test_user):
"""Test that weak new passwords are rejected."""
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch(
response = await client.patch(
"/api/v1/users/me/password",
headers=headers,
json={
@@ -458,9 +488,10 @@ class TestChangePassword:
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_change_password_no_auth(self, client):
@pytest.mark.asyncio
async def test_change_password_no_auth(self, client):
"""Test that unauthenticated requests are rejected."""
response = client.patch(
response = await client.patch(
"/api/v1/users/me/password",
json={
"current_password": "TestPassword123",
@@ -475,7 +506,8 @@ class TestChangePassword:
class TestDeleteUser:
"""Tests for DELETE /users/{user_id} endpoint."""
def test_delete_user_as_superuser(self, client, test_superuser, test_db):
@pytest.mark.asyncio
async def test_delete_user_as_superuser(self, client, async_test_superuser, test_db):
"""Test deleting a user as superuser."""
# Create a user to delete
user_to_delete = User(
@@ -489,9 +521,9 @@ class TestDeleteUser:
test_db.commit()
test_db.refresh(user_to_delete)
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.delete(f"/api/v1/users/{user_to_delete.id}", headers=headers)
response = await client.delete(f"/api/v1/users/{user_to_delete.id}", headers=headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
@@ -501,15 +533,17 @@ class TestDeleteUser:
test_db.refresh(user_to_delete)
assert user_to_delete.deleted_at is not None
def test_cannot_delete_self(self, client, test_superuser):
@pytest.mark.asyncio
async def test_cannot_delete_self(self, client, async_test_superuser):
"""Test that users cannot delete their own account."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.delete(f"/api/v1/users/{test_superuser.id}", headers=headers)
response = await client.delete(f"/api/v1/users/{async_test_superuser.id}", headers=headers)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_delete_user_as_regular_user(self, client, test_user, test_db):
@pytest.mark.asyncio
async def test_delete_user_as_regular_user(self, client, async_test_user, test_db):
"""Test that regular users cannot delete users."""
# Create another user
other_user = User(
@@ -523,24 +557,26 @@ class TestDeleteUser:
test_db.commit()
test_db.refresh(other_user)
headers = get_auth_headers(client, test_user.email, "TestPassword123")
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.delete(f"/api/v1/users/{other_user.id}", headers=headers)
response = await client.delete(f"/api/v1/users/{other_user.id}", headers=headers)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_delete_nonexistent_user(self, client, test_superuser):
@pytest.mark.asyncio
async def test_delete_nonexistent_user(self, client, async_test_superuser):
"""Test deleting non-existent user."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123")
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
fake_id = uuid.uuid4()
response = client.delete(f"/api/v1/users/{fake_id}", headers=headers)
response = await client.delete(f"/api/v1/users/{fake_id}", headers=headers)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_delete_user_no_auth(self, client, test_user):
@pytest.mark.asyncio
async def test_delete_user_no_auth(self, client, async_test_user):
"""Test that unauthenticated requests are rejected."""
response = client.delete(f"/api/v1/users/{test_user.id}")
response = await client.delete(f"/api/v1/users/{async_test_user.id}")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# Note: Removed test_delete_user_unexpected_error - see comment above

82
backend/tests/conftest.py Normal file → Executable file
View File

@@ -4,14 +4,15 @@ import uuid
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
import pytest_asyncio
from httpx import AsyncClient
# Set IS_TEST environment variable BEFORE importing app
# This prevents the scheduler from starting during tests
os.environ["IS_TEST"] = "True"
from app.main import app
from app.core.database import get_db
from app.core.database_async import get_async_db
from app.models.user import User
from app.core.auth import get_password_hash
from app.utils.test_utils import setup_test_db, teardown_test_db, setup_async_test_db, teardown_async_test_db
@@ -35,7 +36,7 @@ def db_session():
teardown_test_db(test_engine)
@pytest.fixture(scope="function") # Define a fixture
@pytest_asyncio.fixture(scope="function") # Define a fixture
async def async_test_db():
"""Fixture provides new testing engine and session for each test run to improve isolation."""
@@ -92,22 +93,25 @@ def test_db():
teardown_test_db(test_engine)
@pytest.fixture(scope="function")
def client(test_db):
@pytest_asyncio.fixture(scope="function")
async def client(async_test_db):
"""
Create a FastAPI test client with a test database.
Create a FastAPI async test client with a test database.
This overrides the get_db dependency to use the test database.
This overrides the get_async_db dependency to use the test database.
"""
def override_get_db():
test_engine, AsyncTestingSessionLocal = async_test_db
async def override_get_async_db():
async with AsyncTestingSessionLocal() as session:
try:
yield test_db
yield session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_async_db] = override_get_async_db
with TestClient(app) as test_client:
async with AsyncClient(app=app, base_url="http://test") as test_client:
yield test_client
app.dependency_overrides.clear()
@@ -116,7 +120,7 @@ def client(test_db):
@pytest.fixture
def test_user(test_db):
"""
Create a test user in the database.
Create a test user in the database (sync version for legacy tests).
Password: TestPassword123
"""
@@ -140,7 +144,7 @@ def test_user(test_db):
@pytest.fixture
def test_superuser(test_db):
"""
Create a test superuser in the database.
Create a test superuser in the database (sync version for legacy tests).
Password: SuperPassword123
"""
@@ -159,3 +163,55 @@ def test_superuser(test_db):
test_db.commit()
test_db.refresh(user)
return user
@pytest_asyncio.fixture
async def async_test_user(async_test_db):
"""
Create a test user in the database (async version).
Password: TestPassword123
"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
user = User(
id=uuid.uuid4(),
email="testuser@example.com",
password_hash=get_password_hash("TestPassword123"),
first_name="Test",
last_name="User",
phone_number="+1234567890",
is_active=True,
is_superuser=False,
preferences=None,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@pytest_asyncio.fixture
async def async_test_superuser(async_test_db):
"""
Create a test superuser in the database (async version).
Password: SuperPassword123
"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
user = User(
id=uuid.uuid4(),
email="superuser@example.com",
password_hash=get_password_hash("SuperPassword123"),
first_name="Super",
last_name="User",
phone_number="+9876543210",
is_active=True,
is_superuser=True,
preferences=None,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user

0
backend/tests/core/__init__.py Normal file → Executable file
View File

0
backend/tests/core/test_auth.py Normal file → Executable file
View File

0
backend/tests/core/test_config.py Normal file → Executable file
View File

0
backend/tests/crud/__init__.py Normal file → Executable file
View File

0
backend/tests/crud/test_crud_base.py Normal file → Executable file
View File

0
backend/tests/crud/test_crud_error_paths.py Normal file → Executable file
View File

0
backend/tests/crud/test_soft_delete.py Normal file → Executable file
View File

0
backend/tests/crud/test_user.py Normal file → Executable file
View File

0
backend/tests/models/__init__.py Normal file → Executable file
View File

0
backend/tests/models/test_user.py Normal file → Executable file
View File

0
backend/tests/schemas/__init__.py Normal file → Executable file
View File

0
backend/tests/schemas/test_user_schemas.py Normal file → Executable file
View File

0
backend/tests/services/__init__.py Normal file → Executable file
View File

0
backend/tests/services/test_auth_service.py Normal file → Executable file
View File

0
backend/tests/services/test_email_service.py Normal file → Executable file
View File

0
backend/tests/test_init_db.py Normal file → Executable file
View File

0
backend/tests/utils/__init__.py Normal file → Executable file
View File

0
backend/tests/utils/test_security.py Normal file → Executable file
View File