import importlib.util import sys import types from pathlib import Path from unittest.mock import MagicMock, patch def _load_transmission_client_module(): """ 使用轻量桩加载 Transmission 客户端封装,避免测试依赖完整应用启动。 """ repo_root = Path(__file__).resolve().parents[1] app_module = types.ModuleType("app") app_module.__path__ = [] log_module = types.ModuleType("app.log") utils_module = types.ModuleType("app.utils") utils_module.__path__ = [] url_module = types.ModuleType("app.utils.url") transmission_rpc_module = types.ModuleType("transmission_rpc") transmission_rpc_session_module = types.ModuleType("transmission_rpc.session") class _Logger: """ 测试日志桩,仅提供被客户端封装调用的方法。 """ def info(self, *_args, **_kwargs): """ 忽略信息日志。 """ pass def warning(self, *_args, **_kwargs): """ 忽略警告日志。 """ pass def error(self, *_args, **_kwargs): """ 忽略错误日志。 """ pass class _UrlUtils: """ 测试 URL 工具桩,满足按 URL 配置下载器时的解析接口。 """ @staticmethod def parse_url_params(url): """ 返回固定的 Transmission 连接参数。 """ return "http", url, 9091, "" log_module.logger = _Logger() url_module.UrlUtils = _UrlUtils transmission_rpc_module.Client = object transmission_rpc_module.Torrent = object transmission_rpc_module.File = object transmission_rpc_session_module.SessionStats = object transmission_rpc_session_module.Session = object app_module.log = log_module app_module.utils = utils_module utils_module.url = url_module stub_modules = { "app": app_module, "app.log": log_module, "app.utils": utils_module, "app.utils.url": url_module, "transmission_rpc": transmission_rpc_module, "transmission_rpc.session": transmission_rpc_session_module, } transmission_path = repo_root / "app" / "modules" / "transmission" / "transmission.py" transmission_spec = importlib.util.spec_from_file_location( "app.modules.transmission.transmission", transmission_path, ) transmission_module = importlib.util.module_from_spec(transmission_spec) assert transmission_spec and transmission_spec.loader with patch.dict(sys.modules, stub_modules): transmission_spec.loader.exec_module(transmission_module) return transmission_module transmission_module = _load_transmission_client_module() Transmission = transmission_module.Transmission def test_login_enables_incomplete_file_suffix_by_default(): """ 登录成功后默认开启未完成文件后缀,避免下载中的媒体文件被提前整理。 """ fake_client = MagicMock() fake_client.get_session.return_value = {"rename-partial-files": False} with patch.object(transmission_module.transmission_rpc, "Client", return_value=fake_client): downloader = Transmission(host="127.0.0.1", port=9091) assert downloader.trc is fake_client fake_client.set_session.assert_called_once_with(rename_partial_files=True) def test_login_disables_incomplete_file_suffix_when_configured(): """ 用户关闭配置后应同步关闭 Transmission 未完成文件后缀偏好。 """ fake_client = MagicMock() fake_client.get_session.return_value = types.SimpleNamespace(rename_partial_files=True) with patch.object(transmission_module.transmission_rpc, "Client", return_value=fake_client): downloader = Transmission(host="127.0.0.1", port=9091, rename_partial_files=False) assert downloader.trc is fake_client fake_client.set_session.assert_called_once_with(rename_partial_files=False) def test_login_skips_incomplete_file_suffix_when_already_matches(): """ 远端未完成文件后缀状态已匹配配置时不重复写入全局会话配置。 """ fake_client = MagicMock() fake_client.get_session.return_value = types.SimpleNamespace(rename_partial_files=True) with patch.object(transmission_module.transmission_rpc, "Client", return_value=fake_client): downloader = Transmission(host="127.0.0.1", port=9091) assert downloader.trc is fake_client fake_client.set_session.assert_not_called() def test_get_files_uses_transmission_rpc_v7_get_files(): """ transmission-rpc v7 任务对象应使用 get_files 获取文件列表。 """ downloader = Transmission.__new__(Transmission) torrent_files = [object()] torrent = types.SimpleNamespace(get_files=MagicMock(return_value=torrent_files)) fake_client = MagicMock() fake_client.get_torrent.return_value = torrent downloader.trc = fake_client assert downloader.get_files("1") == torrent_files fake_client.get_torrent.assert_called_once_with("1") torrent.get_files.assert_called_once_with() def test_get_files_falls_back_to_legacy_files_method(): """ 旧版 transmission-rpc 任务对象仍应通过 files 获取文件列表。 """ downloader = Transmission.__new__(Transmission) torrent_files = [object()] torrent = types.SimpleNamespace(files=MagicMock(return_value=torrent_files)) fake_client = MagicMock() fake_client.get_torrent.return_value = torrent downloader.trc = fake_client assert downloader.get_files("1") == torrent_files fake_client.get_torrent.assert_called_once_with("1") torrent.files.assert_called_once_with() def test_change_torrent_only_sends_explicit_fields(): """ 修改单个任务时只能写入显式传入的策略字段。 """ downloader = Transmission.__new__(Transmission) fake_client = MagicMock() downloader.trc = fake_client assert downloader.change_torrent("hash", ratio_limit=2.5) fake_client.change_torrent.assert_called_once_with( ids="hash", seedRatioMode=1, seedRatioLimit=2.5, ) def test_change_torrent_disables_speed_limit_with_zero_value(): """ 单任务限速传 0 时应显式关闭对应限速。 """ downloader = Transmission.__new__(Transmission) fake_client = MagicMock() downloader.trc = fake_client assert downloader.change_torrent("hash", download_limit=0, upload_limit=512) fake_client.change_torrent.assert_called_once_with( ids="hash", uploadLimited=True, uploadLimit=512, downloadLimited=False, downloadLimit=0, ) def test_set_torrent_location_prefers_move_torrent_data(): """ Transmission 修改保存目录应优先使用移动数据接口。 """ downloader = Transmission.__new__(Transmission) fake_client = MagicMock() downloader.trc = fake_client assert downloader.set_torrent_location("hash", "/downloads/new") fake_client.move_torrent_data.assert_called_once_with( ids="hash", location="/downloads/new", ) fake_client.change_torrent.assert_not_called() def test_set_torrent_location_falls_back_to_change_torrent(): """ 旧版 transmission-rpc 没有移动数据接口时回退到 change_torrent。 """ downloader = Transmission.__new__(Transmission) fake_client = MagicMock() fake_client.move_torrent_data = None downloader.trc = fake_client assert downloader.set_torrent_location("hash", "/downloads/new") fake_client.change_torrent.assert_called_once_with( ids="hash", download_dir="/downloads/new", )