mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-19 05:03:57 +08:00
- 字幕编排上移 DownloadChain.download_site_subtitles,SubtitleModule 仅保留站点链接解析 - TransferChain.recommend_name 上移 TV episodes_info 获取,filemanager 模块不再导入 TmdbChain - endpoint 穿透修复:WXBizMsgCrypt3 迁至 adapters/external/wechat_crypt.py; music/tmdb 缓存管理、listenbrainz 常量、TMDbException、WechatClawBot 辅助统一经 chain 包装 - RuleParser 与 builtin_rules 合并为 application/filter_rules.py; fsproxy/fsworker 迁至 adapters/system/ - chain/__init__.py 删除 qbittorrentapi/transmission_rpc 导入,消除后端协议类型泄漏 - 架构守护测试新增三项检查:模块间隔离、入口层穿透、下载器 SDK 泄漏 - 文档同步:05-architecture.md 记录 DB/Oper 聚合例外与迁移文件位置,AGENTS.md 更新所有权表
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.adapters.system.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.adapters.system.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.adapters.system.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.adapters.system.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/adapters/system/fsworker.py").read_text(encoding="utf-8")
|
||
|
||
assert "from app." not in worker
|
||
assert "import app" not in worker
|