Files
MoviePilot/tests/test_fs_proxy_copy.py
jxxghp 02f0dd0b9a refactor(architecture): 修复模块依赖违规并强化架构守护
- 字幕编排上移 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 更新所有权表
2026-08-16 04:59:17 +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.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