"""整理规划检查点的事务顺序、恢复与无副作用边界合同。""" import ast import inspect import threading from dataclasses import replace from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock, patch import pytest from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker from app.application import transfer as transfer_application from app.application.transfer import TransferTask from app.chain.transfer import TransferChain from app.db.adapters.transfer import TransactionalTransferAdmissionRepository from app.db.models.transferpending import TransferPending from app.domain.context import MediaInfo from app.domain.meta.metabase import MetaBase from app.modules.filemanager.module import FileManagerModule from app.modules.filemanager.transhandler import TransHandler from app.runtime.extensions.module.dispatcher import ( FrozenModuleProviderMissingError, ModuleInvocationDispatcher, ) from app.schemas.exception import StorageQueryError from app.schemas.file import FileItem from app.schemas.system import TransferDirectoryConf from app.schemas.transfer import TransferInfo def _planning_contracts(): """延迟解析新合同,使未落地时每个 pytest 用例给出精确缺口。""" return ( getattr(transfer_application, "TransferPlanningInput"), getattr(transfer_application, "TransferPlanItem"), getattr(transfer_application, "TransferPlanCheckpoint"), ) def _source_snapshot() -> dict[str, object]: """返回可独立跨重启还原的源文件快照。""" return { "storage": "local", "path": "/downloads/Movie.2026.mkv", "type": "file", "name": "Movie.2026.mkv", "basename": "Movie.2026", "extension": "mkv", "size": 1024, "modify_time": 1770000000, "fileid": "source-1", } def _planning_input(*, preview: bool = False, target_path: str = "/library"): """构造包含旧调用意图、媒体提示和目标配置的规划输入。""" TransferPlanningInput, _TransferPlanItem, _TransferPlanCheckpoint = ( _planning_contracts() ) return TransferPlanningInput( source_fileitem=_source_snapshot(), meta={"title": "Movie.2026", "year": "2026"}, mediainfo={ "media_source": "themoviedb", "media_id": "123", "type": "电影", }, target_directory={"library_path": target_path}, target_storage="local", target_path=target_path, requested_transfer_type="copy", media_source="themoviedb", media_id="123", media_type="电影", need_scrape=True, need_rename=True, need_notify=True, overwrite_mode="always", episodes_info=(), preview=preview, options={ "manual": True, "background": False, "cleanup_dest_fileitem": None, }, ) def _checkpoint(*, preview: bool = False, target_path: str = "/library/Movie (2026)/Movie.mkv"): """构造带完整输入和有序执行项的冻结计划。""" _TransferPlanningInput, TransferPlanItem, TransferPlanCheckpoint = ( _planning_contracts() ) planning_input = _planning_input(preview=preview) item = TransferPlanItem( sequence=0, source_fileitem=_source_snapshot(), target_storage="local", target_path=target_path, action="transfer", ) return TransferPlanCheckpoint( planning_input=planning_input, target_storage="local", root_target_path="/library", final_target_path=target_path, resolved_transfer_type="copy", items=(item,), resolved_meta={ "org_string": "Movie.2026.mkv", "title": "Movie 2026", "type": "电影", }, resolved_meta_kind="MetaVideo", resolved_mediainfo={ "title": "Movie", "year": "2026", "type": "电影", "media_source": "themoviedb", "media_id": "123", }, resolved_mediainfo_kind="MediaInfo", need_scrape=True, need_rename=True, need_notify=True, overwrite_mode="always", preview=preview, ) def _resolved_checkpoint(): """构造包含在线识别完成后领域快照的 planned checkpoint。""" return replace( _checkpoint(), resolved_meta={ "org_string": "Frozen.Show.S01E01.mkv", "title": "Frozen Show S01E01", "name": "Frozen Show", "year": "2026", "type": "电视剧", "begin_season": 1, "begin_episode": 1, "episode_list": [1], }, resolved_mediainfo={ "title": "Frozen Show", "year": "2026", "type": "电视剧", "media_source": "themoviedb", "media_id": "987", "tmdb_id": 987, }, resolved_episodes_info=( { "episode_number": 1, "season_number": 1, "name": "Frozen Episode", }, ), ) def _task(*, preview: bool = False) -> TransferTask: """构造不触碰真实文件系统的规划任务。""" return TransferTask( fileitem=FileItem(**_source_snapshot()), target_storage="local", target_path=Path("/library"), transfer_type="copy", scrape=True, manual=True, background=False, preview=preview, ) def _bind_planning_input(task: TransferTask, planning_input) -> None: """通过正式绑定接口保存私有输入,禁止测试写入 Pydantic 内部字典。""" binder = getattr(task, "bind_planning_input") binder(planning_input) def _bind_checkpoint(task: TransferTask, checkpoint) -> None: """通过正式绑定接口模拟 planned 任务的跨重启恢复。""" binder = getattr(task, "bind_plan_checkpoint") binder(checkpoint) def _planned_admission(task: TransferTask, checkpoint): """构造仓储 checkpoint 提交后返回的冻结投影。""" return SimpleNamespace( task_id=task.admission_task_id, state="planned", planning_input=task.planning_input, checkpoint=checkpoint, ) def _chain(*, repository=None, checkpoint=None, result=None) -> TransferChain: """构造只保留规划编排依赖的 TransferChain 骨架。""" chain = object.__new__(TransferChain) chain._transfer_admissions = repository or Mock() chain._worker_owner_id = "planning-owner" chain._owned_leases = {} chain._queued_lease_tokens = set() chain._worker_state_lock = threading.RLock() chain._closing = False chain._recovery_wakeup_event = threading.Event() chain._TransferChain__ensure_lease_heartbeat_owner = Mock() def claim_task(**kwargs): """为规划测试返回与进程 owner 匹配的稳定 claim。""" return transfer_application.TransferAdmission( task_id=kwargs["task_id"], storage="local", src_path="/downloads/Movie.2026.mkv", state="accepted", created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", lease_owner=kwargs["owner_id"], lease_token=f"lease-{kwargs['task_id']}", lease_expires_at="2026-08-27 10:02:00.000000", heartbeat_at="2026-08-27 10:00:00.000000", attempt_count=1, ) chain._transfer_admissions.claim_task.side_effect = claim_task chain._module_dispatcher = Mock() chain._module_dispatcher.freeze_plugin_providers.return_value = () chain.eventmanager = Mock() chain.eventmanager.send_event.return_value = None chain.plan_transfer = Mock(return_value=checkpoint or _checkpoint()) chain.execute_transfer_plan = Mock( return_value=result or TransferInfo( success=True, fileitem=_task().fileitem, transfer_type="copy", ) ) return chain def _replay_chain(repository) -> TransferChain: """构造绑定固定恢复 owner 且不启动真实 heartbeat 线程的测试链。""" chain = object.__new__(TransferChain) chain._transfer_admissions = repository chain._worker_owner_id = "replay-owner" chain._owned_leases = {} chain._queued_lease_tokens = set() chain._worker_state_lock = threading.RLock() chain._closing = False chain._recovery_wakeup_event = threading.Event() chain._TransferChain__ensure_lease_heartbeat_owner = Mock() return chain def _real_dispatcher(plugins: dict) -> ModuleInvocationDispatcher: """构造使用真实冻结解析与执行内核的内存插件调度器。""" plugin_catalog = Mock() plugin_catalog.get_plugin_modules.return_value = plugins return ModuleInvocationDispatcher( module_catalog=Mock(), plugin_catalog=plugin_catalog, plugin_error_handler=Mock(), system_error_handler=Mock(), rate_limit_handler=Mock(), ) def test_legacy_provider_runs_only_after_checkpoint_commit_and_short_circuits_host(): """旧插件 provider 必须随计划冻结,并在 CAS 提交后才能接管执行。""" calls = [] task = _task() task.meta = MetaBase("Movie.2026.mkv") task.mediainfo = MediaInfo(title="Movie") task.bind_admission_task_id("task-plugin-short-circuit") _bind_planning_input(task, _planning_input()) checkpoint = _checkpoint() provider = SimpleNamespace( plugin_id="LegacyTransfer", plugin_name="旧整理插件", method="transfer", ) plugin_result = TransferInfo( success=True, fileitem=task.fileitem, transfer_type="copy", ) repository = Mock() def checkpoint_plan(**kwargs): """记录 CAS 顺序并回读包含 provider 路由的持久检查点。""" calls.append("checkpoint") persisted_checkpoint = kwargs["checkpoint"] assert persisted_checkpoint.legacy_transfer_providers[0].plugin_id == ( "LegacyTransfer" ) return _planned_admission(task, persisted_checkpoint) repository.checkpoint_plan.side_effect = checkpoint_plan chain = _chain(repository=repository, checkpoint=checkpoint) chain._module_dispatcher.freeze_plugin_providers.return_value = (provider,) chain._module_dispatcher.execute_frozen_plugin_providers.side_effect = ( lambda *_args, **_kwargs: calls.append("plugin") or plugin_result ) returned = chain._plan_checkpoint_and_execute(task) assert returned is plugin_result assert calls == ["checkpoint", "plugin"] chain.plan_transfer.assert_not_called() chain.execute_transfer_plan.assert_not_called() frozen_call = chain._module_dispatcher.execute_frozen_plugin_providers.call_args assert frozen_call.args[0] == "transfer" assert frozen_call.args[1][0].plugin_id == "LegacyTransfer" assert frozen_call.kwargs["target_path"] == Path("/library") assert frozen_call.kwargs["preview"] is False def test_legacy_provider_cleanup_runs_before_provider_without_duplicate_intercept(): """旧 provider 前只做 cleanup,不重复发送插件自行负责的真实目标拦截事件。""" calls = [] cleanup_item = FileItem( storage="plugin-storage", path="/old-library/old.mkv", name="old.mkv", type="file", extension="mkv", ) resolved = _resolved_checkpoint() planning_input = replace( resolved.planning_input, options={ **resolved.planning_input.options, "cleanup_dest_fileitem": cleanup_item.model_dump(mode="json"), }, ) invocation = transfer_application.TransferProviderInvocationSnapshot( fileitem=_source_snapshot(), meta=resolved.resolved_meta, meta_kind=resolved.resolved_meta_kind, mediainfo=resolved.resolved_mediainfo, mediainfo_kind=resolved.resolved_mediainfo_kind, target_directory={ "library_storage": "local", "library_path": "/library", "transfer_type": "copy", }, target_storage=None, target_path=None, transfer_type=None, scrape=True, library_type_folder=False, library_category_folder=False, episodes_info=resolved.resolved_episodes_info, preview=False, ) provider = transfer_application.TransferProviderReference( plugin_id="LegacyTransfer", plugin_name="旧整理插件", ) checkpoint = transfer_application.TransferPlanCheckpoint( planning_input=planning_input, target_storage="", root_target_path="", final_target_path="", resolved_transfer_type="", items=(), legacy_transfer_providers=(provider,), provider_invocation=invocation, need_notify=True, ) task = _task() task.meta = MetaBase("Drifted.mkv") task.mediainfo = MediaInfo(title="Drifted") chain = object.__new__(TransferChain) chain.eventmanager = Mock() storage_chain = Mock() storage_chain.get_file_item_strict.side_effect = ( lambda **_kwargs: calls.append("lookup") or cleanup_item ) storage_chain.delete_media_file.side_effect = ( lambda _fileitem: calls.append("cleanup") or True ) chain._transfer_storage_chain = Mock(return_value=storage_chain) provider_result = TransferInfo( success=True, fileitem=task.fileitem, transfer_type="copy", ) def execute_provider(*_args, **kwargs): """记录 provider 文件副作用入口并核对旧 ABI 的原始 None 语义。""" calls.append("provider") assert kwargs["target_storage"] is None assert kwargs["target_path"] is None assert kwargs["transfer_type"] is None return provider_result chain._module_dispatcher = _real_dispatcher( { ("LegacyTransfer", "旧整理插件"): { "transfer": execute_provider, } } ) returned = chain._TransferChain__execute_legacy_transfer_providers( task, checkpoint=checkpoint, source_oper=None, target_oper=None, ) assert returned is provider_result assert calls == ["lookup", "cleanup", "provider"] chain.eventmanager.send_event.assert_not_called() def test_empty_frozen_legacy_providers_fall_back_to_host_checkpoint_executor(): """provider 空结果在已完成 cleanup 后持久提升宿主计划且只清理一次。""" calls = [] task = _task() task.meta = MetaBase("Movie.2026.mkv") task.mediainfo = MediaInfo(title="Movie") task.bind_admission_task_id("task-plugin-fallback") cleanup_item = FileItem( storage="local", path="/old-library/old.mkv", name="old.mkv", type="file", extension="mkv", ) planning_input = replace( _planning_input(), options={ **_planning_input().options, "cleanup_dest_fileitem": cleanup_item.model_dump(mode="json"), }, ) _bind_planning_input(task, planning_input) checkpoint = _checkpoint() provider = SimpleNamespace( plugin_id="EmptyTransfer", plugin_name="空整理插件", method="transfer", ) repository = Mock() def checkpoint_plan(**kwargs): """记录宿主 fallback 前的 CAS 提交。""" calls.append("checkpoint") if not kwargs["checkpoint"].is_provider_pending: assert kwargs["checkpoint"].pre_execution_cleanup_completed is True return _planned_admission(task, kwargs["checkpoint"]) repository.checkpoint_plan.side_effect = checkpoint_plan chain = _chain(repository=repository, checkpoint=checkpoint) chain._module_dispatcher = _real_dispatcher( { (provider.plugin_id, provider.plugin_name): { "transfer": lambda **_kwargs: calls.append("plugin") } } ) storage_chain = Mock() storage_chain.get_file_item_strict.side_effect = ( lambda **_kwargs: calls.append("lookup") or cleanup_item ) storage_chain.delete_media_file.side_effect = ( lambda _fileitem: calls.append("cleanup") or True ) chain._transfer_storage_chain = Mock(return_value=storage_chain) chain.execute_transfer_plan.side_effect = ( lambda *_args, **_kwargs: calls.append("host") or TransferInfo( success=True, fileitem=task.fileitem, transfer_type="copy", ) ) returned = chain._plan_checkpoint_and_execute(task) assert returned.success is True assert calls == [ "checkpoint", "lookup", "cleanup", "plugin", "checkpoint", "host", ] chain.execute_transfer_plan.assert_called_once() executed_checkpoint = chain.execute_transfer_plan.call_args.args[0] assert executed_checkpoint.pre_execution_cleanup_completed is True storage_chain.get_file_item_strict.assert_called_once() storage_chain.delete_media_file.assert_called_once_with(cleanup_item) def test_missing_frozen_provider_keeps_pending_and_skips_cleanup() -> None: """提交后任一冻结引用缺失时应保留 pending 且不得提前 cleanup。""" calls = [] task = _task() task.meta = MetaBase("Movie.2026.mkv") task.mediainfo = MediaInfo(title="Movie") task.bind_admission_task_id("task-provider-missing") cleanup_item = FileItem( storage="local", path="/old-library/old.mkv", name="old.mkv", type="file", extension="mkv", ) planning_input = replace( _planning_input(), options={ **_planning_input().options, "cleanup_dest_fileitem": cleanup_item.model_dump(mode="json"), }, ) _bind_planning_input(task, planning_input) first_provider = Mock(return_value=None) plugins = { ("ProviderOne", "插件一"): {"transfer": first_provider}, ("ProviderTwo", "插件二"): {"transfer": Mock(return_value=None)}, } repository = Mock() def checkpoint_plan(**kwargs): """模拟提交后第二个 provider 被卸载的崩溃恢复窗口。""" calls.append("checkpoint") plugins.pop(("ProviderTwo", "插件二")) return _planned_admission(task, kwargs["checkpoint"]) repository.checkpoint_plan.side_effect = checkpoint_plan chain = _chain(repository=repository) chain._module_dispatcher = _real_dispatcher(plugins) storage_chain = Mock() chain._transfer_storage_chain = Mock(return_value=storage_chain) with pytest.raises( FrozenModuleProviderMissingError, match=r"ProviderTwo/插件二\.transfer", ): chain._plan_checkpoint_and_execute(task) assert calls == ["checkpoint"] assert task.plan_checkpoint is not None assert task.plan_checkpoint.is_provider_pending is True repository.checkpoint_plan.assert_called_once() repository.record_planning_failure.assert_not_called() storage_chain.get_file_item_strict.assert_not_called() storage_chain.delete_media_file.assert_not_called() first_provider.assert_not_called() chain.plan_transfer.assert_not_called() chain.execute_transfer_plan.assert_not_called() def test_frozen_provider_failure_keeps_pending_without_host_fallback() -> None: """真实冻结调度器必须传播 provider 异常并保留可重放检查点。""" calls = [] task = _task() task.meta = MetaBase("Movie.2026.mkv") task.mediainfo = MediaInfo(title="Movie") task.bind_admission_task_id("task-provider-failed") cleanup_item = FileItem( storage="local", path="/old-library/old.mkv", name="old.mkv", type="file", extension="mkv", ) planning_input = replace( _planning_input(), options={ **_planning_input().options, "cleanup_dest_fileitem": cleanup_item.model_dump(mode="json"), }, ) _bind_planning_input(task, planning_input) repository = Mock() def checkpoint_plan(**kwargs): """记录 provider_pending 已在插件副作用前完成提交。""" calls.append("checkpoint") return _planned_admission(task, kwargs["checkpoint"]) def failed_provider(**_kwargs): """模拟冻结旧插件在 cleanup 完成后的执行失败。""" calls.append("provider") raise RuntimeError("legacy provider failed") repository.checkpoint_plan.side_effect = checkpoint_plan chain = _chain(repository=repository) chain._module_dispatcher = _real_dispatcher( { ("FailedProvider", "失败插件"): { "transfer": failed_provider, } } ) storage_chain = Mock() storage_chain.get_file_item_strict.side_effect = ( lambda **_kwargs: calls.append("lookup") or cleanup_item ) storage_chain.delete_media_file.side_effect = ( lambda _fileitem: calls.append("cleanup") or True ) chain._transfer_storage_chain = Mock(return_value=storage_chain) with pytest.raises(RuntimeError, match="legacy provider failed"): chain._plan_checkpoint_and_execute(task) assert calls == ["checkpoint", "lookup", "cleanup", "provider"] assert task.plan_checkpoint is not None assert task.plan_checkpoint.is_provider_pending is True repository.checkpoint_plan.assert_called_once() repository.record_planning_failure.assert_not_called() chain.plan_transfer.assert_not_called() chain.execute_transfer_plan.assert_not_called() def test_auto_directory_provider_empty_preserves_legacy_none_values_for_fallback(): """自动目录 provider 与空结果后的宿主规划都必须保留旧 ABI 的可空原值。""" calls = [] task = _task() task.meta = MetaBase("Movie.2026.mkv") task.mediainfo = MediaInfo(title="Movie") task.target_directory = TransferDirectoryConf( name="auto-library", transfer_type="copy", library_path="/library/Movies", library_storage="alist", renaming=True, scraping=True, notify=True, ) task.target_storage = "alist" task.target_path = None task.transfer_type = None task.scrape = None task.library_type_folder = False task.library_category_folder = None task.bind_admission_task_id("task-auto-provider-fallback") planning_input = replace( _planning_input(), target_directory=None, target_storage=None, target_path=None, requested_transfer_type=None, ) _bind_planning_input(task, planning_input) host_checkpoint = replace(_checkpoint(), planning_input=planning_input) provider = SimpleNamespace( plugin_id="AutoDirectoryProvider", plugin_name="自动目录插件", method="transfer", ) repository = Mock() def checkpoint_plan(**kwargs): """回读每个阶段检查点并记录 provider_pending 到 planned 的顺序。""" checkpoint = kwargs["checkpoint"] calls.append( "commit-provider" if checkpoint.is_provider_pending else "commit-host" ) return _planned_admission(task, checkpoint) def execute_provider(*_args, **kwargs): """核对自动目录只冻结解析目录,不改写旧 ABI 的可空参数。""" calls.append("provider") assert kwargs["target_directory"].name == "auto-library" assert kwargs["target_storage"] == "alist" assert kwargs["target_path"] is None assert kwargs["transfer_type"] is None assert kwargs["scrape"] is None assert kwargs["library_type_folder"] is False assert kwargs["library_category_folder"] is None return None def plan_host(**kwargs): """核对 provider 重放恢复未用空宿主字段污染 fallback 参数。""" calls.append("plan-host") assert kwargs["target_directory"].name == "auto-library" assert kwargs["target_storage"] == "alist" assert kwargs["target_path"] is None assert kwargs["transfer_type"] is None assert kwargs["scrape"] is None assert kwargs["library_type_folder"] is False assert kwargs["library_category_folder"] is None return host_checkpoint repository.checkpoint_plan.side_effect = checkpoint_plan chain = _chain(repository=repository, checkpoint=host_checkpoint) chain._module_dispatcher.freeze_plugin_providers.return_value = (provider,) chain._module_dispatcher.execute_frozen_plugin_providers.side_effect = ( execute_provider ) chain.plan_transfer.side_effect = plan_host chain.execute_transfer_plan.side_effect = ( lambda *_args, **_kwargs: calls.append("execute-host") or TransferInfo( success=True, fileitem=task.fileitem, transfer_type="copy", ) ) returned = chain._plan_checkpoint_and_execute(task) assert returned.success is True assert calls == [ "commit-provider", "provider", "plan-host", "commit-host", "execute-host", ] promoted = repository.checkpoint_plan.call_args_list[1].kwargs["checkpoint"] assert promoted.pre_execution_cleanup_completed is True def test_provider_empty_fallback_cas_failure_blocks_host_execution(): """provider 空结果后的 host checkpoint 升级失败时不得执行宿主副作用。""" task = _task() task.meta = MetaBase("Movie.2026.mkv") task.mediainfo = MediaInfo(title="Movie") task.bind_admission_task_id("task-provider-cas-failure") planning_input = _planning_input() _bind_planning_input(task, planning_input) host_checkpoint = replace(_checkpoint(), planning_input=planning_input) provider = SimpleNamespace( plugin_id="EmptyProvider", plugin_name="空结果插件", method="transfer", ) repository = Mock() checkpoint_calls = [] def checkpoint_plan(**kwargs): """只允许 provider_pending 提交,模拟 host promotion CAS 竞争失败。""" checkpoint_calls.append(kwargs["checkpoint"]) if len(checkpoint_calls) == 1: return _planned_admission(task, kwargs["checkpoint"]) raise transfer_application.TransferPlanningStateError("CAS failed") repository.checkpoint_plan.side_effect = checkpoint_plan chain = _chain(repository=repository, checkpoint=host_checkpoint) chain._module_dispatcher.freeze_plugin_providers.return_value = (provider,) chain._module_dispatcher.execute_frozen_plugin_providers.return_value = None with pytest.raises( transfer_application.TransferPlanningStateError, match="CAS failed", ): chain._plan_checkpoint_and_execute(task) assert checkpoint_calls[0].is_provider_pending is True assert checkpoint_calls[1].is_provider_pending is False chain.plan_transfer.assert_called_once() chain.execute_transfer_plan.assert_not_called() repository.record_planning_failure.assert_called_once_with( task_id="task-provider-cas-failure", lease_token="lease-task-provider-cas-failure", error="CAS failed", ) def test_provider_pending_crash_replay_executes_snapshot_without_host_planning(): """provider 提交后崩溃重放必须直接执行冻结调用,不重新冻结或宿主规划。""" planning_input = _planning_input() provider = SimpleNamespace( plugin_id="CrashProvider", plugin_name="崩溃恢复插件", method="transfer", ) first_task = _task() first_task.meta = MetaBase("Movie.2026.mkv") first_task.mediainfo = MediaInfo(title="Movie") first_task.bind_admission_task_id("task-provider-replay") _bind_planning_input(first_task, planning_input) first_repository = Mock() first_repository.checkpoint_plan.side_effect = lambda **kwargs: ( _planned_admission(first_task, kwargs["checkpoint"]) ) first_chain = _chain(repository=first_repository) first_chain._module_dispatcher.freeze_plugin_providers.return_value = (provider,) first_chain._module_dispatcher.execute_frozen_plugin_providers.side_effect = ( RuntimeError("process crashed") ) with pytest.raises(RuntimeError, match="process crashed"): first_chain._plan_checkpoint_and_execute(first_task) provider_checkpoint = first_task.plan_checkpoint assert provider_checkpoint is not None assert provider_checkpoint.is_provider_pending is True first_chain.plan_transfer.assert_not_called() recovered_task = _task() recovered_task.meta = MetaBase("Drifted.mkv") recovered_task.mediainfo = MediaInfo(title="Drifted") recovered_task.bind_admission_task_id("task-provider-replay") _bind_planning_input(recovered_task, planning_input) _bind_checkpoint(recovered_task, provider_checkpoint) recovered_result = TransferInfo( success=True, fileitem=recovered_task.fileitem, transfer_type="copy", ) recovered_chain = _chain(repository=Mock()) recovered_chain._module_dispatcher.execute_frozen_plugin_providers.return_value = ( recovered_result ) returned = recovered_chain._plan_checkpoint_and_execute(recovered_task) assert returned is recovered_result recovered_chain._module_dispatcher.freeze_plugin_providers.assert_not_called() recovered_chain.plan_transfer.assert_not_called() recovered_chain._transfer_admissions.checkpoint_plan.assert_not_called() provider_call = ( recovered_chain._module_dispatcher.execute_frozen_plugin_providers.call_args ) assert provider_call.kwargs["meta"].org_string == "Movie.2026.mkv" assert provider_call.kwargs["mediainfo"].title == "Movie" def test_legacy_transfer_command_uses_durable_pipeline_and_settles_pending(): """旧同步调用必须经过 admission、checkpoint、执行和终态注销。""" calls = [] repository = Mock() repository.admit.side_effect = ( lambda **_kwargs: calls.append("admit") or SimpleNamespace(task_id="task-legacy-command") ) def checkpoint_plan(**kwargs): """回读刚提交的检查点并记录顺序。""" calls.append("checkpoint") return SimpleNamespace(checkpoint=kwargs["checkpoint"]) repository.checkpoint_plan.side_effect = checkpoint_plan repository.discard_claimed.side_effect = ( lambda **_kwargs: calls.append("discard") or 1 ) result = TransferInfo( success=True, fileitem=_task().fileitem, transfer_type="copy", ) chain = _chain(repository=repository, checkpoint=_checkpoint(), result=result) chain.execute_transfer_plan.side_effect = ( lambda *_args, **_kwargs: calls.append("execute") or result ) meta = MetaBase("Movie.2026.mkv") mediainfo = MediaInfo() returned = chain.execute_legacy_transfer_command( fileitem=_task().fileitem, meta=meta, mediainfo=mediainfo, target_storage="local", target_path=Path("/library"), transfer_type="copy", ) assert returned is result assert calls == ["admit", "checkpoint", "execute", "discard"] repository.discard_claimed.assert_called_once_with( task_id="task-legacy-command", lease_token="lease-task-legacy-command", ) def test_cleanup_destination_is_idempotent_and_uses_storage_safety_policy(): """cleanup 不存在时成功,存在时必须委托 StorageChain 安全删除。""" cleanup_item = FileItem(**_source_snapshot()) chain = object.__new__(TransferChain) storage_chain = Mock() chain._transfer_storage_chain = Mock(return_value=storage_chain) storage_chain.get_file_item_strict.return_value = None assert chain._TransferChain__cleanup_transfer_destination(cleanup_item) is True storage_chain.delete_media_file.assert_not_called() current_item = cleanup_item.model_copy(update={"fileid": "current"}) storage_chain.get_file_item_strict.return_value = current_item storage_chain.delete_media_file.return_value = True assert chain._TransferChain__cleanup_transfer_destination(cleanup_item) is True storage_chain.delete_media_file.assert_called_once_with(current_item) def test_cleanup_destination_propagates_strict_lookup_failure() -> None: """cleanup 查询失败必须保留 planned,不能被投影成目标不存在。""" cleanup_item = FileItem(**_source_snapshot()) chain = object.__new__(TransferChain) storage_chain = Mock() chain._transfer_storage_chain = Mock(return_value=storage_chain) storage_chain.get_file_item_strict.side_effect = StorageQueryError( "provider lookup failed" ) with pytest.raises(StorageQueryError, match="provider lookup failed"): chain._TransferChain__cleanup_transfer_destination(cleanup_item) storage_chain.delete_media_file.assert_not_called() def test_planning_payload_round_trip_is_self_contained_and_stable(): """planned 行仅凭 checkpoint payload 即可还原输入、目标与有序动作。""" TransferPlanningInput, _TransferPlanItem, TransferPlanCheckpoint = ( _planning_contracts() ) checkpoint = _checkpoint() restored = TransferPlanCheckpoint.from_payload(checkpoint.to_payload()) assert restored == checkpoint assert isinstance(restored.planning_input, TransferPlanningInput) assert restored.planning_input.source_fileitem == _source_snapshot() assert restored.planning_input.fingerprint == checkpoint.planning_input.fingerprint assert tuple(item.sequence for item in restored.items) == (0,) assert restored.final_target_path == "/library/Movie (2026)/Movie.mkv" def test_repository_rejects_checkpoint_with_mismatched_planning_fingerprint(tmp_path): """checkpoint 内嵌输入与 accepted 指纹不一致时必须拒绝状态跃迁。""" engine = create_engine(f"sqlite:///{tmp_path / 'fingerprint.db'}") TransferPending.__table__.create(engine) repository = TransactionalTransferAdmissionRepository(sessionmaker(bind=engine)) accepted_input = _planning_input(target_path="/library/A") admission = repository.admit( storage="local", src_path="/downloads/Movie.2026.mkv", planning_input=accepted_input, ) claimed = repository.claim_task( task_id=admission.task_id, owner_id="fingerprint-test", lease_seconds=120, ) assert claimed is not None assert claimed.lease_token is not None mismatched = _checkpoint(target_path="/library/B/Movie.mkv") with pytest.raises(ValueError, match="指纹|fingerprint|规划输入"): repository.checkpoint_plan( task_id=admission.task_id, lease_token=claimed.lease_token, input_fingerprint=accepted_input.fingerprint, checkpoint=mismatched, ) assert repository.release_claim( task_id=admission.task_id, lease_token=claimed.lease_token, ) is True recovered = repository.claim_recoverable( owner_id="fingerprint-recovery", limit=10, lease_seconds=120, ) assert len(recovered) == 1 assert recovered[0].state == "accepted" assert recovered[0].checkpoint is None engine.dispose() def test_repository_round_trips_accepted_and_planned_recovery_states(tmp_path): """仓储必须同时恢复 accepted 输入和 planned 自包含 checkpoint。""" engine = create_engine(f"sqlite:///{tmp_path / 'recoverable.db'}") TransferPending.__table__.create(engine) repository = TransactionalTransferAdmissionRepository(sessionmaker(bind=engine)) planning_input = _planning_input() admission = repository.admit( storage="local", src_path="/downloads/Movie.2026.mkv", planning_input=planning_input, ) claimed = repository.claim_task( task_id=admission.task_id, owner_id="roundtrip-owner", lease_seconds=120, ) assert claimed is not None assert claimed.lease_token is not None assert claimed.state == "accepted" assert claimed.planning_input == planning_input assert claimed.checkpoint is None repository.record_planning_failure( task_id=admission.task_id, lease_token=claimed.lease_token, error="rename unavailable", ) with sessionmaker(bind=engine)() as session: retryable = session.execute( select(TransferPending).where( TransferPending.task_id == admission.task_id ) ).scalar_one() assert retryable.state == "accepted" assert retryable.last_error == "rename unavailable" checkpoint = _checkpoint() planned = repository.checkpoint_plan( task_id=admission.task_id, lease_token=claimed.lease_token, input_fingerprint=planning_input.fingerprint, checkpoint=checkpoint, ) assert planned.state == "planned" assert planned.checkpoint == checkpoint assert planned.last_error is None assert repository.release_claim( task_id=admission.task_id, lease_token=claimed.lease_token, ) is True recovered = repository.claim_recoverable( owner_id="roundtrip-recovery", limit=10, lease_seconds=120, ) assert len(recovered) == 1 assert recovered[0].state == "planned" assert recovered[0].checkpoint == checkpoint engine.dispose() def test_checkpoint_commit_precedes_executor_and_sync_task_is_admitted(): """同步非 preview 任务也必须按 admit、plan、commit、execute 顺序运行。""" order = [] repository = Mock() task = _task() planning_input = _planning_input() _bind_planning_input(task, planning_input) checkpoint = _checkpoint() result = TransferInfo(success=True, fileitem=task.fileitem, transfer_type="copy") def admit(**_kwargs): """记录同步任务首次持久准入。""" order.append("admit") return SimpleNamespace( task_id="task-sync", state="accepted", planning_input=planning_input, checkpoint=None, ) def checkpoint_plan(**_kwargs): """记录 durable checkpoint 已经提交并返回 planned 投影。""" order.append("commit-checkpoint") return _planned_admission(task, checkpoint) repository.admit.side_effect = admit repository.checkpoint_plan.side_effect = checkpoint_plan chain = _chain(repository=repository, checkpoint=checkpoint, result=result) claim_task = repository.claim_task.side_effect repository.claim_task.side_effect = lambda **kwargs: ( order.append("claim") or claim_task(**kwargs) ) chain.plan_transfer.side_effect = lambda *_args, **_kwargs: ( order.append("plan") or checkpoint ) chain.execute_transfer_plan.side_effect = lambda *args, **_kwargs: ( order.append("execute") or result ) returned = chain._plan_checkpoint_and_execute(task) assert returned is result assert order == ["admit", "claim", "plan", "commit-checkpoint", "execute"] assert task.admission_task_id == "task-sync" repository.admit.assert_called_once() assert repository.admit.call_args.kwargs["planning_input"] is planning_input repository.checkpoint_plan.assert_called_once_with( task_id="task-sync", lease_token="lease-task-sync", input_fingerprint=planning_input.fingerprint, checkpoint=checkpoint, ) chain.execute_transfer_plan.assert_called_once() def test_checkpoint_commit_failure_blocks_executor_and_keeps_accepted(): """checkpoint 提交失败时不得产生文件副作用,错误留在 accepted 记录。""" repository = Mock() task = _task() planning_input = _planning_input() checkpoint = _checkpoint() task.bind_admission_task_id("task-accepted") _bind_planning_input(task, planning_input) repository.checkpoint_plan.side_effect = RuntimeError("commit failed") chain = _chain(repository=repository, checkpoint=checkpoint) with pytest.raises(RuntimeError, match="commit failed"): chain._plan_checkpoint_and_execute(task) chain.plan_transfer.assert_called_once() chain.execute_transfer_plan.assert_not_called() repository.record_planning_failure.assert_called_once() assert repository.record_planning_failure.call_args.kwargs["task_id"] == ( "task-accepted" ) assert "commit failed" in repository.record_planning_failure.call_args.kwargs["error"] def test_post_commit_crash_replays_frozen_plan_without_replanning(): """commit 后执行崩溃仍保留 planned checkpoint,重启后跳过 rename 规划。""" repository = Mock() planning_input = _planning_input() checkpoint = _checkpoint() first_task = _task() first_task.bind_admission_task_id("task-planned") _bind_planning_input(first_task, planning_input) repository.checkpoint_plan.side_effect = lambda **_kwargs: _planned_admission( first_task, checkpoint ) first_chain = _chain(repository=repository, checkpoint=checkpoint) first_chain.execute_transfer_plan.side_effect = RuntimeError("process crashed") with pytest.raises(RuntimeError, match="process crashed"): first_chain._plan_checkpoint_and_execute(first_task) assert first_task.plan_checkpoint == checkpoint repository.record_planning_failure.assert_not_called() recovered_task = _task() recovered_task.bind_admission_task_id("task-planned") _bind_planning_input(recovered_task, planning_input) _bind_checkpoint(recovered_task, checkpoint) recovered_chain = _chain(repository=Mock(), checkpoint=checkpoint) recovered_chain._plan_checkpoint_and_execute(recovered_task) recovered_chain.plan_transfer.assert_not_called() recovered_chain._transfer_admissions.checkpoint_plan.assert_not_called() recovered_chain.execute_transfer_plan.assert_called_once() execute_call = recovered_chain.execute_transfer_plan.call_args executed_checkpoint = ( execute_call.args[0] if execute_call.args else execute_call.kwargs["checkpoint"] ) assert executed_checkpoint is checkpoint def test_first_execution_and_planned_replay_consume_identical_persisted_context(): """首次提交后执行与 planned 重放必须消费同一份持久领域上下文。""" _TransferPlanningInput, _TransferPlanItem, TransferPlanCheckpoint = ( _planning_contracts() ) planner_checkpoint = _resolved_checkpoint() persisted_checkpoint = TransferPlanCheckpoint.from_payload( planner_checkpoint.to_payload() ) planning_input = planner_checkpoint.planning_input first_task = _task() first_task.meta = MetaBase("Drifted.Before.Commit.mkv") first_task.bind_admission_task_id("task-first-context") _bind_planning_input(first_task, planning_input) first_repository = Mock() first_repository.checkpoint_plan.return_value = SimpleNamespace( task_id="task-first-context", state="planned", planning_input=planning_input, checkpoint=persisted_checkpoint, ) first_chain = _chain( repository=first_repository, checkpoint=planner_checkpoint, ) first_chain._plan_checkpoint_and_execute(first_task) replay_task = _task() replay_task.bind_admission_task_id("task-first-context") _bind_planning_input(replay_task, planning_input) _bind_checkpoint(replay_task, persisted_checkpoint) replay_chain = _chain(repository=Mock(), checkpoint=persisted_checkpoint) replay_chain._plan_checkpoint_and_execute(replay_task) first_call = first_chain.execute_transfer_plan.call_args replay_call = replay_chain.execute_transfer_plan.call_args first_meta = first_call.kwargs["meta"] replay_meta = replay_call.kwargs["meta"] first_media = first_call.kwargs["mediainfo"] replay_media = replay_call.kwargs["mediainfo"] assert first_task.plan_checkpoint is persisted_checkpoint assert first_meta.to_dict() == replay_meta.to_dict() assert first_media.to_dict() == replay_media.to_dict() assert [item.model_dump() for item in first_task.episodes_info] == [ item.model_dump() for item in replay_task.episodes_info ] assert first_meta.title == "Frozen Show S01E01" assert first_media.media_id == "987" def test_accepted_recovery_replans_but_planned_recovery_does_not(): """accepted 可重新触发 rename 规划,planned 必须只读冻结计划。""" planning_input = _planning_input() checkpoint = _checkpoint() accepted_task = _task() accepted_task.bind_admission_task_id("task-accepted") _bind_planning_input(accepted_task, planning_input) accepted_repository = Mock() accepted_repository.checkpoint_plan.side_effect = lambda **_kwargs: ( _planned_admission(accepted_task, checkpoint) ) accepted_chain = _chain( repository=accepted_repository, checkpoint=checkpoint, ) accepted_chain._plan_checkpoint_and_execute(accepted_task) accepted_chain.plan_transfer.assert_called_once() accepted_chain.execute_transfer_plan.assert_called_once() planned_task = _task() planned_task.bind_admission_task_id("task-planned") _bind_planning_input(planned_task, planning_input) _bind_checkpoint(planned_task, checkpoint) planned_chain = _chain(repository=Mock(), checkpoint=checkpoint) planned_chain._plan_checkpoint_and_execute(planned_task) planned_chain.plan_transfer.assert_not_called() planned_chain._transfer_admissions.checkpoint_plan.assert_not_called() planned_chain.execute_transfer_plan.assert_called_once() def test_accepted_replay_restores_explicit_context_without_online_lookup( tmp_path, monkeypatch, ): """accepted 重放必须恢复显式 meta/media/episodes,并可离线完成重新规划。""" media_path = tmp_path / "Frozen.Show.S01E01.mkv" media_path.write_bytes(b"frozen") planning_input = replace( _resolved_checkpoint().planning_input, source_fileitem={ **_source_snapshot(), "path": media_path.as_posix(), "name": media_path.name, "basename": media_path.stem, }, meta=_resolved_checkpoint().resolved_meta, mediainfo=_resolved_checkpoint().resolved_mediainfo, target_directory={ "library_path": "/library", "library_storage": "local", "renaming": True, "notify": True, }, episodes_info=_resolved_checkpoint().resolved_episodes_info, options={ **_resolved_checkpoint().planning_input.options, "_meta_kind": "MetaVideo", "_mediainfo_kind": "MediaInfo", }, ) admission = transfer_application.TransferAdmission( task_id="task-accepted-offline", storage="local", src_path=media_path.as_posix(), state="accepted", created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", input_fingerprint=planning_input.fingerprint, planning_input=planning_input, lease_owner="replay-owner", lease_token="lease-task-accepted-offline", lease_expires_at="2026-08-27 10:02:00.000000", heartbeat_at="2026-08-27 10:00:00.000000", attempt_count=1, ) replay_repository = Mock() replay_repository.claim_recoverable.return_value = [admission] replay_chain = _replay_chain(replay_repository) replay_chain._execute_transfer = Mock() queued_tasks = [] replay_chain.put_to_queue = Mock( side_effect=lambda task: queued_tasks.append(task) or True ) replay_chain._TransferChain__replay_pending() assert len(queued_tasks) == 1 restored_task = queued_tasks[0] assert restored_task.admission_task_id == "task-accepted-offline" assert restored_task.planning_input is planning_input assert restored_task.planning_context_restored is True assert restored_task.meta.title == "Frozen Show S01E01" assert restored_task.mediainfo.media_id == "987" assert restored_task.episodes_info[0].name == "Frozen Episode" replay_chain._execute_transfer.assert_not_called() checkpoint = replace( _resolved_checkpoint(), planning_input=planning_input, ) execution_repository = Mock() execution_repository.checkpoint_plan.return_value = SimpleNamespace( task_id=admission.task_id, state="planned", planning_input=planning_input, checkpoint=checkpoint, ) execution_chain = _chain( repository=execution_repository, checkpoint=checkpoint, ) execution_chain._worker_owner_id = "replay-owner" execution_chain._owned_leases = { admission.task_id: (str(admission.lease_token), float("inf")) } execution_chain.jobview = Mock() execution_chain.eventmanager = Mock() execution_chain.eventmanager.send_event.return_value = None execution_chain.runtime_config = SimpleNamespace(scrape_follow_tmdb=True) execution_chain._TransferChain__finish_scrape_batch_task = Mock() monkeypatch.setattr( "app.chain.transfer.get_chain_transfer_history_port", lambda: Mock(), ) monkeypatch.setattr( "app.chain.transfer.MediaChain", Mock(side_effect=AssertionError("accepted replay 不应重新在线识别")), ) monkeypatch.setattr( "app.chain.transfer.TmdbChain", Mock(side_effect=AssertionError("accepted replay 不应重新在线获取剧集")), ) result = execution_chain._TransferChain__handle_transfer(restored_task) assert result[0] is True execution_chain.plan_transfer.assert_called_once() execution_chain.execute_transfer_plan.assert_called_once() def test_accepted_replay_with_explicit_empty_episodes_stays_offline( tmp_path, monkeypatch, ): """显式媒体快照即使没有剧集明细,也不得在 accepted 重放时在线补查。""" media_path = tmp_path / "Frozen.Show.S01.mkv" media_path.write_bytes(b"frozen") resolved = _resolved_checkpoint() planning_input = replace( resolved.planning_input, source_fileitem={ **_source_snapshot(), "path": media_path.as_posix(), "name": media_path.name, "basename": media_path.stem, }, meta=resolved.resolved_meta, mediainfo=resolved.resolved_mediainfo, target_directory={ "library_path": "/library", "library_storage": "local", }, episodes_info=(), options={ **resolved.planning_input.options, "_meta_kind": "MetaVideo", "_mediainfo_kind": "MediaInfo", }, ) admission = transfer_application.TransferAdmission( task_id="task-accepted-empty-episodes", storage="local", src_path=media_path.as_posix(), state="accepted", created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", planning_input=planning_input, lease_owner="replay-owner", lease_token="lease-task-accepted-empty-episodes", lease_expires_at="2026-08-27 10:02:00.000000", heartbeat_at="2026-08-27 10:00:00.000000", attempt_count=1, ) replay_repository = Mock() replay_repository.claim_recoverable.return_value = [admission] replay_chain = _replay_chain(replay_repository) queued_tasks = [] replay_chain.put_to_queue = Mock( side_effect=lambda task: queued_tasks.append(task) or True ) replay_chain._TransferChain__replay_pending() restored_task = queued_tasks[0] checkpoint = replace( resolved, planning_input=planning_input, resolved_episodes_info=(), ) execution_repository = Mock() execution_repository.checkpoint_plan.return_value = SimpleNamespace( task_id=admission.task_id, checkpoint=checkpoint, ) execution_chain = _chain( repository=execution_repository, checkpoint=checkpoint, ) execution_chain._worker_owner_id = "replay-owner" execution_chain._owned_leases = { admission.task_id: (str(admission.lease_token), float("inf")) } execution_chain.jobview = Mock() execution_chain.eventmanager = Mock() execution_chain.eventmanager.send_event.return_value = None execution_chain.runtime_config = SimpleNamespace(scrape_follow_tmdb=True) execution_chain._TransferChain__finish_scrape_batch_task = Mock() monkeypatch.setattr( "app.chain.transfer.get_chain_transfer_history_port", lambda: Mock(), ) monkeypatch.setattr( "app.chain.transfer.MediaChain", Mock(side_effect=AssertionError("accepted replay 不应重新在线识别")), ) monkeypatch.setattr( "app.chain.transfer.TmdbChain", Mock(side_effect=AssertionError("accepted replay 不应重新在线获取剧集")), ) result = execution_chain._TransferChain__handle_transfer(restored_task) assert result[0] is True assert restored_task.episodes_info == [] def test_planned_execution_restores_resolved_context_without_online_recognition(): """planned 恢复必须从 checkpoint 还原 meta/media/episodes,不再访问识别服务。""" checkpoint = _resolved_checkpoint() task = _task() task.bind_admission_task_id("task-planned-context") _bind_planning_input(task, checkpoint.planning_input) _bind_checkpoint(task, checkpoint) chain = _chain(repository=Mock(), checkpoint=checkpoint) chain.jobview = Mock() chain.eventmanager = Mock() chain.eventmanager.send_event.return_value = None with patch( "app.chain.transfer.MediaChain", side_effect=AssertionError("planned replay 不应重新在线识别"), ): result = chain._TransferChain__handle_transfer(task) assert result[0] is True chain.plan_transfer.assert_not_called() chain.execute_transfer_plan.assert_called_once() execute_call = chain.execute_transfer_plan.call_args restored_meta = execute_call.kwargs["meta"] restored_media = execute_call.kwargs["mediainfo"] assert isinstance(restored_meta, MetaBase) assert restored_meta.title == "Frozen Show S01E01" assert restored_meta.begin_episode == 1 assert restored_media.title == "Frozen Show" assert restored_media.media_id == "987" assert task.meta is restored_meta assert task.mediainfo is restored_media assert len(task.episodes_info) == 1 assert task.episodes_info[0].episode_number == 1 assert task.episodes_info[0].name == "Frozen Episode" def test_filemanager_resolves_drifted_target_from_checkpoint(monkeypatch): """运行时目录配置漂移后,executor 仍只按 checkpoint.target_storage 选适配器。""" checkpoint = _checkpoint() frozen_target = object() drifted_target = object() source_oper = object() requested_storages = [] module = object.__new__(FileManagerModule) def get_storage(storage: str): """记录执行期选择的存储并为漂移配置提供错误适配器。""" requested_storages.append(storage) return frozen_target if storage == checkpoint.target_storage else drifted_target monkeypatch.setattr( module, "_FileManagerModule__get_storage_oper", get_storage, ) execute = Mock( return_value=TransferInfo( success=True, fileitem=_task().fileitem, transfer_type="copy", ) ) monkeypatch.setattr(TransHandler, "execute_transfer_plan", execute) FileManagerModule.execute_transfer_plan( module, checkpoint, meta=None, mediainfo=None, source_oper=source_oper, ) assert requested_storages == [checkpoint.target_storage] assert execute.call_args.kwargs["target_oper"] is frozen_target assert drifted_target is not frozen_target def test_pre_checkpoint_recognition_failure_records_retryable_error(monkeypatch): """准入后、checkpoint 前的业务失败必须写 last_error 并保持 accepted。""" task = _task() task.meta = MetaBase("Unrecognized.Movie.2026.mkv") task.bind_admission_task_id("task-before-checkpoint") task.bind_execution_lease( owner_id="recognition-owner", lease_token="lease-task-before-checkpoint", ) chain = object.__new__(TransferChain) chain._transfer_admissions = Mock() chain._worker_owner_id = "recognition-owner" chain._owned_leases = { "task-before-checkpoint": ( "lease-task-before-checkpoint", float("inf"), ) } chain._worker_state_lock = threading.RLock() chain.jobview = Mock() chain.queue_failed_transfer_notification = Mock() chain.runtime_config = SimpleNamespace( ai_agent_enable=False, ai_agent_retry_transfer=False, ) chain._TransferChain__mark_torrent_completed_if_done = Mock() media_chain = Mock() media_chain.recognize_by_meta.return_value = None monkeypatch.setattr("app.chain.transfer.MediaChain", lambda: media_chain) monkeypatch.setattr( "app.chain.transfer.get_chain_transfer_history_port", lambda: SimpleNamespace(), ) monkeypatch.setattr("app.chain.transfer.record_transfer_failure", Mock()) monkeypatch.setattr("app.chain.transfer.add_transfer_fail", lambda **_kwargs: None) result = chain._TransferChain__handle_transfer(task) assert result == (False, "未识别到媒体信息") chain._transfer_admissions.record_planning_failure.assert_called_once_with( task_id="task-before-checkpoint", lease_token="lease-task-before-checkpoint", error="未识别到媒体信息", ) def test_preview_plans_without_persistence_or_file_side_effects(): """preview 只生成和投影计划,不得触碰准入仓储或存储执行器。""" repository = Mock() task = _task(preview=True) planning_input = _planning_input(preview=True) checkpoint = _checkpoint(preview=True) _bind_planning_input(task, planning_input) chain = _chain(repository=repository, checkpoint=checkpoint) chain._plan_checkpoint_and_execute(task) chain.plan_transfer.assert_called_once() repository.admit.assert_not_called() repository.checkpoint_plan.assert_not_called() repository.record_planning_failure.assert_not_called() chain.execute_transfer_plan.assert_called_once() execute_call = chain.execute_transfer_plan.call_args executed_checkpoint = ( execute_call.args[0] if execute_call.args else execute_call.kwargs["checkpoint"] ) assert executed_checkpoint.preview is True def test_preview_executor_projects_result_without_touching_storage(): """FileManager 的 preview executor 不得查询、创建、复制或删除存储对象。""" checkpoint = _checkpoint(preview=True) source_oper = Mock() target_oper = Mock() result = TransHandler().execute_transfer_plan( checkpoint, meta=None, mediainfo=None, source_oper=source_oper, target_oper=target_oper, ) assert result.success is True assert result.target_item.path == checkpoint.final_target_path assert result.file_list_new == [checkpoint.final_target_path] assert source_oper.mock_calls == [] assert target_oper.mock_calls == [] def test_filemanager_explicit_plan_then_execute_uses_same_checkpoint(): """FileManager 的调用方必须显式把冻结计划交给唯一执行入口。""" module = object.__new__(FileManagerModule) checkpoint = _checkpoint() result = TransferInfo(success=True, fileitem=_task().fileitem, transfer_type="copy") module.plan_transfer = Mock(return_value=checkpoint) module.execute_transfer_plan = Mock(return_value=result) fileitem = _task().fileitem planned = module.plan_transfer( fileitem=fileitem, meta=None, mediainfo=None, target_storage="local", target_path=Path("/library"), transfer_type="copy", preview=False, ) returned = module.execute_transfer_plan( planned, meta=None, mediainfo=None, ) assert returned is result module.plan_transfer.assert_called_once() module.execute_transfer_plan.assert_called_once() execute_call = module.execute_transfer_plan.call_args assert execute_call.args[0] is checkpoint def test_sync_and_worker_paths_share_checkpoint_orchestrator(): """同步直跑和后台 worker 必须汇入同一个检查点编排,不得旁路旧 transfer。""" module = ast.parse(inspect.getsource(inspect.getmodule(TransferChain))) functions = { node.name: node for node in ast.walk(module) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) } handler = functions["__handle_transfer"] handler_calls = { node.func.attr for node in ast.walk(handler) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) } performer = functions["__perform_transfer"] performer_calls = { node.func.attr for node in ast.walk(performer) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) } execute_batch = functions["_execute_transfer"] execute_calls = { node.func.attr for node in ast.walk(execute_batch) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) } assert "__perform_transfer" in handler_calls assert "_plan_checkpoint_and_execute" in performer_calls assert "transfer" not in handler_calls assert "__handle_transfer" in execute_calls