mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor: 推进后端分层架构治理
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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))
|
||||
|
||||
def session(self):
|
||||
"""返回无需真实数据库的上下文。"""
|
||||
return nullcontext(object())
|
||||
|
||||
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.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"
|
||||
|
||||
|
||||
def test_scheduler_cleanup_is_a_compatibility_delegate() -> None:
|
||||
"""旧 SchedulerChain 入口应原样转发参数和返回值。"""
|
||||
service = MagicMock()
|
||||
service.execute.return_value = {"enabled": True}
|
||||
progress = MagicMock()
|
||||
|
||||
with patch("app.scheduler.build_cleanup_service", return_value=service):
|
||||
result = SchedulerChain().cleanup(batch_size=7, progress_callback=progress)
|
||||
|
||||
assert result == {"enabled": True}
|
||||
service.execute.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)
|
||||
Reference in New Issue
Block a user