test: 测试套件自隔离与全量离线化(collection 清零 + 杜绝真实网络) (#5873)

This commit is contained in:
InfinityPacer
2026-06-02 12:23:08 +08:00
committed by GitHub
parent 1c41d9f253
commit 437baec620
85 changed files with 14588 additions and 1163 deletions

8
app/testing/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
"""测试辅助工具(主程序与插件仓共享)。
提供测试期对 ``sys.modules`` 的临时打桩能力,保证打桩在使用后还原,避免测试间
因残留假模块而相互污染。仅供测试使用,不参与运行时逻辑。
"""
from app.testing.stub import stub_modules
__all__ = ["stub_modules"]

75
app/testing/stub.py Normal file
View File

@@ -0,0 +1,75 @@
"""sys.modules 临时打桩与快照还原工具。
测试常需在 import 目标模块前,用假模块替换其依赖(避免连真实库 / 外部服务 / 重依赖)。
若打桩后不还原,假模块会残留在 ``sys.modules`` 中污染后续测试的 import。本模块提供两类能力
1. :func:`stub_modules` —— 上下文管理器,进入时替换、退出时精确还原;
2. :func:`snapshot_modules` / :func:`restore_modules` —— 快照与还原 ``sys.modules``
供测试在 setUp/tearDown 做整体自隔离,消除测试间通过 ``sys.modules`` 传播的污染。
"""
import sys
from contextlib import contextmanager
from typing import Any, Dict, Iterator, Optional
@contextmanager
def stub_modules(stubs: Dict[str, Any]) -> Iterator[None]:
"""在上下文内用假模块临时替换 ``sys.modules`` 中的指定项,退出时还原。
典型用法:在测试模块顶层包裹依赖打桩的 import使打桩只在 import 期生效、
随后立即还原,从而既满足导入需求又不污染其他测试。
:param stubs: ``{模块全名: 假模块对象}``,假模块通常为 ``MagicMock()`` 或自建桩。
用例::
with stub_modules({"app.helper.sites": MagicMock()}):
from app.chain.media import MediaChain
# 此处 app.helper.sites 已还原为真实模块MediaChain 已绑定可用
"""
saved: Dict[str, Any] = {}
for name, module in stubs.items():
saved[name] = sys.modules.get(name)
sys.modules[name] = module
try:
yield
finally:
for name, original in saved.items():
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
def snapshot_modules(prefix: Optional[str] = None) -> Dict[str, Any]:
"""对当前 ``sys.modules`` 取浅快照,用于稍后还原。
:param prefix: 仅快照名称匹配该前缀的模块(如 ``"app."``);为 ``None`` 时快照全部。
还原以快照为准,能恢复被替换、删除的条目,并移除快照后新增的条目。
:return: 快照字典(模块名 -> 模块对象),传给 :func:`restore_modules`。
"""
if prefix is None:
return dict(sys.modules)
return {k: v for k, v in sys.modules.items() if k == prefix.rstrip(".") or k.startswith(prefix)}
def restore_modules(snapshot: Dict[str, Any], prefix: Optional[str] = None) -> None:
"""把 ``sys.modules`` 还原到 :func:`snapshot_modules` 的状态。
被替换 / 删除的恢复为快照值;快照之后新增的(同前缀范围内)移除,避免假桩残留。
:param snapshot: :func:`snapshot_modules` 返回的快照。
:param prefix: 还原范围前缀;须与取快照时一致。为 ``None`` 时按全量还原。
"""
if prefix is None:
in_scope = lambda name: True # noqa: E731
else:
head = prefix.rstrip(".")
in_scope = lambda name: name == head or name.startswith(prefix) # noqa: E731
# 移除范围内、快照中没有的新增项(通常是测试塞入的假桩)
for name in [n for n in sys.modules if in_scope(n) and n not in snapshot]:
sys.modules.pop(name, None)
# 恢复范围内被替换/删除的项
for name, module in snapshot.items():
if in_scope(name) and sys.modules.get(name) is not module:
sys.modules[name] = module

View File

@@ -2,7 +2,9 @@
import atexit
import os
import shutil
import sys
import tempfile
from types import ModuleType
# 必须早于首个 import app.*app.db 在导入时即按 CONFIG_PATH 连接 user.db
if not os.environ.get("CONFIG_DIR"):
@@ -10,6 +12,17 @@ if not os.environ.get("CONFIG_DIR"):
os.environ["CONFIG_DIR"] = _isolated_config_dir
atexit.register(shutil.rmtree, _isolated_config_dir, ignore_errors=True)
# app.helper.sites 由独立仓库动态拉取CI / 全新环境无该模块),而众多 app.chain.* /
# app.modules.* 在 import 期依赖它。在此统一补一个最小垫片,省去各测试文件各自打桩;
# 若真实模块已存在(本地已拉取)则 setdefault 不覆盖,不影响真实行为。
if "app.helper.sites" not in sys.modules:
try:
import app.helper.sites # noqa: F401 本地已拉取时用真实模块
except ModuleNotFoundError:
_sites_stub = ModuleType("app.helper.sites")
_sites_stub.SitesHelper = object
sys.modules["app.helper.sites"] = _sites_stub
# 必须在 CONFIG_DIR 设好之后再 import空库会让运行期查表报 no such table故建表
from app.db.init import init_db # noqa: E402

14206
tests/fixtures/tmdb_recognize_cassette.json vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,14 @@
"""核心回归集入口:以 pytest 跑一组核心测试文件,命令行参数透传给 pytest。"""
"""全量单测入口:以 pytest 跑 tests 目录全部用例,命令行参数透传给 pytest。
用 __file__ 推导 tests 目录绝对路径,使脚本不依赖当前工作目录,从任意位置调用均可。
"""
import sys
from pathlib import Path
import pytest
CORE = [
"tests/test_metainfo.py",
"tests/test_object.py",
"tests/test_bluray.py",
"tests/test_mediascrape.py",
"tests/test_subscribe_chain.py",
]
# 本文件即位于 tests/ 下,其所在目录即测试根目录
_TESTS_DIR = Path(__file__).resolve().parent
if __name__ == "__main__":
sys.exit(pytest.main(CORE + sys.argv[1:]))
sys.exit(pytest.main([str(_TESTS_DIR), *sys.argv[1:]]))

View File

@@ -85,7 +85,3 @@ class TestAgentAddSubscribeTool(unittest.TestCase):
self.assertEqual(async_add.await_args.kwargs["username"], "moviepilot-user")
self.assertIn("成功添加订阅The Matrix (1999)", result)
if __name__ == "__main__":
unittest.main()

View File

@@ -461,7 +461,3 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(ReplyMode.CAPTURE_ONLY, captured["reply_mode"])
self.assertFalse(captured["allow_message_tools"])
self.assertEqual(SYSTEM_INTERNAL_USER_ID, captured["user_id"])
if __name__ == "__main__":
unittest.main()

View File

@@ -62,7 +62,3 @@ class TestAgentFilterRuleTools(unittest.TestCase):
"SPECSUB & CNVOI & 4K & !BLU",
parsed["levels"][0]["expression"],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1323,6 +1323,3 @@ class AgentImageSupportTest(unittest.TestCase):
)
client.send_file.assert_called_once()
if __name__ == "__main__":
unittest.main()

View File

@@ -220,7 +220,3 @@ class TestAgentInteraction(unittest.TestCase):
)
handle_ai_message.assert_called_once()
if __name__ == "__main__":
unittest.main()

View File

@@ -76,7 +76,3 @@ status: pending
jobs = await _alist_jobs(AsyncPath(str(root)))
self.assertEqual(["a-job", "m-job", "z-job"], [job["id"] for job in jobs])
if __name__ == "__main__":
unittest.main()

View File

@@ -1,31 +1,22 @@
import sys
import unittest
import importlib.util
from base64 import b64encode
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import Mock, patch
sys.modules.setdefault("psutil", Mock())
sys.modules.setdefault("pyquery", Mock())
from app.core.config import settings
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import MessageChannel
module_path = Path(__file__).resolve().parents[1] / "app" / "agent" / "llm" / "capability.py"
spec = importlib.util.spec_from_file_location("test_agent_llm_capability_module", module_path)
capability_module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
sys.modules[spec.name] = capability_module
spec.loader.exec_module(capability_module)
AgentCapabilityManager = capability_module.AgentCapabilityManager
MiMoAudioProvider = capability_module.MiMoAudioProvider
MiniMaxAudioProvider = capability_module.MiniMaxAudioProvider
OpenAIChatAudioProvider = capability_module.OpenAIChatAudioProvider
OpenAIAudioProvider = capability_module.OpenAIAudioProvider
from app.agent.llm import capability as capability_module
from app.agent.llm.capability import (
AgentCapabilityManager,
MiMoAudioProvider,
MiniMaxAudioProvider,
OpenAIChatAudioProvider,
OpenAIAudioProvider,
)
class AgentCapabilityManagerTest(unittest.TestCase):
@@ -406,7 +397,3 @@ class AgentCapabilityManagerTest(unittest.TestCase):
provider._decode_audio_payload(b64encode(b"opus-bytes").decode("utf-8")),
b"opus-bytes",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -83,7 +83,3 @@ class TestPatchToolCallsMiddleware(unittest.TestCase):
patched_messages = result["messages"].value
self.assertEqual([msg.type for msg in patched_messages], ["human", "ai", "tool"])
if __name__ == "__main__":
unittest.main()

View File

