Files
fast-next-template/backend/tests/services/test_session_cleanup.py
Felipe Cardoso c589b565f0 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.
2025-11-10 11:55:15 +01:00

387 lines
14 KiB
Python

# tests/services/test_session_cleanup.py
"""
Comprehensive tests for session cleanup service.
"""
import asyncio
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy import select
from app.models.user_session import UserSession
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(UTC) + timedelta(days=7),
created_at=datetime.now(UTC) - timedelta(days=1),
last_used_at=datetime.now(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(UTC) - timedelta(days=10),
created_at=datetime.now(UTC) - timedelta(days=40),
last_used_at=datetime.now(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(UTC) - timedelta(days=1),
created_at=datetime.now(UTC) - timedelta(days=5),
last_used_at=datetime.now(UTC),
)
session.add_all(
[active_session, old_expired_session, recent_expired_session]
)
await session.commit()
# Mock SessionLocal to return our test session
with patch(
"app.services.session_cleanup.SessionLocal",
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(UTC) + timedelta(days=7),
created_at=datetime.now(UTC),
last_used_at=datetime.now(UTC),
)
session.add(active)
await session.commit()
with patch(
"app.services.session_cleanup.SessionLocal",
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.SessionLocal",
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(UTC) - timedelta(hours=1),
created_at=datetime.now(UTC) - timedelta(hours=2),
last_used_at=datetime.now(UTC),
)
session.add(today_expired)
await session.commit()
with patch(
"app.services.session_cleanup.SessionLocal",
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(UTC) - timedelta(days=10),
created_at=datetime.now(UTC) - timedelta(days=40),
last_used_at=datetime.now(UTC),
)
sessions_to_add.append(expired)
session.add_all(sessions_to_add)
await session.commit()
with patch(
"app.services.session_cleanup.SessionLocal",
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.SessionLocal",
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(UTC) + timedelta(days=7),
created_at=datetime.now(UTC),
last_used_at=datetime.now(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(UTC) - timedelta(days=1),
created_at=datetime.now(UTC) - timedelta(days=2),
last_used_at=datetime.now(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(UTC) - timedelta(hours=1),
created_at=datetime.now(UTC) - timedelta(days=1),
last_used_at=datetime.now(UTC),
)
session.add(expired_active)
await session.commit()
with patch(
"app.services.session_cleanup.SessionLocal",
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.SessionLocal",
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.SessionLocal",
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(UTC) - timedelta(days=10),
created_at=datetime.now(UTC) - timedelta(days=40),
last_used_at=datetime.now(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.SessionLocal",
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