Add pyproject.toml for consolidated project configuration and replace Black, isort, and Flake8 with Ruff

- Introduced `pyproject.toml` to centralize backend tool configurations (e.g., Ruff, mypy, coverage, pytest).
- Replaced Black, isort, and Flake8 with Ruff for linting, formatting, and import sorting.
- Updated `requirements.txt` to include Ruff and remove replaced tools.
- Added `Makefile` to streamline development workflows with commands for linting, formatting, type-checking, testing, and cleanup.
This commit is contained in:
2025-11-10 11:55:15 +01:00
parent a5c671c133
commit c589b565f0
86 changed files with 4572 additions and 3956 deletions

View File

@@ -3,32 +3,29 @@
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
import uuid
from unittest.mock import patch
import pytest
from fastapi import status
from app.models.user import User
from app.models.user import User
from app.schemas.users import UserUpdate
# Disable rate limiting for tests
@pytest.fixture(autouse=True)
def disable_rate_limit():
"""Disable rate limiting for all tests in this module."""
with patch('app.api.routes.users.limiter.enabled', False):
with patch('app.api.routes.auth.limiter.enabled', False):
with patch("app.api.routes.users.limiter.enabled", False):
with patch("app.api.routes.auth.limiter.enabled", False):
yield
async def get_auth_headers(client, email, password):
"""Helper to get authentication headers."""
response = await client.post(
"/api/v1/auth/login",
json={"email": email, "password": password}
"/api/v1/auth/login", json={"email": email, "password": password}
)
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@@ -40,7 +37,9 @@ class TestListUsers:
@pytest.mark.asyncio
async def test_list_users_as_superuser(self, client, async_test_superuser):
"""Test listing users as superuser."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.get("/api/v1/users", headers=headers)
@@ -53,16 +52,20 @@ class TestListUsers:
@pytest.mark.asyncio
async def test_list_users_as_regular_user(self, client, async_test_user):
"""Test that regular users cannot list users."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.get("/api/v1/users", headers=headers)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.asyncio
async def test_list_users_pagination(self, client, async_test_superuser, async_test_db):
async def test_list_users_pagination(
self, client, async_test_superuser, async_test_db
):
"""Test pagination works correctly."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create multiple users
async with AsyncTestingSessionLocal() as session:
@@ -72,12 +75,14 @@ class TestListUsers:
password_hash="hash",
first_name=f"PagUser{i}",
is_active=True,
is_superuser=False
is_superuser=False,
)
session.add(user)
await session.commit()
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
# Get first page
response = await client.get("/api/v1/users?page=1&limit=5", headers=headers)
@@ -88,9 +93,11 @@ class TestListUsers:
assert data["pagination"]["total"] >= 15
@pytest.mark.asyncio
async def test_list_users_filter_active(self, client, async_test_superuser, async_test_db):
async def test_list_users_filter_active(
self, client, async_test_superuser, async_test_db
):
"""Test filtering by active status."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create active and inactive users
async with AsyncTestingSessionLocal() as session:
@@ -99,19 +106,21 @@ class TestListUsers:
password_hash="hash",
first_name="Active",
is_active=True,
is_superuser=False
is_superuser=False,
)
inactive_user = User(
email="inactivefilter@example.com",
password_hash="hash",
first_name="Inactive",
is_active=False,
is_superuser=False
is_superuser=False,
)
session.add_all([active_user, inactive_user])
await session.commit()
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
# Filter for active users
response = await client.get("/api/v1/users?is_active=true", headers=headers)
@@ -130,9 +139,13 @@ class TestListUsers:
@pytest.mark.asyncio
async def test_list_users_sort_by_email(self, client, async_test_superuser):
"""Test sorting users by email."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await 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"]]
@@ -154,7 +167,9 @@ class TestGetCurrentUserProfile:
@pytest.mark.asyncio
async def test_get_own_profile(self, client, async_test_user):
"""Test getting own profile."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.get("/api/v1/users/me", headers=headers)
@@ -176,12 +191,14 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_update_own_profile(self, client, async_test_user):
"""Test updating own profile."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
"/api/v1/users/me",
headers=headers,
json={"first_name": "Updated", "last_name": "Name"}
json={"first_name": "Updated", "last_name": "Name"},
)
assert response.status_code == status.HTTP_200_OK
@@ -192,12 +209,12 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_update_profile_phone_number(self, client, async_test_user, test_db):
"""Test updating phone number with validation."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
"/api/v1/users/me",
headers=headers,
json={"phone_number": "+19876543210"}
"/api/v1/users/me", headers=headers, json={"phone_number": "+19876543210"}
)
assert response.status_code == status.HTTP_200_OK
@@ -207,12 +224,12 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_update_profile_invalid_phone(self, client, async_test_user):
"""Test that invalid phone numbers are rejected."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
"/api/v1/users/me",
headers=headers,
json={"phone_number": "invalid"}
"/api/v1/users/me", headers=headers, json={"phone_number": "invalid"}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@@ -220,14 +237,16 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_cannot_elevate_to_superuser(self, client, async_test_user):
"""Test that users cannot make themselves superuser."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
# Note: is_superuser is now in UserUpdate schema with explicit validation
# This tests that Pydantic rejects the attempt at the schema level
response = await client.patch(
"/api/v1/users/me",
headers=headers,
json={"first_name": "Test", "is_superuser": True}
json={"first_name": "Test", "is_superuser": True},
)
# Pydantic validation should reject this at the schema level
@@ -242,10 +261,7 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_update_profile_no_auth(self, client):
"""Test that unauthenticated requests are rejected."""
response = await client.patch(
"/api/v1/users/me",
json={"first_name": "Hacker"}
)
response = await client.patch("/api/v1/users/me", json={"first_name": "Hacker"})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# Note: Removed test_update_profile_unexpected_error - see comment above
@@ -257,16 +273,22 @@ class TestGetUserById:
@pytest.mark.asyncio
async def test_get_own_profile_by_id(self, client, async_test_user):
"""Test getting own profile by ID."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.get(f"/api/v1/users/{async_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"] == async_test_user.email
@pytest.mark.asyncio
async def test_get_other_user_as_regular_user(self, client, async_test_user, test_db):
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(
@@ -274,24 +296,32 @@ class TestGetUserById:
password_hash="hash",
first_name="Other",
is_active=True,
is_superuser=False
is_superuser=False,
)
test_db.add(other_user)
test_db.commit()
test_db.refresh(other_user)
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.get(f"/api/v1/users/{other_user.id}", headers=headers)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.asyncio
async def test_get_other_user_as_superuser(self, client, async_test_superuser, async_test_user):
async def test_get_other_user_as_superuser(
self, client, async_test_superuser, async_test_user
):
"""Test that superusers can view other profiles."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.get(f"/api/v1/users/{async_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()
@@ -300,7 +330,9 @@ class TestGetUserById:
@pytest.mark.asyncio
async def test_get_nonexistent_user(self, client, async_test_superuser):
"""Test getting non-existent user."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
fake_id = uuid.uuid4()
response = await client.get(f"/api/v1/users/{fake_id}", headers=headers)
@@ -310,7 +342,9 @@ class TestGetUserById:
@pytest.mark.asyncio
async def test_get_user_invalid_uuid(self, client, async_test_superuser):
"""Test getting user with invalid UUID format."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.get("/api/v1/users/not-a-uuid", headers=headers)
@@ -323,12 +357,14 @@ class TestUpdateUserById:
@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 = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers=headers,
json={"first_name": "SelfUpdated"}
json={"first_name": "SelfUpdated"},
)
assert response.status_code == status.HTTP_200_OK
@@ -336,7 +372,9 @@ class TestUpdateUserById:
assert data["first_name"] == "SelfUpdated"
@pytest.mark.asyncio
async def test_update_other_user_as_regular_user(self, client, async_test_user, test_db):
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(
@@ -344,18 +382,20 @@ class TestUpdateUserById:
password_hash="hash",
first_name="Other",
is_active=True,
is_superuser=False
is_superuser=False,
)
test_db.add(other_user)
test_db.commit()
test_db.refresh(other_user)
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
f"/api/v1/users/{other_user.id}",
headers=headers,
json={"first_name": "Hacked"}
json={"first_name": "Hacked"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -365,14 +405,18 @@ class TestUpdateUserById:
assert other_user.first_name == "Other"
@pytest.mark.asyncio
async def test_update_other_user_as_superuser(self, client, async_test_superuser, async_test_user, test_db):
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 = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers=headers,
json={"first_name": "AdminUpdated"}
json={"first_name": "AdminUpdated"},
)
assert response.status_code == status.HTTP_200_OK
@@ -380,16 +424,20 @@ class TestUpdateUserById:
assert data["first_name"] == "AdminUpdated"
@pytest.mark.asyncio
async def test_regular_user_cannot_modify_superuser_status(self, client, async_test_user):
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 = await get_auth_headers(client, async_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 = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers=headers,
json={"first_name": "Test"}
json={"first_name": "Test"},
)
assert response.status_code == status.HTTP_200_OK
@@ -397,14 +445,18 @@ class TestUpdateUserById:
assert data["is_superuser"] is False
@pytest.mark.asyncio
async def test_superuser_can_update_users(self, client, async_test_superuser, async_test_user, test_db):
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 = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers=headers,
json={"first_name": "AdminChanged", "is_active": False}
json={"first_name": "AdminChanged", "is_active": False},
)
assert response.status_code == status.HTTP_200_OK
@@ -415,13 +467,13 @@ class TestUpdateUserById:
@pytest.mark.asyncio
async def test_update_nonexistent_user(self, client, async_test_superuser):
"""Test updating non-existent user."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
fake_id = uuid.uuid4()
response = await client.patch(
f"/api/v1/users/{fake_id}",
headers=headers,
json={"first_name": "Ghost"}
f"/api/v1/users/{fake_id}", headers=headers, json={"first_name": "Ghost"}
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -435,15 +487,17 @@ class TestChangePassword:
@pytest.mark.asyncio
async def test_change_password_success(self, client, async_test_user, test_db):
"""Test successful password change."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
"/api/v1/users/me/password",
headers=headers,
json={
"current_password": "TestPassword123!",
"new_password": "NewPassword123!"
}
"new_password": "NewPassword123!",
},
)
assert response.status_code == status.HTTP_200_OK
@@ -453,25 +507,24 @@ class TestChangePassword:
# Verify can login with new password
login_response = await client.post(
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "NewPassword123!"
}
json={"email": async_test_user.email, "password": "NewPassword123!"},
)
assert login_response.status_code == status.HTTP_200_OK
@pytest.mark.asyncio
async def test_change_password_wrong_current(self, client, async_test_user):
"""Test that wrong current password is rejected."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
"/api/v1/users/me/password",
headers=headers,
json={
"current_password": "WrongPassword123",
"new_password": "NewPassword123!"
}
"new_password": "NewPassword123!",
},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -479,15 +532,14 @@ class TestChangePassword:
@pytest.mark.asyncio
async def test_change_password_weak_new_password(self, client, async_test_user):
"""Test that weak new passwords are rejected."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
"/api/v1/users/me/password",
headers=headers,
json={
"current_password": "TestPassword123!",
"new_password": "weak"
}
json={"current_password": "TestPassword123!", "new_password": "weak"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@@ -499,8 +551,8 @@ class TestChangePassword:
"/api/v1/users/me/password",
json={
"current_password": "TestPassword123!",
"new_password": "NewPassword123!"
}
"new_password": "NewPassword123!",
},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -511,9 +563,11 @@ class TestDeleteUser:
"""Tests for DELETE /users/{user_id} endpoint."""
@pytest.mark.asyncio
async def test_delete_user_as_superuser(self, client, async_test_superuser, async_test_db):
async def test_delete_user_as_superuser(
self, client, async_test_superuser, async_test_db
):
"""Test deleting a user as superuser."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create a user to delete
async with AsyncTestingSessionLocal() as session:
@@ -522,14 +576,16 @@ class TestDeleteUser:
password_hash="hash",
first_name="Delete",
is_active=True,
is_superuser=False
is_superuser=False,
)
session.add(user_to_delete)
await session.commit()
await session.refresh(user_to_delete)
user_id = user_to_delete.id
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.delete(f"/api/v1/users/{user_id}", headers=headers)
@@ -540,6 +596,7 @@ class TestDeleteUser:
# Verify user is soft-deleted (has deleted_at timestamp)
async with AsyncTestingSessionLocal() as session:
from sqlalchemy import select
result = await session.execute(select(User).where(User.id == user_id))
deleted_user = result.scalar_one_or_none()
assert deleted_user.deleted_at is not None
@@ -547,9 +604,13 @@ class TestDeleteUser:
@pytest.mark.asyncio
async def test_cannot_delete_self(self, client, async_test_superuser):
"""Test that users cannot delete their own account."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.delete(f"/api/v1/users/{async_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
@@ -562,22 +623,28 @@ class TestDeleteUser:
password_hash="hash",
first_name="Protected",
is_active=True,
is_superuser=False
is_superuser=False,
)
test_db.add(other_user)
test_db.commit()
test_db.refresh(other_user)
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await 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
@pytest.mark.asyncio
async def test_delete_nonexistent_user(self, client, async_test_superuser):
"""Test deleting non-existent user."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
fake_id = uuid.uuid4()
response = await client.delete(f"/api/v1/users/{fake_id}", headers=headers)