forked from cardosofelipe/fast-next-template
Infrastructure: - Add Redis and Celery workers to all docker-compose files - Fix celery migration race condition in entrypoint.sh - Add healthchecks and resource limits to dev compose - Update .env.template with Redis/Celery variables Backend Models & Schemas: - Rename Sprint.completed_points to velocity (per requirements) - Add AgentInstance.name as required field - Rename Issue external tracker fields for consistency - Add IssueSource and TrackerType enums - Add Project.default_tracker_type field Backend Fixes: - Add Celery retry configuration with exponential backoff - Remove unused sequence counter from EventBus - Add mypy overrides for test dependencies - Fix test file using wrong schema (UserUpdate -> dict) Frontend Fixes: - Fix memory leak in useProjectEvents (proper cleanup) - Fix race condition with stale closure in reconnection - Sync TokenWithUser type with regenerated API client - Fix expires_in null handling in useAuth - Clean up unused imports in prototype pages - Add ESLint relaxed rules for prototype files CI/CD: - Add E2E testing stage with Testcontainers - Add security scanning with Trivy and pip-audit - Add dependency caching for faster builds Tests: - Update all tests to use renamed fields (velocity, name, etc.) - Fix 14 schema test failures - All 1500 tests pass with 91% coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1023 lines
39 KiB
Python
1023 lines
39 KiB
Python
# tests/crud/test_base.py
|
|
"""
|
|
Comprehensive tests for CRUDBase class covering all error paths and edge cases.
|
|
"""
|
|
|
|
from datetime import UTC
|
|
from unittest.mock import patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from sqlalchemy.exc import DataError, IntegrityError, OperationalError
|
|
from sqlalchemy.orm import joinedload
|
|
|
|
from app.crud.user import user as user_crud
|
|
from app.schemas.users import UserCreate, UserUpdate
|
|
|
|
|
|
class TestCRUDBaseGet:
|
|
"""Tests for get method covering UUID validation and options."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_with_invalid_uuid_string(self, async_test_db):
|
|
"""Test get with invalid UUID string returns None."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
result = await user_crud.get(session, id="invalid-uuid")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_with_invalid_uuid_type(self, async_test_db):
|
|
"""Test get with invalid UUID type returns None."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
result = await user_crud.get(session, id=12345) # int instead of UUID
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_with_uuid_object(self, async_test_db, async_test_user):
|
|
"""Test get with UUID object instead of string."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
# Pass UUID object directly
|
|
result = await user_crud.get(session, id=async_test_user.id)
|
|
assert result is not None
|
|
assert result.id == async_test_user.id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_with_options(self, async_test_db, async_test_user):
|
|
"""Test get with eager loading options (tests lines 76-78)."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
# Test that options parameter is accepted and doesn't error
|
|
# We pass an empty list which still tests the code path
|
|
result = await user_crud.get(
|
|
session, id=str(async_test_user.id), options=[]
|
|
)
|
|
assert result is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_database_error(self, async_test_db):
|
|
"""Test get handles database errors properly."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
# Mock execute to raise an exception
|
|
with patch.object(session, "execute", side_effect=Exception("DB error")):
|
|
with pytest.raises(Exception, match="DB error"):
|
|
await user_crud.get(session, id=str(uuid4()))
|
|
|
|
|
|
class TestCRUDBaseGetMulti:
|
|
"""Tests for get_multi method covering pagination validation and options."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_negative_skip(self, async_test_db):
|
|
"""Test get_multi with negative skip raises ValueError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with pytest.raises(ValueError, match="skip must be non-negative"):
|
|
await user_crud.get_multi(session, skip=-1)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_negative_limit(self, async_test_db):
|
|
"""Test get_multi with negative limit raises ValueError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with pytest.raises(ValueError, match="limit must be non-negative"):
|
|
await user_crud.get_multi(session, limit=-1)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_limit_too_large(self, async_test_db):
|
|
"""Test get_multi with limit > 1000 raises ValueError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with pytest.raises(ValueError, match="Maximum limit is 1000"):
|
|
await user_crud.get_multi(session, limit=1001)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_options(self, async_test_db, async_test_user):
|
|
"""Test get_multi with eager loading options (tests lines 118-120)."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
# Test that options parameter is accepted
|
|
results = await user_crud.get_multi(session, skip=0, limit=10, options=[])
|
|
assert isinstance(results, list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_database_error(self, async_test_db):
|
|
"""Test get_multi handles database errors."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with patch.object(session, "execute", side_effect=Exception("DB error")):
|
|
with pytest.raises(Exception, match="DB error"):
|
|
await user_crud.get_multi(session)
|
|
|
|
|
|
class TestCRUDBaseCreate:
|
|
"""Tests for create method covering various error conditions."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_duplicate_unique_field(self, async_test_db, async_test_user):
|
|
"""Test create with duplicate unique field raises ValueError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
# Try to create user with duplicate email
|
|
user_data = UserCreate(
|
|
email=async_test_user.email, # Duplicate!
|
|
password="TestPassword123!",
|
|
first_name="Test",
|
|
last_name="Duplicate",
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="already exists"):
|
|
await user_crud.create(session, obj_in=user_data)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_integrity_error_non_duplicate(self, async_test_db):
|
|
"""Test create with non-duplicate IntegrityError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
# Mock commit to raise IntegrityError without "unique" in message
|
|
|
|
async def mock_commit():
|
|
error = IntegrityError(
|
|
"statement", {}, Exception("foreign key violation")
|
|
)
|
|
raise error
|
|
|
|
with patch.object(session, "commit", side_effect=mock_commit):
|
|
user_data = UserCreate(
|
|
email="test@example.com",
|
|
password="TestPassword123!",
|
|
first_name="Test",
|
|
last_name="User",
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Database integrity error"):
|
|
await user_crud.create(session, obj_in=user_data)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_operational_error(self, async_test_db):
|
|
"""Test create with OperationalError (user CRUD catches as generic Exception)."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with patch.object(
|
|
session,
|
|
"commit",
|
|
side_effect=OperationalError(
|
|
"statement", {}, Exception("connection lost")
|
|
),
|
|
):
|
|
user_data = UserCreate(
|
|
email="test@example.com",
|
|
password="TestPassword123!",
|
|
first_name="Test",
|
|
last_name="User",
|
|
)
|
|
|
|
# User CRUD catches this as generic Exception and re-raises
|
|
with pytest.raises(OperationalError):
|
|
await user_crud.create(session, obj_in=user_data)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_data_error(self, async_test_db):
|
|
"""Test create with DataError (user CRUD catches as generic Exception)."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with patch.object(
|
|
session,
|
|
"commit",
|
|
side_effect=DataError("statement", {}, Exception("invalid data")),
|
|
):
|
|
user_data = UserCreate(
|
|
email="test@example.com",
|
|
password="TestPassword123!",
|
|
first_name="Test",
|
|
last_name="User",
|
|
)
|
|
|
|
# User CRUD catches this as generic Exception and re-raises
|
|
with pytest.raises(DataError):
|
|
await user_crud.create(session, obj_in=user_data)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_unexpected_error(self, async_test_db):
|
|
"""Test create with unexpected exception."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with patch.object(
|
|
session, "commit", side_effect=RuntimeError("Unexpected error")
|
|
):
|
|
user_data = UserCreate(
|
|
email="test@example.com",
|
|
password="TestPassword123!",
|
|
first_name="Test",
|
|
last_name="User",
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="Unexpected error"):
|
|
await user_crud.create(session, obj_in=user_data)
|
|
|
|
|
|
class TestCRUDBaseUpdate:
|
|
"""Tests for update method covering error conditions."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_duplicate_unique_field(self, async_test_db, async_test_user):
|
|
"""Test update with duplicate unique field raises ValueError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create another user
|
|
async with SessionLocal() as session:
|
|
from app.crud.user import user as user_crud
|
|
|
|
user2_data = UserCreate(
|
|
email="user2@example.com",
|
|
password="TestPassword123!",
|
|
first_name="User",
|
|
last_name="Two",
|
|
)
|
|
user2 = await user_crud.create(session, obj_in=user2_data)
|
|
await session.commit()
|
|
|
|
# Try to update user2 with user1's email
|
|
async with SessionLocal() as session:
|
|
user2_obj = await user_crud.get(session, id=str(user2.id))
|
|
|
|
with patch.object(
|
|
session,
|
|
"commit",
|
|
side_effect=IntegrityError(
|
|
"statement", {}, Exception("UNIQUE constraint failed")
|
|
),
|
|
):
|
|
# Use dict since UserUpdate doesn't allow email changes
|
|
update_data = {"email": async_test_user.email}
|
|
|
|
with pytest.raises(ValueError, match="already exists"):
|
|
await user_crud.update(
|
|
session, db_obj=user2_obj, obj_in=update_data
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_with_dict(self, async_test_db, async_test_user):
|
|
"""Test update with dict instead of schema."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
user = await user_crud.get(session, id=str(async_test_user.id))
|
|
|
|
# Update with dict (tests lines 164-165)
|
|
updated = await user_crud.update(
|
|
session, db_obj=user, obj_in={"first_name": "UpdatedName"}
|
|
)
|
|
assert updated.first_name == "UpdatedName"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_integrity_error(self, async_test_db, async_test_user):
|
|
"""Test update with IntegrityError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
user = await user_crud.get(session, id=str(async_test_user.id))
|
|
|
|
with patch.object(
|
|
session,
|
|
"commit",
|
|
side_effect=IntegrityError(
|
|
"statement", {}, Exception("constraint failed")
|
|
),
|
|
):
|
|
with pytest.raises(ValueError, match="Database integrity error"):
|
|
await user_crud.update(
|
|
session, db_obj=user, obj_in={"first_name": "Test"}
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_operational_error(self, async_test_db, async_test_user):
|
|
"""Test update with OperationalError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
user = await user_crud.get(session, id=str(async_test_user.id))
|
|
|
|
with patch.object(
|
|
session,
|
|
"commit",
|
|
side_effect=OperationalError(
|
|
"statement", {}, Exception("connection error")
|
|
),
|
|
):
|
|
with pytest.raises(ValueError, match="Database operation failed"):
|
|
await user_crud.update(
|
|
session, db_obj=user, obj_in={"first_name": "Test"}
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_unexpected_error(self, async_test_db, async_test_user):
|
|
"""Test update with unexpected error."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
user = await user_crud.get(session, id=str(async_test_user.id))
|
|
|
|
with patch.object(
|
|
session, "commit", side_effect=RuntimeError("Unexpected")
|
|
):
|
|
with pytest.raises(RuntimeError):
|
|
await user_crud.update(
|
|
session, db_obj=user, obj_in={"first_name": "Test"}
|
|
)
|
|
|
|
|
|
class TestCRUDBaseRemove:
|
|
"""Tests for remove method covering UUID validation and error conditions."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_invalid_uuid(self, async_test_db):
|
|
"""Test remove with invalid UUID returns None."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
result = await user_crud.remove(session, id="invalid-uuid")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_with_uuid_object(self, async_test_db, async_test_user):
|
|
"""Test remove with UUID object."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create a user to delete
|
|
async with SessionLocal() as session:
|
|
user_data = UserCreate(
|
|
email="todelete@example.com",
|
|
password="TestPassword123!",
|
|
first_name="To",
|
|
last_name="Delete",
|
|
)
|
|
user = await user_crud.create(session, obj_in=user_data)
|
|
user_id = user.id
|
|
await session.commit()
|
|
|
|
# Delete with UUID object
|
|
async with SessionLocal() as session:
|
|
result = await user_crud.remove(session, id=user_id) # UUID object
|
|
assert result is not None
|
|
assert result.id == user_id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_nonexistent(self, async_test_db):
|
|
"""Test remove of nonexistent record returns None."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
result = await user_crud.remove(session, id=str(uuid4()))
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_integrity_error(self, async_test_db, async_test_user):
|
|
"""Test remove with IntegrityError (foreign key constraint)."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
# Mock delete to raise IntegrityError
|
|
with patch.object(
|
|
session,
|
|
"commit",
|
|
side_effect=IntegrityError(
|
|
"statement", {}, Exception("FOREIGN KEY constraint")
|
|
),
|
|
):
|
|
with pytest.raises(
|
|
ValueError, match="Cannot delete.*referenced by other records"
|
|
):
|
|
await user_crud.remove(session, id=str(async_test_user.id))
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_unexpected_error(self, async_test_db, async_test_user):
|
|
"""Test remove with unexpected error."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with patch.object(
|
|
session, "commit", side_effect=RuntimeError("Unexpected")
|
|
):
|
|
with pytest.raises(RuntimeError):
|
|
await user_crud.remove(session, id=str(async_test_user.id))
|
|
|
|
|
|
class TestCRUDBaseGetMultiWithTotal:
|
|
"""Tests for get_multi_with_total method covering pagination, filtering, sorting."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_basic(self, async_test_db, async_test_user):
|
|
"""Test get_multi_with_total basic functionality."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
items, total = await user_crud.get_multi_with_total(
|
|
session, skip=0, limit=10
|
|
)
|
|
assert isinstance(items, list)
|
|
assert isinstance(total, int)
|
|
assert total >= 1 # At least the test user
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_negative_skip(self, async_test_db):
|
|
"""Test get_multi_with_total with negative skip raises ValueError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with pytest.raises(ValueError, match="skip must be non-negative"):
|
|
await user_crud.get_multi_with_total(session, skip=-1)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_negative_limit(self, async_test_db):
|
|
"""Test get_multi_with_total with negative limit raises ValueError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with pytest.raises(ValueError, match="limit must be non-negative"):
|
|
await user_crud.get_multi_with_total(session, limit=-1)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_limit_too_large(self, async_test_db):
|
|
"""Test get_multi_with_total with limit > 1000 raises ValueError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with pytest.raises(ValueError, match="Maximum limit is 1000"):
|
|
await user_crud.get_multi_with_total(session, limit=1001)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_with_filters(
|
|
self, async_test_db, async_test_user
|
|
):
|
|
"""Test get_multi_with_total with filters."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
filters = {"email": async_test_user.email}
|
|
items, total = await user_crud.get_multi_with_total(
|
|
session, filters=filters
|
|
)
|
|
assert total == 1
|
|
assert len(items) == 1
|
|
assert items[0].email == async_test_user.email
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_with_sorting_asc(
|
|
self, async_test_db, async_test_user
|
|
):
|
|
"""Test get_multi_with_total with ascending sort."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create additional users
|
|
async with SessionLocal() as session:
|
|
user_data1 = UserCreate(
|
|
email="aaa@example.com",
|
|
password="TestPassword123!",
|
|
first_name="AAA",
|
|
last_name="User",
|
|
)
|
|
user_data2 = UserCreate(
|
|
email="zzz@example.com",
|
|
password="TestPassword123!",
|
|
first_name="ZZZ",
|
|
last_name="User",
|
|
)
|
|
await user_crud.create(session, obj_in=user_data1)
|
|
await user_crud.create(session, obj_in=user_data2)
|
|
await session.commit()
|
|
|
|
async with SessionLocal() as session:
|
|
items, total = await user_crud.get_multi_with_total(
|
|
session, sort_by="email", sort_order="asc"
|
|
)
|
|
assert total >= 3
|
|
# Check first email is alphabetically first
|
|
assert items[0].email == "aaa@example.com"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_with_sorting_desc(
|
|
self, async_test_db, async_test_user
|
|
):
|
|
"""Test get_multi_with_total with descending sort."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create additional users
|
|
async with SessionLocal() as session:
|
|
user_data1 = UserCreate(
|
|
email="bbb@example.com",
|
|
password="TestPassword123!",
|
|
first_name="BBB",
|
|
last_name="User",
|
|
)
|
|
user_data2 = UserCreate(
|
|
email="ccc@example.com",
|
|
password="TestPassword123!",
|
|
first_name="CCC",
|
|
last_name="User",
|
|
)
|
|
await user_crud.create(session, obj_in=user_data1)
|
|
await user_crud.create(session, obj_in=user_data2)
|
|
await session.commit()
|
|
|
|
async with SessionLocal() as session:
|
|
items, _total = await user_crud.get_multi_with_total(
|
|
session, sort_by="email", sort_order="desc", limit=1
|
|
)
|
|
assert len(items) == 1
|
|
# First item should have higher email alphabetically
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_with_pagination(self, async_test_db):
|
|
"""Test get_multi_with_total pagination works correctly."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create minimal users for pagination test (3 instead of 5)
|
|
async with SessionLocal() as session:
|
|
for i in range(3):
|
|
user_data = UserCreate(
|
|
email=f"user{i}@example.com",
|
|
password="TestPassword123!",
|
|
first_name=f"User{i}",
|
|
last_name="Test",
|
|
)
|
|
await user_crud.create(session, obj_in=user_data)
|
|
await session.commit()
|
|
|
|
async with SessionLocal() as session:
|
|
# Get first page
|
|
items1, total = await user_crud.get_multi_with_total(
|
|
session, skip=0, limit=2
|
|
)
|
|
assert len(items1) == 2
|
|
assert total >= 3
|
|
|
|
# Get second page
|
|
items2, total2 = await user_crud.get_multi_with_total(
|
|
session, skip=2, limit=2
|
|
)
|
|
assert len(items2) >= 1
|
|
assert total2 == total
|
|
|
|
# Ensure no overlap
|
|
ids1 = {item.id for item in items1}
|
|
ids2 = {item.id for item in items2}
|
|
assert ids1.isdisjoint(ids2)
|
|
|
|
|
|
class TestCRUDBaseCount:
|
|
"""Tests for count method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_count_basic(self, async_test_db, async_test_user):
|
|
"""Test count returns correct number."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
count = await user_crud.count(session)
|
|
assert isinstance(count, int)
|
|
assert count >= 1 # At least the test user
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_count_multiple_users(self, async_test_db, async_test_user):
|
|
"""Test count with multiple users."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create additional users
|
|
async with SessionLocal() as session:
|
|
initial_count = await user_crud.count(session)
|
|
|
|
user_data1 = UserCreate(
|
|
email="count1@example.com",
|
|
password="TestPassword123!",
|
|
first_name="Count",
|
|
last_name="One",
|
|
)
|
|
user_data2 = UserCreate(
|
|
email="count2@example.com",
|
|
password="TestPassword123!",
|
|
first_name="Count",
|
|
last_name="Two",
|
|
)
|
|
await user_crud.create(session, obj_in=user_data1)
|
|
await user_crud.create(session, obj_in=user_data2)
|
|
await session.commit()
|
|
|
|
async with SessionLocal() as session:
|
|
new_count = await user_crud.count(session)
|
|
assert new_count == initial_count + 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_count_database_error(self, async_test_db):
|
|
"""Test count handles database errors."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with patch.object(session, "execute", side_effect=Exception("DB error")):
|
|
with pytest.raises(Exception, match="DB error"):
|
|
await user_crud.count(session)
|
|
|
|
|
|
class TestCRUDBaseExists:
|
|
"""Tests for exists method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exists_true(self, async_test_db, async_test_user):
|
|
"""Test exists returns True for existing record."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
result = await user_crud.exists(session, id=str(async_test_user.id))
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exists_false(self, async_test_db):
|
|
"""Test exists returns False for non-existent record."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
result = await user_crud.exists(session, id=str(uuid4()))
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exists_invalid_uuid(self, async_test_db):
|
|
"""Test exists returns False for invalid UUID."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
result = await user_crud.exists(session, id="invalid-uuid")
|
|
assert result is False
|
|
|
|
|
|
class TestCRUDBaseSoftDelete:
|
|
"""Tests for soft_delete method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_soft_delete_success(self, async_test_db):
|
|
"""Test soft delete sets deleted_at timestamp."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create a user to soft delete
|
|
async with SessionLocal() as session:
|
|
user_data = UserCreate(
|
|
email="softdelete@example.com",
|
|
password="TestPassword123!",
|
|
first_name="Soft",
|
|
last_name="Delete",
|
|
)
|
|
user = await user_crud.create(session, obj_in=user_data)
|
|
user_id = user.id
|
|
await session.commit()
|
|
|
|
# Soft delete the user
|
|
async with SessionLocal() as session:
|
|
deleted = await user_crud.soft_delete(session, id=str(user_id))
|
|
assert deleted is not None
|
|
assert deleted.deleted_at is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_soft_delete_invalid_uuid(self, async_test_db):
|
|
"""Test soft delete with invalid UUID returns None."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
result = await user_crud.soft_delete(session, id="invalid-uuid")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_soft_delete_nonexistent(self, async_test_db):
|
|
"""Test soft delete of nonexistent record returns None."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
result = await user_crud.soft_delete(session, id=str(uuid4()))
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_soft_delete_with_uuid_object(self, async_test_db):
|
|
"""Test soft delete with UUID object."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create a user to soft delete
|
|
async with SessionLocal() as session:
|
|
user_data = UserCreate(
|
|
email="softdelete2@example.com",
|
|
password="TestPassword123!",
|
|
first_name="Soft",
|
|
last_name="Delete2",
|
|
)
|
|
user = await user_crud.create(session, obj_in=user_data)
|
|
user_id = user.id
|
|
await session.commit()
|
|
|
|
# Soft delete with UUID object
|
|
async with SessionLocal() as session:
|
|
deleted = await user_crud.soft_delete(session, id=user_id) # UUID object
|
|
assert deleted is not None
|
|
assert deleted.deleted_at is not None
|
|
|
|
|
|
class TestCRUDBaseRestore:
|
|
"""Tests for restore method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_success(self, async_test_db):
|
|
"""Test restore clears deleted_at timestamp."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create and soft delete a user
|
|
async with SessionLocal() as session:
|
|
user_data = UserCreate(
|
|
email="restore@example.com",
|
|
password="TestPassword123!",
|
|
first_name="Restore",
|
|
last_name="Test",
|
|
)
|
|
user = await user_crud.create(session, obj_in=user_data)
|
|
user_id = user.id
|
|
await session.commit()
|
|
|
|
async with SessionLocal() as session:
|
|
await user_crud.soft_delete(session, id=str(user_id))
|
|
|
|
# Restore the user
|
|
async with SessionLocal() as session:
|
|
restored = await user_crud.restore(session, id=str(user_id))
|
|
assert restored is not None
|
|
assert restored.deleted_at is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_invalid_uuid(self, async_test_db):
|
|
"""Test restore with invalid UUID returns None."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
result = await user_crud.restore(session, id="invalid-uuid")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_nonexistent(self, async_test_db):
|
|
"""Test restore of nonexistent record returns None."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
result = await user_crud.restore(session, id=str(uuid4()))
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_not_deleted(self, async_test_db, async_test_user):
|
|
"""Test restore of non-deleted record returns None."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
# Try to restore a user that's not deleted
|
|
result = await user_crud.restore(session, id=str(async_test_user.id))
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_with_uuid_object(self, async_test_db):
|
|
"""Test restore with UUID object."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create and soft delete a user
|
|
async with SessionLocal() as session:
|
|
user_data = UserCreate(
|
|
email="restore2@example.com",
|
|
password="TestPassword123!",
|
|
first_name="Restore",
|
|
last_name="Test2",
|
|
)
|
|
user = await user_crud.create(session, obj_in=user_data)
|
|
user_id = user.id
|
|
await session.commit()
|
|
|
|
async with SessionLocal() as session:
|
|
await user_crud.soft_delete(session, id=str(user_id))
|
|
|
|
# Restore with UUID object
|
|
async with SessionLocal() as session:
|
|
restored = await user_crud.restore(session, id=user_id) # UUID object
|
|
assert restored is not None
|
|
assert restored.deleted_at is None
|
|
|
|
|
|
class TestCRUDBasePaginationValidation:
|
|
"""Tests for pagination parameter validation (covers lines 254-260)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_negative_skip(self, async_test_db):
|
|
"""Test that negative skip raises ValueError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with pytest.raises(ValueError, match="skip must be non-negative"):
|
|
await user_crud.get_multi_with_total(session, skip=-1, limit=10)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_negative_limit(self, async_test_db):
|
|
"""Test that negative limit raises ValueError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with pytest.raises(ValueError, match="limit must be non-negative"):
|
|
await user_crud.get_multi_with_total(session, skip=0, limit=-1)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_limit_too_large(self, async_test_db):
|
|
"""Test that limit > 1000 raises ValueError."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
with pytest.raises(ValueError, match="Maximum limit is 1000"):
|
|
await user_crud.get_multi_with_total(session, skip=0, limit=1001)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_with_filters(
|
|
self, async_test_db, async_test_user
|
|
):
|
|
"""Test pagination with filters (covers lines 270-273)."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
users, total = await user_crud.get_multi_with_total(
|
|
session, skip=0, limit=10, filters={"is_active": True}
|
|
)
|
|
assert isinstance(users, list)
|
|
assert total >= 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_with_sorting_desc(self, async_test_db):
|
|
"""Test pagination with descending sort (covers lines 283-284)."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
users, _total = await user_crud.get_multi_with_total(
|
|
session, skip=0, limit=10, sort_by="created_at", sort_order="desc"
|
|
)
|
|
assert isinstance(users, list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_total_with_sorting_asc(self, async_test_db):
|
|
"""Test pagination with ascending sort (covers lines 285-286)."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
async with SessionLocal() as session:
|
|
users, _total = await user_crud.get_multi_with_total(
|
|
session, skip=0, limit=10, sort_by="created_at", sort_order="asc"
|
|
)
|
|
assert isinstance(users, list)
|
|
|
|
|
|
class TestCRUDBaseModelsWithoutSoftDelete:
|
|
"""
|
|
Test soft_delete and restore on models without deleted_at column.
|
|
Covers lines 342-343, 383-384 - error handling for unsupported models.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_soft_delete_model_without_deleted_at(
|
|
self, async_test_db, async_test_user
|
|
):
|
|
"""Test soft_delete on Organization model (no deleted_at) raises ValueError (covers lines 342-343)."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create an organization (which doesn't have deleted_at)
|
|
from app.crud.organization import organization as org_crud
|
|
from app.models.organization import Organization
|
|
|
|
async with SessionLocal() as session:
|
|
org = Organization(name="Test Org", slug="test-org")
|
|
session.add(org)
|
|
await session.commit()
|
|
org_id = org.id
|
|
|
|
# Try to soft delete organization (should fail)
|
|
async with SessionLocal() as session:
|
|
with pytest.raises(ValueError, match="does not have a deleted_at column"):
|
|
await org_crud.soft_delete(session, id=str(org_id))
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_model_without_deleted_at(self, async_test_db):
|
|
"""Test restore on Organization model (no deleted_at) raises ValueError (covers lines 383-384)."""
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create an organization (which doesn't have deleted_at)
|
|
from app.crud.organization import organization as org_crud
|
|
from app.models.organization import Organization
|
|
|
|
async with SessionLocal() as session:
|
|
org = Organization(name="Restore Test", slug="restore-test")
|
|
session.add(org)
|
|
await session.commit()
|
|
org_id = org.id
|
|
|
|
# Try to restore organization (should fail)
|
|
async with SessionLocal() as session:
|
|
with pytest.raises(ValueError, match="does not have a deleted_at column"):
|
|
await org_crud.restore(session, id=str(org_id))
|
|
|
|
|
|
class TestCRUDBaseEagerLoadingWithRealOptions:
|
|
"""
|
|
Test eager loading with actual SQLAlchemy load options.
|
|
Covers lines 77-78, 119-120 - options loop execution.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_with_real_eager_loading_options(
|
|
self, async_test_db, async_test_user
|
|
):
|
|
"""Test get() with actual eager loading options (covers lines 77-78)."""
|
|
from datetime import datetime, timedelta
|
|
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create a session for the user
|
|
from app.crud.session import session as session_crud
|
|
from app.models.user_session import UserSession
|
|
|
|
async with SessionLocal() as session:
|
|
user_session = UserSession(
|
|
user_id=async_test_user.id,
|
|
refresh_token_jti="test_jti_eager",
|
|
device_id="test-device",
|
|
ip_address="192.168.1.1",
|
|
user_agent="Test Agent",
|
|
last_used_at=datetime.now(UTC),
|
|
expires_at=datetime.now(UTC) + timedelta(days=60),
|
|
)
|
|
session.add(user_session)
|
|
await session.commit()
|
|
session_id = user_session.id
|
|
|
|
# Get session with eager loading of user relationship
|
|
async with SessionLocal() as session:
|
|
result = await session_crud.get(
|
|
session,
|
|
id=str(session_id),
|
|
options=[joinedload(UserSession.user)], # Real option, not empty list
|
|
)
|
|
assert result is not None
|
|
assert result.id == session_id
|
|
# User should be loaded (accessing it won't cause additional query)
|
|
assert result.user.email == async_test_user.email
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_real_eager_loading_options(
|
|
self, async_test_db, async_test_user
|
|
):
|
|
"""Test get_multi() with actual eager loading options (covers lines 119-120)."""
|
|
from datetime import datetime, timedelta
|
|
|
|
_test_engine, SessionLocal = async_test_db
|
|
|
|
# Create multiple sessions for the user
|
|
from app.crud.session import session as session_crud
|
|
from app.models.user_session import UserSession
|
|
|
|
async with SessionLocal() as session:
|
|
for i in range(3):
|
|
user_session = UserSession(
|
|
user_id=async_test_user.id,
|
|
refresh_token_jti=f"jti_eager_{i}",
|
|
device_id=f"device-{i}",
|
|
ip_address=f"192.168.1.{i}",
|
|
user_agent=f"Agent {i}",
|
|
last_used_at=datetime.now(UTC),
|
|
expires_at=datetime.now(UTC) + timedelta(days=60),
|
|
)
|
|
session.add(user_session)
|
|
await session.commit()
|
|
|
|
# Get sessions with eager loading
|
|
async with SessionLocal() as session:
|
|
results = await session_crud.get_multi(
|
|
session,
|
|
skip=0,
|
|
limit=10,
|
|
options=[joinedload(UserSession.user)], # Real option, not empty list
|
|
)
|
|
assert len(results) >= 3
|
|
# Verify we can access user without additional queries
|
|
for result in results:
|
|
if result.user_id == async_test_user.id:
|
|
assert result.user.email == async_test_user.email
|