mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-02 07:26:46 +00:00
test: 测试套件自隔离与全量离线化(collection 清零 + 杜绝真实网络) (#5873)
This commit is contained in:
8
app/testing/__init__.py
Normal file
8
app/testing/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""测试辅助工具(主程序与插件仓共享)。
|
||||
|
||||
提供测试期对 ``sys.modules`` 的临时打桩能力,保证打桩在使用后还原,避免测试间
|
||||
因残留假模块而相互污染。仅供测试使用,不参与运行时逻辑。
|
||||
"""
|
||||
from app.testing.stub import stub_modules
|
||||
|
||||
__all__ = ["stub_modules"]
|
||||
75
app/testing/stub.py
Normal file
75
app/testing/stub.py
Normal 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
|
||||
@@ -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
14206
tests/fixtures/tmdb_recognize_cassette.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
17
tests/run.py
17
tests/run.py
@@ -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:]]))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -62,7 +62,3 @@ class TestAgentFilterRuleTools(unittest.TestCase):
|
||||
"SPECSUB & CNVOI & 4K & !BLU",
|
||||
parsed["levels"][0]["expression"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1323,6 +1323,3 @@ class AgentImageSupportTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
client.send_file.assert_called_once()
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -220,7 +220,3 @@ class TestAgentInteraction(unittest.TestCase):
|
||||
)
|
||||
|
||||
handle_ai_message.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -177,7 +177,3 @@ class TestAgentRuntimeConfig(unittest.TestCase):
|
||||
for warning in runtime_config.warnings
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -31,7 +31,3 @@ description: test
|
||||
["a-skill", "m-skill", "z-skill"],
|
||||
[skill["id"] for skill in skills],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -270,7 +270,3 @@ class TestSubAgentTaskControlMiddleware(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
|
||||
self.assertEqual("cancelled", status_payload["tasks"][0]["status"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/** 被 gitignore,CI / 全新环境无此插件),
|
||||
# 缺失时跳过本模块,避免 collection 阶段 ImportError。
|
||||
AgentTokens = pytest.importorskip("app.plugins.agenttokens").AgentTokens
|
||||
from app.schemas.types import ChainEventType, EventType
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -89,7 +89,3 @@ class BrowserHelperTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(source, "<html>ok</html>")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -359,7 +359,3 @@ class TransmissionPathMappingTest(unittest.TestCase):
|
||||
Path("/mnt/raid5/home_lt999lt/video/downloads/movie/Movie"),
|
||||
"tr",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -315,7 +315,3 @@ class TestPrepareAndSubmitScripts(FeedbackIssueScriptTestCase):
|
||||
|
||||
self.assertEqual(result["reason"], "rate_limited_user")
|
||||
self.assertIn("30 分钟", result["message"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`,
|
||||
# 这里临时把该名指向假类,使修补作用到 _FakeChatDeepSeek;patch 在用例结束自动还原。
|
||||
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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -315,7 +315,3 @@ class LocalSetupLlmProviderPromptTests(unittest.TestCase):
|
||||
base_url_preset="minimax-cn-coding",
|
||||
runtime_python=None,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -397,7 +397,3 @@ class TestMessageChannelPermissions(unittest.TestCase):
|
||||
client.send_msg.assert_called_once_with(
|
||||
title="只有管理员才有权限执行此命令", userid="normal-user"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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_async(loop.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()
|
||||
|
||||
@@ -89,7 +89,3 @@ class TestSystemNotificationDispatch(unittest.TestCase):
|
||||
send.assert_called_once_with("payload")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 属于不可接受的
|
||||
# 外部 IO(CI 冷缓存下单文件 ~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):
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -197,7 +197,3 @@ class TorrentFilterTest(unittest.TestCase):
|
||||
filter_params={"size": "<1000"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -117,7 +117,3 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
|
||||
post_message.call_args_list[0].args[0].title,
|
||||
"已将整理记录 #34 交给智能助手处理",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -135,7 +135,3 @@ class TransferRenameBuildEventTest(unittest.TestCase):
|
||||
|
||||
self.assertIsNone(captured["source_path"])
|
||||
self.assertIsNone(captured["source_item"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -55,7 +55,3 @@ class WebPushSubscriptionTest(unittest.TestCase):
|
||||
SimpleNamespace(response=SimpleNamespace(status_code=500))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user