""" 验证文件整理计划的输入冻结、操作顺序、事件拦截和清理兼容。 """ from dataclasses import replace from pathlib import Path from unittest.mock import Mock import pytest from app.application.transfer.workflow import ( TransferPlanCheckpoint, TransferPlanItem, 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.files = {existing.path: existing} if existing else {} def get_item_strict(self, path: Path): """ 记录严格目标查询。 """ self.calls.append("strict") return self.files.get(path.as_posix()) def get_item(self, path: Path): """ 记录普通目标查询。 """ self.calls.append("get_item") return self.files.get(path.as_posix()) 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.files.pop(fileitem.path, None) return True def rename(self, fileitem: FileItem, name: str) -> bool: """ 模拟拒绝覆盖已有目标的同目录重命名。 """ target = Path(fileitem.path).with_name(name) self.calls.append(f"rename:{target.as_posix()}") assert target.as_posix() not in self.files source = self.files.pop(fileitem.path) self.files[target.as_posix()] = source.model_copy(update={"path": target.as_posix(), "name": name}) 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()}") self.files[(path / name).as_posix()] = fileitem.model_copy(update={ "path": (path / name).as_posix(), "name": name, }) 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, modify_time=1.0, ) 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, modify_time=1.0, ) 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"rename:{frozen_target}" in calls assert next(index for index, call in enumerate(calls) if call.startswith("copy:")) < calls.index("delete") 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_new_target_is_materialized(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("get_folder") < calls.index("cleanup_compat") 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_keeps_the_new_target_and_raises_for_retry(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" in calls assert any(call.startswith("copy:") for call in calls) assert checkpoint.final_target_path in transfer_storage.files def test_cleanup_is_not_called_when_materialization_fails(monkeypatch): """ 新目标未落地时保留历史旧目的,不调用任何兼容删除能力。 """ calls = [] checkpoint, meta, mediainfo, _ = _build_cleanup_checkpoint(monkeypatch, calls) storage = RecordingStorage(calls) monkeypatch.setattr(storage, "copy", lambda *_args: False) cleanup = Mock(return_value=True) result = FileManagerModule().execute_transfer_plan( checkpoint, meta=meta, mediainfo=mediainfo, source_oper=storage, target_oper=storage, cleanup_media_file=cleanup, ) assert result.success is False cleanup.assert_not_called() @pytest.mark.parametrize("ancestor", [False, True]) def test_cleanup_never_removes_the_new_target_or_its_parent(monkeypatch, ancestor): """ 同路径或祖先目录的历史目的交文件级覆盖,不能整块删除新版本。 """ calls = [] checkpoint, meta, mediainfo, _ = _build_cleanup_checkpoint(monkeypatch, calls) path = Path(checkpoint.final_target_path) cleanup = FileItem(storage="alist", path=(path.parent if ancestor else path).as_posix(), type="dir" if ancestor else "file") planning_input = replace(checkpoint.planning_input, options={ "cleanup_dest_fileitem": cleanup.model_dump(mode="json"), }) checkpoint = replace(checkpoint, planning_input=planning_input) storage = RecordingStorage(calls, existing=FileItem( storage="alist", path=path.as_posix(), name=path.name, type="file", size=100, modify_time=1.0, )) unexpected_cleanup = Mock(side_effect=AssertionError("不可删除重合目的")) result = FileManagerModule().execute_transfer_plan( checkpoint, meta=meta, mediainfo=mediainfo, source_oper=storage, target_oper=storage, cleanup_media_file=unexpected_cleanup, ) assert result.success assert path.as_posix() in storage.files unexpected_cleanup.assert_not_called() 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 False assert result.message == "源目录中没有可整理文件" assert result.failure_stage == "planning" assert result.target_item is None assert result.need_notify is False 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 def test_directory_intercept_step_uses_frozen_source_after_size_projection(): """ 目录执行期修正根目录大小时,持久拦截意图仍使用冻结源快照。 """ root = FileItem( storage="alist", path="/downloads/disc", name="disc", type="dir", ) stream = FileItem( storage="alist", path="/downloads/disc/BDMV/STREAM/00001.m2ts", name="00001.m2ts", type="file", extension="m2ts", size=123, ) planning_input = TransferPlanningInput( source_fileitem=root.model_dump(mode="json"), target_storage="alist", target_path="/library", requested_transfer_type="copy", need_rename=False, ) checkpoint = TransferPlanCheckpoint( planning_input=planning_input, target_storage="alist", root_target_path="/library/disc", final_target_path="/library/disc", resolved_transfer_type="copy", items=(TransferPlanItem( sequence=0, source_fileitem=stream.model_dump(mode="json"), target_storage="alist", target_path="/library/disc/BDMV/STREAM/00001.m2ts", ),), need_rename=False, overwrite_mode="never", ) runner = Mock() steps = [] def run_step(*, phase, kind, payload, execute, observe): """ 模拟 durable runner,并记录其接受的冻结步骤意图。 """ steps.append((phase, kind, payload)) return execute() runner.run.side_effect = run_step storage = RecordingStorage([]) result = TransHandler().execute_transfer_plan( checkpoint, meta=_build_media()[0], mediainfo=_build_media()[1], source_oper=storage, target_oper=storage, step_runner=runner, ) assert result.success is True intercept = next( payload for _phase, kind, payload in steps if kind == "plugin_transfer_intercept" ) assert intercept["source"] == planning_input.source_fileitem