@@ -116,7 +116,3 @@ class TestAgentPersonaTools(unittest.TestCase):
self.assertEqual(created_persona.label, "分析型")
self.assertIn("推理", created_persona.aliases)
self.assertIn("analytical and structured", created_persona.text)
if __name__ == "__main__":
unittest.main()

View File

@@ -265,7 +265,3 @@ class TestAgentPluginTools(unittest.TestCase):
self.assertIn("data_preview", payload)
self.assertNotIn("data", payload)
self.assertIn("已截断", payload["data_preview"])
if __name__ == "__main__":
unittest.main()

View File

@@ -289,7 +289,3 @@ class TestAgentPromptStyle(unittest.TestCase):
self.assertIn("Do NOT interrupt the current task", MEMORY_ONBOARDING_PROMPT)
self.assertIn("Do NOT proactively greet warmly", MEMORY_ONBOARDING_PROMPT)
self.assertNotIn("greet the user warmly", MEMORY_ONBOARDING_PROMPT)
if __name__ == "__main__":
unittest.main()

View File

@@ -50,7 +50,3 @@ class TestQueryWorkflowsTool(unittest.TestCase):
self.assertEqual(len(payload), 1)
self.assertEqual(payload[0]["name"], "demo")
self.assertNotIn("result", payload[0])
if __name__ == "__main__":
unittest.main()

View File

@@ -177,7 +177,3 @@ class TestAgentRuntimeConfig(unittest.TestCase):
for warning in runtime_config.warnings
)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -153,7 +153,3 @@ class TestAgentSearchWebTool(unittest.TestCase):
self.assertEqual("http://proxy.example.com:7890", ddgs_kwargs["proxy"])
self.assertEqual(1, len(results))
if __name__ == "__main__":
unittest.main()

View File

