- Fix UV index strategy: mark PyTorch CPU index as explicit with name - Add --index-strategy unsafe-best-match to Dockerfile uv pip install - Fix redis version constraint (>=5.0,<6) for ARQ compatibility - Fix Anthropic model name (claude-sonnet-4-5-20250929) - Fix IMAGE_PROVIDER enum value (gemini_flash, not google) - Resolve middlewares.py vs middlewares/ package conflict - Fix worker import paths (models.sql.presentation, models.sql.slide, utils split) - Fix seed script FK resolution by importing all related models - Fix test suite: async fixture scoping, greenlet dep, regex patterns, fixture params - Fix frontend TypeScript error (Boolean cast for layout.react_code) - Regenerate package-lock.json with i18n packages - Add initial Alembic migration (autogenerated from all models) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
127 lines
4.4 KiB
Python
127 lines
4.4 KiB
Python
"""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)
|