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

694 lines
20 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.
from types import SimpleNamespace
from app.api.endpoints.transfer import (
manual_transfer as manual_transfer_endpoint,
query_manual_transfer_history,
)
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.db.transferhistory_oper import TransferHistoryOper
from app.helper.transferhistory import (
clear_transfer_failures,
failed_retry_count,
max_failed_retries,
record_transfer_failure,
)
from app.schemas import FileItem, ManualTransferItem
from tests.test_transfer_sync_extra_files import (
FakeMeta,
make_fileitem,
make_transfer_chain,
)
def _reset_failed_retries(src_path, storage=None):
"""清空失败重试计数,隔离用例之间共享的模块级计数缓存。"""
clear_transfer_failures(src_path, storage)
def _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, deleted):
"""隔离整理规划依赖,并记录历史及旧目标清理动作。"""
monkeypatch.setattr(
chain,
"_TransferChain__get_trans_fileitems",
lambda current_fileitem, predicate: [(fileitem, False)],
)
monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True)
monkeypatch.setattr(
chain,
"_TransferChain__register_scrape_batch_task",
lambda task: None,
)
monkeypatch.setattr(
chain,
"_TransferChain__close_scrape_batch",
lambda batch_id: None,
)
def fake_handle_transfer(task, callback=None):
"""记录进入实际整理阶段的文件。"""
planned.append(task.fileitem.path)
return True, ""
monkeypatch.setattr(chain, "_TransferChain__handle_transfer", fake_handle_transfer)
history_oper = SimpleNamespace(
get_by_src=lambda src, storage=None: history,
get_success_by_src=lambda src, storage=None: history if history and history.status else None,
get_by_dest=lambda dest, storage=None: None,
delete=lambda history_id: deleted.append(("history", history_id)),
)
monkeypatch.setattr(
"app.chain.transfer.TransferHistoryOper",
lambda: history_oper,
)
monkeypatch.setattr(
"app.chain.transfer.DownloadHistoryOper",
lambda: SimpleNamespace(
get_by_hash=lambda download_hash: None,
get_file_by_fullpath=lambda fullpath: None,
get_files_by_savepath=lambda savepath: [],
get_by_path=lambda path: None,
),
)
monkeypatch.setattr(
"app.chain.transfer.SystemConfigOper",
lambda: SimpleNamespace(get=lambda key: None),
)
monkeypatch.setattr(
"app.chain.transfer.StorageChain",
lambda: SimpleNamespace(
exists=lambda current_fileitem: True,
delete_media_file=lambda current_fileitem: deleted.append(
("target", current_fileitem.path)
)
or True,
),
)
monkeypatch.setattr(
"app.chain.transfer.MetaInfoPath",
lambda path, custom_words=None, **kwargs: FakeMeta(1),
)
def test_query_manual_transfer_history_returns_success_summary(monkeypatch):
"""手动整理初始化接口应只返回成功历史摘要供前端切换重整状态。"""
fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
captured = []
class _FakeTransferChain:
"""记录历史查询收到的文件项。"""
def get_manual_transfer_histories(self, fileitems):
"""返回两条成功历史记录。"""
captured.extend(fileitems)
return [SimpleNamespace(id=1), SimpleNamespace(id=2)]
monkeypatch.setattr(
"app.api.endpoints.transfer.TransferChain",
_FakeTransferChain,
)
response = query_manual_transfer_history(
transer_item=ManualTransferItem(fileitem=fileitem),
db=object(),
_="token",
)
assert response.success is True
assert response.data == {"reorganize": True, "history_count": 2}
assert captured == [fileitem]
def test_manual_transfer_endpoint_passes_reorganize_confirmation(monkeypatch):
"""手动整理接口应把前端重整确认传入整理链。"""
fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
captured = {}
class _FakeTransferChain:
"""记录手动整理调用参数。"""
def manual_transfer(self, **kwargs):
"""保存整理参数并返回成功。"""
captured.update(kwargs)
return True, ""
monkeypatch.setattr(
"app.api.endpoints.transfer.TransferChain",
_FakeTransferChain,
)
response = manual_transfer_endpoint(
transer_item=ManualTransferItem(
fileitem=fileitem,
reorganize=True,
),
background=False,
db=object(),
_="token",
)
assert response.success is True
assert captured["reorganize"] is True
def test_history_endpoint_reorganize_uses_chain_cleanup(monkeypatch):
"""历史 ID 重整应交由整理链清理历史,不能再传入旧目标重复删除。"""
src_fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
dest_fileitem = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv")
history = SimpleNamespace(
id=21,
status=True,
mode="copy",
src_fileitem=src_fileitem.model_dump(),
dest_fileitem=dest_fileitem.model_dump(),
)
captured = {}
class _FakeTransferChain:
"""记录历史 ID 重整调用参数。"""
def manual_transfer(self, **kwargs):
"""保存整理参数并返回成功。"""
captured.update(kwargs)
return True, ""
monkeypatch.setattr(
"app.api.endpoints.transfer.TransferHistory.get",
lambda db, logid: history,
)
monkeypatch.setattr(
"app.api.endpoints.transfer.TransferChain",
_FakeTransferChain,
)
response = manual_transfer_endpoint(
transer_item=ManualTransferItem(
logid=history.id,
reorganize=True,
),
background=False,
db=object(),
_="token",
)
assert response.success is True
assert captured["force"] is True
assert captured["reorganize"] is True
assert captured["cleanup_dest_fileitem"] is None
def test_success_history_directory_query_excludes_failed_and_siblings():
"""目录历史查询应限定路径边界,并且只返回成功记录。"""
transfer_history_oper = TransferHistoryOper()
created_histories = [
transfer_history_oper.add_force(
src="/issue-6191/show/episode-1.mkv",
src_storage="local",
status=True,
),
transfer_history_oper.add_force(
src="/issue-6191/show/episode-2.mkv",
src_storage="local",
status=False,
),
transfer_history_oper.add_force(
src="/issue-6191/show-other/episode-3.mkv",
src_storage="local",
status=True,
),
]
try:
histories = transfer_history_oper.list_success_by_src(
"/issue-6191/show",
storage="local",
recursive=True,
)
assert [history.src for history in histories] == [
"/issue-6191/show/episode-1.mkv"
]
finally:
for history in created_histories:
transfer_history_oper.delete(history.id)
def test_success_history_directory_query_escapes_sql_wildcards():
"""目录名中的 SQL 通配符应按普通字符匹配,不能扩大查询范围。"""
transfer_history_oper = TransferHistoryOper()
created_histories = [
transfer_history_oper.add_force(
src="/issue-6191/show_100%/episode-1.mkv",
src_storage="local",
status=True,
),
transfer_history_oper.add_force(
src="/issue-6191/showA100B/episode-2.mkv",
src_storage="local",
status=True,
),
]
try:
histories = transfer_history_oper.list_success_by_src(
"/issue-6191/show_100%",
storage="local",
recursive=True,
)
assert [history.src for history in histories] == [
"/issue-6191/show_100%/episode-1.mkv"
]
finally:
for history in created_histories:
transfer_history_oper.delete(history.id)
def test_successful_move_history_is_found_by_current_destination():
"""成功移动后从媒体库现址打开整理界面时,应识别原整理历史。"""
transfer_history_oper = TransferHistoryOper()
destination = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv")
history = transfer_history_oper.add_force(
src="/downloads/Test.Show.S01E01.mkv",
src_storage="local",
dest=destination.path,
dest_storage="local",
dest_fileitem=destination.model_dump(),
mode="move",
status=True,
)
try:
histories = TransferChain.get_manual_transfer_histories(
make_transfer_chain(),
[destination],
)
assert [current.id for current in histories] == [history.id]
finally:
transfer_history_oper.delete(history.id)
def test_manual_transfer_removes_failed_history_before_retry(monkeypatch):
"""失败历史不应阻塞手动整理,并应在重试前清理旧目标和记录。"""
chain = make_transfer_chain()
fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
old_dest = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv")
history = SimpleNamespace(
id=7,
status=False,
mode="copy",
dest_fileitem=old_dest.model_dump(),
download_hash=None,
downloader=None,
src=None,
src_storage=None,
)
planned = []
deleted = []
_patch_transfer_planning(
monkeypatch,
chain,
fileitem,
history,
planned,
deleted,
)
state, message = TransferChain.do_transfer(
chain,
fileitem=fileitem,
background=False,
manual=True,
)
assert state is True
assert message == ""
assert deleted == [
("target", old_dest.path),
("history", history.id),
]
assert planned == [fileitem.path]
def test_automatic_transfer_retries_failed_history_within_retry_budget(monkeypatch):
"""失败重试次数未达上限(默认计数为 0自动整理遇到失败记录应放行重试避免一次瞬时故障永久锁死文件。"""
chain = make_transfer_chain()
fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
history = SimpleNamespace(
id=12,
status=False,
mode="copy",
dest_fileitem=make_fileitem(
"/library/Test Show/Test.Show.S01E01.mkv"
).model_dump(),
download_hash=None,
downloader=None,
src=fileitem.path,
src_storage=fileitem.storage,
)
planned = []
deleted = []
_patch_transfer_planning(
monkeypatch,
chain,
fileitem,
history,
planned,
deleted,
)
_reset_failed_retries(fileitem.path, fileitem.storage)
try:
state, message = TransferChain.do_transfer(
chain,
fileitem=fileitem,
background=False,
manual=False,
)
assert state is True
assert message == ""
assert deleted == []
assert planned == [fileitem.path]
finally:
_reset_failed_retries(fileitem.path, fileitem.storage)
def test_automatic_transfer_skips_failed_history_when_retry_budget_exhausted(monkeypatch):
"""失败重试次数已达上限时,自动整理遇到失败记录仍应被拦截、不进入整理链,避免失败任务自动循环。"""
monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 1)
chain = make_transfer_chain()
fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
history = SimpleNamespace(
id=13,
status=False,
mode="copy",
dest_fileitem=make_fileitem(
"/library/Test Show/Test.Show.S01E01.mkv"
).model_dump(),
download_hash=None,
downloader=None,
src=fileitem.path,
src_storage=fileitem.storage,
)
planned = []
deleted = []
_patch_transfer_planning(
monkeypatch,
chain,
fileitem,
history,
planned,
deleted,
)
_reset_failed_retries(fileitem.path, fileitem.storage)
try:
record_transfer_failure(fileitem.path, fileitem.storage)
state, message = TransferChain.do_transfer(
chain,
fileitem=fileitem,
background=False,
manual=False,
)
assert state is False
assert message == f"{fileitem.name} 已整理过"
assert deleted == []
assert planned == []
finally:
_reset_failed_retries(fileitem.path, fileitem.storage)
def test_automatic_transfer_new_version_bypasses_exhausted_retry_budget(monkeypatch):
"""失败预算耗尽后同路径新版本必须进入整理,且不能给下载任务补打已整理标签。"""
monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 1)
chain = make_transfer_chain()
fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
fileitem.size = 200
history = SimpleNamespace(
id=15,
status=False,
mode="copy",
dest_fileitem=None,
download_hash="abc123",
downloader="qbittorrent",
src=fileitem.path,
src_storage=fileitem.storage,
src_fileitem={"size": 100},
)
planned = []
deleted = []
_patch_transfer_planning(
monkeypatch,
chain,
fileitem,
history,
planned,
deleted,
)
completed = []
chain.transfer_completed = lambda download_hash, downloader: completed.append(
(download_hash, downloader)
)
_reset_failed_retries(fileitem.path, fileitem.storage)
try:
record_transfer_failure(fileitem.path, fileitem.storage, file_size=100)
state, message = TransferChain.do_transfer(
chain,
fileitem=fileitem,
downloader="qbittorrent",
download_hash="abc123",
background=False,
manual=False,
)
assert state is True
assert message == ""
assert deleted == []
assert planned == [fileitem.path]
assert completed == []
finally:
_reset_failed_retries(fileitem.path, fileitem.storage)
def test_manual_transfer_bypasses_retry_budget_when_exhausted(monkeypatch):
"""
手动整理不受重试次数上限限制manual=True 时查重闸根本不参与判定
do_transfer 中 `if transferd and not manual` 已把 manual 路径排除在外),
即使失败计数已达上限(自动路径会因此拦截),仍应清理失败记录、重置计数并放行重试。
"""
monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 1)
chain = make_transfer_chain()
fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
old_dest = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv")
history = SimpleNamespace(
id=14,
status=False,
mode="copy",
dest_fileitem=old_dest.model_dump(),
download_hash=None,
downloader=None,
src=fileitem.path,
src_storage=fileitem.storage,
)
planned = []
deleted = []
_patch_transfer_planning(
monkeypatch,
chain,
fileitem,
history,
planned,
deleted,
)
_reset_failed_retries(fileitem.path, fileitem.storage)
try:
record_transfer_failure(fileitem.path, fileitem.storage)
assert failed_retry_count(fileitem.path, fileitem.storage) >= max_failed_retries()
state, message = TransferChain.do_transfer(
chain,
fileitem=fileitem,
background=False,
manual=True,
)
assert state is True
assert message == ""
assert deleted == [
("target", old_dest.path),
("history", history.id),
]
assert planned == [fileitem.path]
assert failed_retry_count(fileitem.path, fileitem.storage) == 0
finally:
_reset_failed_retries(fileitem.path, fileitem.storage)
def test_manual_transfer_keeps_success_history_without_confirmation(monkeypatch):
"""未携带重整标志时,成功历史仍应阻止普通手动整理。"""
chain = make_transfer_chain()
fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
history = SimpleNamespace(
id=8,
status=True,
mode="copy",
dest_fileitem=make_fileitem(
"/library/Test Show/Test.Show.S01E01.mkv"
).model_dump(),
download_hash=None,
downloader=None,
)
planned = []
deleted = []
_patch_transfer_planning(
monkeypatch,
chain,
fileitem,
history,
planned,
deleted,
)
state, message = TransferChain.do_transfer(
chain,
fileitem=fileitem,
background=False,
manual=True,
)
assert state is True
assert message == f"{fileitem.name} 已整理过"
assert deleted == []
assert planned == []
def test_manual_reorganize_removes_success_history_and_old_target(monkeypatch):
"""用户确认重新整理后,应清理成功历史及非移动模式旧目标再执行。"""
chain = make_transfer_chain()
fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
old_dest = make_fileitem("/library/Wrong Show/Wrong.Show.S02E01.mkv")
history = SimpleNamespace(
id=9,
status=True,
mode="copy",
dest_fileitem=old_dest.model_dump(),
download_hash=None,
downloader=None,
src=None,
src_storage=None,
)
planned = []
deleted = []
_patch_transfer_planning(
monkeypatch,
chain,
fileitem,
history,
planned,
deleted,
)
state, message = TransferChain.do_transfer(
chain,
fileitem=fileitem,
background=False,
manual=True,
reorganize=True,
)
assert state is True
assert message == ""
assert deleted == [
("target", old_dest.path),
("history", history.id),
]
assert planned == [fileitem.path]
def test_manual_reorganize_keeps_successful_move_target_as_source(monkeypatch):
"""成功移动后的目标是当前重整源,只能删历史记录,不能先删除文件。"""
chain = make_transfer_chain()
fileitem = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv")
history = SimpleNamespace(
id=10,
status=True,
mode="move",
dest_fileitem=fileitem.model_dump(),
download_hash=None,
downloader=None,
src=None,
src_storage=None,
)
planned = []
deleted = []
_patch_transfer_planning(
monkeypatch,
chain,
fileitem,
history,
planned,
deleted,
)
state, message = TransferChain.do_transfer(
chain,
fileitem=fileitem,
background=False,
manual=True,
reorganize=True,
)
assert state is True
assert message == ""
assert deleted == [("history", history.id)]
assert planned == [fileitem.path]
def test_forced_manual_reorganize_still_removes_history(monkeypatch):
"""从历史入口强制重新整理时,仍应按重整确认清理旧目标和记录。"""
chain = make_transfer_chain()
fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
old_dest = make_fileitem("/library/Wrong Show/Wrong.Show.S02E01.mkv")
history = SimpleNamespace(
id=11,
status=True,
mode="copy",
dest_fileitem=old_dest.model_dump(),
download_hash=None,
downloader=None,
src=None,
src_storage=None,
)
planned = []
deleted = []
_patch_transfer_planning(
monkeypatch,
chain,
fileitem,
history,
planned,
deleted,
)
state, message = TransferChain.do_transfer(
chain,
fileitem=fileitem,
background=False,
manual=True,
force=True,
reorganize=True,
)
assert state is True
assert message == ""
assert deleted == [
("target", old_dest.path),
("history", history.id),
]
assert planned == [fileitem.path]