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

195 lines
6.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.
"""
文件系统代理的流式复制测试。
复制大文件可能持续几小时,固定超时无法区分「正常但慢」和「已经挂死」。
worker 每秒上报一次进度作为心跳,父进程判定的是**两次上报之间的间隔**
超过 stall 阈值收不到任何一行,才认定挂载无响应并强杀 worker。
这些测试固定的不变量:内容与时间戳正确、进度可回调、慢传输不被误杀、
挂死能被判定并回收、取消可即时生效。
"""
import os
import time
from pathlib import Path
import pytest
from app.modules.filemanager.fsproxy import FileSystemProxy, FileSystemTimeout
@pytest.fixture
def proxy():
"""
每个用例一个独立代理,用完回收进程。
"""
instance = FileSystemProxy(timeout=30, stall_timeout=30)
yield instance
instance.close()
def test_copy_preserves_content_and_mtime(tmp_path, proxy):
"""
复制结果的内容与修改时间必须与源一致。
时间戳要保留但权限不能覆盖:目标目录的默认权限与继承 ACL 是媒体库的访问
策略,用源文件权限盖掉会清除已继承的 ACL。
"""
src = tmp_path / "Movie.2024.mkv"
src.write_bytes(b"payload" * 5000)
old = time.time() - 86400
os.utime(src, (old, old))
dst = tmp_path / "out.mkv"
size = src.stat().st_size
assert proxy.copy(src, dst) == {"copied": size, "total": size}
assert dst.read_bytes() == src.read_bytes()
assert dst.stat().st_mtime == pytest.approx(src.stat().st_mtime, abs=1)
def test_copy_empty_file(tmp_path, proxy):
"""
空文件不能因为没有任何数据块就走进异常分支。
"""
src = tmp_path / "empty.mkv"
src.write_bytes(b"")
dst = tmp_path / "out.mkv"
assert proxy.copy(src, dst)["total"] == 0
assert dst.exists()
def test_copy_reports_progress(tmp_path, proxy):
"""
进度必须被回调出来,且单调递增、不超过 100。
"""
src = tmp_path / "big.mkv"
src.write_bytes(b"x" * (3 * 1024 * 1024))
dst = tmp_path / "out.mkv"
seen = []
# 用小 chunk 让传输持续足够久,确保能观察到进度上报
assert proxy.copy(src, dst, progress_cb=seen.append, chunk_size=4096)
assert seen == sorted(seen), "进度不是单调递增的"
assert all(0 <= value <= 100 for value in seen)
def test_copy_missing_source_raises_file_not_found(tmp_path, proxy):
"""
源文件不存在要还原成 FileNotFoundError调用方的既有分支才能生效。
"""
with pytest.raises(FileNotFoundError):
proxy.copy(tmp_path / "nope.mkv", tmp_path / "out.mkv")
def test_slow_transfer_is_not_killed(tmp_path):
"""
关键区分之一:传输很慢但仍在推进时,绝不能被判定为挂死。
stall 阈值取得远小于可能的总耗时——只要心跳不断,就必须一路跑完。
"""
src = tmp_path / "slow.mkv"
src.write_bytes(b"x" * (2 * 1024 * 1024))
dst = tmp_path / "out.mkv"
proxy = FileSystemProxy(timeout=30, stall_timeout=3)
try:
assert proxy.copy(src, dst, chunk_size=8192)
assert dst.read_bytes() == src.read_bytes()
finally:
proxy.close()
def test_stalled_transfer_is_detected_and_killed(tmp_path, monkeypatch):
"""
关键区分之二:传输彻底不推进时必须被判定并强杀,而不是永久等待。
"""
import app.modules.filemanager.fsproxy as fsproxy_module
# worker 替身:读到请求后完全不响应,模拟卡死在挂载上的传输
stuck = tmp_path / "stuck_worker.py"
stuck.write_text(
"import sys, time\nsys.stdin.readline()\nwhile True:\n time.sleep(60)\n",
encoding="utf-8"
)
monkeypatch.setattr(fsproxy_module, "_WORKER_PATH", stuck)
proxy = FileSystemProxy(timeout=30, stall_timeout=0.5)
try:
started = time.monotonic()
with pytest.raises(FileSystemTimeout):
proxy.copy(tmp_path / "a.mkv", tmp_path / "b.mkv")
assert time.monotonic() - started < 15, "挂死的传输没有被及时放弃"
assert proxy._process is None, "冻住的代理进程没有被回收"
finally:
proxy.close()
def test_copy_can_be_cancelled_midway(tmp_path):
"""
取消要即时生效:父进程收到进度就检查取消标记,命中即杀掉 worker 中断传输,
不必等它把整个文件读完。
"""
src = tmp_path / "big.mkv"
src.write_bytes(b"x" * (4 * 1024 * 1024))
dst = tmp_path / "out.mkv"
proxy = FileSystemProxy(timeout=30, stall_timeout=30)
try:
assert proxy.copy(src, dst, cancel_cb=lambda: True, chunk_size=4096) is False
# 取消后代理已被回收,下一次调用会重启一个干净的 worker
assert proxy._process is None
finally:
proxy.close()
def test_copy_falls_back_to_direct_when_disabled(tmp_path, monkeypatch):
"""
代理关闭时复制退回进程内直接执行,行为与引入代理之前一致。
"""
import app.modules.filemanager.fsproxy as fsproxy_module
monkeypatch.setattr(fsproxy_module.settings, "FS_PROXY_ENABLED", False, raising=False)
src = tmp_path / "a.mkv"
src.write_bytes(b"direct" * 100)
dst = tmp_path / "b.mkv"
proxy = FileSystemProxy()
try:
assert proxy.copy(src, dst) is True
assert dst.read_bytes() == src.read_bytes()
# 关闭时不应启动任何子进程
assert proxy._process is None
finally:
proxy.close()
def test_direct_copy_honours_cancel(tmp_path, monkeypatch):
"""
关闭代理时取消同样要生效,否则关掉开关就丢了取消能力。
"""
import app.modules.filemanager.fsproxy as fsproxy_module
monkeypatch.setattr(fsproxy_module.settings, "FS_PROXY_ENABLED", False, raising=False)
src = tmp_path / "a.mkv"
src.write_bytes(b"x" * 8192)
dst = tmp_path / "b.mkv"
proxy = FileSystemProxy()
try:
assert proxy.copy(src, dst, cancel_cb=lambda: True, chunk_size=1024) is False
finally:
proxy.close()
def test_worker_still_standalone_after_streaming_support():
"""
加了流式协议之后 worker 仍须只依赖标准库——一旦引入 app 导入链,
强杀后的重启成本会从毫秒级涨到秒级,整个代理方案就不成立了。
"""
worker = Path("app/modules/filemanager/fsworker.py").read_text(encoding="utf-8")
assert "from app." not in worker
assert "import app" not in worker