@@ -31,7 +31,3 @@ description: test
["a-skill", "m-skill", "z-skill"],
[skill["id"] for skill in skills],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -270,7 +270,3 @@ class TestSubAgentTaskControlMiddleware(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual("cancelled", status_payload["tasks"][0]["status"])
if __name__ == "__main__":
unittest.main()

View File

@@ -193,7 +193,3 @@ class TestAgentSummarizationStreaming(unittest.TestCase):
self.assertFalse(
any(type(middleware).__name__ == "AgentHooksMiddleware" for middleware in captured["middleware"])
)
if __name__ == "__main__":
unittest.main()

View File

@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
from app.agent.tools.manager import MoviePilotToolsManager
from app.core.config import settings
class TestAgentSystemSettingsTools(unittest.TestCase):
@@ -102,8 +103,11 @@ class TestAgentSystemSettingsTools(unittest.TestCase):
def test_update_system_settings_updates_basic_settings(self):
tool = UpdateSystemSettingsTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.update_system_settings.settings.update_setting",
# settings 是 pydantic 模型实例,不能直接 patch 其实例方法__setattr__ 会拦截),
# 改 patch 类上的方法;经实例调用时不带 self断言参数不受影响。
with patch.object(
type(settings),
"update_setting",
return_value=(True, ""),
) as update_setting, patch.object(
UpdateSystemSettingsTool,
@@ -141,7 +145,3 @@ class TestAgentSystemSettingsTools(unittest.TestCase):
payload = json.loads(result)
self.assertIn("error", payload)
self.assertIn("系统管理员", payload["error"])
if __name__ == "__main__":
unittest.main()

View File

@@ -2,11 +2,14 @@ import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from langchain_core.messages import AIMessage
from app.agent import MoviePilotAgent
from app.agent.memory import memory_manager
from app.plugins.agenttokens import AgentTokens
# agenttokens 为动态安装插件app/plugins/** 被 gitignoreCI / 全新环境无此插件),
# 缺失时跳过本模块,避免 collection 阶段 ImportError。
AgentTokens = pytest.importorskip("app.plugins.agenttokens").AgentTokens
from app.schemas.types import ChainEventType, EventType

View File

@@ -45,7 +45,3 @@ class TestAgentToolResultLimits(unittest.TestCase):
self.assertEqual(payload["cookie"], "uid=abc; token=secret")
self.assertEqual(payload["nested"]["api_key"], "secret-key")
self.assertEqual(payload["nested"]["plugin_author"], "MoviePilot")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,43 +1,11 @@
import asyncio
import importlib.util
import sys
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from types import SimpleNamespace
from unittest.mock import patch
from langchain_core.messages import HumanMessage
def _stub_module(name: str, **attrs):
module = sys.modules.get(name)
if module is None:
module = ModuleType(name)
sys.modules[name] = module
for key, value in attrs.items():
setattr(module, key, value)
return module
sys.modules.pop("app.agent.middleware.tool_selection", None)
_stub_module(
"app.log",
logger=SimpleNamespace(debug=lambda *args, **kwargs: None),
log_settings=lambda *args, **kwargs: None,
LogConfigModel=type("LogConfigModel", (), {}),
)
module_path = (
Path(__file__).resolve().parents[1]
/ "app"
/ "agent"
/ "middleware"
/ "tool_selection.py"
)
spec = importlib.util.spec_from_file_location("test_tool_selector_module", module_path)
tool_selector_module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(tool_selector_module)
from app.agent.middleware import tool_selection as tool_selector_module
class _FakeBoundModel:
@@ -253,7 +221,3 @@ class ToolSelectorMiddlewareTest(unittest.TestCase):
normalized = middleware._normalize_selection_response(response)
self.assertEqual(normalized, {"tools": ["search"]})
if __name__ == "__main__":
unittest.main()

View File

@@ -510,7 +510,3 @@ class TestAgentToolStreaming(unittest.TestCase):
synthesize_speech.assert_not_called()
self.assertEqual(notification.text, "你好")
self.assertIsNone(notification.voice_path)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,136 +1,10 @@
import importlib.util
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
def _load_alist_module():
module_name = "_test_alist_module"
app_module = types.ModuleType("app")
schemas_module = types.ModuleType("app.schemas")
cache_module = types.ModuleType("app.core.cache")
config_module = types.ModuleType("app.core.config")
log_module = types.ModuleType("app.log")
storages_module = types.ModuleType("app.modules.filemanager.storages")
exception_module = types.ModuleType("app.schemas.exception")
types_module = types.ModuleType("app.schemas.types")
http_module = types.ModuleType("app.utils.http")
singleton_module = types.ModuleType("app.utils.singleton")
url_module = types.ModuleType("app.utils.url")
class _FileItem:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
class _StorageSchemaValue:
def __init__(self, value):
self.value = value
class _Logger:
def debug(self, *_args, **_kwargs):
pass
def warn(self, *_args, **_kwargs):
pass
def warning(self, *_args, **_kwargs):
pass
def error(self, *_args, **_kwargs):
pass
def critical(self, *_args, **_kwargs):
pass
def info(self, *_args, **_kwargs):
pass
class _StorageBase:
def __init__(self):
pass
def get_conf(self):
return {}
class _OperationInterrupted(Exception):
pass
class _RequestUtils:
def __init__(self, *args, **kwargs):
pass
class _UrlUtils:
@staticmethod
def standardize_base_url(url):
return url.rstrip("/") if url else ""
@staticmethod
def adapt_request_url(base, path):
return f"{base() if callable(base) else base}{path}"
@staticmethod
def quote(path):
return path
def _cached(*_args, **_kwargs):
def decorator(func):
func.cache_clear = lambda: None
return func
return decorator
schemas_module.FileItem = _FileItem
schemas_module.StorageUsage = object
cache_module.cached = _cached
config_module.settings = types.SimpleNamespace(
OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME=True,
TEMP_PATH=Path("/tmp"),
)
config_module.global_vars = types.SimpleNamespace(
is_transfer_stopped=lambda *_args, **_kwargs: False
)
log_module.logger = _Logger()
storages_module.StorageBase = _StorageBase
storages_module.transfer_process = lambda *_args, **_kwargs: (lambda *_a, **_k: None)
exception_module.OperationInterrupted = _OperationInterrupted
types_module.StorageSchema = types.SimpleNamespace(Alist=_StorageSchemaValue("alist"))
http_module.RequestUtils = _RequestUtils
singleton_module.WeakSingleton = type
url_module.UrlUtils = _UrlUtils
app_module.schemas = schemas_module
stub_modules = {
"app": app_module,
"app.schemas": schemas_module,
"app.core.cache": cache_module,
"app.core.config": config_module,
"app.log": log_module,
"app.modules.filemanager.storages": storages_module,
"app.schemas.exception": exception_module,
"app.schemas.types": types_module,
"app.utils.http": http_module,
"app.utils.singleton": singleton_module,
"app.utils.url": url_module,
}
for stub_module in stub_modules.values():
stub_module._alist_test_stub = True
alist_path = Path(__file__).resolve().parents[1] / "app" / "modules" / "filemanager" / "storages" / "alist.py"
spec = importlib.util.spec_from_file_location(module_name, alist_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
with patch.dict(sys.modules, stub_modules):
spec.loader.exec_module(module)
return module
alist_module = _load_alist_module()
Alist = alist_module.Alist
FileItem = alist_module.schemas.FileItem
from app.modules.filemanager.storages import alist as alist_module
from app.modules.filemanager.storages.alist import Alist
from app.schemas import FileItem
class _FakeResponse:
@@ -263,7 +137,3 @@ class AlistStorageTest(unittest.TestCase):
self.assertEqual("alist", target.storage)
self.assertEqual("file", target.type)
self.assertEqual(1024, target.size)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,15 +1,10 @@
#!/usr/bin/env python
# -*- coding:utf-8 -*-
from pathlib import Path
import sys
from types import ModuleType
from typing import Optional
from unittest import TestCase
from unittest.mock import patch
sys.modules.setdefault("app.helper.sites", ModuleType("app.helper.sites"))
setattr(sys.modules["app.helper.sites"], "SitesHelper", object)
from app import schemas
from app.chain.media import MediaChain
from app.chain.storage import StorageChain

View File

@@ -89,7 +89,3 @@ class BrowserHelperTests(unittest.TestCase):
)
self.assertEqual(source, "<html>ok</html>")
if __name__ == "__main__":
unittest.main()

View File

@@ -89,7 +89,3 @@ class ChainRateLimitTest(unittest.TestCase):
self.assertIsNone(result)
chain.messagehelper.put.assert_not_called()
chain.eventmanager.send_event.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -110,7 +110,3 @@ class CliAutoUpdateTests(unittest.TestCase):
command = run_mock.call_args.args[0]
self.assertEqual(command[1:5], [str(module._repo_root() / "scripts" / "local_setup.py"), "update", "all", "--ref"])
self.assertNotIn("--frontend-version", command)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,46 +1,6 @@
import sys
import unittest
from types import ModuleType
from unittest.mock import patch
def _stub_module(name: str, **attrs):
module = sys.modules.get(name)
if module is None:
module = ModuleType(name)
sys.modules[name] = module
for key, value in attrs.items():
setattr(module, key, value)
return module
class _Dummy:
def __init__(self, *args, **kwargs):
pass
def __getattr__(self, _name):
return lambda *args, **kwargs: None
for _module_name in ("pillow_avif", "aiofiles", "psutil"):
_stub_module(_module_name)
_stub_module("app.chain.download", DownloadChain=_Dummy)
_stub_module("app.chain.media", MediaChain=_Dummy)
_stub_module("app.core.context", MediaInfo=_Dummy, Context=_Dummy, TorrentInfo=_Dummy)
_stub_module("app.core.metainfo", MetaInfo=_Dummy)
_stub_module("app.core.security", verify_token=_Dummy)
_stub_module("app.db.models.user", User=_Dummy)
_stub_module("app.db.systemconfig_oper", SystemConfigOper=_Dummy)
_stub_module("app.db.user_oper", get_current_active_user=_Dummy)
_stub_module(
"app.log",
logger=_Dummy(),
log_settings=_Dummy(),
LogConfigModel=type("LogConfigModel", (), {}),
)
_stub_module("version", APP_VERSION="test")
from app.api.endpoints import download as download_endpoint

View File

@@ -359,7 +359,3 @@ class TransmissionPathMappingTest(unittest.TestCase):
Path("/mnt/raid5/home_lt999lt/video/downloads/movie/Movie"),
"tr",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -141,7 +141,3 @@ class EmbyDashboardLinksTest(unittest.TestCase):
self.assertEqual(response.data["item_id"], "emby-item-id")
self.assertEqual(response.data["server_id"], "server-id")
self.assertEqual(response.data["server_type"], "emby")
if __name__ == "__main__":
unittest.main()

View File

@@ -103,7 +103,8 @@ class TestExecuteCommandTool(unittest.TestCase):
payload = json.loads(result)
self.assertEqual(payload["status"], "error")
self.assertIn("禁止使用", payload["error"])
# rm -rf / 命中删除根目录防护;断言拒绝原因点明 rm 根目录,避免锁死单一文案
self.assertIn("不允许使用 rm", payload["error"])
class TestExecuteCommandSessionTool(unittest.IsolatedAsyncioTestCase):
@@ -224,7 +225,3 @@ class TestExecuteCommandSessionTool(unittest.IsolatedAsyncioTestCase):
self.assertIn("started", read_payload["output"])
self.assertIn(kill_payload["status"], {"killed", "exited"})
if __name__ == "__main__":
unittest.main()

View File

@@ -315,7 +315,3 @@ class TestPrepareAndSubmitScripts(FeedbackIssueScriptTestCase):
self.assertEqual(result["reason"], "rate_limited_user")
self.assertIn("30 分钟", result["message"])
if __name__ == "__main__":
unittest.main()

View File

@@ -8,7 +8,6 @@ from unittest.mock import ANY, MagicMock, patch
sys.modules.setdefault("psutil", ModuleType("psutil"))
sys.modules.setdefault("cn2an", ModuleType("cn2an"))
sys.modules.setdefault("dateparser", ModuleType("dateparser"))
if "Pinyin2Hanzi" not in sys.modules:
@@ -1477,7 +1476,3 @@ class TestFeishu(unittest.TestCase):
client.send_notification.call_args.kwargs["original_message_id"],
"om_source",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,116 +1,9 @@
import importlib.util
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import patch
def _load_jellyfin_module():
module_name = "_test_jellyfin_module"
app_module = types.ModuleType("app")
core_module = types.ModuleType("app.core")
utils_module = types.ModuleType("app.utils")
log_module = types.ModuleType("app.log")
config_module = types.ModuleType("app.core.config")
schemas_module = types.ModuleType("app.schemas")
http_module = types.ModuleType("app.utils.http")
url_module = types.ModuleType("app.utils.url")
class _Logger:
def info(self, *_args, **_kwargs):
pass
def warning(self, *_args, **_kwargs):
pass
def error(self, *_args, **_kwargs):
pass
def debug(self, *_args, **_kwargs):
pass
class _RequestUtils:
def __init__(self, *args, **kwargs):
pass
def get_res(self, *args, **kwargs):
return None
class _UrlUtils:
@staticmethod
def standardize_base_url(host):
if not host:
return host
if not host.endswith("/"):
host += "/"
if not host.startswith("http://") and not host.startswith("https://"):
host = "http://" + host
return host
@staticmethod
def combine_url(host, path=None, query=None):
from urllib.parse import urljoin
if path is None:
path = "/"
host = _UrlUtils.standardize_base_url(host)
return urljoin(host, path)
log_module.logger = _Logger()
config_module.settings = types.SimpleNamespace(
SUPERUSER="admin", USER_AGENT="MoviePilot"
)
schemas_module.MediaType = types.SimpleNamespace(
MOVIE=types.SimpleNamespace(value="movie")
)
schemas_module.MediaServerItem = object
schemas_module.MediaServerLibrary = object
schemas_module.Statistic = object
schemas_module.WebhookEventInfo = object
schemas_module.MediaServerItemUserState = object
schemas_module.MediaServerPlayItem = object
http_module.RequestUtils = _RequestUtils
url_module.UrlUtils = _UrlUtils
app_module.schemas = schemas_module
app_module.log = log_module
app_module.core = core_module
app_module.utils = utils_module
core_module.config = config_module
utils_module.http = http_module
utils_module.url = url_module
stub_modules = {
"app": app_module,
"app.log": log_module,
"app.core": core_module,
"app.core.config": config_module,
"app.schemas": schemas_module,
"app.utils": utils_module,
"app.utils.http": http_module,
"app.utils.url": url_module,
}
for stub_module in stub_modules.values():
stub_module._jellyfin_test_stub = True
jellyfin_path = (
Path(__file__).resolve().parents[1]
/ "app"
/ "modules"
/ "jellyfin"
/ "jellyfin.py"
)
spec = importlib.util.spec_from_file_location(module_name, jellyfin_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
with patch.dict(sys.modules, stub_modules):
spec.loader.exec_module(module)
return module
jellyfin_module = _load_jellyfin_module()
Jellyfin = jellyfin_module.Jellyfin
from app.modules.jellyfin import jellyfin as jellyfin_module
from app.modules.jellyfin.jellyfin import Jellyfin
class _FakeResponse:
@@ -265,7 +158,3 @@ class JellyfinUserResolutionTest(unittest.TestCase):
"http://jellyfin.local:8096/Users/user-id/Views",
{"api_key": "api-key"},
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,25 +1,9 @@
import importlib.util
import sys
import unittest
from pathlib import Path
from types import ModuleType
from unittest.mock import patch
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
def _stub_module(name: str, **attrs):
module = sys.modules.get(name)
if module is None:
module = ModuleType(name)
sys.modules[name] = module
for key, value in attrs.items():
setattr(module, key, value)
return module
class _DummyLogger:
def __getattr__(self, _name):
return lambda *args, **kwargs: None
from app.agent.llm import helper as llm_module
def _build_tool_call(name: str = "search", arguments: str = "{}"):
@@ -65,34 +49,16 @@ class _FakeChatDeepSeek:
_ORIGINAL_GET_REQUEST_PAYLOAD = _FakeChatDeepSeek._get_request_payload
sys.modules.pop("app.agent.llm.helper", None)
_stub_module(
"app.core.config",
settings=ModuleType("settings"),
)
sys.modules["app.core.config"].settings.LLM_PROVIDER = "deepseek"
sys.modules["app.core.config"].settings.LLM_MODEL = "deepseek-v4-pro"
sys.modules["app.core.config"].settings.LLM_API_KEY = "sk-test"
sys.modules["app.core.config"].settings.LLM_BASE_URL = "https://api.deepseek.com"
sys.modules["app.core.config"].settings.LLM_THINKING_LEVEL = None
sys.modules["app.core.config"].settings.LLM_TEMPERATURE = 0.1
sys.modules["app.core.config"].settings.LLM_MAX_CONTEXT_TOKENS = 64
sys.modules["app.core.config"].settings.PROXY_HOST = None
_stub_module("app.log", logger=_DummyLogger())
_stub_module("langchain_deepseek", ChatDeepSeek=_FakeChatDeepSeek)
module_path = Path(__file__).resolve().parents[1] / "app" / "agent" / "llm" / "helper.py"
spec = importlib.util.spec_from_file_location("test_llm_module_for_deepseek_compat", module_path)
llm_module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(llm_module)
class DeepSeekCompatPatchTest(unittest.TestCase):
def setUp(self):
_FakeChatDeepSeek._get_request_payload = _ORIGINAL_GET_REQUEST_PAYLOAD
if hasattr(_FakeChatDeepSeek, "_moviepilot_reasoning_content_patched"):
delattr(_FakeChatDeepSeek, "_moviepilot_reasoning_content_patched")
# helper 的修补函数内部 `from langchain_deepseek import ChatDeepSeek`
# 这里临时把该名指向假类,使修补作用到 _FakeChatDeepSeekpatch 在用例结束自动还原。
patcher = patch("langchain_deepseek.ChatDeepSeek", _FakeChatDeepSeek)
patcher.start()
self.addCleanup(patcher.stop)
llm_module._patch_deepseek_reasoning_content_support()
def test_injects_reasoning_content_for_assistant_tool_calls(self):

View File

@@ -8,15 +8,7 @@ from unittest.mock import AsyncMock, patch
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
def _stub_module(name: str, **attrs):
module = sys.modules.get(name)
if module is None:
module = ModuleType(name)
sys.modules[name] = module
for key, value in attrs.items():
setattr(module, key, value)
return module
from app.testing import stub_modules
class _DummyLogger:
@@ -127,42 +119,90 @@ def _build_fake_openai_modules(chat_openai_cls=_FakeChatOpenAIForPatch):
}, base_module
_ORIGINAL_STUBBED_MODULES = {
name: sys.modules.get(name)
for name in ("app.core.config", "app.log")
}
sys.modules.pop("app.agent.llm.helper", None)
_stub_module(
"app.core.config",
settings=SimpleNamespace(
LLM_PROVIDER="global-provider",
LLM_MODEL="global-model",
LLM_API_KEY="global-key",
LLM_BASE_URL="https://global.example.com",
LLM_BASE_URL_PRESET=None,
LLM_USER_AGENT=None,
LLM_THINKING_LEVEL=None,
LLM_TEMPERATURE=0.1,
LLM_MAX_CONTEXT_TOKENS=64,
LLM_USE_PROXY=True,
PROXY_HOST=None,
),
# 以假 settings/log 控制 helper 加载期行为;用唯一模块名加载,并以 stub_modules 上下文
# 在 import 期注入、退出后还原真实 app.core.config / app.log避免污染其他测试。
_config_stub = ModuleType("app.core.config")
_config_stub.settings = SimpleNamespace(
LLM_PROVIDER="global-provider",
LLM_MODEL="global-model",
LLM_API_KEY="global-key",
LLM_BASE_URL="https://global.example.com",
LLM_BASE_URL_PRESET=None,
LLM_USER_AGENT=None,
LLM_THINKING_LEVEL=None,
LLM_TEMPERATURE=0.1,
LLM_MAX_CONTEXT_TOKENS=64,
LLM_USE_PROXY=True,
PROXY_HOST=None,
)
_stub_module("app.log", logger=_DummyLogger())
_log_stub = ModuleType("app.log")
_log_stub.logger = _DummyLogger()
module_path = Path(__file__).resolve().parents[1] / "app" / "agent" / "llm" / "helper.py"
spec = importlib.util.spec_from_file_location("test_llm_module", module_path)
llm_module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(llm_module)
for _module_name, _module in _ORIGINAL_STUBBED_MODULES.items():
if _module is None:
sys.modules.pop(_module_name, None)
else:
sys.modules[_module_name] = _module
with stub_modules({"app.core.config": _config_stub, "app.log": _log_stub}):
spec = importlib.util.spec_from_file_location("test_llm_module", module_path)
llm_module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(llm_module)
class _OfflineProviderManager:
"""离线 provider 解析替身,杜绝单测访问 models.dev。
真实 ``LLMProviderManager.resolve_runtime`` 会请求 models.dev 目录、并按
base_url 列模型,单测中走它会产生不可接受的网络 IO且结果随外部可达性漂移。
这里按 provider 直接给出运行时结构provider→runtime 映射与
``helper._build_legacy_runtime`` 保持一致google/gemini→google、
deepseek→deepseek、其余→openai_compatible``use_responses_api`` 等留空,
交由 ``get_llm`` 自身逻辑(如 ChatGPT 官方推理模型)推导,避免改变被测行为。
"""
# provider 标识到运行时类型的映射,与 helper 内置回退逻辑保持一致
_RUNTIME_BY_PROVIDER = {
"google": "google",
"gemini": "google",
"deepseek": "deepseek",
}
async def resolve_runtime(
self,
*,
provider_id,
model=None,
api_key=None,
base_url=None,
base_url_preset_id=None,
user_agent=None,
use_proxy=None,
):
"""按 provider 返回离线运行时结构,全程不触发网络请求。"""
normalized = (provider_id or "").strip().lower()
return {
"provider_id": normalized,
"runtime": self._RUNTIME_BY_PROVIDER.get(normalized, "openai_compatible"),
"model_id": model,
"api_key": api_key,
"base_url": base_url,
"default_headers": None,
"use_responses_api": None,
"model_record": None,
"model_metadata": None,
}
class LlmHelperTestCallTest(unittest.TestCase):
def setUp(self):
"""为每个用例默认注入离线 provider确保 get_llm 不会真访问 models.dev。
需要校验特定 resolve_runtime 行为的用例,可在自身 patch.dict 中再覆盖
``sys.modules['app.agent.llm.provider']``;用例结束后由 addCleanup 还原。
"""
provider_module = ModuleType("app.agent.llm.provider")
provider_module.LLMProviderManager = _OfflineProviderManager
patcher = patch.dict(sys.modules, {"app.agent.llm.provider": provider_module})
patcher.start()
self.addCleanup(patcher.stop)
def test_extract_text_content_ignores_non_text_blocks(self):
content = [
{"type": "reasoning", "text": "internal"},
@@ -773,7 +813,3 @@ class LlmHelperTestCallTest(unittest.TestCase):
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].get("thinking_level"), "high")
self.assertFalse(calls[0].get("include_thoughts"))
if __name__ == "__main__":
unittest.main()

View File

@@ -1,72 +1,14 @@
import asyncio
import importlib.util
import sys
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import AsyncMock, patch
def _stub_module(name: str, **attrs):
module = sys.modules.get(name)
if module is None:
module = ModuleType(name)
sys.modules[name] = module
for key, value in attrs.items():
setattr(module, key, value)
return module
class _DummyLogger:
def __getattr__(self, _name):
return lambda *args, **kwargs: None
class _DummySystemConfigOper:
def get(self, _key):
return {}
async def async_set(self, _key, _value):
return None
for _module_name in ("aiofiles", "jwt"):
_stub_module(_module_name)
_stub_module(
"app.core.config",
settings=SimpleNamespace(
TEMP_PATH="/tmp",
PROXY_HOST=None,
LLM_MAX_CONTEXT_TOKENS=64,
RCLONE_SNAPSHOT_CHECK_FOLDER_MODTIME=True,
RMT_MEDIAEXT=[".mkv", ".mp4"],
RMT_SUBEXT=[".srt"],
RMT_AUDIOEXT=[".flac"],
),
from app.agent.llm import provider as provider_module
from app.agent.llm.provider import (
LLMProviderError,
LLMProviderManager,
PendingAuthSession,
)
_stub_module("app.db.systemconfig_oper", SystemConfigOper=_DummySystemConfigOper)
_stub_module("app.log", logger=_DummyLogger())
_stub_module(
"app.schemas.types",
SystemConfigKey=SimpleNamespace(
AIAgentConfig="agent",
CustomReleaseGroups="custom_release_groups",
Customization="customization",
CustomIdentifiers="custom_identifiers",
),
)
provider_path = Path(__file__).resolve().parents[1] / "app" / "agent" / "llm" / "provider.py"
spec = importlib.util.spec_from_file_location("test_llm_provider_module", provider_path)
provider_module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
sys.modules[spec.name] = provider_module
spec.loader.exec_module(provider_module)
LLMProviderError = provider_module.LLMProviderError
LLMProviderManager = provider_module.LLMProviderManager
PendingAuthSession = provider_module.PendingAuthSession
class LlmProviderRegistryTest(unittest.TestCase):
@@ -646,7 +588,3 @@ class LlmProviderRegistryTest(unittest.TestCase):
self.assertNotIn("session-old", manager._pending_sessions)
self.assertNotIn("state-old", manager._oauth_state_index)
if __name__ == "__main__":
unittest.main()

View File

@@ -87,7 +87,3 @@ class LocalSetupConfigDirTests(unittest.TestCase):
[str(venv_pip), "install", "-r", str(module.ROOT / "requirements.txt")]
)
install_browser.assert_called_once_with(venv_python)
if __name__ == "__main__":
unittest.main()

View File

@@ -68,7 +68,3 @@ class LocalSetupFrontendVersionTests(unittest.TestCase):
self.assertIsNone(install_args.version)
self.assertIsNone(setup_args.frontend_version)
self.assertIsNone(update_args.frontend_version)
if __name__ == "__main__":
unittest.main()

View File

@@ -315,7 +315,3 @@ class LocalSetupLlmProviderPromptTests(unittest.TestCase):
base_url_preset="minimax-cn-coding",
runtime_python=None,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -161,7 +161,3 @@ class LocalSetupUninstallTests(unittest.TestCase):
self.assertTrue(result["config_deleted"])
self.assertFalse(config_dir.exists())
self.assertFalse(install_env_file.exists())
if __name__ == "__main__":
unittest.main()

View File

@@ -1,18 +1,11 @@
import sys
import unittest
from types import ModuleType
from unittest.mock import patch
sys.modules.setdefault("qbittorrentapi", ModuleType("qbittorrentapi"))
setattr(sys.modules["qbittorrentapi"], "TorrentFilesList", list)
sys.modules.setdefault("transmission_rpc", ModuleType("transmission_rpc"))
setattr(sys.modules["transmission_rpc"], "File", object)
sys.modules.setdefault("psutil", ModuleType("psutil"))
from app.chain.media import MediaChain, media_interaction_manager
from app.chain.message import MessageChain
from app.chain.media import MediaChain
from app.chain.message import MediaInteractionChain, MessageChain
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.helper.interaction import media_interaction_manager
from app.schemas.types import MessageChannel
@@ -43,7 +36,7 @@ class TestMediaInteraction(unittest.TestCase):
self.assertIsNotNone(request)
with patch.object(chain, "_record_user_message"), patch(
"app.chain.message.MediaChain.handle_text_interaction",
"app.chain.message.MediaInteractionChain.handle_text_interaction",
return_value=True,
) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai:
chain.handle_message(
@@ -72,7 +65,7 @@ class TestMediaInteraction(unittest.TestCase):
)
with patch(
"app.chain.message.MediaChain.handle_callback_interaction",
"app.chain.message.MediaInteractionChain.handle_callback_interaction",
return_value=True,
) as handle_callback:
chain._handle_callback(
@@ -86,7 +79,7 @@ class TestMediaInteraction(unittest.TestCase):
handle_callback.assert_called_once()
def test_media_interaction_starts_search_and_posts_media_list(self):
chain = MediaChain()
chain = MediaInteractionChain()
meta = self._build_meta("星际穿越")
medias = [
MediaInfo(title="星际穿越", year="2014"),
@@ -119,7 +112,7 @@ class TestMediaInteraction(unittest.TestCase):
self.assertEqual(len(request.items), 2)
def test_media_interaction_legacy_page_callback_updates_existing_request(self):
chain = MediaChain()
chain = MediaInteractionChain()
request = media_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
@@ -152,7 +145,3 @@ class TestMediaInteraction(unittest.TestCase):
notification = post_medias_message.call_args.args[0]
self.assertEqual(notification.original_message_id, 123)
self.assertEqual(notification.original_chat_id, "456")
if __name__ == "__main__":
unittest.main()

View File

@@ -437,7 +437,3 @@ class TestMediaRecognizeShare(unittest.TestCase):
self.assertIs(result, mediainfo)
recognize_mock.assert_awaited_once()
obtain_images_mock.assert_not_awaited()
if __name__ == "__main__":
unittest.main()

View File

@@ -3,16 +3,21 @@ import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
# ruff: noqa: E402
sys.modules['app.helper.sites'] = MagicMock()
sys.modules['app.db.systemconfig_oper'] = MagicMock()
sys.modules['app.db.systemconfig_oper'].SystemConfigOper.return_value.get.return_value = None
from app.testing import stub_modules
from app import schemas
from app.chain.media import MediaChain, ScrapingConfig, ScrapingOption
from app.core.context import MediaInfo
from app.core.event import Event
from app.core.metainfo import MetaInfo
from app.schemas.types import EventType, MediaType, ScrapingTarget, ScrapingMetadata, ScrapingPolicy
# 仅在 import 期用假模块替换依赖,退出 with 后还原,避免污染后续测试的 sys.modules
_systemconfig_stub = MagicMock()
_systemconfig_stub.SystemConfigOper.return_value.get.return_value = None
with stub_modules({
'app.helper.sites': MagicMock(),
'app.db.systemconfig_oper': _systemconfig_stub,
}):
from app import schemas
from app.chain.media import MediaChain, ScrapingConfig, ScrapingOption
from app.core.context import MediaInfo
from app.core.event import Event
from app.core.metainfo import MetaInfo
from app.schemas.types import EventType, MediaType, ScrapingTarget, ScrapingMetadata, ScrapingPolicy
def reset_media_chain_singleton():
@@ -878,6 +883,3 @@ class TestMediaScrapeEvents(unittest.TestCase):
fileitem=fileitem
)
mock_logger.assert_called_with(f"{Path(fileitem.path)} 无法识别文件媒体信息!")
if __name__ == "__main__":
unittest.main()

View File

@@ -76,7 +76,3 @@ class MediaServerImageSigningTest(unittest.TestCase):
result = chain.get_latest_wallpapers()
self.assertEqual(SecurityUtils.verify_signed_url(result[0]), wallpaper)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,8 +1,4 @@
import importlib.util
import sqlite3
import sys
import tempfile
import types
import unittest
from pathlib import Path
from types import SimpleNamespace
@@ -11,96 +7,14 @@ from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
if "psutil" not in sys.modules:
sys.modules["psutil"] = types.ModuleType("psutil")
if "aiosqlite" not in sys.modules:
aiosqlite_module = types.ModuleType("aiosqlite")
for attr in (
"DatabaseError",
"Error",
"IntegrityError",
"InterfaceError",
"InternalError",
"NotSupportedError",
"OperationalError",
"ProgrammingError",
"sqlite_version",
"sqlite_version_info",
):
setattr(aiosqlite_module, attr, getattr(sqlite3, attr))
aiosqlite_module.connect = sqlite3.connect
aiosqlite_module.paramstyle = "qmark"
aiosqlite_module.threadsafety = sqlite3.threadsafety
sys.modules["aiosqlite"] = aiosqlite_module
if "app.log" not in sys.modules:
log_module = types.ModuleType("app.log")
class _Logger:
def info(self, *_args, **_kwargs):
return None
def debug(self, *_args, **_kwargs):
return None
def warning(self, *_args, **_kwargs):
return None
def error(self, *_args, **_kwargs):
return None
log_module.logger = _Logger()
log_module.log_settings = SimpleNamespace()
log_module.LogConfigModel = type("LogConfigModel", (), {})
sys.modules["app.log"] = log_module
from app import schemas
from app.chain import mediaserver as MEDIA_SERVER_CHAIN_MODULE
from app.chain.mediaserver import MediaServerChain
from app.db import Base
from app.db.mediaserver_oper import MediaServerOper
from app.db.models.mediaserver import MediaServerItem
def _load_mediaserver_chain_class():
"""隔离加载 MediaServerChain避免测试依赖完整运行时环境。"""
module_name = "_test_mediaserver_chain"
if module_name in sys.modules:
module = sys.modules[module_name]
return module, module.MediaServerChain
if "app.chain" not in sys.modules:
chain_module = types.ModuleType("app.chain")
chain_module.ChainBase = type("ChainBase", (), {})
sys.modules["app.chain"] = chain_module
if "app.core.config" not in sys.modules:
config_module = types.ModuleType("app.core.config")
config_module.global_vars = SimpleNamespace(is_system_stopped=False)
sys.modules["app.core.config"] = config_module
if "app.helper.service" not in sys.modules:
service_module = types.ModuleType("app.helper.service")
class _ServiceConfigHelper:
@staticmethod
def get_mediaserver_configs():
return []
service_module.ServiceConfigHelper = _ServiceConfigHelper
sys.modules["app.helper.service"] = service_module
mediaserver_path = Path(__file__).resolve().parents[1] / "app" / "chain" / "mediaserver.py"
spec = importlib.util.spec_from_file_location(module_name, mediaserver_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
assert spec and spec.loader
spec.loader.exec_module(module)
return module, module.MediaServerChain
MEDIA_SERVER_CHAIN_MODULE, MediaServerChain = _load_mediaserver_chain_class()
class MediaServerIncrementalSyncTest(unittest.TestCase):
"""验证媒体库同步改为按条目增量更新。"""
@@ -237,7 +151,3 @@ class MediaServerIncrementalSyncTest(unittest.TestCase):
self.assertEqual(items[0].title, "New Title")
self.assertEqual(items[0].path, "/media/new.mkv")
self.assertNotEqual(items[0].lst_mod_date, old_sync_time)
if __name__ == "__main__":
unittest.main()

View File

@@ -223,7 +223,3 @@ class MediaServerTvStaleItemIdTest(unittest.TestCase):
self.assertEqual(item_id, "new-series-id")
self.assertEqual(episodes, {1: [1]})
client._TrimeMedia__get_series_id_by_name.assert_called_once_with("测试剧集", "2026")
if __name__ == "__main__":
unittest.main()

View File

@@ -397,7 +397,3 @@ class TestMessageChannelPermissions(unittest.TestCase):
client.send_msg.assert_called_once_with(
title="只有管理员才有权限执行此命令", userid="normal-user"
)
if __name__ == "__main__":
unittest.main()

View File

@@ -175,7 +175,3 @@ class TestDiscordTypingLifecycle(IsolatedAsyncioTestCase):
self.assertTrue(stopped)
channel.trigger_typing.assert_not_called()
self.assertNotIn("chat:30003", discord_client._typing_tasks)
if __name__ == "__main__":
unittest.main()

View File

@@ -55,7 +55,3 @@ class PlexImageLookupTest(unittest.TestCase):
image_url,
"http://192.168.8.254:32400/library/metadata/29242/art/1?X-Plex-Token=plex-token",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,37 +1,4 @@
import sys
import unittest
from enum import Enum
from types import ModuleType
def _stub_module(name: str, **attrs):
module = sys.modules.get(name)
if module is None:
module = ModuleType(name)
sys.modules[name] = module
for key, value in attrs.items():
setattr(module, key, value)
return module
class _DummyLogger:
def __getattr__(self, _name):
return lambda *args, **kwargs: None
_stub_module(
"app.log",
logger=_DummyLogger(),
log_settings=_DummyLogger(),
LogConfigModel=type("LogConfigModel", (), {}),
)
_stub_module("psutil")
_schemas_module = _stub_module(
"app.schemas", MediaType=Enum("MediaType", {"Movie": "Movie", "TV": "TV"})
)
_schemas_module.__getattr__ = lambda name: type(name, (), {})
_stub_module("version", APP_VERSION="test")
from app.core.config import Settings
@@ -67,7 +34,8 @@ class PostgreSQLSocketConfigTests(unittest.TestCase):
)
self.assertTrue(settings.DB_POSTGRESQL_SOCKET_MODE)
self.assertIsNone(settings.DB_POSTGRESQL_PORT_VALUE)
# socket 模式下不带端口:未显式设置时 DB_POSTGRESQL_PORT 为空串
self.assertEqual(settings.DB_POSTGRESQL_PORT, "")
self.assertEqual(
settings.DB_POSTGRESQL_URL(),
"postgresql://user:pass@/moviepilot?host=%2Fvar%2Frun%2Fpostgresql",
@@ -95,7 +63,3 @@ class PostgreSQLSocketConfigTests(unittest.TestCase):
settings.DB_POSTGRESQL_TARGET,
"socket /var/run/postgresql (port 5432)",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -212,7 +212,3 @@ class RcloneStorageTest(unittest.TestCase):
self.assertIn("/C", rclone_module._folder_locks)
self.assertIsNot(first_lock, third_lock)
self.assertIs(second_lock, rclone_module._folder_locks["/B"])
if __name__ == "__main__":
unittest.main()

View File

@@ -475,7 +475,3 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("ask_user_choice", tool_names)
self.assertNotIn("send_local_file", tool_names)
self.assertNotIn("send_voice_message", tool_names)
if __name__ == "__main__":
unittest.main()

View File

@@ -150,7 +150,3 @@ class MoviePilotServerHelperTests(unittest.TestCase):
with patch.object(MoviePilotServerHelper, "get_github_user", return_value="user"), \
patch.object(MoviePilotServerHelper, "user_permissions", return_value=response):
self.assertFalse(MoviePilotServerHelper.is_admin_user())
if __name__ == "__main__":
unittest.main()

View File

@@ -15,9 +15,6 @@ sys.modules.setdefault("psutil", ModuleType("psutil"))
sys.modules.setdefault("aioshutil", ModuleType("aioshutil"))
sys.modules.setdefault("pyquery", ModuleType("pyquery"))
setattr(sys.modules["pyquery"], "PyQuery", object)
sys.modules.setdefault("cn2an", ModuleType("cn2an"))
setattr(sys.modules["cn2an"], "cn2an", lambda value, mode=None: value)
setattr(sys.modules["cn2an"], "an2cn", lambda value, mode=None: str(value))
sys.modules.setdefault("dateparser", ModuleType("dateparser"))
setattr(sys.modules["dateparser"], "parse", lambda *args, **kwargs: None)
sys.modules.setdefault("dateutil", ModuleType("dateutil"))
@@ -718,7 +715,3 @@ class TestSkillsCommand(unittest.TestCase):
buttons=buttons,
)
post_message.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -11,9 +11,6 @@ sys.modules.setdefault("psutil", ModuleType("psutil"))
sys.modules.setdefault("aioshutil", ModuleType("aioshutil"))
sys.modules.setdefault("pyquery", ModuleType("pyquery"))
setattr(sys.modules["pyquery"], "PyQuery", object)
sys.modules.setdefault("cn2an", ModuleType("cn2an"))
setattr(sys.modules["cn2an"], "cn2an", lambda value, mode=None: value)
setattr(sys.modules["cn2an"], "an2cn", lambda value, mode=None: str(value))
sys.modules.setdefault("dateparser", ModuleType("dateparser"))
setattr(sys.modules["dateparser"], "parse", lambda *args, **kwargs: None)
sys.modules.setdefault("dateutil", ModuleType("dateutil"))

View File

@@ -318,7 +318,3 @@ class LlmTestEndpointTest(unittest.TestCase):
self.assertNotIn("sk-secret", resp.message)
self.assertNotIn("Authorization: Bearer", resp.message)
self.assertIn("***", resp.message)
if __name__ == "__main__":
unittest.main()

View File

@@ -172,13 +172,12 @@ class NettestSecurityTest(unittest.TestCase):
), patch.object(
system_endpoint.RequestUtils, "generate_cache_headers", return_value={}, create=True
), patch.object(
# is_safe_image_url_async 经 evaluate_url_safety_async 走异步解析
# _hostname_addresses_asyncloop.getaddrinfo必须 mock 异步版本,
# 否则真实 DNS 逃逸到 img1.doubanio.com且私网放行分支根本不会被执行到。
system_endpoint.SecurityUtils,
"_is_global_hostname",
return_value=False,
), patch.object(
system_endpoint.SecurityUtils,
"_hostname_addresses",
return_value=[ipaddress.ip_address("198.18.16.96")],
"_hostname_addresses_async",
new=AsyncMock(return_value=[ipaddress.ip_address("198.18.16.96")]),
), patch.object(
system_endpoint.settings,
"IMAGE_PROXY_ALLOWED_PRIVATE_RANGES",
@@ -385,7 +384,3 @@ class NettestSecurityTest(unittest.TestCase):
self.assertFalse(resp.success)
self.assertIn("PIP加速代理", resp.message)
if __name__ == "__main__":
unittest.main()

View File

@@ -89,7 +89,3 @@ class TestSystemNotificationDispatch(unittest.TestCase):
send.assert_called_once_with("payload")
asyncio.run(_run())
if __name__ == "__main__":
unittest.main()

View File

@@ -3,6 +3,7 @@
Telegram模块单元测试
"""
import unittest
from unittest.mock import MagicMock, patch
from app.core.context import MediaInfo, Context, TorrentInfo
from app.core.metainfo import MetaInfo
@@ -13,13 +14,30 @@ from app.schemas.types import MediaType
class TestTelegram(unittest.TestCase):
def setUp(self):
"""测试前准备"""
# 创建Telegram实例使用虚假的token和chat_id防止真实发送
self.telegram = Telegram(TELEGRAM_TOKEN='', TELEGRAM_CHAT_ID='')
"""测试前准备
模拟 telebot.TeleBot 以避免真实 API 调用:空 token 会让 Telegram.__init__ 提前返回、
属性未初始化导致 send_* 抛错;这里用假 bot 让初始化完整且消息发送走内存桩。
"""
self.telebot_patcher = patch("app.modules.telegram.telegram.TeleBot")
mock_telebot_cls = self.telebot_patcher.start()
self.mock_bot_instance = MagicMock()
# get_me 用于初始化 bot 用户名,需返回带 username 的对象
self.mock_bot_instance.get_me.return_value = MagicMock(username="test_bot")
mock_telebot_cls.return_value = self.mock_bot_instance
# send_medias/send_msg 发图时会经 ImageHelper().fetch_image 按 poster_path 真实下载海报,
# 单测必须打桩,否则对 raw.githubusercontent.com 等外链发起真实 HTTP外部 IO 不可接受且拖慢用例)。
self.image_patcher = patch("app.modules.telegram.telegram.ImageHelper")
mock_image_cls = self.image_patcher.start()
mock_image_cls.return_value.fetch_image.return_value = b"fake-image-bytes"
self.telegram = Telegram(TELEGRAM_TOKEN="fake_token", TELEGRAM_CHAT_ID="fake_chat_id")
def tearDown(self):
"""测试后清理"""
pass
"""测试后清理:停止 TeleBot 与 ImageHelper 打桩。"""
self.telebot_patcher.stop()
self.image_patcher.stop()
def test_send_msg_success(self):
"""测试发送普通消息成功"""
@@ -30,7 +48,7 @@ class TestTelegram(unittest.TestCase):
)
# 验证返回值
self.assertTrue(result is True)
self.assertTrue(result)
def test_send_msg_with_longtext(self):
"""测试发送长消息"""
@@ -65,7 +83,7 @@ class TestTelegram(unittest.TestCase):
title="推荐媒体列表"
)
self.assertTrue(result is True)
self.assertTrue(result)
def test_send_medias_msg_without_vote_average(self):
"""测试发送无评分的媒体列表消息"""
@@ -83,13 +101,13 @@ class TestTelegram(unittest.TestCase):
title="推荐媒体列表"
)
self.assertTrue(result is True)
self.assertTrue(result)
def test_send_medias_msg_with_link_and_buttons(self):
"""测试发送带链接和按钮的媒体列表消息"""
media1 = MediaInfo()
media1.type = MediaType.MOVIE
media1.title = "测试*-|\.电影1"
media1.title = r"测试*-|\.电影1"
media1.year = "2023"
media1.vote_average = 8.5
media1.poster_path = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png"
@@ -108,7 +126,7 @@ class TestTelegram(unittest.TestCase):
buttons=buttons
)
self.assertTrue(result is True)
self.assertTrue(result)
@@ -122,7 +140,7 @@ class TestTelegram(unittest.TestCase):
media_info.poster_path = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png"
torrent_info = TorrentInfo()
torrent_info.site_name = "测试*-|\.站点"
torrent_info.site_name = r"测试*-|\.站点"
torrent_info.title = "唐朝诡事录"
torrent_info.description = "唐朝诡事录之长安3 / 唐朝诡事录3 / 唐朝诡事录 第三部 / 唐朝诡事录·长安 / 唐诡3 / Horror Stories of Tang Dynasty Ⅲ / Strange Legend of Tang Dynasty Ⅲ 第3季 第31-32集 | 主演: 杨旭文 杨志刚 郜思雯 [内封简繁英多国软字幕] 【去头尾广告纯享版】[非伪去头] *发现未去净的广告或片头片尾奖励魔力1W"
torrent_info.page_url = "http://example.com/torrent"
@@ -145,7 +163,7 @@ class TestTelegram(unittest.TestCase):
title="种子列表"
)
self.assertTrue(result is True)
self.assertTrue(result)
def test_send_torrents_msg_with_link_and_buttons(self):
"""测试发送带链接和按钮的种子列表消息"""
@@ -185,7 +203,7 @@ class TestTelegram(unittest.TestCase):
buttons=buttons
)
self.assertTrue(result is True)
self.assertTrue(result)
def test_send_msg_with_buttons_and_link(self):
"""测试发送带按钮和链接的消息"""
@@ -201,7 +219,7 @@ class TestTelegram(unittest.TestCase):
)
# 验证返回值
self.assertTrue(result is True)
self.assertTrue(result)
def test_send_msg_with_url_buttons(self):
"""测试发送带URL按钮的消息"""
@@ -216,7 +234,7 @@ class TestTelegram(unittest.TestCase):
)
# 验证返回值
self.assertTrue(result is True)
self.assertTrue(result)
def test_send_msg_markdown_escaping(self):
@@ -227,7 +245,4 @@ class TestTelegram(unittest.TestCase):
)
# 验证返回值
self.assertTrue(result is True)
if __name__ == '__main__':
unittest.main()
self.assertTrue(result)

View File

@@ -1,12 +1,9 @@
import asyncio
import sys
import time
import unittest
from types import ModuleType, SimpleNamespace
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
sys.modules.setdefault("app.helper.sites", ModuleType("app.helper.sites"))
setattr(sys.modules["app.helper.sites"], "SitesHelper", object)
from app.agent import AgentManager, _MessageTask, _async_start_processing_status
from app.chain.message import MessageChain
@@ -493,7 +490,3 @@ class TestTelegramTypingLifecycle(unittest.TestCase):
)
asyncio.run(_run())
if __name__ == "__main__":
unittest.main()

View File

@@ -99,7 +99,3 @@ class TemplateContextBuilderConcurrencyTest(unittest.TestCase):
self.assertEqual(context.get("videoCodec"), "x265 10bit")
self.assertEqual(context.get("videoBit"), "10bit")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,12 +1,90 @@
# -*- coding: utf-8 -*-
import asyncio
import json
from copy import deepcopy
from pathlib import Path
from unittest import TestCase
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import parse_qsl, urlencode, urlsplit
from app.core.metainfo import MetaInfo
from app.chain import ChainBase
from app.helper.server import MoviePilotServerHelper
from app.modules.themoviedb import TheMovieDbModule
from app.modules.themoviedb.tmdbv3api.tmdb import TMDb
from app.schemas.types import MediaType
# 离线 TMDB 响应回放:识别测试断言的是 tmdbid 优先/电影电视消歧/类型推断等逻辑,
# 这些逻辑需要真实结构的 TMDB 响应才有意义,但直连 api.themoviedb.org 属于不可接受的
# 外部 IOCI 冷缓存下单文件 ~75s 且 flaky。这里用一次性录制的真实响应 cassette 回放
# TMDb 的 HTTP 出入口,既保持识别逻辑被真实数据驱动,又彻底离线。重新录制见提交说明。
_CASSETTE_PATH = Path(__file__).resolve().parent / "fixtures" / "tmdb_recognize_cassette.json"
_CASSETTE: dict = json.loads(_CASSETTE_PATH.read_text(encoding="utf-8"))
# 响应快照标记键,与 TMDb._snapshot_response 写入的结构保持一致
_MARKER = TMDb._RESPONSE_SNAPSHOT_MARKER
def _cassette_key(url: str) -> str:
"""把 TMDB 请求 URL 归一化为 cassette 键:剥离易变的 api_key其余 query 排序。
`_build_url` 生成形如 `/3/movie/23155?api_key=...&append_to_response=...&language=zh`
剥离 api_key 后键在不同环境/不同 key 下保持稳定。
"""
parts = urlsplit(url)
query = sorted((k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) if k != "api_key")
return f"{parts.path}?{urlencode(query)}"
def _replay(url: str) -> dict:
"""按归一化键回放录制的响应快照;未命中即报错提示重新录制,避免静默漏过新请求。"""
key = _cassette_key(url)
if key not in _CASSETTE:
raise AssertionError(
f"TMDB cassette 未命中:{key};如识别流程新增请求,请重新录制 "
f"tests/fixtures/tmdb_recognize_cassette.json"
)
# headers 置空:识别只消费 json丢弃录制头可规避限流/ETag 等无关分支
return {_MARKER: True, "headers": {}, "json": deepcopy(_CASSETTE[key])}
def _replay_request(self, method, url, data, json=None, **kwargs): # noqa: A002 - 对齐被替换方法签名
"""TMDb.request 的离线替身(同步)。"""
return _replay(url)
async def _replay_async_request(self, method, url, data, json=None, **kwargs): # noqa: A002 - 同上
"""TMDb.async_request 的离线替身(异步)。"""
return _replay(url)
_PATCHERS: list = []
def setUpModule():
"""整文件生效:离线化 TMDB HTTP 与共享识别 API确保零真实请求。
ChainBase.async_recognize_media 在识别成功后会经 MoviePilotServerHelper 向
MP 服务器movie-pilot.org的「共享识别 API」上报/查询;识别失败时还会反向
查询。这两条链路与 TMDB 目录无关,必须一并打桩,否则 Chain 端到端用例仍会真发请求。
"""
_PATCHERS.extend([
patch.object(TMDb, "request", _replay_request),
patch.object(TMDb, "async_request", _replay_async_request),
patch.object(MoviePilotServerHelper, "async_report_recognize_share", new=AsyncMock(return_value=None)),
patch.object(MoviePilotServerHelper, "async_query_recognize_share", new=AsyncMock(return_value=None)),
patch.object(MoviePilotServerHelper, "report_recognize_share", new=MagicMock(return_value=None)),
patch.object(MoviePilotServerHelper, "query_recognize_share", new=MagicMock(return_value=None)),
])
for patcher in _PATCHERS:
patcher.start()
def tearDownModule():
"""还原 TMDb HTTP 出口打桩,避免影响其它测试模块。"""
for patcher in _PATCHERS:
patcher.stop()
_PATCHERS.clear()
class TmdbRecognizeModuleTest(TestCase):
"""

View File

@@ -1,130 +1,10 @@
import asyncio
import importlib.util
import pickle
import sys
from contextlib import asynccontextmanager, contextmanager
from functools import wraps
from pathlib import Path
from threading import RLock
from types import ModuleType, SimpleNamespace
from unittest import TestCase
TMDB_MODULE_NAME = "app.modules.themoviedb.tmdbv3api.tmdb"
TMDB_FILE_PATH = Path(__file__).resolve().parents[1] / "app/modules/themoviedb/tmdbv3api/tmdb.py"
def _ensure_package(name: str) -> ModuleType:
module = sys.modules.get(name)
if module is None:
module = ModuleType(name)
module.__path__ = []
sys.modules[name] = module
return module
def _install_tmdb_test_stubs() -> None:
for package_name in [
"app",
"app.core",
"app.utils",
"app.modules",
"app.modules.themoviedb",
"app.modules.themoviedb.tmdbv3api",
]:
_ensure_package(package_name)
cache_module = ModuleType("app.core.cache")
def cached(*args, **kwargs):
def decorator(func):
if asyncio.iscoroutinefunction(func):
@wraps(func)
async def async_wrapper(*wrapper_args, **wrapper_kwargs):
return await func(*wrapper_args, **wrapper_kwargs)
return async_wrapper
@wraps(func)
def wrapper(*wrapper_args, **wrapper_kwargs):
return func(*wrapper_args, **wrapper_kwargs)
return wrapper
return decorator
@contextmanager
def fresh(*args, **kwargs):
yield
@asynccontextmanager
async def async_fresh(*args, **kwargs):
yield
cache_module.cached = cached
cache_module.fresh = fresh
cache_module.async_fresh = async_fresh
sys.modules[cache_module.__name__] = cache_module
config_module = ModuleType("app.core.config")
config_module.settings = SimpleNamespace(
TMDB_API_KEY="dummy-key",
TMDB_LOCALE="en-US",
PROXY=None,
TMDB_API_DOMAIN="example.com",
NORMAL_USER_AGENT="MoviePilot-Test-UA",
CONF=SimpleNamespace(tmdb=8, meta=60),
)
sys.modules[config_module.__name__] = config_module
http_module = ModuleType("app.utils.http")
class RequestUtils:
def __init__(self, *args, **kwargs):
pass
def get_res(self, *args, **kwargs): # pragma: no cover - 测试中会替换
raise NotImplementedError
def post_res(self, *args, **kwargs): # pragma: no cover - 测试中会替换
raise NotImplementedError
class AsyncRequestUtils:
def __init__(self, *args, **kwargs):
pass
async def get_res(self, *args, **kwargs): # pragma: no cover - 测试中会替换
raise NotImplementedError
async def post_res(self, *args, **kwargs): # pragma: no cover - 测试中会替换
raise NotImplementedError
http_module.RequestUtils = RequestUtils
http_module.AsyncRequestUtils = AsyncRequestUtils
sys.modules[http_module.__name__] = http_module
exceptions_module = ModuleType("app.modules.themoviedb.tmdbv3api.exceptions")
class TMDbException(Exception):
pass
exceptions_module.TMDbException = TMDbException
sys.modules[exceptions_module.__name__] = exceptions_module
def _load_tmdb_class():
_install_tmdb_test_stubs()
sys.modules.pop(TMDB_MODULE_NAME, None)
spec = importlib.util.spec_from_file_location(TMDB_MODULE_NAME, TMDB_FILE_PATH)
module = importlib.util.module_from_spec(spec)
sys.modules[TMDB_MODULE_NAME] = module
assert spec and spec.loader
spec.loader.exec_module(module)
return module.TMDb
TMDb = _load_tmdb_class()
TMDbException = sys.modules["app.modules.themoviedb.tmdbv3api.exceptions"].TMDbException
from app.modules.themoviedb.tmdbv3api.tmdb import TMDb
from app.modules.themoviedb.tmdbv3api.exceptions import TMDbException
class _FakeResponse:

View File

@@ -197,7 +197,3 @@ class TorrentFilterTest(unittest.TestCase):
filter_params={"size": "<1000"},
)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -122,7 +122,11 @@ class TransferDownloadHistoryLookupTest(unittest.TestCase):
histories_by_hash={"hash1": expected},
files_by_savepath={
"/downloads": [
SimpleNamespace(download_hash="hash1", filepath="Other.Show.mkv"),
SimpleNamespace(
download_hash="hash1",
fullpath="/downloads/Other.Show.mkv",
filepath="Other.Show.mkv",
),
]
},
)
@@ -152,7 +156,11 @@ class TransferDownloadHistoryLookupTest(unittest.TestCase):
histories_by_hash={"hash1": expected},
files_by_savepath={
"/downloads": [
SimpleNamespace(download_hash="hash1", filepath="Ghost.Concert.mkv"),
SimpleNamespace(
download_hash="hash1",
fullpath="/downloads/Ghost.Concert.mkv",
filepath="Ghost.Concert.mkv",
),
]
},
)
@@ -203,7 +211,3 @@ class TransferDownloadHistoryLookupTest(unittest.TestCase):
)
self.assertIsNone(history)
if __name__ == "__main__":
unittest.main()

