import importlib.util import sys import types from enum import Enum from pathlib import Path from unittest.mock import call, MagicMock, patch def _load_qbittorrent_modules(): repo_root = Path(__file__).resolve().parents[1] app_module = types.ModuleType("app") app_module.__path__ = [] core_module = types.ModuleType("app.core") core_module.__path__ = [] domain_module = types.ModuleType("app.domain") domain_module.__path__ = [] foundation_module = types.ModuleType("app.foundation") foundation_module.__path__ = [] torrent_rules_module = types.ModuleType("app.domain.torrent") size_tools_module = types.ModuleType("app.foundation.size") temporal_tools_module = types.ModuleType("app.foundation.temporal") text_tools_module = types.ModuleType("app.foundation.text") url_tools_module = types.ModuleType("app.foundation.url") modules_module = types.ModuleType("app.modules") modules_module.__path__ = [] qbittorrent_package_module = types.ModuleType("app.modules.qbittorrent") qbittorrent_package_module.__path__ = [] runtime_module = types.ModuleType("app.runtime") runtime_module.__path__ = [] log_module = types.ModuleType("app.runtime.log") cache_module = types.ModuleType("app.runtime.cache") config_module = types.ModuleType("app.runtime.config") runtime_settings_module = types.ModuleType("app.runtime.settings") metainfo_module = types.ModuleType("app.domain.metainfo") schemas_module = types.ModuleType("app.schemas") schemas_module.__path__ = [] schema_dashboard_module = types.ModuleType("app.schemas.dashboard") schema_transfer_module = types.ModuleType("app.schemas.transfer") schema_types_module = types.ModuleType("app.schemas.types") torrentool_module = types.ModuleType("torrentool") torrentool_module.__path__ = [] torrentool_torrent_module = types.ModuleType("torrentool.torrent") qbittorrentapi_module = types.ModuleType("qbittorrentapi") qbittorrentapi_client_module = types.ModuleType("qbittorrentapi.client") qbittorrentapi_transfer_module = types.ModuleType("qbittorrentapi.transfer") class _Logger: def info(self, *_args, **_kwargs): pass def warn(self, *_args, **_kwargs): pass def warning(self, *_args, **_kwargs): pass def error(self, *_args, **_kwargs): pass def _is_magnet_link(value): """按生产领域规则识别测试磁力链接。""" if isinstance(value, bytes): return value.startswith(b"magnet:") return isinstance(value, str) and value.startswith("magnet:") def _parse_address(address, include_scheme=False): """返回隔离测试使用的主机和固定端口。""" return address, 8080 def _random_string(_length): """生成可断言的固定临时标签。""" return "tmp-tag-01" def _format_size(value): """返回隔离测试需要的简化容量文本。""" return str(value) def _format_duration(value): """返回隔离测试需要的简化时长文本。""" return str(value) class _FileCache: def get(self, *_args, **_kwargs): return None class _RuntimeSettingsCompat: """隔离测试用动态配置代理,保持生产模块的兼容读取语义。""" def __getattr__(self, key): """从测试提供的旧 Settings 桩读取配置项。""" return getattr(config_module.settings, key) class _MetaInfo: def __init__(self, name): self.name = name self.year = None self.season_episode = "" self.episode_list = [] class _ModuleBase: pass class _DownloaderBase: def __class_getitem__(cls, _item): return cls # 隔离环境下的下载器业务样板基类,镜像 app.modules._base.downloader 的行为 class _DownloaderModuleBase(_ModuleBase, _DownloaderBase): def test(self): return True, "" def scheduler_job(self): pass @staticmethod def _normalize_torrent_files(files, item_factory): """镜像宿主边界的文件集合投影。""" if files is None: return None source = getattr(files, "data", files) return [item_factory(item) for item in source] def _get_torrent_info(self, content): torrent_info, torrent_content = None, None if isinstance(content, Path): torrent_content = content.read_bytes() if content.exists() else None else: torrent_content = content if torrent_content: if torrent_rules_module.is_magnet_link(torrent_content): return None, torrent_content torrent_info = torrentool_torrent_module.Torrent.from_string(torrent_content) return torrent_info, torrent_content @staticmethod def _normalize_query_status(status): status_value = getattr(status, "value", status) status_text = str(status_value or "").strip().lower() if status_text in {"transfer", TorrentStatus.TRANSFER.value}: return TorrentQueryStatus.TRANSFER if status_text in {"downloading", TorrentStatus.DOWNLOADING.value}: return TorrentQueryStatus.DOWNLOADING if status_text in {"completed", "seeding", "complete", "完成", "已完成"}: return TorrentQueryStatus.COMPLETED if status_text in {"paused", "pause", "暂停", "已暂停"}: return TorrentQueryStatus.PAUSED return TorrentQueryStatus.ALL class _Torrent: @staticmethod def from_string(content): return types.SimpleNamespace(name="test", total_size=len(content)) class _DownloaderTorrent: def __init__(self, **kwargs): self.__dict__.update(kwargs) class _DownloaderFile: """隔离加载测试使用的最小下载器文件 DTO。""" def __init__(self, **kwargs): """保存文件字段。""" self.__dict__.update(kwargs) @classmethod def model_validate(cls, item): """兼容字典和属性对象输入。""" if isinstance(item, dict): return cls(**item) return cls(**vars(item)) class TorrentStatus(Enum): TRANSFER = "transfer" DOWNLOADING = "downloading" class TorrentQueryStatus(Enum): ALL = "all" TRANSFER = "transfer" DOWNLOADING = "downloading" COMPLETED = "completed" PAUSED = "paused" class DownloadTaskState(Enum): DOWNLOADING = "downloading" PAUSED = "paused" COMPLETED = "completed" class ModuleType(Enum): Downloader = "Downloader" class DownloaderType(Enum): Qbittorrent = "Qbittorrent" log_module.logger = _Logger() cache_module.FileCache = _FileCache config_module.settings = types.SimpleNamespace(TORRENT_TAG="moviepilot-tag") runtime_settings_module.RuntimeSettingsCompat = _RuntimeSettingsCompat metainfo_module.MetaInfo = _MetaInfo schema_dashboard_module.DownloaderInfo = object schema_transfer_module.TransferTorrent = object schema_transfer_module.DownloadingTorrent = object schema_transfer_module.DownloaderTorrent = _DownloaderTorrent schema_transfer_module.DownloaderFile = _DownloaderFile schema_types_module.TorrentStatus = TorrentStatus schema_types_module.TorrentQueryStatus = TorrentQueryStatus schema_types_module.DownloadTaskState = DownloadTaskState schema_types_module.ModuleType = ModuleType schema_types_module.DownloaderType = DownloaderType torrent_rules_module.is_magnet_link = _is_magnet_link url_tools_module.parse_address = _parse_address text_tools_module.random_string = _random_string size_tools_module.format_compact_size = _format_size temporal_tools_module.format_duration = _format_duration modules_module._ModuleBase = _ModuleBase modules_module._DownloaderBase = _DownloaderBase base_module = types.ModuleType("app.modules._base") base_module._DownloaderModuleBase = _DownloaderModuleBase modules_module._base = base_module torrentool_torrent_module.Torrent = _Torrent qbittorrentapi_module.TorrentDictionary = dict qbittorrentapi_module.TorrentFilesList = list qbittorrentapi_module.LoginFailed = type("LoginFailed", (Exception,), {}) qbittorrentapi_module.Forbidden403Error = type("Forbidden403Error", (Exception,), {}) qbittorrentapi_module.Unauthorized401Error = type("Unauthorized401Error", (Exception,), {}) qbittorrentapi_module.Client = object qbittorrentapi_client_module.Client = object qbittorrentapi_transfer_module.TransferInfoDictionary = dict app_module.core = core_module app_module.domain = domain_module app_module.foundation = foundation_module app_module.log = log_module app_module.modules = modules_module app_module.runtime = runtime_module app_module.schemas = schemas_module domain_module.torrent = torrent_rules_module foundation_module.size = size_tools_module foundation_module.temporal = temporal_tools_module foundation_module.text = text_tools_module foundation_module.url = url_tools_module core_module.cache = cache_module core_module.config = config_module core_module.metainfo = metainfo_module runtime_module.cache = cache_module runtime_module.config = config_module runtime_module.log = log_module runtime_module.settings = runtime_settings_module schemas_module.dashboard = schema_dashboard_module schemas_module.transfer = schema_transfer_module schemas_module.types = schema_types_module modules_module.qbittorrent = qbittorrent_package_module torrentool_module.torrent = torrentool_torrent_module stub_modules = { "app": app_module, "app.core": core_module, "app.domain": domain_module, "app.domain.torrent": torrent_rules_module, "app.foundation": foundation_module, "app.foundation.size": size_tools_module, "app.foundation.temporal": temporal_tools_module, "app.foundation.text": text_tools_module, "app.foundation.url": url_tools_module, "app.runtime.cache": cache_module, "app.runtime.config": config_module, "app.runtime": runtime_module, "app.domain.metainfo": metainfo_module, "app.runtime.log": log_module, "app.runtime.settings": runtime_settings_module, "app.modules": modules_module, "app.modules._base": base_module, "app.modules.qbittorrent": qbittorrent_package_module, "app.schemas": schemas_module, "app.schemas.dashboard": schema_dashboard_module, "app.schemas.transfer": schema_transfer_module, "app.schemas.types": schema_types_module, "qbittorrentapi": qbittorrentapi_module, "qbittorrentapi.client": qbittorrentapi_client_module, "qbittorrentapi.transfer": qbittorrentapi_transfer_module, "torrentool": torrentool_module, "torrentool.torrent": torrentool_torrent_module, } for stub_module in stub_modules.values(): stub_module._qbittorrent_test_stub = True qbittorrent_path = repo_root / "app" / "modules" / "qbittorrent" / "qbittorrent.py" qbittorrent_spec = importlib.util.spec_from_file_location( "app.modules.qbittorrent.qbittorrent", qbittorrent_path, ) qbittorrent_module = importlib.util.module_from_spec(qbittorrent_spec) assert qbittorrent_spec and qbittorrent_spec.loader module_path = repo_root / "app" / "modules" / "qbittorrent" / "__init__.py" qbittorrent_module_spec = importlib.util.spec_from_file_location( "_test_qbittorrent_module", module_path, ) module_package = importlib.util.module_from_spec(qbittorrent_module_spec) assert qbittorrent_module_spec and qbittorrent_module_spec.loader with patch.dict(sys.modules, stub_modules): sys.modules[qbittorrent_spec.name] = qbittorrent_module qbittorrent_spec.loader.exec_module(qbittorrent_module) qbittorrent_package_module.qbittorrent = qbittorrent_module qbittorrent_module_spec.loader.exec_module(module_package) return qbittorrent_module, module_package qbittorrent_module, qbittorrent_package_module = _load_qbittorrent_modules() Qbittorrent = qbittorrent_module.Qbittorrent QbittorrentModule = qbittorrent_package_module.QbittorrentModule def test_login_uses_api_key_header_without_auth_login(): """API Key 登录时应使用 Bearer Header 并跳过用户名密码登录。""" fake_client = MagicMock() fake_client.app_version.return_value = "v5.2.0" with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client) as client_cls: downloader = Qbittorrent(host="http://127.0.0.1", port=8080, apikey="secret-token") assert downloader.qbc is fake_client fake_client.auth_log_in.assert_not_called() fake_client.app_version.assert_called_once_with() assert client_cls.call_args.kwargs["EXTRA_HEADERS"] == {"Authorization": "Bearer secret-token"} def test_login_enables_incomplete_file_suffix_by_default(): """ 登录成功后默认开启未完成文件后缀,避免下载中的媒体文件被提前整理。 """ fake_client = MagicMock() fake_client.app_preferences.return_value = {"incomplete_files_ext": False} with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client): downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") assert downloader.qbc is fake_client fake_client.app_set_preferences.assert_called_once_with({"incomplete_files_ext": True}) def test_login_disables_incomplete_file_suffix_when_configured(): """ 用户关闭配置后应同步关闭 qBittorrent 未完成文件后缀偏好。 """ fake_client = MagicMock() fake_client.app_preferences.return_value = {"incomplete_files_ext": True} with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client): downloader = Qbittorrent( host="http://127.0.0.1", port=8080, username="admin", password="adminadmin", incomplete_files_ext=False, ) assert downloader.qbc is fake_client fake_client.app_set_preferences.assert_called_once_with({"incomplete_files_ext": False}) def test_login_skips_incomplete_file_suffix_when_already_matches(): """ 远端未完成文件后缀状态已匹配配置时不重复写入全局偏好。 """ fake_client = MagicMock() fake_client.app_preferences.return_value = {"incomplete_files_ext": True} with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client): downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") assert downloader.qbc is fake_client fake_client.app_set_preferences.assert_not_called() def test_completed_status_includes_qbittorrent_finished_upload_states(): """ qBittorrent 按完成状态查询时应包含非下载中、非暂停的上传侧状态。 """ server = MagicMock() server.get_torrents.return_value = ( [ { "name": "QB Done", "content_path": "/downloads/QB Done", "hash": "hash-qb", "total_size": 1024, "completed": 1024, "progress": 1, "state": "stalledUP", "tags": "moviepilot-tag", "dlspeed": 0, "upspeed": 128, }, { "name": "QB Downloading", "content_path": "/downloads/QB Downloading", "hash": "hash-downloading", "total_size": 2048, "completed": 1024, "progress": 0.5, "state": "queuedDL", "tags": "moviepilot-tag", "dlspeed": 64, "upspeed": 0, }, ], False, ) module = QbittorrentModule.__new__(QbittorrentModule) module.get_instances = MagicMock(return_value={"qb": server}) module.normalize_return_path = MagicMock(side_effect=lambda path, _name: str(path)) torrents = module.list_torrents(status="completed") assert [torrent.hash for torrent in torrents] == ["hash-qb"] assert torrents[0].state == "completed" server.get_torrents.assert_called_once_with(tags="moviepilot-tag") def test_get_completed_torrents_includes_finished_stopped_tasks(): """ 已完成但不再做种的 qBittorrent 任务仍应进入待整理列表。 """ fake_client = MagicMock() fake_client.torrents_info.return_value = [ { "hash": "hash-stalled-up", "progress": 1, "amount_left": 0, "state": "stalledUP", }, { "hash": "hash-stopped-up", "progress": 1, "amount_left": 0, "state": "stoppedUP", }, { "hash": "hash-moving", "progress": 1, "amount_left": 0, "state": "moving", }, { "hash": "hash-metadata", "progress": 0, "amount_left": 0, "state": "metaDL", }, { "hash": "hash-download-left", "progress": 0.9, "amount_left": 1024, "state": "stalledDL", }, ] with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") torrents = downloader.get_completed_torrents() assert [torrent["hash"] for torrent in torrents] == ["hash-stalled-up", "hash-stopped-up"] fake_client.torrents_info.assert_called_once_with(torrent_hashes=None, status_filter="completed") def test_list_torrents_include_all_tags_removes_builtin_tag_filter(): """ 智能体扩大查询范围时,qBittorrent 查询应取消内置标签过滤。 """ server = MagicMock() server.get_torrents.return_value = ( [ { "name": "External Task", "content_path": "/downloads/External Task", "hash": "hash-external", "total_size": 1024, "completed": 1024, "progress": 1, "state": "stalledUP", "tags": "external", "dlspeed": 0, "upspeed": 0, } ], False, ) module = QbittorrentModule.__new__(QbittorrentModule) module.get_instances = MagicMock(return_value={"qb": server}) module.normalize_return_path = MagicMock(side_effect=lambda path, _name: str(path)) torrents = module.list_torrents(include_all_tags=True) assert [torrent.hash for torrent in torrents] == ["hash-external"] server.get_torrents.assert_called_once_with(tags=None) def test_add_torrent_accepts_structured_success_response(): """新版 qBittorrent API 结构化成功响应应返回新增种子 ID。""" fake_client = MagicMock() fake_client.torrents_add.return_value = { "success_count": 1, "failure_count": 0, "pending_count": 0, "added_torrent_ids": ["abc123"], } with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") success, added_torrent_ids = downloader.add_torrent(content="https://example.com/test.torrent") assert success assert added_torrent_ids == ["abc123"] def test_add_torrent_accepts_pending_success_response_without_ids(): """新版 qBittorrent API 待处理成功响应没有 ID 时仍应视为添加成功。""" fake_client = MagicMock() fake_client.torrents_add.return_value = { "success_count": 0, "failure_count": 0, "pending_count": 1, "added_torrent_ids": [], } with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") success, added_torrent_ids = downloader.add_torrent(content="https://example.com/test.torrent") assert success assert added_torrent_ids == [] def test_add_torrent_uses_cookie_api_for_qbittorrent_52(): """qBittorrent 5.2 对应 Web API 应通过 Cookie API 同步站点 Cookie。""" fake_client = MagicMock() fake_client.app_web_api_version.return_value = "2.11.3" fake_client.app_cookies.return_value = [ { "domain": "old.example.com", "path": "/", "name": "old", "value": "cookie", } ] fake_client.torrents_add.return_value = "Ok." with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") success, added_torrent_ids = downloader.add_torrent( content="https://tracker.example.com/download?id=1", cookie="uid=1; passkey=abc", ) assert success assert added_torrent_ids == [] set_cookie_call = fake_client.app_set_cookies.call_args.kwargs["cookies"] assert { "domain": "tracker.example.com", "path": "/", "name": "uid", "value": "1", } in set_cookie_call assert { "domain": "tracker.example.com", "path": "/", "name": "passkey", "value": "abc", } in set_cookie_call assert fake_client.torrents_add.call_args.kwargs["cookie"] is None def test_add_torrent_keeps_legacy_cookie_param_for_old_webapi(): """旧版 qBittorrent Web API 不支持 Cookie API 时保留添加种子 Cookie 参数。""" fake_client = MagicMock() fake_client.app_web_api_version.return_value = "2.11.2" fake_client.torrents_add.return_value = "Ok." with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") success, added_torrent_ids = downloader.add_torrent( content="https://tracker.example.com/download?id=1", cookie="uid=1", ) assert success assert added_torrent_ids == [] fake_client.app_set_cookies.assert_not_called() assert fake_client.torrents_add.call_args.kwargs["cookie"] == "uid=1" def _build_module(server): """构造仅包含下载所需方法的 QbittorrentModule 测试实例。""" module = QbittorrentModule.__new__(QbittorrentModule) module.get_instance = MagicMock(return_value=server) module.normalize_path = MagicMock(side_effect=lambda path, _downloader: path) module.get_default_config_name = MagicMock(return_value="default-qb") return module def test_download_prefers_added_torrent_ids_before_tag_lookup(): """添加任务响应包含种子 ID 时应优先使用响应值。""" fake_server = MagicMock() fake_server.add_torrent.return_value = (True, ["abc123"]) fake_server.get_content_layout.return_value = "Original" fake_server.is_force_resume.return_value = False module = _build_module(fake_server) result = module.download( content="magnet:?xt=urn:btih:123", download_dir=Path("/downloads"), cookie="", downloader="qb", ) assert result == ("qb", "abc123", "Original", "添加下载成功") fake_server.delete_torrents_tag.assert_called_once_with("abc123", "tmp-tag-01") fake_server.get_torrent_id_by_tag.assert_not_called() assert fake_server.add_torrent.call_args.kwargs["tag"] == ["tmp-tag-01", "moviepilot-tag"] assert fake_server.mock_calls.index( call.delete_torrents_tag("abc123", "tmp-tag-01") ) < fake_server.mock_calls.index(call.get_content_layout()) def test_download_falls_back_to_tag_lookup_when_added_ids_missing(): """添加任务响应缺少种子 ID 时应回退到临时标签查询。""" fake_server = MagicMock() fake_server.add_torrent.return_value = (True, []) fake_server.get_content_layout.return_value = "Original" fake_server.get_torrent_id_by_tag.return_value = "def456" fake_server.is_force_resume.return_value = False module = _build_module(fake_server) result = module.download( content="magnet:?xt=urn:btih:456", download_dir=Path("/downloads"), cookie="", downloader="qb", ) assert result == ("qb", "def456", "Original", "添加下载成功") fake_server.delete_torrents_tag.assert_not_called() fake_server.get_torrent_id_by_tag.assert_called_once_with(tags="tmp-tag-01") def test_download_removes_temporary_tag_from_existing_torrent(): """重复添加任务时应从已存在的种子中删除本次临时标签。""" fake_server = MagicMock() fake_server.add_torrent.return_value = (False, []) fake_server.get_content_layout.return_value = "Original" fake_server.get_torrents.return_value = ([{ "name": "test", "total_size": len(b"torrent-content"), "hash": "existing123", "tags": None, }], None) module = _build_module(fake_server) result = module.download( content=b"torrent-content", download_dir=Path("/downloads"), cookie="", downloader="qb", ) assert result == ("qb", "existing123", "Original", "下载任务已存在") fake_server.delete_torrents_tag.assert_called_once_with("existing123", "tmp-tag-01") assert fake_server.mock_calls.index( call.delete_torrents_tag("existing123", "tmp-tag-01") ) < fake_server.mock_calls.index(call.get_content_layout()) def test_delete_torrents_tag_uses_supported_qbittorrent_api_arguments(): """删除标签时应分别调用任务移除接口和全局标签删除接口。""" fake_client = MagicMock() downloader = Qbittorrent.__new__(Qbittorrent) downloader.qbc = fake_client assert downloader.delete_torrents_tag("abc123", "tmp-tag-01") fake_client.torrents_remove_tags.assert_called_once_with( torrent_hashes="abc123", tags="tmp-tag-01", ) fake_client.torrents_delete_tags.assert_called_once_with(tags="tmp-tag-01") def test_get_files_retries_until_qbittorrent_files_available(): """qBittorrent 添加任务后文件列表短暂未就绪时应重试。""" torrent_files = [{"id": 12, "name": "Show.S01E12.mkv"}] fake_client = MagicMock() fake_client.torrents_files.side_effect = [ Exception("Torrent hash(es): abc123"), torrent_files, ] with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") with patch.object(qbittorrent_module.time, "sleep") as sleep: result = downloader.get_files("abc123", retry=2, interval=1) assert result == torrent_files assert fake_client.torrents_files.call_count == 2 fake_client.torrents_files.assert_called_with(torrent_hash="abc123") sleep.assert_called_once_with(1) def test_download_episode_selection_retries_file_list_after_add(): """按集选择下载时应等待 qBittorrent 刚添加的任务文件列表。""" class _EpisodeMetaInfo: """测试用集数识别对象。""" def __init__(self, name): self.episode_list = [12] if "E12" in name else [1] fake_server = MagicMock() fake_server.add_torrent.return_value = (True, ["abc123"]) fake_server.get_content_layout.return_value = "Original" fake_server.get_files.return_value = [ {"id": 1, "name": "Show.S01E01.mkv"}, {"id": 12, "name": "Show.S01E12.mkv"}, ] fake_server.is_force_resume.return_value = False module = _build_module(fake_server) with patch.object(qbittorrent_package_module, "MetaInfo", _EpisodeMetaInfo): result = module.download( content=b"torrent-content", download_dir=Path("/downloads"), cookie="", episodes={12}, downloader="qb", ) assert result == ("qb", "abc123", "Original", "添加下载成功,已选择集数:[12]") fake_server.get_files.assert_called_once_with("abc123", retry=5, interval=1) fake_server.set_files.assert_called_once_with(torrent_hash="abc123", file_ids=[1], priority=0) fake_server.start_torrents.assert_called_once_with("abc123") def test_set_speed_limit_allows_single_direction_limit(): """ 设置全局限速时允许只传一个方向,未传方向按不限速处理。 """ fake_client = MagicMock() with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") assert downloader.set_speed_limit(download_limit=1024) assert fake_client.transfer.download_limit == 1024 * 1024 assert fake_client.transfer.upload_limit == 0