mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 02:05:13 +08:00
* 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>
195 lines
6.4 KiB
Python
195 lines
6.4 KiB
Python
"""
|
||
文件系统代理的流式复制测试。
|
||
|
||
复制大文件可能持续几小时,固定超时无法区分「正常但慢」和「已经挂死」。
|
||
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
|