"""Tests for RetentionService: cleanup and purge logic. Uses mocked async_session_maker since RetentionService creates its own sessions. """ import uuid from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest from models.sql.client import ClientModel from models.sql.presentation import PresentationModel from services.retention_service import RetentionService @pytest.fixture def svc(): return RetentionService() class TestRunCleanup: async def test_soft_deletes_expired_presentations( self, svc, session, make_client, make_presentation, make_user ): """Presentations older than client.retention_days get soft-deleted.""" client = await make_client(name="RetCorp", slug="retcorp", retention_days=30) user = await make_user(email="ret@test.com") # Old presentation (60 days ago) — should be deleted old = await make_presentation( owner_id=user.id, client_id=client.id, title="Old Deck", created_at=datetime.now(timezone.utc) - timedelta(days=60), ) # Recent presentation — should survive recent = await make_presentation( owner_id=user.id, client_id=client.id, title="New Deck", created_at=datetime.now(timezone.utc) - timedelta(days=5), ) # Mock async_session_maker to use our test session with patch("services.retention_service.async_session_maker") as mock_sm: mock_sm.return_value.__aenter__ = AsyncMock(return_value=session) mock_sm.return_value.__aexit__ = AsyncMock(return_value=False) result = await svc.run_cleanup() assert result["deleted"] == 1 await session.refresh(old) await session.refresh(recent) assert old.deleted_at is not None assert recent.deleted_at is None async def test_no_clients_with_retention_policy(self, session, make_client): """Clients without retention_days are skipped.""" await make_client(name="NoPol", slug="nopol", retention_days=None) with patch("services.retention_service.async_session_maker") as mock_sm: mock_sm.return_value.__aenter__ = AsyncMock(return_value=session) mock_sm.return_value.__aexit__ = AsyncMock(return_value=False) result = await RetentionService().run_cleanup() assert result["deleted"] == 0 class TestPurgeSoftDeleted: async def test_purges_old_soft_deleted( self, session, make_client, make_presentation, make_user ): """Soft-deleted presentations older than threshold get permanently deleted.""" client = await make_client(name="Purge Corp", slug="purge-corp") user = await make_user(email="purge@test.com") # Soft-deleted 45 days ago — should be purged pres = await make_presentation( owner_id=user.id, client_id=client.id, title="Dead Deck", deleted_at=datetime.now(timezone.utc) - timedelta(days=45), ) with patch("services.retention_service.async_session_maker") as mock_sm: mock_sm.return_value.__aenter__ = AsyncMock(return_value=session) mock_sm.return_value.__aexit__ = AsyncMock(return_value=False) result = await RetentionService().purge_soft_deleted(days_after_soft_delete=30) assert result["purged"] == 1 # Should be gone from DB deleted = await session.get(PresentationModel, pres.id) assert deleted is None class TestCleanupFiles: def test_cleanup_files_removes_existing_files(self, svc, tmp_path): """_cleanup_files removes files listed in presentation.file_paths.""" f = tmp_path / "test.pptx" f.write_text("dummy") pres = MagicMock() pres.id = uuid.uuid4() pres.file_paths = [str(f)] svc._cleanup_files(pres) assert not f.exists() def test_cleanup_files_handles_missing_files(self, svc): """Missing files don't raise errors.""" pres = MagicMock() pres.id = uuid.uuid4() pres.file_paths = ["/nonexistent/file.pptx"] # Should not raise svc._cleanup_files(pres) def test_cleanup_files_handles_none(self, svc): pres = MagicMock() pres.id = uuid.uuid4() pres.file_paths = None svc._cleanup_files(pres)