mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
163 lines
5.4 KiB
Python
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)
|