View File

@@ -117,7 +117,3 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
post_message.call_args_list[0].args[0].title,
"已将整理记录 #34 交给智能助手处理",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,10 +1,5 @@
from types import ModuleType, SimpleNamespace
import sys
from types import SimpleNamespace
# The endpoint import pulls in a wide plugin/helper graph. Some optional modules are
# not present in this test environment, so stub them before importing the endpoint.
sys.modules.setdefault("app.helper.sites", ModuleType("app.helper.sites"))
setattr(sys.modules["app.helper.sites"], "SitesHelper", object)
from app.api.endpoints.transfer import (
manual_transfer,

View File

@@ -1113,6 +1113,3 @@ class TransferJobManagerTest(unittest.TestCase):
["/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv"],
event_data["file_list"],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,9 +1,5 @@
from pathlib import Path
import sys
from types import ModuleType
sys.modules.setdefault("app.helper.sites", ModuleType("app.helper.sites"))
setattr(sys.modules["app.helper.sites"], "SitesHelper", object)
from app.core.context import MediaInfo
from app.core.meta import MetaBase

View File

@@ -135,7 +135,3 @@ class TransferRenameBuildEventTest(unittest.TestCase):
self.assertIsNone(captured["source_path"])
self.assertIsNone(captured["source_item"])
if __name__ == "__main__":
unittest.main()

View File

@@ -119,7 +119,3 @@ class TestTransmissionCompat(unittest.TestCase):
self.assertIs(downloader.trc, fake_client)
fake_client.set_session.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -107,7 +107,3 @@ class UgreenApiVerifySslTest(unittest.TestCase):
self.assertEqual(fake_session.calls[0][1].get("verify"), False)
self.assertEqual(fake_session.calls[1][1].get("verify"), False)
self.assertEqual(fake_session.calls[2][1].get("verify"), False)
if __name__ == "__main__":
unittest.main()

View File

@@ -89,7 +89,3 @@ class UgreenCryptoTest(unittest.TestCase):
}
decoded = self.crypto.decrypt_response(resp, req.aes_key)
self.assertEqual(decoded, server_payload)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,11 +1,8 @@
import unittest
from unittest.mock import patch
import importlib.util
import sys
import types
from pathlib import Path
from app import schemas
from app.modules.ugreen.ugreen import Ugreen
try:
from app.api.endpoints import dashboard as dashboard_endpoint
@@ -13,81 +10,6 @@ except Exception:
dashboard_endpoint = None
def _load_ugreen_class():
"""
在测试中动态加载 Ugreen避免受可选依赖如 pyquery/sqlalchemy影响。
"""
module_name = "_test_ugreen_module"
if module_name in sys.modules:
return sys.modules[module_name].Ugreen
# 轻量日志桩
if "app.log" not in sys.modules:
log_module = types.ModuleType("app.log")
class _Logger:
def info(self, *_args, **_kwargs):
pass
def warning(self, *_args, **_kwargs):
pass
def error(self, *_args, **_kwargs):
pass
def debug(self, *_args, **_kwargs):
pass
log_module.logger = _Logger()
sys.modules["app.log"] = log_module
# SystemConfigOper 桩
if "app.db.systemconfig_oper" not in sys.modules:
db_module = types.ModuleType("app.db.systemconfig_oper")
class _SystemConfigOper:
@staticmethod
def get(_key):
return {}
@staticmethod
def set(_key, _value):
return None
db_module.SystemConfigOper = _SystemConfigOper
sys.modules["app.db.systemconfig_oper"] = db_module
# app.modules / app.modules.ugreen / app.modules.ugreen.api 桩
if "app.modules" not in sys.modules:
pkg = types.ModuleType("app.modules")
pkg.__path__ = []
sys.modules["app.modules"] = pkg
if "app.modules.ugreen" not in sys.modules:
subpkg = types.ModuleType("app.modules.ugreen")
subpkg.__path__ = []
sys.modules["app.modules.ugreen"] = subpkg
if "app.modules.ugreen.api" not in sys.modules:
api_module = types.ModuleType("app.modules.ugreen.api")
class _Api:
host = ""
token = None
api_module.Api = _Api
sys.modules["app.modules.ugreen.api"] = api_module
ugreen_path = Path(__file__).resolve().parents[1] / "app" / "modules" / "ugreen" / "ugreen.py"
spec = importlib.util.spec_from_file_location(module_name, ugreen_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
assert spec and spec.loader
spec.loader.exec_module(module)
return module.Ugreen
Ugreen = _load_ugreen_class()
class _FakeUgreenApi:
host = "http://127.0.0.1:9999"
token = "test-token"
@@ -205,7 +127,7 @@ class UgreenReconnectTest(unittest.TestCase):
ugreen._userinfo = None
with patch.object(Ugreen, "_Ugreen__restore_persisted_session", return_value=False), patch(
"_test_ugreen_module.Api", return_value=_FakeReconnectApi()
"app.modules.ugreen.ugreen.Api", return_value=_FakeReconnectApi()
), patch.object(Ugreen, "_Ugreen__save_persisted_session", return_value=None), patch.object(
Ugreen, "disconnect", wraps=ugreen.disconnect
), patch.object(Ugreen, "get_librarys") as mocked_get_librarys:
@@ -273,7 +195,3 @@ class DashboardStatisticTest(unittest.TestCase):
self.assertEqual(ret.tv_count, 22)
self.assertEqual(ret.user_count, 3)
self.assertEqual(ret.episode_count, 6)
if __name__ == "__main__":
unittest.main()

View File

@@ -73,7 +73,3 @@ class UvPipCompatTests(unittest.TestCase):
self.assertEqual(["pip", "sync", "--python", argv[3], "requirements.txt"], argv)
self.assertTrue(argv[3].endswith("/venv/bin/python"))
if __name__ == "__main__":
unittest.main()

View File

@@ -55,7 +55,3 @@ class WebPushSubscriptionTest(unittest.TestCase):
SimpleNamespace(response=SimpleNamespace(status_code=500))
)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -219,8 +219,9 @@ class WechatClawBotTest(unittest.TestCase):
with patch("app.modules.wechatclawbot.wechatclawbot.RequestUtils.post", return_value=response):
ok, message = client.test_connection()
# `ilink_user_id required` 仅表示自检接口缺少额外参数,不代表连接失败:视为连接正常
self.assertTrue(ok)
self.assertIn("iLink 自检接口要求额外的 ilink_user_id", message)
self.assertEqual(message, "连接正常")
def test_wechatclawbot_send_msg_uses_plain_text_payload(self):
state = {
@@ -372,7 +373,3 @@ class WechatClawBotTest(unittest.TestCase):
mime_type="text/plain",
context_token=None,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -41,7 +41,3 @@ class FetchRssActionTest(unittest.TestCase):
self.assertIsNone(torrent_info.category)
self.assertTrue(callable(getattr(torrent_info, "to_dict", None)))
self.assertEqual("2026-05-19 08:30:00", torrent_info.pubdate)
if __name__ == "__main__":
unittest.main()

View File

@@ -142,7 +142,3 @@ class ZSpaceMediaServerTest(unittest.TestCase):
client._ZSpace__get_local_image_by_id("item-id"),
"http://zspace.local/emby/Items/item-id/Images/Primary?api_key=zspace-token",
)
if __name__ == "__main__":
unittest.main()