test: 共享测试 harness 入 app/testing(网络守卫 + 引导)并统一 sys.modules 打桩原语 (#5888)

This commit is contained in:
InfinityPacer
2026-06-03 18:34:20 +08:00
committed by GitHub
parent 6405ff1191
commit 791f1fe4ac
12 changed files with 393 additions and 250 deletions

View File

@@ -1,7 +1,13 @@
"""测试辅助工具(主程序与插件仓共享)。
提供测试期对 ``sys.modules`` 的临时打桩能力,保证打桩在使用后还原,避免测试间
因残留假模块而相互污染。仅供测试使用,不参与运行时逻辑。
汇集主程序与插件仓共用的测试 harness仅供测试使用、不参与运行时逻辑
- :mod:`app.testing.stub`:测试期对 ``sys.modules`` 的临时打桩并自动还原,避免残留假模块相互污染;
- :mod:`app.testing.bootstrap`:隔离 CONFIG_DIR、建表、插件目录注入与 v1/v2 marker 等引导逻辑;
- :mod:`app.testing.network_guard`autouse 拦截测试期对非本地主机的真实出站。
子模块各自按需 import如 ``network_guard`` 依赖 pytest故此处只 re-export 无第三方依赖的
:func:`stub_modules`,保持 ``import app.testing`` 不引入 pytest 等测试期依赖。
"""
from app.testing.stub import stub_modules

169
app/testing/bootstrap.py Normal file
View File

@@ -0,0 +1,169 @@
"""测试引导共享实现(主程序与插件仓同源)。
主程序 ``tests/conftest.py`` 与各插件仓的极薄 shim``tests/_bootstrap.py``,仅负责把
后端定位并加入 ``sys.path``)都委托到这里,使「隔离 CONFIG_DIR / 建表 / 注入插件目录 /
按目录打 v1·v2 marker / 退出清理」等引导逻辑只在主程序维护一处,所有消费方行为与修复一致。
其中 :func:`isolate_config_dir` 为主程序与插件仓共用,``prepare_v1/v2_backend`` 与
:func:`mark_plugin_generation` 为插件仓专用。
本模块只依赖标准库,``import`` 期不连库、不触发 ``app.db``:调用方可安全地「先 import 本模块、
再隔离 CONFIG_DIR」不破坏「隔离必须早于首个 ``import app.db``」这一硬约束。
"""
from __future__ import annotations
import atexit
import os
import shutil
import sys
import tempfile
from pathlib import Path
from typing import Optional
# 本进程隔离出的临时 CONFIG_DIR兼作幂等标记
_isolated_config_dir: Optional[str] = None
def isolate_config_dir() -> str:
"""把 ``CONFIG_DIR`` 指向进程私有临时目录,隔离主程序真实库与配置(幂等)。
``import app.db`` / ``import app.chain.*`` 在 import 期即按 ``settings.CONFIG_PATH`` 连接
``user.db``,故本函数必须在首个 ``import app.db`` 之前调用。调用方已显式设置 ``CONFIG_DIR``
(如 CI 指定隔离目录)时尊重之、不覆盖。
:return: 实际生效的 CONFIG_DIR 绝对路径
"""
global _isolated_config_dir
if _isolated_config_dir is not None:
return _isolated_config_dir
existing = os.environ.get("CONFIG_DIR")
if existing:
_isolated_config_dir = existing
return existing
tmp = tempfile.mkdtemp(prefix="mp-test-config-")
os.environ["CONFIG_DIR"] = tmp
_isolated_config_dir = tmp
def _cleanup(path: str = tmp, rmtree=shutil.rmtree, sys_mod=sys) -> None:
"""进程退出时释放 SQLite 连接池再删临时目录。
默认参数绑定 ``rmtree``/``path``/``sys_mod``:解释器关停期标准库模块可能已被回收为 ``None``
绑定后仍可安全调用。先 ``Engine.dispose`` 释放 ``user.db`` 连接,规避 Windows 下
文件锁导致 ``rmtree`` 静默失败(``ignore_errors``)、残留临时目录。
"""
try:
db_mod = sys_mod.modules.get("app.db")
if db_mod is not None:
db_mod.Engine.dispose()
except Exception:
pass
rmtree(path, ignore_errors=True)
atexit.register(_cleanup)
return tmp
def _prepend_sys_path(path: Path) -> None:
"""把目录前置到 ``sys.path``(去重),使其内顶层包可被导入。"""
value = str(path)
if value not in sys.path:
sys.path.insert(0, value)
def ensure_sites_stub() -> None:
"""为 ``app.helper.sites`` 补最小垫片(仅在缺失时)。
``app.helper.sites`` 由独立仓库动态拉取CI / 全新环境无该模块,而众多 ``app.chain.*`` /
``app.modules.*`` 在 import 期依赖它。统一补一个最小垫片,省去各测试文件各自打桩;若真实模块
已存在(本地已拉取)则用真实模块、不覆盖,不影响真实行为。须在隔离 CONFIG_DIR 之后调用,
以免试探性 ``import app.helper.sites`` 触发的连库落到真实库。
"""
if "app.helper.sites" in sys.modules:
return
try:
import app.helper.sites # noqa: F401 本地已拉取时用真实模块
except ModuleNotFoundError:
from types import ModuleType
stub = ModuleType("app.helper.sites")
stub.SitesHelper = object
sys.modules["app.helper.sites"] = stub
def ensure_optional_stub(name: str, **attrs) -> None:
"""为可选第三方依赖补占位模块(仅在缺失时),可带属性。
用例 import 的 app 代码会牵入可选三方库(如 psutil / dateparser / Pinyin2Hanzi /
qbittorrentapi / transmission_rpcCI / 全新环境可能未安装。本函数在该库缺失时补一个带
指定属性的占位,使 import 不致失败;若已真实安装则保留真实模块、不覆盖。占位为进程级常驻
(与 import 生命周期一致、不作用域还原),是「让可选 import 不失败」的垫片——与
:func:`stub_modules`(作用域内打桩并还原)属不同用途,故不收进 stub_modules。
:param name: 可选依赖的顶层模块名
:param attrs: 占位模块需暴露的属性(仅在真正创建占位时设置)
"""
if name in sys.modules:
return
try:
__import__(name)
return
except ImportError:
pass
from types import ModuleType
module = ModuleType(name)
for key, value in attrs.items():
setattr(module, key, value)
sys.modules[name] = module
def prepare_backend() -> None:
"""隔离 CONFIG_DIR、补 sites 垫片并建表(后端须已在 ``sys.path`` 上)。
主程序中后端即当前包;插件仓由其 ``tests/_bootstrap.py`` shim 在 import 本模块前
先把后端目录注入 ``sys.path``。顺序固定:先隔离 CONFIG_DIR再补 ``app.helper.sites`` 垫片,
最后建表——隔离出的临时库为空,运行期查 ``systemconfig`` 等表会报 no such table故建表
``init_db`` 仅 import models + create_all无 alembic/网络、幂等、毫秒级。
"""
isolate_config_dir()
ensure_sites_stub()
from app.db.init import init_db
init_db()
def prepare_v2_backend(plugins_repo: Path) -> None:
"""v2 插件单测引导:``prepare_backend`` + 把 ``<repo>/plugins.v2`` 注入 ``sys.path``。
与 :func:`prepare_v1_backend` 互斥v1/v2 存在同名插件包,同一进程同时加载会相互覆盖,
须在各自独立的 pytest 会话中运行。
:param plugins_repo: 插件仓根目录(由调用方 shim 传入)
"""
prepare_backend()
_prepend_sys_path(Path(plugins_repo) / "plugins.v2")
def prepare_v1_backend(plugins_repo: Path) -> None:
"""v1 插件单测引导:``prepare_backend`` + 把 ``<repo>/plugins`` 注入 ``sys.path``(与 v2 互斥)。
:param plugins_repo: 插件仓根目录(由调用方 shim 传入)
"""
prepare_backend()
_prepend_sys_path(Path(plugins_repo) / "plugins")
def mark_plugin_generation(items, pytest_module) -> None:
"""按用例所在目录自动给其打 ``v1`` / ``v2`` marker供按代筛选与分会话运行。
优先读取 pytest 7+ 的 ``item.path``,旧版 pytest 缺失该属性时回退到 ``item.fspath``。用
「不带前导斜杠」的子串匹配(``tests/v2/`` / ``tests/v1/``),兼容相对路径与绝对路径两种
运行方式:以 ``pytest tests/v2`` 等相对路径运行时收集路径可能不含前导斜杠。
``pytest`` 模块由各仓 conftest 传入,避免本模块在非测试态强依赖 pytest。
:param items: pytest 收集到的用例集合
:param pytest_module: 调用方传入的 ``pytest`` 模块对象
"""
for item in items:
item_path = getattr(item, "path", None)
path = str(item_path if item_path is not None else item.fspath).replace("\\", "/")
if "tests/v2/" in path:
item.add_marker(pytest_module.mark.v2)
elif "tests/v1/" in path:
item.add_marker(pytest_module.mark.v1)

View File

@@ -0,0 +1,40 @@
"""测试网络守卫(主程序与插件仓共享)。
提供一个 autouse 的 pytest fixture拦截测试期对非本地主机的真实出站网络。主程序
``tests/conftest.py`` 与各插件仓 conftest 只需 ``from app.testing.network_guard import
block_real_network`` 即复用同一道守卫——pytest 会把 conftest 命名空间内(含 import 进来的)
fixture 一并识别autouse 自动作用于每个用例,无需逐用例改动。
仅供测试使用,不参与运行时逻辑。
"""
from __future__ import annotations
import pytest
# 本地回环/通配地址放行其余主机一律视为真实出站getaddrinfo 的 host 可能为 str 或 bytes
_ALLOWED_NETWORK_HOSTS = {"127.0.0.1", "::1", "localhost", "0.0.0.0", "::", ""}
@pytest.fixture(autouse=True)
def block_real_network(monkeypatch):
"""防御纵深:拦截对非本地主机的真实出站,强制测试零真实网络。
补在各用例自身 mock 之上:某用例万一漏 mock 外部依赖TMDB / LLM 目录 / 下载器 /
媒体服务器 / 任意外链),其真实 DNS 解析会在此被拦并报错,而非静默发请求。本地回环放行
sqlite 等。asyncio 默认解析器经线程池调用 ``socket.getaddrinfo``,故拦此一处即覆盖
同步与异步出站。``monkeypatch`` 在用例结束后自动还原,不影响其他用例与进程退出。
"""
import socket
_real_getaddrinfo = socket.getaddrinfo
def _guarded_getaddrinfo(host, *args, **kwargs):
normalized = host.decode() if isinstance(host, (bytes, bytearray)) else host
if normalized is not None and normalized not in _ALLOWED_NETWORK_HOSTS:
raise RuntimeError(
f"测试禁止真实出站网络:尝试解析 {normalized!r};请 mock 对应外部依赖"
)
return _real_getaddrinfo(host, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", _guarded_getaddrinfo)
yield

View File

@@ -1,43 +1,14 @@
"""pytest 全局引导:在 import 任何测试模块前把 CONFIG_DIR 指向临时目录并建表,隔离真实库。"""
import atexit
import os
import shutil
import sys
import tempfile
from types import ModuleType
"""pytest 全局引导:隔离 CONFIG_DIR、补 sites 垫片、建表、装载网络守卫。
# 必须早于首个 import app.*app.db 在导入时即按 CONFIG_PATH 连接 user.db
if not os.environ.get("CONFIG_DIR"):
_isolated_config_dir = tempfile.mkdtemp(prefix="mp-test-config-")
os.environ["CONFIG_DIR"] = _isolated_config_dir
引导与网络守卫均复用 ``app/testing`` 的共享 harness与插件仓 conftest 同源),
引导逻辑只在 ``app/testing`` 维护一处。
"""
# 必须早于首个 import app.db其在 import 期即按 CONFIG_PATH 连库prepare_backend 内部
# 先隔离 CONFIG_DIR、补 app.helper.sites 垫片再建表。app/testing 仅依赖标准库、import 不连库,
# 故此处先 import 再调用是安全的。
from app.testing.bootstrap import prepare_backend
def _cleanup_isolated_config_dir():
"""进程退出时先释放 SQLite 连接池再删临时目录。
prepare_backend()
Windows 下 Engine 若仍持有 user.db 的文件锁,直接 rmtree 会因占用而静默失败
ignore_errors=True、残留临时目录先 dispose 释放连接再删可规避。
"""
try:
from app.db import Engine
Engine.dispose()
except Exception:
pass
shutil.rmtree(_isolated_config_dir, ignore_errors=True)
atexit.register(_cleanup_isolated_config_dir)
# 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
init_db()
# 复用共享 autouse 网络守卫;同一实现亦供各插件仓 conftest import 复用,避免逐仓维护
from app.testing.network_guard import block_real_network # noqa: E402,F401

View File

@@ -1,19 +1,16 @@
import sys
import asyncio
import json
import tempfile
import unittest
from types import ModuleType, SimpleNamespace
from types import SimpleNamespace
from unittest.mock import ANY, MagicMock, patch
from app.testing.bootstrap import ensure_optional_stub
sys.modules.setdefault("psutil", ModuleType("psutil"))
sys.modules.setdefault("dateparser", ModuleType("dateparser"))
if "Pinyin2Hanzi" not in sys.modules:
pinyin_module = ModuleType("Pinyin2Hanzi")
setattr(pinyin_module, "is_pinyin", lambda value: False)
sys.modules["Pinyin2Hanzi"] = pinyin_module
# 可选三方依赖在 CI / 全新环境可能未安装,补占位避免 app.modules.feishu 导入失败
ensure_optional_stub("psutil")
ensure_optional_stub("dateparser")
ensure_optional_stub("Pinyin2Hanzi", is_pinyin=lambda value: False)
from app.modules.feishu import FeishuModule
from app.modules.feishu.feishu import Feishu

View File

@@ -1,19 +1,6 @@
import sys
import unittest
from unittest.mock import Mock
for _module_name in (
"app.chain.mediaserver",
"app.db.models",
"app.db.user_oper",
"app.helper.message",
"app.utils.crypto",
):
if _module_name in sys.modules and not hasattr(
sys.modules[_module_name], "__file__"
):
del sys.modules[_module_name]
from app.chain.mediaserver import MediaServerChain
from app.schemas import MediaServerLibrary, MediaServerPlayItem
from app.utils.security import SecurityUtils

View File

@@ -131,14 +131,15 @@ class PluginHelperTest(TestCase):
self.skipTest(f"missing dependency: {exc}")
module_names = ["app.plugins.dynamicwechat.helper", "Crypto.Cipher._mode_cbc"]
previous_modules = {name: sys.modules.get(name) for name in module_names}
def fake_execute(_cmd):
for module_name in module_names:
sys.modules[module_name] = ModuleType(module_name)
return True, "ok"
try:
# patch.dict 进入时快照 sys.modules、退出时整体还原替代手写逐项 save/restore
# 保证 fake_execute 在安装窗口注入的运行态模块在用例结束后被清理、不污染其他用例
with patch.dict(sys.modules):
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
@@ -149,12 +150,6 @@ class PluginHelperTest(TestCase):
self.assertEqual("ok", message)
for module_name in module_names:
self.assertIn(module_name, sys.modules)
finally:
for module_name, previous_module in previous_modules.items():
if previous_module is None:
sys.modules.pop(module_name, None)
else:
sys.modules[module_name] = previous_module
def test_pip_install_serializes_concurrent_calls(self):
"""

View File

@@ -1,28 +1,15 @@
import asyncio
import importlib.machinery
import sys
import unittest
from types import SimpleNamespace
from types import ModuleType
from unittest.mock import AsyncMock, patch
from app.testing.bootstrap import ensure_optional_stub
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
_stub_module("qbittorrentapi", TorrentFilesList=list)
_stub_module("transmission_rpc", File=object)
_stub_module(
"psutil",
__spec__=importlib.machinery.ModuleSpec("psutil", loader=None),
)
# 可选三方依赖在 CI / 全新环境可能未安装,补占位(带用例所需属性)避免导入失败
ensure_optional_stub("qbittorrentapi", TorrentFilesList=list)
ensure_optional_stub("transmission_rpc", File=object)
ensure_optional_stub("psutil", __spec__=importlib.machinery.ModuleSpec("psutil", loader=None))
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent import ReplyMode

View File

@@ -7,6 +7,7 @@ from unittest import TestCase
from unittest.mock import patch
from app.schemas.types import MediaType
from app.testing import stub_modules
def _load_subscribe_chain_class():
@@ -16,13 +17,11 @@ def _load_subscribe_chain_class():
module = sys.modules[module_name]
return module, module.SubscribeChain
original_modules = {}
stub_deps = {}
def ensure_module(name: str, module: types.ModuleType):
"""临时替换模块依赖,并记录原模块以便加载完成后恢复"""
if name not in original_modules:
original_modules[name] = sys.modules.get(name)
sys.modules[name] = module
"""登记一个加载期临时替换模块;实际替换与精确还原由 stub_modules 在加载时统一处理"""
stub_deps[name] = module
return module
chain_module = ensure_module("app.chain", types.ModuleType("app.chain"))
@@ -298,18 +297,12 @@ def _load_subscribe_chain_class():
subscribe_path = Path(__file__).resolve().parents[1] / "app" / "chain" / "subscribe.py"
spec = importlib.util.spec_from_file_location(module_name, subscribe_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
assert spec and spec.loader
spec.loader.exec_module(module)
module._injected_modules = {
name: sys.modules.get(name)
for name in original_modules
}
for injected_name, original_module in original_modules.items():
if original_module is None:
sys.modules.pop(injected_name, None)
else:
sys.modules[injected_name] = original_module
# 加载期用 stub_modules 精确替换依赖、退出时统一还原module_name 非桩,缓存入 sys.modules 供复用
with stub_modules(stub_deps):
sys.modules[module_name] = module
spec.loader.exec_module(module)
module._injected_modules = {name: sys.modules.get(name) for name in stub_deps}
return module, module.SubscribeChain

View File

@@ -1,24 +1,17 @@
import asyncio
import sys
import unittest
from types import ModuleType
from unittest.mock import AsyncMock, patch
_ORIGINAL_STUBBED_MODULES = {}
from app.testing import stub_modules
def _stub_module(name: str, **attrs):
"""
安装临时 stub 模块,并记录原模块用于导入后恢复。
"""
if name not in _ORIGINAL_STUBBED_MODULES:
_ORIGINAL_STUBBED_MODULES[name] = sys.modules.get(name)
def _stub(name: str, **attrs) -> tuple:
"""构造带指定属性的占位模块,返回 ``(模块名, 模块)`` 供 :func:`stub_modules` 使用。"""
module = ModuleType(name)
for key, value in attrs.items():
setattr(module, key, value)
sys.modules[name] = module
return module
return name, module
class _Dummy:
@@ -35,69 +28,44 @@ class _DummyError(Exception):
self.duration_ms = duration_ms
for _module_name in ("pillow_avif", "aiofiles", "psutil"):
_stub_module(_module_name)
# 在 import 期用占位模块替换重依赖/外部模块import 完由 stub_modules 精确还原,避免污染其它用例
_STUB_MODULES = dict([
_stub("pillow_avif"),
_stub("aiofiles"),
_stub("psutil"),
_stub("app.helper.sites", SitesHelper=_Dummy),
_stub("app.chain.mediaserver", MediaServerChain=_Dummy),
_stub("app.chain.search", SearchChain=_Dummy),
_stub("app.chain.system", SystemChain=_Dummy),
_stub("app.agent.llm", LLMHelper=_Dummy, LLMProviderManager=_Dummy,
LLMTestError=_DummyError, LLMTestTimeout=_DummyError,
render_auth_result_html=lambda success, message: message),
_stub("app.core.event", eventmanager=_Dummy(), Event=_Dummy, EventManager=_Dummy),
_stub("app.core.metainfo", MetaInfo=_Dummy),
_stub("app.core.module", ModuleManager=_Dummy),
_stub("app.core.security", verify_apitoken=_Dummy, verify_resource_token=_Dummy, verify_token=_Dummy),
_stub("app.db.models", User=_Dummy),
_stub("app.db.systemconfig_oper", SystemConfigOper=_Dummy),
_stub("app.db.user_oper", get_current_active_superuser=_Dummy,
get_current_active_superuser_async=_Dummy, get_current_active_user_async=_Dummy),
_stub("app.helper.llm", LLMHelper=_Dummy, LLMTestError=_DummyError, LLMTestTimeout=_DummyError),
_stub("app.helper.mediaserver", MediaServerHelper=_Dummy),
_stub("app.helper.message", MessageHelper=_Dummy),
_stub("app.helper.progress", ProgressHelper=_Dummy),
_stub("app.helper.rule", RuleHelper=_Dummy),
_stub("app.helper.server", MoviePilotServerHelper=_Dummy),
_stub("app.helper.system", SystemHelper=_Dummy),
_stub("app.helper.image", ImageHelper=_Dummy),
_stub("app.scheduler", Scheduler=_Dummy),
_stub("app.log", logger=_Dummy(), log_settings=_Dummy(),
LogConfigModel=type("LogConfigModel", (), {})),
_stub("app.utils.crypto", HashUtils=_Dummy),
_stub("app.utils.http", RequestUtils=_Dummy, AsyncRequestUtils=_Dummy),
_stub("version", APP_VERSION="test"),
])
_stub_module("app.helper.sites", SitesHelper=_Dummy)
_stub_module("app.chain.mediaserver", MediaServerChain=_Dummy)
_stub_module("app.chain.search", SearchChain=_Dummy)
_stub_module("app.chain.system", SystemChain=_Dummy)
_stub_module(
"app.agent.llm",
LLMHelper=_Dummy,
LLMProviderManager=_Dummy,
LLMTestError=_DummyError,
LLMTestTimeout=_DummyError,
render_auth_result_html=lambda success, message: message,
)
_stub_module("app.core.event", eventmanager=_Dummy(), Event=_Dummy, EventManager=_Dummy)
_stub_module("app.core.metainfo", MetaInfo=_Dummy)
_stub_module("app.core.module", ModuleManager=_Dummy)
_stub_module(
"app.core.security",
verify_apitoken=_Dummy,
verify_resource_token=_Dummy,
verify_token=_Dummy,
)
_stub_module("app.db.models", User=_Dummy)
_stub_module("app.db.systemconfig_oper", SystemConfigOper=_Dummy)
_stub_module(
"app.db.user_oper",
get_current_active_superuser=_Dummy,
get_current_active_superuser_async=_Dummy,
get_current_active_user_async=_Dummy,
)
_stub_module(
"app.helper.llm",
LLMHelper=_Dummy,
LLMTestError=_DummyError,
LLMTestTimeout=_DummyError,
)
_stub_module("app.helper.mediaserver", MediaServerHelper=_Dummy)
_stub_module("app.helper.message", MessageHelper=_Dummy)
_stub_module("app.helper.progress", ProgressHelper=_Dummy)
_stub_module("app.helper.rule", RuleHelper=_Dummy)
_stub_module("app.helper.server", MoviePilotServerHelper=_Dummy)
_stub_module("app.helper.system", SystemHelper=_Dummy)
_stub_module("app.helper.image", ImageHelper=_Dummy)
_stub_module("app.scheduler", Scheduler=_Dummy)
_stub_module(
"app.log",
logger=_Dummy(),
log_settings=_Dummy(),
LogConfigModel=type("LogConfigModel", (), {}),
)
_stub_module("app.utils.crypto", HashUtils=_Dummy)
_stub_module("app.utils.http", RequestUtils=_Dummy, AsyncRequestUtils=_Dummy)
_stub_module("version", APP_VERSION="test")
from app.api.endpoints import llm as system_endpoint
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(_STUB_MODULES):
from app.api.endpoints import llm as system_endpoint
class LlmTestEndpointTest(unittest.TestCase):

View File

@@ -1,25 +1,18 @@
import asyncio
import ipaddress
import sys
import unittest
from types import ModuleType, SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
_ORIGINAL_STUBBED_MODULES = {}
from app.testing import stub_modules
def _stub_module(name: str, **attrs):
"""
安装临时 stub 模块,并记录原模块用于导入后恢复。
"""
if name not in _ORIGINAL_STUBBED_MODULES:
_ORIGINAL_STUBBED_MODULES[name] = sys.modules.get(name)
def _stub(name: str, **attrs) -> tuple:
"""构造带指定属性的占位模块,返回 ``(模块名, 模块)`` 供 :func:`stub_modules` 使用。"""
module = ModuleType(name)
for key, value in attrs.items():
setattr(module, key, value)
sys.modules[name] = module
return module
return name, module
class _Dummy:
@@ -36,67 +29,42 @@ class _DummyError(Exception):
self.duration_ms = duration_ms
for _module_name in ("pillow_avif", "aiofiles", "psutil"):
_stub_module(_module_name)
# 在 import 期用占位模块替换重依赖/外部模块import 完由 stub_modules 精确还原,避免污染其它用例
_STUB_MODULES = dict([
_stub("pillow_avif"),
_stub("aiofiles"),
_stub("psutil"),
_stub("app.helper.sites", SitesHelper=_Dummy),
_stub("app.chain.media", MediaChain=_Dummy),
_stub("app.chain.mediaserver", MediaServerChain=_Dummy),
_stub("app.chain.search", SearchChain=_Dummy),
_stub("app.chain.system", SystemChain=_Dummy),
_stub("app.core.event", eventmanager=_Dummy(), Event=_Dummy, EventManager=_Dummy),
_stub("app.core.metainfo", MetaInfo=_Dummy),
_stub("app.core.module", ModuleManager=_Dummy),
_stub("app.core.security", verify_apitoken=_Dummy, verify_resource_token=_Dummy, verify_token=_Dummy),
_stub("app.db.models", User=_Dummy),
_stub("app.db.systemconfig_oper", SystemConfigOper=_Dummy),
_stub("app.db.user_oper", get_current_active_superuser=_Dummy,
get_current_active_superuser_async=_Dummy, get_current_active_user_async=_Dummy),
_stub("app.helper.llm", LLMHelper=_Dummy, LLMTestError=_DummyError, LLMTestTimeout=_DummyError),
_stub("app.helper.mediaserver", MediaServerHelper=_Dummy),
_stub("app.helper.message", MessageHelper=_Dummy),
_stub("app.helper.progress", ProgressHelper=_Dummy),
_stub("app.helper.rule", RuleHelper=_Dummy),
_stub("app.helper.server", MoviePilotServerHelper=_Dummy),
_stub("app.helper.system", SystemHelper=_Dummy),
_stub("app.helper.image", ImageHelper=_Dummy),
_stub("app.scheduler", Scheduler=_Dummy),
_stub("app.log", logger=_Dummy(), log_settings=_Dummy(),
LogConfigModel=type("LogConfigModel", (), {})),
_stub("app.utils.crypto", HashUtils=_Dummy),
_stub("app.utils.http", RequestUtils=_Dummy, AsyncRequestUtils=_Dummy),
_stub("version", APP_VERSION="test", FRONTEND_VERSION="frontend-test"),
])
_stub_module("app.helper.sites", SitesHelper=_Dummy)
_stub_module("app.chain.media", MediaChain=_Dummy)
_stub_module("app.chain.mediaserver", MediaServerChain=_Dummy)
_stub_module("app.chain.search", SearchChain=_Dummy)
_stub_module("app.chain.system", SystemChain=_Dummy)
_stub_module(
"app.core.event",
eventmanager=_Dummy(),
Event=_Dummy,
EventManager=_Dummy,
)
_stub_module("app.core.metainfo", MetaInfo=_Dummy)
_stub_module("app.core.module", ModuleManager=_Dummy)
_stub_module(
"app.core.security",
verify_apitoken=_Dummy,
verify_resource_token=_Dummy,
verify_token=_Dummy,
)
_stub_module("app.db.models", User=_Dummy)
_stub_module("app.db.systemconfig_oper", SystemConfigOper=_Dummy)
_stub_module(
"app.db.user_oper",
get_current_active_superuser=_Dummy,
get_current_active_superuser_async=_Dummy,
get_current_active_user_async=_Dummy,
)
_stub_module(
"app.helper.llm",
LLMHelper=_Dummy,
LLMTestError=_DummyError,
LLMTestTimeout=_DummyError,
)
_stub_module("app.helper.mediaserver", MediaServerHelper=_Dummy)
_stub_module("app.helper.message", MessageHelper=_Dummy)
_stub_module("app.helper.progress", ProgressHelper=_Dummy)
_stub_module("app.helper.rule", RuleHelper=_Dummy)
_stub_module("app.helper.server", MoviePilotServerHelper=_Dummy)
_stub_module("app.helper.system", SystemHelper=_Dummy)
_stub_module("app.helper.image", ImageHelper=_Dummy)
_stub_module("app.scheduler", Scheduler=_Dummy)
_stub_module(
"app.log",
logger=_Dummy(),
log_settings=_Dummy(),
LogConfigModel=type("LogConfigModel", (), {}),
)
_stub_module("app.utils.crypto", HashUtils=_Dummy)
_stub_module("app.utils.http", RequestUtils=_Dummy, AsyncRequestUtils=_Dummy)
_stub_module("version", APP_VERSION="test", FRONTEND_VERSION="frontend-test")
from app.api.endpoints import system as system_endpoint
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(_STUB_MODULES):
from app.api.endpoints import system as system_endpoint
class NettestSecurityTest(unittest.TestCase):

View File

@@ -0,0 +1,62 @@
"""共享测试引导工具的回归用例。"""
from __future__ import annotations
import builtins
import types
from pathlib import Path
from app.testing import bootstrap
def test_isolate_config_cleanup_uses_loaded_db_module_without_late_import(monkeypatch):
"""清理回调只读取已加载模块,避免解释器关停期触发二次导入。"""
captured = {}
import_calls = []
def fake_import(name, *args, **kwargs):
"""记录清理回调是否试图重新导入数据库模块。"""
if name == "app.db":
import_calls.append(name)
return original_import(name, *args, **kwargs)
def fake_register(func):
"""截获 atexit 回调,便于直接验证清理行为。"""
captured["cleanup"] = func
monkeypatch.setattr(bootstrap, "_isolated_config_dir", None)
monkeypatch.delenv("CONFIG_DIR", raising=False)
monkeypatch.setattr(bootstrap.tempfile, "mkdtemp", lambda prefix: "/tmp/mp-test-config-demo")
monkeypatch.setattr(bootstrap.shutil, "rmtree", lambda *args, **kwargs: None)
monkeypatch.setattr(bootstrap.atexit, "register", fake_register)
original_import = builtins.__import__
monkeypatch.setattr(builtins, "__import__", fake_import)
bootstrap.isolate_config_dir()
captured["cleanup"]()
assert import_calls == []
def test_mark_plugin_generation_prefers_pathlib_item_path():
"""pytest 新版 item.path 可独立驱动 v1/v2 marker 标记。"""
class FakeItem:
"""只暴露 pytest 7+ 的 path 属性,模拟新版收集对象。"""
def __init__(self, value: str):
self.path = Path(value)
self.markers = []
def add_marker(self, marker):
"""记录被添加的 marker。"""
self.markers.append(marker)
pytest_module = types.SimpleNamespace(mark=types.SimpleNamespace(v1="v1", v2="v2"))
v2_item = FakeItem("/repo/tests/v2/test_demo.py")
v1_item = FakeItem("/repo/tests/v1/test_demo.py")
bootstrap.mark_plugin_generation([v2_item, v1_item], pytest_module)
assert v2_item.markers == ["v2"]
assert v1_item.markers == ["v1"]