Files
MoviePilot/tests/test_data_cleanup_service.py
T

163 lines
5.4 KiB
Python

import ast
from contextlib import nullcontext
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from app.application.maintenance import CleanupPolicy, DataCleanupService
from app.scheduler import SchedulerChain
class FakeCleanupRepository:
"""记录维护用例调用,并可模拟单表失败。"""
def __init__(self, *, failing_table: str | None = None) -> None:
"""保存故障表名并初始化调用记录。"""
self.failing_table = failing_table
self.calls: list[str] = []
self._message_results = iter((2, 1, 0))
self.commits = 0
self.rollbacks = 0
def session(self):
"""返回无需真实数据库的上下文。"""
return nullcontext(object())
def unit_of_work(self, db):
"""返回记录提交和回滚次数的测试事务边界。"""
return self
def commit(self) -> None:
"""记录一个成功清理批次。"""
self.commits += 1
def rollback(self) -> None:
"""记录一个失败清理批次。"""
self.rollbacks += 1
def _delete(self, name: str) -> int:
"""记录删除调用并按配置模拟结果或异常。"""
self.calls.append(name)
if name == self.failing_table:
raise RuntimeError("boom")
if name == "message":
return next(self._message_results)
return 0
def delete_messages(self, db, cutoff: str, limit: int) -> int:
"""模拟消息删除。"""
return self._delete("message")
def delete_download_history(self, db, cutoff: str, limit: int) -> int:
"""模拟下载历史删除。"""
return self._delete("downloadhistory")
def delete_download_orphans(self, db, limit: int) -> int:
"""模拟孤儿文件删除。"""
return self._delete("downloadfiles")
def delete_site_userdata(self, db, cutoff: str, limit: int) -> int:
"""模拟站点用户数据删除。"""
return self._delete("siteuserdata")
def delete_transfer_history(self, db, cutoff: str, limit: int) -> int:
"""模拟整理历史删除。"""
return self._delete("transferhistory")
def delete_download_failures(self, db, cutoff: str, limit: int) -> int:
"""模拟下载失败记录删除。"""
return self._delete("downloadfailure")
def _policy(**overrides) -> CleanupPolicy:
"""构造所有表默认启用的测试策略。"""
values = {
"enabled": True,
"message_days": 1,
"download_history_days": 1,
"site_userdata_days": 1,
"transfer_history_days": 1,
"download_failure_days": 1,
}
values.update(overrides)
return CleanupPolicy(**values)
def test_cleanup_service_owns_batching_report_and_progress() -> None:
"""应用服务应独立完成分批循环、报告和进度语义。"""
repository = FakeCleanupRepository()
progress = MagicMock()
service = DataCleanupService(
repository=repository,
policy_reader=_policy,
clock=lambda: datetime(2026, 8, 17, 12, 0, 0),
)
report = service.execute(batch_size=2, progress_callback=progress)
assert report["started_at"] == "2026-08-17 12:00:00"
assert report["tables"]["message"]["deleted"] == 3
assert report["tables"]["message"]["batches"] == 2
assert report["total_deleted"] == 3
assert repository.commits == 2
assert repository.rollbacks == 0
assert repository.calls == [
"message",
"message",
"message",
"downloadhistory",
"downloadfiles",
"siteuserdata",
"transferhistory",
"downloadfailure",
]
assert progress.call_args.kwargs["value"] == 100
def test_cleanup_service_finishes_other_tables_before_raising_partial_failure() -> None:
"""单表失败应被汇总,后续表仍继续处理,再按旧契约抛出异常。"""
repository = FakeCleanupRepository(failing_table="downloadhistory")
service = DataCleanupService(
repository=repository,
policy_reader=_policy,
clock=lambda: datetime(2026, 8, 17, 12, 0, 0),
)
with pytest.raises(RuntimeError, match="downloadhistory: boom"):
service.execute(batch_size=2)
assert repository.calls[-1] == "downloadfailure"
assert repository.rollbacks == 1
def test_scheduler_cleanup_is_a_compatibility_delegate() -> None:
"""旧 SchedulerChain 入口应原样转发参数和返回值。"""
governance = MagicMock()
governance.cleanup.return_value = {"enabled": True}
progress = MagicMock()
with patch("app.scheduler.get_database_governance", return_value=governance):
result = SchedulerChain().cleanup(batch_size=7, progress_callback=progress)
assert result == {"enabled": True}
governance.cleanup.assert_called_once_with(
batch_size=7,
progress_callback=progress,
)
def test_scheduler_does_not_reclaim_database_cleanup_ownership() -> None:
"""调度模块不得重新导入清理模型或数据库会话。"""
scheduler_path = Path(__file__).parents[1] / "app" / "scheduler.py"
tree = ast.parse(scheduler_path.read_text(encoding="utf-8"))
imports = {
node.module
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom) and node.module
}
assert "app.db.session" not in imports
assert not any(module.startswith("app.db.models") for module in imports)