Files
MoviePilot/tests/test_transfer_overwrite_declined.py
Aqr-K a2e70b443d fix(monitor,transfer): 修复 FUSE 挂载无响应导致的监控冻死、整理链锁死与漏件 (#6276)
* wip(v3): 移植监控与整理韧性修复到 v3 基线

包含:监控看门狗隔离/挂载探测、整理队列持久化、文件系统子进程代理、
写入原子化。迁移重挂到 v3 链 8a4c7e1d2f90 -> 7f5c1d2e3a4b -> e3d9f4b7c806。
tmdb 相关测试尚未通过,待定位。

* fix(v3): 修正移植引入的 16 项测试失败

- poller.py:合并时我方保留的行仍用旧变量名 merged_snapshot,而 v3 已统一
  改名为 current_snapshot,导致 NameError 被外层 except 吞掉、快照从未保存
- smb.py:采纳 f-string 拆分写法,恢复 Python 3.11 可解析
- dispatcher 测试:历史查重由 _should_skip_by_history 统一承担,mock 点随之调整
- tmdb 缓存测试:补充 v3 新增的 media_source/media_id 字段
- tmdb 重试测试:为 fake 补充 match_multi/async_match_multi

尚余 3 项与 v3 识别流程的连接失败处理有关,待单独判断。

* fix(v3): 测试适配 v3 的 media_source/media_id 重构

v3 将媒体标识从 tmdbid 统一重构为 media_source + media_id,recognize_media
的 tmdbid 参数已被 **kwargs 静默吞掉——传了也不生效,流程会误降级到名称搜索。
tmdb 重试用例改用新参数后恢复正确路径。

同时修正 fake 的 match_multi 语义:真实实现(tmdbapi.match_multi)吞掉所有
异常并返回 None,连接失败与「未找到」在该路径上本就不可区分,fake 需保持一致。

至此移植引入的 19 项失败全部清零。

---------

Co-authored-by: Aqr-K <Aqr-K@users.noreply.github.com>
2026-08-13 08:19:54 +08:00

219 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
覆盖「不覆盖裁决不应降级已有成功记录」的行为。
查重闸放行同路径新版本后,若 overwrite_mode 最终裁定不覆盖,媒体库中原有的
成功版本仍然在位——这是一次正常策略裁决而非整理故障。TransferChain 内部的
__is_overwrite_declined 用于识别这一场景__default_callback 失败分支据此
决定是否写失败历史、发送失败事件与失败通知。本文件覆盖两者。
"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from app.chain.transfer import TransferChain
from app.schemas import TransferInfo
from app.schemas.types import EventType
from tests.test_transfer_job_manager import FakeMedia, make_task, make_transfer_chain
def make_history_oper(history=None, success_history=None, raise_on_query: bool = False,
add_fail_calls=None):
"""构造 __is_overwrite_declined / __default_callback 查询与写入整理历史使用的替身。"""
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
def add_fail(**kwargs):
if add_fail_calls is not None:
add_fail_calls.append(kwargs)
return SimpleNamespace(id=1)
return SimpleNamespace(
get_by_src=get_by_src,
get_success_by_src=get_success_by_src,
add_fail=add_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._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._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._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._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._TransferChain__is_overwrite_declined(
task, transferinfo, transferhis
)
assert result is False
# ---------------------------------------------------------------------------
# __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, add_fail_calls=add_fail_calls
)
transferinfo = TransferInfo(
success=False,
fileitem=task.fileitem,
message="目标已存在,按覆盖策略跳过覆盖",
transfer_type="copy",
overwrite_skipped=True,
need_notify=False,
)
with patch(
"app.chain.transfer.TransferHistoryOper",
return_value=transfer_history_oper,
), patch(
"app.chain.transfer.settings.AI_AGENT_ENABLE", False
), patch(
"app.chain.transfer.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, add_fail_calls=add_fail_calls
)
transferinfo = TransferInfo(
success=False,
fileitem=task.fileitem,
message="目标已存在,按覆盖策略跳过覆盖",
transfer_type="copy",
overwrite_skipped=True,
need_notify=False,
)
with patch(
"app.chain.transfer.TransferHistoryOper",
return_value=transfer_history_oper,
), patch(
"app.chain.transfer.settings.AI_AGENT_ENABLE", False
), patch(
"app.chain.transfer.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