forked from cardosofelipe/fast-next-template
Add comprehensive tests for session cleanup and async CRUD operations; improve error handling and validation across schemas and API routes
- Introduced extensive tests for session cleanup, async session CRUD methods, and concurrent cleanup to ensure reliability and efficiency. - Enhanced `schemas/users.py` with reusable password strength validation logic. - Improved error handling in `admin.py` routes by replacing `detail` with `message` for consistency and readability.
This commit is contained in:
334
backend/tests/services/test_session_cleanup.py
Normal file
334
backend/tests/services/test_session_cleanup.py
Normal file
@@ -0,0 +1,334 @@
|
||||
# tests/services/test_session_cleanup.py
|
||||
"""
|
||||
Comprehensive tests for session cleanup service.
|
||||
"""
|
||||
import pytest
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.models.user_session import UserSession
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
class TestCleanupExpiredSessions:
|
||||
"""Tests for cleanup_expired_sessions function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_expired_sessions_success(self, async_test_db, async_test_user):
|
||||
"""Test successful cleanup of expired sessions."""
|
||||
test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
# Create mix of sessions
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
# 1. Active, not expired (should NOT be deleted)
|
||||
active_session = UserSession(
|
||||
user_id=async_test_user.id,
|
||||
refresh_token_jti="active_jti_123",
|
||||
device_name="Active Device",
|
||||
ip_address="192.168.1.1",
|
||||
user_agent="Mozilla/5.0",
|
||||
is_active=True,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
created_at=datetime.now(timezone.utc) - timedelta(days=1),
|
||||
last_used_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
# 2. Inactive, expired, old (SHOULD be deleted)
|
||||
old_expired_session = UserSession(
|
||||
user_id=async_test_user.id,
|
||||
refresh_token_jti="old_expired_jti",
|
||||
device_name="Old Device",
|
||||
ip_address="192.168.1.2",
|
||||
user_agent="Mozilla/5.0",
|
||||
is_active=False,
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(days=10),
|
||||
created_at=datetime.now(timezone.utc) - timedelta(days=40),
|
||||
last_used_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
# 3. Inactive, expired, recent (should NOT be deleted - within keep_days)
|
||||
recent_expired_session = UserSession(
|
||||
user_id=async_test_user.id,
|
||||
refresh_token_jti="recent_expired_jti",
|
||||
device_name="Recent Device",
|
||||
ip_address="192.168.1.3",
|
||||
user_agent="Mozilla/5.0",
|
||||
is_active=False,
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(days=1),
|
||||
created_at=datetime.now(timezone.utc) - timedelta(days=5),
|
||||
last_used_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
session.add_all([active_session, old_expired_session, recent_expired_session])
|
||||
await session.commit()
|
||||
|
||||
# Mock AsyncSessionLocal to return our test session
|
||||
with patch('app.services.session_cleanup.AsyncSessionLocal', return_value=AsyncTestingSessionLocal()):
|
||||
from app.services.session_cleanup import cleanup_expired_sessions
|
||||
deleted_count = await cleanup_expired_sessions(keep_days=30)
|
||||
|
||||
# Should only delete old_expired_session
|
||||
assert deleted_count == 1
|
||||
|
||||
# Verify remaining sessions
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await session.execute(select(UserSession))
|
||||
remaining = result.scalars().all()
|
||||
assert len(remaining) == 2
|
||||
jtis = [s.refresh_token_jti for s in remaining]
|
||||
assert "active_jti_123" in jtis
|
||||
assert "recent_expired_jti" in jtis
|
||||
assert "old_expired_jti" not in jtis
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_no_sessions_to_delete(self, async_test_db, async_test_user):
|
||||
"""Test cleanup when no sessions meet deletion criteria."""
|
||||
test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
active = UserSession(
|
||||
user_id=async_test_user.id,
|
||||
refresh_token_jti="active_only_jti",
|
||||
device_name="Active Device",
|
||||
ip_address="192.168.1.1",
|
||||
user_agent="Mozilla/5.0",
|
||||
is_active=True,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_used_at=datetime.now(timezone.utc)
|
||||
)
|
||||
session.add(active)
|
||||
await session.commit()
|
||||
|
||||
with patch('app.services.session_cleanup.AsyncSessionLocal', return_value=AsyncTestingSessionLocal()):
|
||||
from app.services.session_cleanup import cleanup_expired_sessions
|
||||
deleted_count = await cleanup_expired_sessions(keep_days=30)
|
||||
|
||||
assert deleted_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_empty_database(self, async_test_db):
|
||||
"""Test cleanup with no sessions in database."""
|
||||
test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
with patch('app.services.session_cleanup.AsyncSessionLocal', return_value=AsyncTestingSessionLocal()):
|
||||
from app.services.session_cleanup import cleanup_expired_sessions
|
||||
deleted_count = await cleanup_expired_sessions(keep_days=30)
|
||||
|
||||
assert deleted_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_with_keep_days_0(self, async_test_db, async_test_user):
|
||||
"""Test cleanup with keep_days=0 deletes all inactive expired sessions."""
|
||||
test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
today_expired = UserSession(
|
||||
user_id=async_test_user.id,
|
||||
refresh_token_jti="today_expired_jti",
|
||||
device_name="Today Expired",
|
||||
ip_address="192.168.1.1",
|
||||
user_agent="Mozilla/5.0",
|
||||
is_active=False,
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
created_at=datetime.now(timezone.utc) - timedelta(hours=2),
|
||||
last_used_at=datetime.now(timezone.utc)
|
||||
)
|
||||
session.add(today_expired)
|
||||
await session.commit()
|
||||
|
||||
with patch('app.services.session_cleanup.AsyncSessionLocal', return_value=AsyncTestingSessionLocal()):
|
||||
from app.services.session_cleanup import cleanup_expired_sessions
|
||||
deleted_count = await cleanup_expired_sessions(keep_days=0)
|
||||
|
||||
assert deleted_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_bulk_delete_efficiency(self, async_test_db, async_test_user):
|
||||
"""Test that cleanup uses bulk DELETE for many sessions."""
|
||||
test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
# Create 50 expired sessions
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
sessions_to_add = []
|
||||
for i in range(50):
|
||||
expired = UserSession(
|
||||
user_id=async_test_user.id,
|
||||
refresh_token_jti=f"bulk_jti_{i}",
|
||||
device_name=f"Device {i}",
|
||||
ip_address="192.168.1.1",
|
||||
user_agent="Mozilla/5.0",
|
||||
is_active=False,
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(days=10),
|
||||
created_at=datetime.now(timezone.utc) - timedelta(days=40),
|
||||
last_used_at=datetime.now(timezone.utc)
|
||||
)
|
||||
sessions_to_add.append(expired)
|
||||
session.add_all(sessions_to_add)
|
||||
await session.commit()
|
||||
|
||||
with patch('app.services.session_cleanup.AsyncSessionLocal', return_value=AsyncTestingSessionLocal()):
|
||||
from app.services.session_cleanup import cleanup_expired_sessions
|
||||
deleted_count = await cleanup_expired_sessions(keep_days=30)
|
||||
|
||||
assert deleted_count == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_database_error_returns_zero(self, async_test_db):
|
||||
"""Test cleanup returns 0 on database errors (doesn't crash)."""
|
||||
test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
# Mock session_crud.cleanup_expired to raise error
|
||||
with patch('app.services.session_cleanup.AsyncSessionLocal', return_value=AsyncTestingSessionLocal()):
|
||||
with patch('app.services.session_cleanup.session_crud.cleanup_expired') as mock_cleanup:
|
||||
mock_cleanup.side_effect = Exception("Database connection lost")
|
||||
|
||||
from app.services.session_cleanup import cleanup_expired_sessions
|
||||
# Should not crash, should return 0
|
||||
deleted_count = await cleanup_expired_sessions(keep_days=30)
|
||||
|
||||
assert deleted_count == 0
|
||||
|
||||
|
||||
class TestGetSessionStatistics:
|
||||
"""Tests for get_session_statistics function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_statistics_with_sessions(self, async_test_db, async_test_user):
|
||||
"""Test getting session statistics with various session types."""
|
||||
test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
# 2 active, not expired
|
||||
for i in range(2):
|
||||
active = UserSession(
|
||||
user_id=async_test_user.id,
|
||||
refresh_token_jti=f"active_stat_{i}",
|
||||
device_name=f"Active {i}",
|
||||
ip_address="192.168.1.1",
|
||||
user_agent="Mozilla/5.0",
|
||||
is_active=True,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_used_at=datetime.now(timezone.utc)
|
||||
)
|
||||
session.add(active)
|
||||
|
||||
# 3 inactive, expired
|
||||
for i in range(3):
|
||||
inactive = UserSession(
|
||||
user_id=async_test_user.id,
|
||||
refresh_token_jti=f"inactive_stat_{i}",
|
||||
device_name=f"Inactive {i}",
|
||||
ip_address="192.168.1.2",
|
||||
user_agent="Mozilla/5.0",
|
||||
is_active=False,
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(days=1),
|
||||
created_at=datetime.now(timezone.utc) - timedelta(days=2),
|
||||
last_used_at=datetime.now(timezone.utc)
|
||||
)
|
||||
session.add(inactive)
|
||||
|
||||
# 1 active but expired
|
||||
expired_active = UserSession(
|
||||
user_id=async_test_user.id,
|
||||
refresh_token_jti="expired_active_stat",
|
||||
device_name="Expired Active",
|
||||
ip_address="192.168.1.3",
|
||||
user_agent="Mozilla/5.0",
|
||||
is_active=True,
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
created_at=datetime.now(timezone.utc) - timedelta(days=1),
|
||||
last_used_at=datetime.now(timezone.utc)
|
||||
)
|
||||
session.add(expired_active)
|
||||
|
||||
await session.commit()
|
||||
|
||||
with patch('app.services.session_cleanup.AsyncSessionLocal', return_value=AsyncTestingSessionLocal()):
|
||||
from app.services.session_cleanup import get_session_statistics
|
||||
stats = await get_session_statistics()
|
||||
|
||||
assert stats["total"] == 6
|
||||
assert stats["active"] == 3 # 2 active + 1 expired but active
|
||||
assert stats["inactive"] == 3
|
||||
assert stats["expired"] == 4 # 3 inactive expired + 1 active expired
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_statistics_empty_database(self, async_test_db):
|
||||
"""Test getting statistics with no sessions."""
|
||||
test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
with patch('app.services.session_cleanup.AsyncSessionLocal', return_value=AsyncTestingSessionLocal()):
|
||||
from app.services.session_cleanup import get_session_statistics
|
||||
stats = await get_session_statistics()
|
||||
|
||||
assert stats["total"] == 0
|
||||
assert stats["active"] == 0
|
||||
assert stats["inactive"] == 0
|
||||
assert stats["expired"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_statistics_database_error_returns_empty_dict(self, async_test_db):
|
||||
"""Test statistics returns empty dict on database errors."""
|
||||
test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
# Create a mock that raises on execute
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute.side_effect = Exception("Database error")
|
||||
|
||||
@asynccontextmanager
|
||||
async def mock_session_local():
|
||||
yield mock_session
|
||||
|
||||
with patch('app.services.session_cleanup.AsyncSessionLocal', return_value=mock_session_local()):
|
||||
from app.services.session_cleanup import get_session_statistics
|
||||
stats = await get_session_statistics()
|
||||
|
||||
assert stats == {}
|
||||
|
||||
|
||||
class TestConcurrentCleanup:
|
||||
"""Tests for concurrent cleanup scenarios."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_cleanup_no_duplicate_deletes(self, async_test_db, async_test_user):
|
||||
"""Test concurrent cleanups don't cause race conditions."""
|
||||
test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
# Create 10 expired sessions
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
for i in range(10):
|
||||
expired = UserSession(
|
||||
user_id=async_test_user.id,
|
||||
refresh_token_jti=f"concurrent_jti_{i}",
|
||||
device_name=f"Device {i}",
|
||||
ip_address="192.168.1.1",
|
||||
user_agent="Mozilla/5.0",
|
||||
is_active=False,
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(days=10),
|
||||
created_at=datetime.now(timezone.utc) - timedelta(days=40),
|
||||
last_used_at=datetime.now(timezone.utc)
|
||||
)
|
||||
session.add(expired)
|
||||
await session.commit()
|
||||
|
||||
# Run two cleanups concurrently
|
||||
# Use side_effect to return fresh session instances for each call
|
||||
with patch('app.services.session_cleanup.AsyncSessionLocal', side_effect=lambda: AsyncTestingSessionLocal()):
|
||||
from app.services.session_cleanup import cleanup_expired_sessions
|
||||
results = await asyncio.gather(
|
||||
cleanup_expired_sessions(keep_days=30),
|
||||
cleanup_expired_sessions(keep_days=30)
|
||||
)
|
||||
|
||||
# Both should report deleting sessions (may overlap due to transaction timing)
|
||||
assert sum(results) >= 10
|
||||
|
||||
# Verify all are deleted
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await session.execute(select(UserSession))
|
||||
remaining = result.scalars().all()
|
||||
assert len(remaining) == 0
|
||||
Reference in New Issue
Block a user