Files
MoviePilot/tests/test_transfer_failed_retry_budget.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

135 lines
5.6 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.
"""
有界重试预算的端到端行为验证。
app/helper/transferhistory.py 的查重闸真值表与计数器 API 已在
tests/test_transfer_history_gate.py 逐项覆盖,本文件换一个角度:把「同一源路径
连续多个监控事件」串成一条时间线,验证瞬时故障能在预算内自愈、耗尽预算后被拦、
以及删除整理记录会让预算重新满额,贴近真实使用场景。
"""
from types import SimpleNamespace
from app import schemas
from app.core.config import settings
from app.helper.transferhistory import (
HistoryGateAction,
clear_transfer_failures,
evaluate_history_gate,
failed_retry_count,
record_transfer_failure,
)
def _reset_failed_retries(src_path, storage=None):
"""清空失败重试计数,隔离用例之间共享的模块级计数缓存。"""
clear_transfer_failures(src_path, storage)
def _failed_history(history_id: int, src_path: str, storage: str):
"""构造一条持续失败的整理记录替身,模拟同一源路径屡次整理失败后落库的状态。"""
return SimpleNamespace(id=history_id, status=False, src_fileitem=None,
src=src_path, src_storage=storage)
def test_transient_failures_self_heal_within_retry_budget(monkeypatch):
"""
瞬时故障自愈:上限为 3 时,连续 2 次失败后第 3 个事件仍应放行重试;
第 3 次也失败后计数达到上限,第 4 个事件应被拦截。
"""
monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 3)
src_path = "/downloads/retry-budget-self-heal.mkv"
storage = "local"
_reset_failed_retries(src_path, storage)
try:
history = _failed_history(1, src_path, storage)
# 事件 1整理失败登记第 1 次失败
record_transfer_failure(src_path, storage)
assert failed_retry_count(src_path, storage) == 1
# 事件 2计数为 1未达上限放行重试本次也失败登记第 2 次失败
assert evaluate_history_gate(history, file_size=None) == HistoryGateAction.PASS_FAILED
record_transfer_failure(src_path, storage)
assert failed_retry_count(src_path, storage) == 2
# 事件 3连续失败 2 次后,计数为 2 仍未达上限3第 3 个事件仍应放行重试
assert evaluate_history_gate(history, file_size=None) == HistoryGateAction.PASS_FAILED
# 第 3 次尝试也失败,登记第 3 次失败,计数达到上限
record_transfer_failure(src_path, storage)
assert failed_retry_count(src_path, storage) == 3
# 事件 4计数达到上限3应被拦截不再自动重试
assert evaluate_history_gate(history, file_size=None) == HistoryGateAction.SKIP_RETRY_EXHAUSTED
finally:
_reset_failed_retries(src_path, storage)
def test_clearing_transfer_history_restores_full_retry_budget(monkeypatch):
"""删除整理记录清零:耗尽重试预算后调用 clear_transfer_failures应重新获得放行资格。"""
monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 2)
src_path = "/downloads/retry-budget-cleared-by-delete.mkv"
storage = "local"
_reset_failed_retries(src_path, storage)
try:
history = _failed_history(2, src_path, storage)
record_transfer_failure(src_path, storage)
record_transfer_failure(src_path, storage)
assert failed_retry_count(src_path, storage) == 2
assert evaluate_history_gate(history, file_size=None) == HistoryGateAction.SKIP_RETRY_EXHAUSTED
# 用户删除整理记录,显式要求重来
clear_transfer_failures(src_path, storage)
assert failed_retry_count(src_path, storage) == 0
assert evaluate_history_gate(history, file_size=None) == HistoryGateAction.PASS_FAILED
finally:
_reset_failed_retries(src_path, storage)
def test_delete_transfer_history_endpoint_clears_retry_count(monkeypatch):
"""
app/api/endpoints/history.py::delete_transfer_history 是用户删除整理记录的入口,
删除时应连带清空失败重试计数,否则重整仍会受上一轮次数限制。
该端点依赖 SQLAlchemy Session 与鉴权依赖,这里按仓库内既有做法(参见
tests/test_manual_transfer_history.py 对 app.api.endpoints.transfer 端点的用法)
直接以关键字参数调用端点函数本身,绕开 FastAPI 的依赖注入,只替换端点内部
实际用到的 TransferHistory.get / TransferHistory.delete 两个类方法。
"""
from app.api.endpoints.history import delete_transfer_history
src_path = "/downloads/retry-budget-delete-endpoint.mkv"
storage = "local"
history = SimpleNamespace(
id=101,
src=src_path,
src_storage=storage,
dest_fileitem=None,
src_fileitem=None,
download_hash=None,
)
monkeypatch.setattr("app.api.endpoints.history.TransferHistory.get",
lambda db, history_id: history)
monkeypatch.setattr("app.api.endpoints.history.TransferHistory.delete",
lambda db, history_id: None)
_reset_failed_retries(src_path, storage)
try:
record_transfer_failure(src_path, storage)
record_transfer_failure(src_path, storage)
assert failed_retry_count(src_path, storage) == 2
response = delete_transfer_history(
history_in=schemas.TransferHistory(id=101),
deletesrc=False,
deletedest=False,
db=object(),
_="token",
)
assert response.success is True
assert failed_retry_count(src_path, storage) == 0
finally:
_reset_failed_retries(src_path, storage)