Add async CRUD base, async database configuration, soft delete for users, and composite indexes

- Introduced `CRUDBaseAsync` for reusable async operations.
- Configured async database connection using SQLAlchemy 2.0 patterns with `asyncpg`.
- Added `deleted_at` column and soft delete functionality to the `User` model, including related Alembic migration.
- Optimized queries by adding composite indexes for common user filtering scenarios.
- Extended tests: added cases for token-based security utilities and user management endpoints.
This commit is contained in:
Felipe Cardoso
2025-10-30 16:45:01 +01:00
parent c684f2ba95
commit 313e6691b5
9 changed files with 1251 additions and 10 deletions

View File

@@ -0,0 +1,487 @@
# tests/api/routes/test_users.py
"""
Tests for user management endpoints.
"""
import uuid
from datetime import datetime, timezone
from unittest.mock import patch, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.api.routes.users import router as users_router
from app.core.database import get_db
from app.models.user import User
from app.api.dependencies.auth import get_current_user, get_current_superuser
@pytest.fixture
def override_get_db(db_session):
"""Override get_db dependency for testing."""
return db_session
@pytest.fixture
def app(override_get_db):
"""Create a FastAPI test application."""
app = FastAPI()
app.include_router(users_router, prefix="/api/v1/users", tags=["users"])
# Override the get_db dependency
app.dependency_overrides[get_db] = lambda: override_get_db
return app
@pytest.fixture
def client(app):
"""Create a FastAPI test client."""
return TestClient(app)
@pytest.fixture
def regular_user():
"""Create a mock regular user."""
return User(
id=uuid.uuid4(),
email="regular@example.com",
password_hash="hashed_password",
first_name="Regular",
last_name="User",
is_active=True,
is_superuser=False,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
@pytest.fixture
def super_user():
"""Create a mock superuser."""
return User(
id=uuid.uuid4(),
email="admin@example.com",
password_hash="hashed_password",
first_name="Admin",
last_name="User",
is_active=True,
is_superuser=True,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
class TestListUsers:
"""Tests for the list_users endpoint."""
def test_list_users_as_superuser(self, client, app, super_user, regular_user, db_session):
"""Test that superusers can list all users."""
from app.crud.user import user as user_crud
# Override auth dependency
app.dependency_overrides[get_current_superuser] = lambda: super_user
# Mock user_crud to return test data
mock_users = [regular_user for _ in range(3)]
with patch.object(user_crud, 'get_multi_with_total', return_value=(mock_users, 3)):
response = client.get("/api/v1/users?page=1&limit=20")
assert response.status_code == 200
data = response.json()
assert "data" in data
assert "pagination" in data
assert len(data["data"]) == 3
assert data["pagination"]["total"] == 3
# Clean up
if get_current_superuser in app.dependency_overrides:
del app.dependency_overrides[get_current_superuser]
def test_list_users_pagination(self, client, app, super_user, regular_user, db_session):
"""Test pagination parameters for list users."""
from app.crud.user import user as user_crud
app.dependency_overrides[get_current_superuser] = lambda: super_user
# Mock user_crud
mock_users = [regular_user for _ in range(10)]
with patch.object(user_crud, 'get_multi_with_total', return_value=(mock_users[:5], 10)):
response = client.get("/api/v1/users?page=1&limit=5")
assert response.status_code == 200
data = response.json()
assert data["pagination"]["page"] == 1
assert data["pagination"]["page_size"] == 5
assert data["pagination"]["total"] == 10
assert data["pagination"]["total_pages"] == 2
# Clean up
if get_current_superuser in app.dependency_overrides:
del app.dependency_overrides[get_current_superuser]
class TestGetCurrentUserProfile:
"""Tests for the get_current_user_profile endpoint."""
def test_get_current_user_profile(self, client, app, regular_user):
"""Test getting current user's profile."""
app.dependency_overrides[get_current_user] = lambda: regular_user
response = client.get("/api/v1/users/me")
assert response.status_code == 200
data = response.json()
assert data["email"] == regular_user.email
assert data["first_name"] == regular_user.first_name
assert data["last_name"] == regular_user.last_name
assert "password" not in data
# Clean up
if get_current_user in app.dependency_overrides:
del app.dependency_overrides[get_current_user]
class TestUpdateCurrentUser:
"""Tests for the update_current_user endpoint."""
def test_update_current_user_success(self, client, app, regular_user, db_session):
"""Test successful profile update."""
from app.crud.user import user as user_crud
app.dependency_overrides[get_current_user] = lambda: regular_user
updated_user = User(
id=regular_user.id,
email=regular_user.email,
password_hash=regular_user.password_hash,
first_name="Updated",
last_name="Name",
is_active=True,
is_superuser=False,
created_at=regular_user.created_at,
updated_at=datetime.now(timezone.utc)
)
with patch.object(user_crud, 'update', return_value=updated_user):
response = client.patch(
"/api/v1/users/me",
json={"first_name": "Updated", "last_name": "Name"}
)
assert response.status_code == 200
data = response.json()
assert data["first_name"] == "Updated"
assert data["last_name"] == "Name"
# Clean up
if get_current_user in app.dependency_overrides:
del app.dependency_overrides[get_current_user]
def test_update_current_user_extra_fields_ignored(self, client, app, regular_user, db_session):
"""Test that extra fields like is_superuser are ignored by schema validation."""
from app.crud.user import user as user_crud
app.dependency_overrides[get_current_user] = lambda: regular_user
# Create updated user without is_superuser changed
updated_user = User(
id=regular_user.id,
email=regular_user.email,
password_hash=regular_user.password_hash,
first_name="Updated",
last_name=regular_user.last_name,
is_active=True,
is_superuser=False, # Should remain False
created_at=regular_user.created_at,
updated_at=datetime.now(timezone.utc)
)
with patch.object(user_crud, 'update', return_value=updated_user):
response = client.patch(
"/api/v1/users/me",
json={"first_name": "Updated", "is_superuser": True} # is_superuser will be ignored
)
# Request should succeed but is_superuser should be unchanged
assert response.status_code == 200
data = response.json()
assert data["is_superuser"] is False
# Clean up
if get_current_user in app.dependency_overrides:
del app.dependency_overrides[get_current_user]
class TestGetUserById:
"""Tests for the get_user_by_id endpoint."""
def test_get_own_profile(self, client, app, regular_user, db_session):
"""Test that users can get their own profile."""
from app.crud.user import user as user_crud
app.dependency_overrides[get_current_user] = lambda: regular_user
with patch.object(user_crud, 'get', return_value=regular_user):
response = client.get(f"/api/v1/users/{regular_user.id}")
assert response.status_code == 200
data = response.json()
assert data["email"] == regular_user.email
# Clean up
if get_current_user in app.dependency_overrides:
del app.dependency_overrides[get_current_user]
def test_get_other_user_as_regular_user(self, client, app, regular_user):
"""Test that regular users cannot view other users."""
app.dependency_overrides[get_current_user] = lambda: regular_user
other_user_id = uuid.uuid4()
response = client.get(f"/api/v1/users/{other_user_id}")
assert response.status_code == 403
# Clean up
if get_current_user in app.dependency_overrides:
del app.dependency_overrides[get_current_user]
def test_get_other_user_as_superuser(self, client, app, super_user, db_session):
"""Test that superusers can view any user."""
from app.crud.user import user as user_crud
app.dependency_overrides[get_current_user] = lambda: super_user
other_user = User(
id=uuid.uuid4(),
email="other@example.com",
password_hash="hashed",
first_name="Other",
last_name="User",
is_active=True,
is_superuser=False,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
with patch.object(user_crud, 'get', return_value=other_user):
response = client.get(f"/api/v1/users/{other_user.id}")
assert response.status_code == 200
data = response.json()
assert data["email"] == other_user.email
# Clean up
if get_current_user in app.dependency_overrides:
del app.dependency_overrides[get_current_user]
def test_get_nonexistent_user(self, client, app, super_user, db_session):
"""Test getting a user that doesn't exist."""
from app.crud.user import user as user_crud
app.dependency_overrides[get_current_user] = lambda: super_user
with patch.object(user_crud, 'get', return_value=None):
response = client.get(f"/api/v1/users/{uuid.uuid4()}")
assert response.status_code == 404
# Clean up
if get_current_user in app.dependency_overrides:
del app.dependency_overrides[get_current_user]
class TestUpdateUser:
"""Tests for the update_user endpoint."""
def test_update_own_profile(self, client, app, regular_user, db_session):
"""Test that users can update their own profile."""
from app.crud.user import user as user_crud
app.dependency_overrides[get_current_user] = lambda: regular_user
updated_user = User(
id=regular_user.id,
email=regular_user.email,
password_hash=regular_user.password_hash,
first_name="NewName",
last_name=regular_user.last_name,
is_active=True,
is_superuser=False,
created_at=regular_user.created_at,
updated_at=datetime.now(timezone.utc)
)
with patch.object(user_crud, 'get', return_value=regular_user), \
patch.object(user_crud, 'update', return_value=updated_user):
response = client.patch(
f"/api/v1/users/{regular_user.id}",
json={"first_name": "NewName"}
)
assert response.status_code == 200
data = response.json()
assert data["first_name"] == "NewName"
# Clean up
if get_current_user in app.dependency_overrides:
del app.dependency_overrides[get_current_user]
def test_update_other_user_as_regular_user(self, client, app, regular_user):
"""Test that regular users cannot update other users."""
app.dependency_overrides[get_current_user] = lambda: regular_user
other_user_id = uuid.uuid4()
response = client.patch(
f"/api/v1/users/{other_user_id}",
json={"first_name": "NewName"}
)
assert response.status_code == 403
# Clean up
if get_current_user in app.dependency_overrides:
del app.dependency_overrides[get_current_user]
def test_user_schema_ignores_extra_fields(self, client, app, regular_user, db_session):
"""Test that UserUpdate schema ignores extra fields like is_superuser."""
from app.crud.user import user as user_crud
app.dependency_overrides[get_current_user] = lambda: regular_user
# Updated user with is_superuser unchanged
updated_user = User(
id=regular_user.id,
email=regular_user.email,
password_hash=regular_user.password_hash,
first_name="Changed",
last_name=regular_user.last_name,
is_active=True,
is_superuser=False, # Should remain False
created_at=regular_user.created_at,
updated_at=datetime.now(timezone.utc)
)
with patch.object(user_crud, 'get', return_value=regular_user), \
patch.object(user_crud, 'update', return_value=updated_user):
response = client.patch(
f"/api/v1/users/{regular_user.id}",
json={"first_name": "Changed", "is_superuser": True} # is_superuser ignored
)
# Should succeed, extra field is ignored
assert response.status_code == 200
data = response.json()
assert data["is_superuser"] is False
# Clean up
if get_current_user in app.dependency_overrides:
del app.dependency_overrides[get_current_user]
def test_superuser_can_update_any_user(self, client, app, super_user, db_session):
"""Test that superusers can update any user."""
from app.crud.user import user as user_crud
app.dependency_overrides[get_current_user] = lambda: super_user
target_user = User(
id=uuid.uuid4(),
email="target@example.com",
password_hash="hashed",
first_name="Target",
last_name="User",
is_active=True,
is_superuser=False,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
updated_user = User(
id=target_user.id,
email=target_user.email,
password_hash=target_user.password_hash,
first_name="Updated",
last_name=target_user.last_name,
is_active=True,
is_superuser=False,
created_at=target_user.created_at,
updated_at=datetime.now(timezone.utc)
)
with patch.object(user_crud, 'get', return_value=target_user), \
patch.object(user_crud, 'update', return_value=updated_user):
response = client.patch(
f"/api/v1/users/{target_user.id}",
json={"first_name": "Updated"}
)
assert response.status_code == 200
data = response.json()
assert data["first_name"] == "Updated"
# Clean up
if get_current_user in app.dependency_overrides:
del app.dependency_overrides[get_current_user]
class TestDeleteUser:
"""Tests for the delete_user endpoint."""
def test_delete_user_as_superuser(self, client, app, super_user, db_session):
"""Test that superusers can delete users."""
from app.crud.user import user as user_crud
app.dependency_overrides[get_current_superuser] = lambda: super_user
target_user = User(
id=uuid.uuid4(),
email="target@example.com",
password_hash="hashed",
first_name="Target",
last_name="User",
is_active=True,
is_superuser=False,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
with patch.object(user_crud, 'get', return_value=target_user), \
patch.object(user_crud, 'remove', return_value=target_user):
response = client.delete(f"/api/v1/users/{target_user.id}")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "deleted successfully" in data["message"]
# Clean up
if get_current_superuser in app.dependency_overrides:
del app.dependency_overrides[get_current_superuser]
def test_delete_nonexistent_user(self, client, app, super_user, db_session):
"""Test deleting a user that doesn't exist."""
from app.crud.user import user as user_crud
app.dependency_overrides[get_current_superuser] = lambda: super_user
with patch.object(user_crud, 'get', return_value=None):
response = client.delete(f"/api/v1/users/{uuid.uuid4()}")
assert response.status_code == 404
# Clean up
if get_current_superuser in app.dependency_overrides:
del app.dependency_overrides[get_current_superuser]
def test_cannot_delete_self(self, client, app, super_user, db_session):
"""Test that users cannot delete their own account."""
app.dependency_overrides[get_current_superuser] = lambda: super_user
response = client.delete(f"/api/v1/users/{super_user.id}")
assert response.status_code == 403
# Clean up
if get_current_superuser in app.dependency_overrides:
del app.dependency_overrides[get_current_superuser]