refactor(backend): enforce route→service→repo layered architecture
- introduce custom repository exception hierarchy (DuplicateEntryError, IntegrityConstraintError, InvalidInputError) replacing raw ValueError - eliminate all direct repository imports and raw SQL from route layer - add UserService, SessionService, OrganizationService to service layer - add get_stats/get_org_distribution service methods replacing admin inline SQL - fix timing side-channel in authenticate_user via dummy bcrypt check - replace SHA-256 client secret fallback with explicit InvalidClientError - replace assert with InvalidGrantError in authorization code exchange - replace N+1 token revocation loops with bulk UPDATE statements - rename oauth account token fields (drop misleading 'encrypted' suffix) - add Alembic migration 0003 for token field column rename - add 45 new service/repository tests; 975 passing, 94% coverage
This commit is contained in:
214
backend/tests/services/test_user_service.py
Normal file
214
backend/tests/services/test_user_service.py
Normal file
@@ -0,0 +1,214 @@
|
||||
# tests/services/test_user_service.py
|
||||
"""Tests for the UserService class."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.models.user import User
|
||||
from app.schemas.users import UserCreate, UserUpdate
|
||||
from app.services.user_service import UserService, user_service
|
||||
|
||||
|
||||
class TestGetUser:
|
||||
"""Tests for UserService.get_user method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_found(self, async_test_db, async_test_user):
|
||||
"""Test getting an existing user by ID returns the user."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await user_service.get_user(session, str(async_test_user.id))
|
||||
assert result is not None
|
||||
assert result.id == async_test_user.id
|
||||
assert result.email == async_test_user.email
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_not_found(self, async_test_db):
|
||||
"""Test getting a non-existent user raises NotFoundError."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
non_existent_id = str(uuid.uuid4())
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
with pytest.raises(NotFoundError):
|
||||
await user_service.get_user(session, non_existent_id)
|
||||
|
||||
|
||||
class TestGetByEmail:
|
||||
"""Tests for UserService.get_by_email method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_email_found(self, async_test_db, async_test_user):
|
||||
"""Test getting an existing user by email returns the user."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await user_service.get_by_email(session, async_test_user.email)
|
||||
assert result is not None
|
||||
assert result.id == async_test_user.id
|
||||
assert result.email == async_test_user.email
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_email_not_found(self, async_test_db):
|
||||
"""Test getting a user by non-existent email returns None."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await user_service.get_by_email(session, "nonexistent@example.com")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCreateUser:
|
||||
"""Tests for UserService.create_user method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user(self, async_test_db):
|
||||
"""Test creating a new user with valid data."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
unique_email = f"test_{uuid.uuid4()}@example.com"
|
||||
user_data = UserCreate(
|
||||
email=unique_email,
|
||||
password="TestPassword123!",
|
||||
first_name="New",
|
||||
last_name="User",
|
||||
)
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await user_service.create_user(session, user_data)
|
||||
assert result is not None
|
||||
assert result.email == unique_email
|
||||
assert result.first_name == "New"
|
||||
assert result.last_name == "User"
|
||||
assert result.is_active is True
|
||||
|
||||
|
||||
class TestUpdateUser:
|
||||
"""Tests for UserService.update_user method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user(self, async_test_db, async_test_user):
|
||||
"""Test updating a user's first_name."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
user = await user_service.get_user(session, str(async_test_user.id))
|
||||
updated = await user_service.update_user(
|
||||
session,
|
||||
user=user,
|
||||
obj_in=UserUpdate(first_name="Updated"),
|
||||
)
|
||||
assert updated.first_name == "Updated"
|
||||
assert updated.id == async_test_user.id
|
||||
|
||||
|
||||
class TestSoftDeleteUser:
|
||||
"""Tests for UserService.soft_delete_user method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_delete_user(self, async_test_db, async_test_user):
|
||||
"""Test soft-deleting a user sets deleted_at."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
await user_service.soft_delete_user(session, str(async_test_user.id))
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.id == async_test_user.id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
assert user is not None
|
||||
assert user.deleted_at is not None
|
||||
|
||||
|
||||
class TestListUsers:
|
||||
"""Tests for UserService.list_users method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users(self, async_test_db, async_test_user):
|
||||
"""Test listing users with pagination returns correct results."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
users, count = await user_service.list_users(session, skip=0, limit=10)
|
||||
assert isinstance(users, list)
|
||||
assert isinstance(count, int)
|
||||
assert count >= 1
|
||||
assert len(users) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_with_search(self, async_test_db, async_test_user):
|
||||
"""Test listing users with email fragment search returns matching users."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
# Search by partial email fragment of the test user
|
||||
email_fragment = async_test_user.email.split("@")[0]
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
users, count = await user_service.list_users(
|
||||
session, skip=0, limit=10, search=email_fragment
|
||||
)
|
||||
assert isinstance(users, list)
|
||||
assert count >= 1
|
||||
emails = [u.email for u in users]
|
||||
assert async_test_user.email in emails
|
||||
|
||||
|
||||
class TestBulkUpdateStatus:
|
||||
"""Tests for UserService.bulk_update_status method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_status(self, async_test_db, async_test_user):
|
||||
"""Test bulk activating users returns correct count."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await user_service.bulk_update_status(
|
||||
session,
|
||||
user_ids=[async_test_user.id],
|
||||
is_active=True,
|
||||
)
|
||||
assert count >= 1
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.id == async_test_user.id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
assert user is not None
|
||||
assert user.is_active is True
|
||||
|
||||
|
||||
class TestBulkSoftDelete:
|
||||
"""Tests for UserService.bulk_soft_delete method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_soft_delete(self, async_test_db, async_test_user):
|
||||
"""Test bulk soft-deleting users returns correct count."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await user_service.bulk_soft_delete(
|
||||
session,
|
||||
user_ids=[async_test_user.id],
|
||||
)
|
||||
assert count >= 1
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.id == async_test_user.id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
assert user is not None
|
||||
assert user.deleted_at is not None
|
||||
|
||||
|
||||
class TestGetStats:
|
||||
"""Tests for UserService.get_stats method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stats(self, async_test_db, async_test_user):
|
||||
"""Test get_stats returns dict with expected keys and correct counts."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
stats = await user_service.get_stats(session)
|
||||
assert "total_users" in stats
|
||||
assert "active_count" in stats
|
||||
assert "inactive_count" in stats
|
||||
assert "all_users" in stats
|
||||
assert stats["total_users"] >= 1
|
||||
assert stats["active_count"] >= 1
|
||||
assert isinstance(stats["all_users"], list)
|
||||
assert len(stats["all_users"]) >= 1
|
||||
Reference in New Issue
Block a user