""" 覆盖「不覆盖裁决不应降级已有成功记录」的行为。 查重闸放行同路径新版本后,若 overwrite_mode 最终裁定不覆盖,媒体库中原有的 成功版本仍然在位——这是一次正常策略裁决而非整理故障。TransferChain 内部的 __is_overwrite_declined 用于识别这一场景,__default_callback 失败分支据此 决定是否写失败历史、发送失败事件与失败通知。本文件覆盖两者。 """ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from app.application.transfer.execution import ( TransferExecutionCheckpoint, TransferSettlementResult, ) from app.chain.transfer import TransferChain, _DurableTransferStepRunner from app.schemas.transfer import TransferInfo from app.schemas.types import EventType from tests.test_transfer_job_manager import ( FakeMedia, bind_terminal_checkpoint, make_fileitem, make_task, make_transfer_chain, ) def make_history_oper(history=None, success_history=None, raise_on_query: bool = False): """构造 __is_overwrite_declined 查询整理历史使用的替身。""" def get_by_src(src, storage=None): if raise_on_query: raise RuntimeError("boom") return history def get_success_by_src(src, storage=None): return success_history return SimpleNamespace( get_by_src=get_by_src, get_success_by_src=get_success_by_src, ) def make_fail_recorder(calls): """替换整理链的失败历史写入函数:只记录调用,不做字段翻译也不落库。""" def add_transfer_fail(**kwargs): calls.append(kwargs) return SimpleNamespace(id=1) return add_transfer_fail # --------------------------------------------------------------------------- # TransferChain.__is_overwrite_declined # --------------------------------------------------------------------------- def test_overwrite_declined_false_when_flag_not_set(): """overwrite_skipped 为假时直接判定为 False,且不应触发历史查询。""" task = make_task(1) transferinfo = TransferInfo(success=False, overwrite_skipped=False) transferhis = make_history_oper(raise_on_query=True) result = TransferChain._is_overwrite_declined( task, transferinfo, transferhis ) assert result is False def test_overwrite_declined_true_when_success_history_exists(): """overwrite_skipped 为真且同源已有成功记录时,应判定为保护场景。""" task = make_task(1) success_history = SimpleNamespace(id=1, status=True) transferinfo = TransferInfo(success=False, overwrite_skipped=True) transferhis = make_history_oper(history=success_history) result = TransferChain._is_overwrite_declined( task, transferinfo, transferhis ) assert result is True def test_overwrite_declined_false_when_no_history(): """overwrite_skipped 为真但没有任何整理记录时,不应判定为保护场景。""" task = make_task(1) transferinfo = TransferInfo(success=False, overwrite_skipped=True) transferhis = make_history_oper(history=None) result = TransferChain._is_overwrite_declined( task, transferinfo, transferhis ) assert result is False def test_overwrite_declined_false_when_only_failed_history(): """overwrite_skipped 为真但只有失败记录时,不应判定为保护场景。""" task = make_task(1) failed_history = SimpleNamespace(id=2, status=False) transferinfo = TransferInfo(success=False, overwrite_skipped=True) transferhis = make_history_oper(history=failed_history, success_history=None) result = TransferChain._is_overwrite_declined( task, transferinfo, transferhis ) assert result is False def test_overwrite_declined_false_when_query_raises(): """查询整理历史异常时应保守返回 False,不阻断原有失败语义。""" task = make_task(1) transferinfo = TransferInfo(success=False, overwrite_skipped=True) transferhis = make_history_oper(raise_on_query=True) result = TransferChain._is_overwrite_declined( task, transferinfo, transferhis ) assert result is False def test_overwrite_declined_uses_successful_durable_settlement(): """覆盖拒绝保留旧成功历史时,durable 终态必须按成功结算。""" task = make_task(1) task.bind_admission_task_id("task-overwrite-declined") task.bind_execution_lease(owner_id="worker", lease_token="lease") transferinfo = TransferInfo( success=False, overwrite_skipped=True, message="目标已存在,按覆盖策略跳过覆盖", ) task.bind_execution_checkpoint(TransferExecutionCheckpoint.create( payload={ "outcome": "overwrite_skipped", "transferinfo": transferinfo.model_dump(mode="json"), }, operation_ids=(), skip_reason="overwrite_declined", )) settlement = TransferChain._TransferChain__build_transfer_result_settlement( task, transferinfo, overwrite_declined=True, ) assert settlement is not None assert settlement.outcome == "succeeded" assert settlement.error is None def test_durable_step_runner_records_overwrite_skip_as_explicit_outcome(): """生产 runner 必须冻结覆盖跳过事实,不能提前把它记成普通失败。""" runner = object.__new__(_DurableTransferStepRunner) runner._task_id = "task-overwrite-skipped" runner._lease_token = "lease" runner._operation_ids = [] runner._command = MagicMock() runner._command.checkpoint.side_effect = lambda **kwargs: SimpleNamespace( checkpoint=kwargs["checkpoint"] ) transferinfo = TransferInfo( success=False, overwrite_skipped=True, message="目标已存在,按覆盖策略跳过覆盖", ) checkpoint = runner.checkpoint(transferinfo) assert checkpoint.payload["outcome"] == "overwrite_skipped" def test_overwrite_skip_without_success_history_uses_failed_settlement(): """未核实既有成功历史时,覆盖跳过标志不能伪造成功终态。""" task = make_task(1) task.bind_admission_task_id("task-overwrite-missing-history") task.bind_execution_lease(owner_id="worker", lease_token="lease") task.bind_execution_checkpoint(TransferExecutionCheckpoint.create( payload={"outcome": "failed"}, operation_ids=(), skip_reason="overwrite_without_history", )) transferinfo = TransferInfo( success=False, overwrite_skipped=True, message="目标已存在,按覆盖策略跳过覆盖", ) settlement = TransferChain._TransferChain__build_transfer_result_settlement( task, transferinfo, overwrite_declined=False, ) assert settlement is not None assert settlement.outcome == "failed" assert settlement.error == transferinfo.message # --------------------------------------------------------------------------- # __default_callback 失败分支 # --------------------------------------------------------------------------- def _make_failed_task(): """构造一个失败回调测试所需的最小整理任务。""" task = make_task(1) task.mediainfo = FakeMedia() # __default_callback 失败通知路径需要读取海报图,FakeMedia 本身不提供该接口 task.mediainfo.get_message_image = lambda: "poster.jpg" task.background = False task.manual = True return task def test_default_callback_skips_history_and_notification_when_overwrite_declined(): """ 同源已有成功记录时,覆盖裁决不覆盖不应写失败历史、不应发送失败事件与通知。 """ chain = make_transfer_chain() chain.eventmanager = MagicMock() chain.post_message = MagicMock() task = _make_failed_task() success_history = SimpleNamespace(id=99, status=True) add_fail_calls = [] transfer_history_oper = make_history_oper(history=success_history) transferinfo = TransferInfo( success=False, fileitem=task.fileitem, message="目标已存在,按覆盖策略跳过覆盖", transfer_type="copy", overwrite_skipped=True, need_notify=False, ) bind_terminal_checkpoint(task, transferinfo) with patch( "app.chain.transfer.get_chain_transfer_history_port", return_value=transfer_history_oper, ), patch( "app.chain.transfer.add_transfer_fail", make_fail_recorder(add_fail_calls), ), patch( "app.runtime.config.settings.AI_AGENT_ENABLE", False ), patch( "app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False ): state, errmsg = chain._TransferChain__default_callback(task, transferinfo) assert state is False assert errmsg == transferinfo.message assert add_fail_calls == [] assert chain.post_message.call_count == 0 transfer_failed_events = [ call for call in chain.eventmanager.send_event.call_args_list if call.args[0] == EventType.TransferFailed ] assert transfer_failed_events == [] def test_default_callback_keeps_original_failure_semantics_without_success_history(): """ 没有已有成功记录时(即使 overwrite_skipped 为真),仍应按原有语义写失败历史并通知。 """ chain = make_transfer_chain() chain.eventmanager = MagicMock() chain.post_message = MagicMock() task = _make_failed_task() add_fail_calls = [] transfer_history_oper = make_history_oper(history=None) transferinfo = TransferInfo( success=False, fileitem=task.fileitem, message="目标已存在,按覆盖策略跳过覆盖", transfer_type="copy", overwrite_skipped=True, need_notify=False, ) bind_terminal_checkpoint(task, transferinfo) with patch( "app.chain.transfer.get_chain_transfer_history_port", return_value=transfer_history_oper, ), patch( "app.chain.transfer.add_transfer_fail", make_fail_recorder(add_fail_calls), ), patch( "app.runtime.config.settings.AI_AGENT_ENABLE", False ), patch( "app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False ): state, errmsg = chain._TransferChain__default_callback(task, transferinfo) assert state is False assert errmsg == transferinfo.message assert len(add_fail_calls) == 1 assert chain.post_message.call_count == 1 transfer_failed_events = [ call for call in chain.eventmanager.send_event.call_args_list if call.args[0] == EventType.TransferFailed ] assert len(transfer_failed_events) == 1 def test_durable_callback_settles_overwrite_skip_without_history_as_failed(): """durable 回调没有既有成功历史时必须原子提交失败历史和失败终态。""" chain = make_transfer_chain() chain.eventmanager = MagicMock() chain.post_message = MagicMock() chain.durable_event_writer = MagicMock() task = _make_failed_task() task.bind_admission_task_id("task-overwrite-no-history") task.bind_execution_lease(owner_id="worker", lease_token="lease") task.bind_execution_checkpoint(TransferExecutionCheckpoint.create( payload={"outcome": "failed"}, operation_ids=(), skip_reason="overwrite_without_history", )) add_fail_calls = [] transfer_history_oper = make_history_oper(history=None) transferinfo = TransferInfo( success=False, fileitem=task.fileitem, message="目标已存在,按覆盖策略跳过覆盖", transfer_type="copy", overwrite_skipped=True, need_notify=False, ) def durable_transfer_result(**kwargs): """执行失败历史暂存并返回 task-aware 结算回执。""" history = kwargs["stage_history"](SimpleNamespace()) assert kwargs["settlement"].outcome == "failed" return TransferSettlementResult( history_id=history.id, settlement_revision=1, pending_deleted=False, ) chain.durable_event_writer.transfer_result.side_effect = durable_transfer_result with patch( "app.chain.transfer.get_chain_transfer_history_port", return_value=transfer_history_oper, ), patch( "app.chain.transfer.add_transfer_fail", make_fail_recorder(add_fail_calls), ), patch( "app.runtime.config.settings.AI_AGENT_ENABLE", False ), patch( "app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False ): state, errmsg = chain._TransferChain__default_callback(task, transferinfo) assert state is False assert errmsg == transferinfo.message assert len(add_fail_calls) == 1 settlement = chain.durable_event_writer.transfer_result.call_args.kwargs[ "settlement" ] assert settlement.outcome == "failed" assert settlement.error == transferinfo.message def test_default_callback_delegates_primary_failure_to_durable_writer(): """正式上下文存在 writer 时,主要媒体失败历史和事件必须走同一事务端口。""" chain = make_transfer_chain() chain.eventmanager = MagicMock() chain.post_message = MagicMock() chain.durable_event_writer = MagicMock() task = _make_failed_task() add_fail_calls = [] transfer_history_oper = make_history_oper(history=None) transferinfo = TransferInfo( success=False, fileitem=task.fileitem, message="copy failed", transfer_type="copy", need_notify=False, ) bind_terminal_checkpoint(task, transferinfo) def durable_transfer_result(**kwargs): """执行 writer 收到的历史暂存与提交后发布回调。""" history = kwargs["stage_history"](SimpleNamespace()) payload = dict(kwargs["event_payload"]) payload["transfer_history_id"] = history.id payload["idempotency_key"] = f"transfer.failed:{history.id}:v1" kwargs["publish"](payload) return TransferSettlementResult( history_id=history.id, settlement_revision=1, pending_deleted=True, ) chain.durable_event_writer.transfer_result.side_effect = durable_transfer_result with patch( "app.chain.transfer.get_chain_transfer_history_port", return_value=transfer_history_oper, ), patch( "app.chain.transfer.add_transfer_fail", make_fail_recorder(add_fail_calls), ), patch( "app.runtime.config.settings.AI_AGENT_ENABLE", False, ): state, errmsg = chain._TransferChain__default_callback(task, transferinfo) assert state is False assert errmsg == "copy failed" assert len(add_fail_calls) == 1 chain.durable_event_writer.transfer_result.assert_called_once() topic = chain.durable_event_writer.transfer_result.call_args.kwargs["topic"] assert topic == "transfer.failed" event_type, event_payload = chain.eventmanager.send_event.call_args.args assert event_type == EventType.TransferFailed assert event_payload["idempotency_key"] == "transfer.failed:1:v1" @pytest.mark.parametrize( ("path", "success", "expected_topic", "expected_event"), [ ( "/downloads/demo.srt", True, "transfer.subtitle.completed", EventType.SubtitleTransferComplete, ), ( "/downloads/demo.srt", False, "transfer.subtitle.failed", EventType.SubtitleTransferFailed, ), ( "/downloads/demo.flac", True, "transfer.audio.completed", EventType.AudioTransferComplete, ), ( "/downloads/demo.flac", False, "transfer.audio.failed", EventType.AudioTransferFailed, ), ], ) def test_subtitle_and_audio_results_use_durable_topics( path: str, success: bool, expected_topic: str, expected_event: EventType, ) -> None: """字幕与音频结果必须和主要媒体结果共用 durable writer 分类。""" chain = make_transfer_chain() task = make_task(1) task.fileitem = make_fileitem(path) assert chain._durable_transfer_event(task, success=success) == ( expected_topic, expected_event, )