Files
MoviePilot/tests/test_transfer_overwrite_declined.py
T

475 lines
16 KiB
Python

"""
覆盖「不覆盖裁决不应降级已有成功记录」的行为。
查重闸放行同路径新版本后,若 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,
)