refactor: add durable transfer planning checkpoints

This commit is contained in:
jxxghp
2026-08-27 13:16:33 +08:00
parent 49d30e5fdf
commit 23bff6b2bb
41 changed files with 7401 additions and 1098 deletions
+577
View File
@@ -0,0 +1,577 @@
from dataclasses import replace
from pathlib import Path
import pytest
from app.application.transfer import TransferPlanCheckpoint, TransferPlanningInput
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.modules.filemanager import transhandler as transhandler_module
from app.modules.filemanager.module import FileManagerModule
from app.modules.filemanager.transhandler import TransHandler
from app.runtime.events import eventmanager
from app.schemas.system import TransferDirectoryConf
from app.schemas.types import ChainEventType, MediaType
from app.schemas.workflow import FileItem
class ReadOnlyTreeStorage:
"""提供目录只读遍历,并让任何写接口调用立即失败。"""
def __init__(self, children: dict[str, list[FileItem]]):
"""保存按源目录路径索引的测试树。"""
self.children = children
self.reads: list[str] = []
def list(self, fileitem: FileItem) -> list[FileItem]:
"""返回目录孩子并记录只读访问。"""
self.reads.append(fileitem.path)
return self.children.get(fileitem.path, [])
def __getattr__(self, name: str):
"""拒绝规划期意外访问的存储接口。"""
raise AssertionError(f"规划阶段不应访问存储接口:{name}")
class RecordingStorage:
"""记录执行期存储调用顺序并模拟同一网盘复制。"""
def __init__(self, calls: list[str], existing: FileItem = None):
"""保存调用日志与可选同名目标。"""
self.calls = calls
self.existing = existing
def get_item_strict(self, path: Path):
"""记录严格目标查询。"""
self.calls.append("strict")
return self.existing
def get_item(self, path: Path):
"""记录普通目标查询。"""
self.calls.append("get_item")
return self.existing
def get_folder(self, path: Path) -> FileItem:
"""记录可能创建目录的接口。"""
self.calls.append("get_folder")
return FileItem(
storage="alist",
path=path.as_posix(),
name=path.name,
type="dir",
)
def delete(self, fileitem: FileItem) -> bool:
"""记录删除副作用。"""
self.calls.append("delete")
self.existing = None
return True
def is_support_transtype(self, transfer_type: str) -> bool:
"""声明测试存储支持复制。"""
return transfer_type == "copy"
def copy(self, fileitem: FileItem, path: Path, name: str) -> bool:
"""记录复制副作用。"""
self.calls.append(f"copy:{(path / name).as_posix()}")
return True
def _build_media() -> tuple[MetaBase, MediaInfo]:
"""构造文件规划所需的最小电视剧领域对象。"""
meta = MetaBase("Test.Show.S01E01.mkv")
meta.type = MediaType.TV
meta.name = "Test Show"
meta.year = "2026"
meta.begin_season = 1
meta.begin_episode = 1
mediainfo = MediaInfo(
type=MediaType.TV,
title="Test Show",
year="2026",
tmdb_id=12345,
)
return meta, mediainfo
def _build_fileitem() -> FileItem:
"""构造无需访问宿主文件系统的网盘源文件。"""
return FileItem(
storage="alist",
path="/downloads/Test.Show.S01E01.mkv",
name="Test.Show.S01E01.mkv",
basename="Test.Show.S01E01",
extension="mkv",
type="file",
size=1024,
)
def _build_input(fileitem: FileItem, **overrides) -> TransferPlanningInput:
"""构造可持久化规划输入。"""
values = {
"source_fileitem": fileitem.model_dump(mode="json"),
"target_storage": "alist",
"target_path": "/library",
"requested_transfer_type": "copy",
"need_rename": True,
"overwrite_mode": "always",
}
values.update(overrides)
return TransferPlanningInput(**values)
def _plan_file(
handler: TransHandler,
planning_input: TransferPlanningInput,
meta: MetaBase,
mediainfo: MediaInfo,
source_oper,
):
"""使用测试固定策略规划单个文件。"""
return handler.plan_transfer(
planning_input,
meta=meta,
mediainfo=mediainfo,
source_oper=source_oper,
target_storage="alist",
target_path=Path("/library"),
transfer_type="copy",
need_scrape=False,
need_rename=True,
need_notify=True,
overwrite_mode="always",
episodes_info=None,
preview=False,
)
def test_directory_planning_only_reads_source_and_freezes_ordered_leaf_operations():
root = FileItem(storage="alist", path="/source/disc", name="disc", type="dir")
nested = FileItem(storage="alist", path="/source/disc/BDMV", name="BDMV", type="dir")
first = FileItem(
storage="alist",
path="/source/disc/BDMV/index.bdmv",
name="index.bdmv",
type="file",
extension="bdmv",
)
second = FileItem(
storage="alist",
path="/source/disc/MovieObject.bdmv",
name="MovieObject.bdmv",
type="file",
extension="bdmv",
)
storage = ReadOnlyTreeStorage(
{
root.path: [nested, second],
nested.path: [first],
}
)
meta, mediainfo = _build_media()
planning_input = _build_input(root, need_rename=False)
checkpoint = TransHandler().plan_transfer(
planning_input,
meta=meta,
mediainfo=mediainfo,
source_oper=storage,
target_storage="alist",
target_path=Path("/library"),
transfer_type="copy",
need_scrape=False,
need_rename=False,
need_notify=True,
overwrite_mode="never",
episodes_info=None,
preview=False,
)
assert storage.reads == [root.path, nested.path]
assert [item.sequence for item in checkpoint.items] == [0, 1]
assert [item.source_fileitem["path"] for item in checkpoint.items] == [
first.path,
second.path,
]
assert [item.target_path for item in checkpoint.items] == [
"/library/disc/BDMV/index.bdmv",
"/library/disc/MovieObject.bdmv",
]
def test_execute_uses_frozen_target_and_intercepts_before_directory_or_delete(
monkeypatch,
):
calls: list[str] = []
fileitem = _build_fileitem()
meta, mediainfo = _build_media()
def record_event(event_type, event_data):
"""记录规划和执行事件顺序。"""
calls.append(event_type.value)
return None
monkeypatch.setattr(eventmanager, "send_event", record_event)
handler = TransHandler()
checkpoint = _plan_file(
handler,
_build_input(fileitem),
meta,
mediainfo,
ReadOnlyTreeStorage({}),
)
frozen_target = checkpoint.final_target_path
assert calls == [
ChainEventType.TransferRenameBuild.value,
ChainEventType.TransferRename.value,
]
calls.clear()
original_get_runtime_setting = transhandler_module.get_runtime_setting
def drifted_runtime_setting(name: str):
"""让执行期重新读取重命名配置时立即失败。"""
if name == "RENAME_FORMAT":
raise AssertionError("执行冻结计划不应重新读取重命名配置")
return original_get_runtime_setting(name)
monkeypatch.setattr(
transhandler_module,
"get_runtime_setting",
drifted_runtime_setting,
)
existing = FileItem(
storage="alist",
path=frozen_target,
name=Path(frozen_target).name,
type="file",
extension="mkv",
size=100,
)
storage = RecordingStorage(calls, existing=existing)
result = handler.execute_transfer_plan(
checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=storage,
target_oper=storage,
)
assert result.success is True
assert ChainEventType.TransferRenameBuild.value not in calls
assert ChainEventType.TransferRename.value not in calls
assert ChainEventType.TransferOverwriteCheck.value in calls
intercept_index = calls.index(ChainEventType.TransferIntercept.value)
assert intercept_index < calls.index("get_folder")
assert intercept_index < calls.index("delete")
assert f"copy:{frozen_target}" in calls
def test_module_preserves_admission_input_while_freezing_resolved_directory():
fileitem = _build_fileitem()
meta, mediainfo = _build_media()
admission_input = _build_input(
fileitem,
target_storage=None,
target_path=None,
requested_transfer_type=None,
target_directory={"name": "library"},
)
fingerprint = admission_input.fingerprint
target_directory = TransferDirectoryConf(
name="library",
transfer_type="copy",
overwrite_mode="never",
library_path="/resolved-library",
library_storage="alist",
renaming=True,
scraping=False,
notify=True,
)
checkpoint = FileManagerModule().plan_transfer(
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
target_directory=target_directory,
source_oper=ReadOnlyTreeStorage({}),
planning_input=admission_input,
)
assert checkpoint.planning_input is admission_input
assert checkpoint.planning_input.fingerprint == fingerprint
assert checkpoint.root_target_path == "/resolved-library"
assert checkpoint.final_target_path.startswith("/resolved-library/")
assert checkpoint.resolved_meta_kind == "MetaBase"
assert checkpoint.resolved_meta["begin_episode"] == 1
assert checkpoint.resolved_mediainfo_kind == "MediaInfo"
assert checkpoint.resolved_mediainfo["title"] == "Test Show"
def _build_cleanup_checkpoint(monkeypatch, calls: list[str]):
"""构造带冻结旧目标清理意图的单文件检查点。"""
fileitem = _build_fileitem()
meta, mediainfo = _build_media()
cleanup_item = FileItem(
storage="cleanup",
path="/old-library/old.mkv",
name="old.mkv",
type="file",
extension="mkv",
)
planning_input = _build_input(
fileitem,
options={"cleanup_dest_fileitem": cleanup_item.model_dump(mode="json")},
)
def record_event(event_type, event_data):
"""记录执行事件顺序。"""
calls.append(event_type.value)
return None
monkeypatch.setattr(eventmanager, "send_event", record_event)
checkpoint = _plan_file(
TransHandler(),
planning_input,
meta,
mediainfo,
ReadOnlyTreeStorage({}),
)
calls.clear()
return checkpoint, meta, mediainfo, cleanup_item
def test_cleanup_runs_after_intercept_and_before_transfer_side_effects(monkeypatch):
calls: list[str] = []
checkpoint, meta, mediainfo, cleanup_item = _build_cleanup_checkpoint(
monkeypatch,
calls,
)
transfer_storage = RecordingStorage(calls)
module = FileManagerModule()
cleaned_items: list[FileItem] = []
def cleanup_media_file(fileitem: FileItem) -> bool:
"""模拟含目录保护与插件路由的统一删除兼容能力。"""
calls.append("cleanup_compat")
cleaned_items.append(fileitem)
return True
result = module.execute_transfer_plan(
checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=transfer_storage,
target_oper=transfer_storage,
cleanup_media_file=cleanup_media_file,
)
assert result.success is True
intercept_index = calls.index(ChainEventType.TransferIntercept.value)
assert intercept_index < calls.index("cleanup_compat")
assert calls.index("cleanup_compat") < calls.index("get_folder")
assert calls.index("cleanup_compat") < next(
index for index, call in enumerate(calls) if call.startswith("copy:")
)
assert cleaned_items == [cleanup_item]
def test_cleanup_compatibility_capability_can_report_idempotent_success(monkeypatch):
calls: list[str] = []
checkpoint, meta, mediainfo, _ = _build_cleanup_checkpoint(monkeypatch, calls)
transfer_storage = RecordingStorage(calls)
module = FileManagerModule()
def cleanup_media_file(fileitem: FileItem) -> bool:
"""统一能力将目标不存在归一为幂等成功。"""
calls.append("cleanup_compat_missing")
return True
result = module.execute_transfer_plan(
checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=transfer_storage,
target_oper=transfer_storage,
cleanup_media_file=cleanup_media_file,
)
assert result.success is True
assert "cleanup_compat_missing" in calls
def test_replayed_checkpoint_skips_cleanup_already_completed_before_provider(
monkeypatch,
):
"""provider 前已完成 cleanup 的持久检查点在宿主重放时不得再次删除。"""
calls: list[str] = []
checkpoint, meta, mediainfo, _ = _build_cleanup_checkpoint(monkeypatch, calls)
persisted_checkpoint = TransferPlanCheckpoint.from_payload(
replace(
checkpoint,
pre_execution_cleanup_completed=True,
).to_payload()
)
transfer_storage = RecordingStorage(calls)
def unexpected_cleanup(_fileitem: FileItem) -> bool:
"""若持久完成事实未被消费则立即暴露重复清理。"""
raise AssertionError("已完成的 provider 前 cleanup 不应在宿主重放时重复执行")
result = FileManagerModule().execute_transfer_plan(
persisted_checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=transfer_storage,
target_oper=transfer_storage,
cleanup_media_file=unexpected_cleanup,
)
assert result.success is True
assert ChainEventType.TransferIntercept.value in calls
assert any(call.startswith("copy:") for call in calls)
def test_cleanup_failure_raises_before_directory_or_copy(monkeypatch):
calls: list[str] = []
checkpoint, meta, mediainfo, cleanup_item = _build_cleanup_checkpoint(
monkeypatch,
calls,
)
transfer_storage = RecordingStorage(calls)
module = FileManagerModule()
def cleanup_media_file(fileitem: FileItem) -> bool:
"""模拟统一删除能力执行保护治理后的失败结果。"""
calls.append("cleanup_compat_failed")
return False
with pytest.raises(RuntimeError, match="整理计划保留待重试"):
module.execute_transfer_plan(
checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=transfer_storage,
target_oper=transfer_storage,
cleanup_media_file=cleanup_media_file,
)
assert ChainEventType.TransferIntercept.value in calls
assert "cleanup_compat_failed" in calls
assert "get_folder" not in calls
assert not any(call.startswith("copy:") for call in calls)
def test_empty_directory_plan_is_zero_operation_without_intercept_or_cleanup(
monkeypatch,
):
calls: list[str] = []
root = FileItem(
storage="alist",
path="/downloads/empty",
name="empty",
type="dir",
)
cleanup_item = FileItem(
storage="alist",
path="/library/old.mkv",
name="old.mkv",
type="file",
)
planning_input = _build_input(
root,
need_rename=False,
options={"cleanup_dest_fileitem": cleanup_item.model_dump(mode="json")},
)
meta, mediainfo = _build_media()
checkpoint = TransHandler().plan_transfer(
planning_input,
meta=meta,
mediainfo=mediainfo,
source_oper=ReadOnlyTreeStorage({root.path: []}),
target_storage="alist",
target_path=Path("/library"),
transfer_type="copy",
need_scrape=False,
need_rename=False,
need_notify=True,
overwrite_mode="never",
episodes_info=None,
preview=False,
)
assert checkpoint.items == ()
def unexpected_event(event_type, event_data):
"""空计划若触发事件则立即失败。"""
raise AssertionError(f"空计划不应触发事件:{event_type}")
monkeypatch.setattr(eventmanager, "send_event", unexpected_event)
result = FileManagerModule().execute_transfer_plan(
checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=RecordingStorage(calls),
target_oper=RecordingStorage(calls),
cleanup_media_file=lambda _fileitem: calls.append("cleanup") or True,
)
assert result.success is True
assert calls == []
def test_directory_intercept_preserves_legacy_payload(monkeypatch):
calls: list[str] = []
root = FileItem(
storage="alist",
path="/downloads/disc",
name="disc",
type="dir",
)
child = FileItem(
storage="alist",
path="/downloads/disc/index.bdmv",
name="index.bdmv",
type="file",
extension="bdmv",
)
meta, mediainfo = _build_media()
checkpoint = TransHandler().plan_transfer(
_build_input(root, need_rename=False),
meta=meta,
mediainfo=mediainfo,
source_oper=ReadOnlyTreeStorage({root.path: [child]}),
target_storage="alist",
target_path=Path("/library"),
transfer_type="copy",
need_scrape=False,
need_rename=False,
need_notify=True,
overwrite_mode="never",
episodes_info=None,
preview=False,
)
intercept_payloads = []
def capture_event(event_type, event_data):
"""捕获目录根拦截事件。"""
if event_type == ChainEventType.TransferIntercept:
intercept_payloads.append(event_data)
return None
monkeypatch.setattr(eventmanager, "send_event", capture_event)
result = TransHandler().execute_transfer_plan(
checkpoint,
meta=meta,
mediainfo=mediainfo,
source_oper=RecordingStorage(calls),
target_oper=RecordingStorage(calls),
)
assert result.success is True
assert len(intercept_payloads) == 1
payload = intercept_payloads[0]
assert payload.meta is None
assert payload.options is None
assert "meta" not in payload.model_fields_set
assert "options" not in payload.model_fields_set