refactor(config): retire RuntimeSettingsCompat host usage

This commit is contained in:
jxxghp
2026-08-26 15:55:21 +08:00
parent cdab54254d
commit 9dbe424c3d
162 changed files with 1966 additions and 1745 deletions
+65
View File
@@ -35,6 +35,71 @@ class _TestDatabaseExecutor:
return await asyncio.to_thread(operation)
class _TestRuntimeSettingsProxy:
"""为仍需覆盖旧配置字段的测试提供局部桩,不回到宿主模块级代理。"""
def __init__(self) -> None:
self._originals: dict[str, tuple[bool, object]] = {}
def __getattr__(self, key: str):
from app.runtime.config import settings
return getattr(settings, key)
def __setattr__(self, key: str, value):
if key == "_originals":
object.__setattr__(self, key, value)
return
from app.runtime.config import settings
if key not in self._originals:
self._originals[key] = (hasattr(settings, key), getattr(settings, key, None))
setattr(settings, key, value)
def __delattr__(self, key: str) -> None:
if key in self._originals:
from app.runtime.config import settings
had_value, original = self._originals.pop(key)
if had_value:
setattr(settings, key, original)
elif hasattr(settings, key):
delattr(settings, key)
return
raise AttributeError(key)
@pytest.fixture(autouse=True)
def install_runtime_settings_test_proxies(monkeypatch):
"""给历史测试 patch 点注入测试专用对象,生产代码不保留 settings 属性。"""
proxy = _TestRuntimeSettingsProxy()
_install_runtime_settings_test_proxies(proxy, monkeypatch)
yield
def _install_runtime_settings_test_proxies(proxy, monkeypatch=None) -> None:
"""把测试专用 patch 点补到当前已导入的 Agent/模块。"""
for module_name, module in tuple(sys.modules.items()):
if not (
module_name.startswith("app.modules.")
or module_name.startswith("app.agent.")
or module_name.startswith("app.startup.")
or module_name == "app.main"
or module_name.startswith("app.adapters.")
):
continue
if hasattr(module, "get_runtime_setting") and "settings" not in vars(module):
if monkeypatch is None:
setattr(module, "settings", proxy)
else:
monkeypatch.setattr(module, "settings", proxy, raising=False)
def pytest_runtest_call(item):
"""显式 fixture 期间才导入的模块也要拥有同一个测试 patch 点。"""
_install_runtime_settings_test_proxies(_TestRuntimeSettingsProxy())
@pytest.fixture(autouse=True)
def configure_plugin_system_services():
"""为绕过完整启动流程的单元测试装配真实插件系统适配器。"""
@@ -10,21 +10,8 @@
]
},
"foundational_settings_boundaries": {
"count": 3,
"entries": [
{
"file": "app/db/base.py",
"reason": "模型声明阶段必须在运行时配置服务装配前确定数据库主键类型"
},
{
"file": "app/db/engine.py",
"reason": "数据库引擎是运行时配置服务的底层依赖,不能通过兼容代理自递归"
},
{
"file": "app/db/session.py",
"reason": "数据库会话与连接配额必须在应用组合根装配前可用"
}
]
"count": 0,
"entries": []
},
"schema_version": 2,
"scope": {
+9 -11
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6809,
"edge_sha256": "0e5ab6c1e8d428de4edbc0ee162f17130143946c483967aaf49bca8c7c75b105",
"edge_count": 6807,
"edge_sha256": "93c39ca5828e23fcb5e4b33ffea101cb1ebc9a4ef6fac1317e2ebd482592ecdd",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -24,12 +24,10 @@
"app.adapters.cache.backends -> app.adapters.cache.redis",
"app.adapters.cache.backends -> app.runtime",
"app.adapters.cache.backends -> app.runtime.cache",
"app.adapters.cache.backends -> app.runtime.config",
"app.adapters.cache.backends -> app.runtime.settings",
"app.adapters.cache.redis -> app.foundation",
"app.adapters.cache.redis -> app.foundation.singleton",
"app.adapters.cache.redis -> app.runtime",
"app.adapters.cache.redis -> app.runtime.config",
"app.adapters.cache.redis -> app.runtime.log",
"app.adapters.cache.redis -> app.runtime.reload",
"app.adapters.cache.redis -> app.runtime.settings",
@@ -171,7 +169,6 @@
"app.adapters.system.resource -> app.foundation",
"app.adapters.system.resource -> app.foundation.version",
"app.adapters.system.resource -> app.runtime",
"app.adapters.system.resource -> app.runtime.config",
"app.adapters.system.resource -> app.runtime.log",
"app.adapters.system.resource -> app.runtime.settings",
"app.adapters.system.rust -> app.foundation",
@@ -447,11 +444,14 @@
"app.agent.skills.registry -> app.agent",
"app.agent.skills.registry -> app.agent.skills",
"app.agent.skills.registry -> app.agent.skills.metadata",
"app.agent.skills.registry -> app.application",
"app.agent.skills.registry -> app.application.configuration",
"app.agent.skills.registry -> app.foundation",
"app.agent.skills.registry -> app.foundation.singleton",
"app.agent.skills.registry -> app.foundation.url",
"app.agent.skills.registry -> app.runtime",
"app.agent.skills.registry -> app.runtime.cache",
"app.agent.skills.registry -> app.runtime.config",
"app.agent.skills.registry -> app.runtime.log",
"app.agent.skills.registry -> app.runtime.settings",
"app.agent.tools.base -> app.agent",
@@ -2556,8 +2556,6 @@
"app.application.chain.durable_events -> app.schemas.types",
"app.application.configuration -> app.application",
"app.application.configuration -> app.application.database",
"app.application.configuration -> app.runtime",
"app.application.configuration -> app.runtime.settings",
"app.application.configuration -> app.schemas",
"app.application.configuration -> app.schemas.types",
"app.application.dashboard -> app.schemas",
@@ -3616,6 +3614,7 @@
"app.chain.workflow -> app.schemas.workflow",
"app.cli -> app.application",
"app.cli -> app.application.backup",
"app.cli -> app.application.configuration",
"app.cli -> app.doctor",
"app.cli -> app.doctor.formatters",
"app.cli -> app.runtime",
@@ -3717,7 +3716,7 @@
"app.db.base -> app.db",
"app.db.base -> app.db.uow",
"app.db.base -> app.runtime",
"app.db.base -> app.runtime.config",
"app.db.base -> app.runtime.settings",
"app.db.decorators -> app.db",
"app.db.decorators -> app.db.session",
"app.db.decorators -> app.runtime",
@@ -3730,9 +3729,9 @@
"app.db.engine -> app.foundation",
"app.db.engine -> app.foundation.environment",
"app.db.engine -> app.runtime",
"app.db.engine -> app.runtime.config",
"app.db.engine -> app.runtime.log",
"app.db.engine -> app.runtime.observability",
"app.db.engine -> app.runtime.settings",
"app.db.health -> app.db",
"app.db.health -> app.db.session",
"app.db.maintenance -> app.db",
@@ -3932,6 +3931,7 @@
"app.db.session -> app.runtime.config",
"app.db.session -> app.runtime.log",
"app.db.session -> app.runtime.observability",
"app.db.session -> app.runtime.settings",
"app.db.worker -> app.runtime",
"app.db.worker -> app.runtime.observability",
"app.db.worker -> app.schemas",
@@ -5102,7 +5102,6 @@
"app.modules.qqbot.qqbot -> app.runtime",
"app.modules.qqbot.qqbot -> app.runtime.cache",
"app.modules.qqbot.qqbot -> app.runtime.log",
"app.modules.qqbot.qqbot -> app.runtime.settings",
"app.modules.qqbot.qqbot -> app.runtime.thread",
"app.modules.redis -> app.adapters",
"app.modules.redis -> app.adapters.cache",
@@ -5643,7 +5642,6 @@
"app.modules.wechat.wechatbot -> app.runtime",
"app.modules.wechat.wechatbot -> app.runtime.cache",
"app.modules.wechat.wechatbot -> app.runtime.log",
"app.modules.wechat.wechatbot -> app.runtime.settings",
"app.modules.wechat.wechatbot -> app.runtime.thread",
"app.modules.wechat.wechatbot -> app.schemas",
"app.modules.wechat.wechatbot -> app.schemas.message",
@@ -6,7 +6,7 @@
"repeat": 3,
"targets": {
"app.startup.lifecycle": {
"loaded_app_module_count": 378,
"loaded_app_module_count": 379,
"max_ms": 909.62,
"median_ms": 908.975,
"min_ms": 904.929,
@@ -17,7 +17,7 @@
]
},
"app.factory": {
"loaded_app_module_count": 390,
"loaded_app_module_count": 391,
"max_ms": 952.709,
"median_ms": 934.785,
"min_ms": 916.888,
@@ -28,7 +28,7 @@
]
},
"app.main": {
"loaded_app_module_count": 392,
"loaded_app_module_count": 393,
"max_ms": 947.928,
"median_ms": 938.251,
"min_ms": 932.597,
+11 -4
View File
@@ -81,7 +81,7 @@ async def test_agent_entrypoint_initializes_on_calling_loop(monkeypatch) -> None
initialized_loops.append(asyncio.get_running_loop())
manager.initialize.side_effect = initialize
monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", True)
_patch_agent_settings(monkeypatch, True)
monkeypatch.setattr(agent_initializer, "agent_manager", manager)
monkeypatch.setattr(
agent_initializer,
@@ -134,7 +134,7 @@ async def test_agent_entrypoint_reuses_tasks_and_closes_idempotently(
memory_manager = MemoryManager()
initializer = agent_initializer.AgentInitializer()
monkeypatch.setattr(agent_module, "memory_manager", memory_manager)
monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", True)
_patch_agent_settings(monkeypatch, True)
monkeypatch.setattr(agent_initializer, "agent_manager", manager)
monkeypatch.setattr(agent_initializer, "agent_initializer", initializer)
@@ -162,7 +162,7 @@ async def test_agent_initialization_failure_does_not_stop_module_startup(
"""Agent 初始化异常只关闭该能力,基础模块仍继续完成启动。"""
manager = AsyncMock()
manager.initialize.side_effect = RuntimeError("agent init failed")
monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", True)
_patch_agent_settings(monkeypatch, True)
monkeypatch.setattr(agent_initializer, "agent_manager", manager)
monkeypatch.setattr(
agent_initializer,
@@ -214,7 +214,7 @@ async def test_agent_initialization_failure_does_not_stop_module_startup(
async def test_disabled_agent_does_not_create_background_tasks(monkeypatch) -> None:
"""Agent 未启用时启动入口不得创建运行时任务。"""
manager = AsyncMock()
monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", False)
_patch_agent_settings(monkeypatch, False)
monkeypatch.setattr(agent_initializer, "agent_manager", manager)
monkeypatch.setattr(
agent_initializer,
@@ -903,3 +903,10 @@ async def test_session_worker_restarts_after_idle_timeout_races_with_full_enqueu
if manager._accepting_tasks:
await manager.clear_session(session_id, "1")
await manager.close()
def _patch_agent_settings(monkeypatch, enabled: bool) -> None:
"""注入 Agent 启动测试所需的只读配置。"""
monkeypatch.setattr(
agent_initializer,
"get_runtime_setting",
lambda key: enabled if key == "AI_AGENT_ENABLE" else None,
)
+18 -6
View File
@@ -82,13 +82,17 @@ class AgentCapabilityManagerTest(unittest.TestCase):
provider, "_build_client", return_value=fake_client
), patch.object(
capability_module,
"settings",
SimpleNamespace(
"get_runtime_setting",
side_effect=lambda key, default=None: getattr(
SimpleNamespace(
TEMP_PATH=Path(temp_dir),
AUDIO_OUTPUT_MODEL="gpt-4o-audio-preview",
AUDIO_OUTPUT_VOICE="alloy",
AUDIO_OUTPUT_API_KEY="sk-test",
AUDIO_OUTPUT_BASE_URL="https://example.com/v1",
),
key,
default,
),
), patch.object(provider, "_convert_wav_to_opus", return_value=None):
output_path = provider.synthesize_speech("你好")
@@ -249,13 +253,17 @@ class AgentCapabilityManagerTest(unittest.TestCase):
provider, "_build_client", return_value=fake_client
), patch.object(
capability_module,
"settings",
SimpleNamespace(
"get_runtime_setting",
side_effect=lambda key, default=None: getattr(
SimpleNamespace(
TEMP_PATH=Path(temp_dir),
AUDIO_OUTPUT_MODEL="mimo-v2.5-tts",
AUDIO_OUTPUT_VOICE="冰糖",
AUDIO_OUTPUT_API_KEY="sk-test",
AUDIO_OUTPUT_BASE_URL="https://api.xiaomimimo.com/v1",
),
key,
default,
),
), patch.object(provider, "_convert_wav_to_opus", return_value=None):
output_path = provider.synthesize_speech("你好")
@@ -370,14 +378,18 @@ class AgentCapabilityManagerTest(unittest.TestCase):
capability_module, "RequestUtils", return_value=request_utils
) as request_utils_cls, patch.object(
capability_module,
"settings",
SimpleNamespace(
"get_runtime_setting",
side_effect=lambda key, default=None: getattr(
SimpleNamespace(
TEMP_PATH=Path(temp_dir),
PROXY={},
AUDIO_OUTPUT_MODEL="gpt-4o-mini-tts",
AUDIO_OUTPUT_VOICE="alloy",
AUDIO_OUTPUT_API_KEY="sk-test",
AUDIO_OUTPUT_BASE_URL="https://api.minimaxi.com/anthropic/v1",
),
key,
default,
),
):
output_path = provider.synthesize_speech("你好")
+9 -1
View File
@@ -365,7 +365,15 @@ async def test_query_task_returns_owner_scoped_ten_recent_runs(monkeypatch) -> N
@pytest.mark.anyio
async def test_agent_manager_records_manual_trigger_source(monkeypatch) -> None:
"""真实执行入口应把手动触发来源写入对应 run。"""
monkeypatch.setattr("app.agent.orchestrator.settings.AI_AGENT_ENABLE", True)
from app.agent import orchestrator
from app.runtime.config import settings
monkeypatch.setattr(settings, "AI_AGENT_ENABLE", True)
monkeypatch.setattr(
orchestrator,
"get_runtime_setting",
lambda key, default=None: getattr(settings, key, default),
)
task = _add_task("run-manager")
manager = AgentManager()
captured = {}
+4 -5
View File
@@ -195,11 +195,10 @@ def test_configuration_debt_baseline_tracks_canonical_direct_access() -> None:
"count": 0,
"calls": [],
}
assert {
entry["file"]
for entry in baseline["foundational_settings_boundaries"]["entries"]
} == {"app/db/base.py", "app/db/engine.py", "app/db/session.py"}
assert baseline["foundational_settings_boundaries"]["count"] == 3
assert baseline["foundational_settings_boundaries"] == {
"count": 0,
"entries": [],
}
assert baseline["composition_root_oper_boundaries"]["count"] == 1
assert baseline["composition_root_oper_boundaries"]["entries"][0]["file"] == (
"app/startup/initializers/modules.py"
+83
View File
@@ -1310,6 +1310,89 @@ def test_modules_read_deployment_settings_through_runtime_port():
assert violations == []
def test_runtime_implementation_does_not_use_legacy_settings_proxy():
"""runtime 实现只能使用只读配置端口,不得重新引入迁移期代理对象。"""
violations: list[str] = []
for path in (APP_ROOT / "runtime").rglob("*.py"):
if path == APP_ROOT / "runtime" / "settings.py":
continue
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom):
continue
if node.module != "app.runtime.settings":
continue
if any(alias.name.lower().endswith("compat") for alias in node.names):
violations.append(path.relative_to(PROJECT_ROOT).as_posix())
break
assert violations == []
def test_deprecated_settings_proxy_imports_are_zero():
"""宿主代码不得导入已删除的 Settings 兼容代理。"""
limits = {
"adapters": 0,
"agent": 0,
"application": 0,
"cli.py": 0,
"doctor": 0,
"factory.py": 0,
"main.py": 0,
"modules": 0,
"startup": 0,
}
counts: dict[str, int] = {}
for path in APP_ROOT.rglob("*.py"):
if path == APP_ROOT / "runtime" / "settings.py":
continue
if path.is_relative_to(APP_ROOT / "plugins"):
continue
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
imports_compat = any(
isinstance(node, ast.ImportFrom)
and node.module == "app.runtime.settings"
and any(alias.name.lower().endswith("compat") for alias in node.names)
for node in ast.walk(tree)
)
if not imports_compat:
continue
relative = path.relative_to(APP_ROOT)
group = relative.parts[0] if len(relative.parts) > 1 else relative.as_posix()
counts[group] = counts.get(group, 0) + 1
unexpected = set(counts) - set(limits)
exceeded = {
group: count
for group, count in counts.items()
if group in limits and count > limits[group]
}
assert unexpected == set()
assert exceeded == {}
def test_global_settings_imports_stay_within_compatibility_baseline():
"""真实 Settings 对象只能保留在已知迁移点和插件 SDK,不得产生新宿主调用。"""
allowed = {
"app/sdk/config.py",
"app/startup/initializers/modules.py",
}
imports: set[str] = set()
for path in APP_ROOT.rglob("*.py"):
if path.is_relative_to(APP_ROOT / "plugins"):
continue
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
if any(
isinstance(node, ast.ImportFrom)
and node.module == "app.runtime.config"
and any(alias.name == "settings" for alias in node.names)
for node in ast.walk(tree)
):
imports.add(path.relative_to(PROJECT_ROOT).as_posix())
assert imports <= allowed
def test_api_does_not_import_factory():
"""装配器(factory)只允许 app.main 使用,HTTP 端点不得回引。"""
violations: dict[str, set[str]] = {}
+8 -2
View File
@@ -177,7 +177,10 @@ def test_default_emulation_uses_cloakbrowser_context():
page = _FakePage()
context = _FakeContext([page])
with patch("app.adapters.network.browser.settings.BROWSER_EMULATION", "cloakbrowser"), patch.object(
with patch(
"app.adapters.network.browser.get_runtime_setting",
return_value="cloakbrowser",
), patch.object(
PlaywrightHelper,
"_PlaywrightHelper__launch_cloakbrowser_context",
return_value=context,
@@ -206,7 +209,10 @@ def test_legacy_playwright_emulation_uses_cloakbrowser_context():
page = _FakePage()
context = _FakeContext([page])
with patch("app.adapters.network.browser.settings.BROWSER_EMULATION", "Playwright"), patch.object(
with patch(
"app.adapters.network.browser.get_runtime_setting",
return_value="Playwright",
), patch.object(
PlaywrightHelper,
"_PlaywrightHelper__launch_cloakbrowser_context",
return_value=context,
+6
View File
@@ -79,6 +79,12 @@ def load_cli_module():
with patch.dict(sys.modules, stub_modules):
spec.loader.exec_module(module)
# CLI 生产代码只依赖读取端口;这个动态加载器仍提供旧字段 patch 点,
# 让历史更新流程测试可以独立于全局测试配置运行。
module.settings = settings
module.get_runtime_setting = lambda key, default=None: getattr(
settings, key, default
)
return module
+5 -7
View File
@@ -7,7 +7,6 @@ import pytest
from app.startup.initializers import modules as modules_initializer
from app.startup.lifecycle import initialize_modules_component
from app.application.configuration import configure_runtime_settings
from app.runtime.settings import RuntimeSettingsCompat
class _InlineWorker:
@@ -46,18 +45,17 @@ class _MutableSettings:
return True, ""
def test_runtime_settings_compat_uses_legacy_settings_from_startup_root(monkeypatch) -> None:
"""组合根装配的兼容代理应读写原始部署配置而不是自身"""
def test_runtime_settings_service_uses_legacy_settings_from_startup_root(monkeypatch) -> None:
"""组合根装配的设置服务应直接读写唯一部署配置对象"""
legacy_settings = _MutableSettings()
monkeypatch.setattr(modules_initializer, "legacy_settings", legacy_settings)
service = modules_initializer._build_runtime_settings_service()
configure_runtime_settings(service)
compat = RuntimeSettingsCompat()
assert compat.model_dump(include={"VALUE"}) == {"VALUE": "before"}
assert compat.update_setting("VALUE", "after") == (True, "")
assert compat.model_dump(include={"VALUE"}) == {"VALUE": "after"}
assert service.snapshot(include={"VALUE"}) == {"VALUE": "before"}
assert service.update("VALUE", "after") == (True, "")
assert service.snapshot(include={"VALUE"}) == {"VALUE": "after"}
@pytest.mark.asyncio
+3 -7
View File
@@ -20,7 +20,6 @@ from app.application.configuration import (
get_transfer_retry_config,
)
from app.application.security.userconfig import UserConfigurationService
from app.runtime.settings import RuntimeSettingsCompat, configure_runtime_settings_compat
class _InlineDatabaseExecutor:
@@ -72,14 +71,11 @@ def test_runtime_settings_service_hides_mutable_settings_implementation() -> Non
assert service.get("VALUE") == "final"
def test_runtime_settings_compat_delegates_to_concrete_service_backend() -> None:
"""兼容 Settings 代理委托到真实设置对象时不会在 model_dump 中递归"""
def test_runtime_settings_service_is_the_only_mutable_settings_port() -> None:
"""可变部署设置只通过应用服务暴露,不再提供宿主级兼容代理"""
service = RuntimeSettingsService(_MutableSettings())
configure_runtime_settings_compat(service)
assert RuntimeSettingsCompat().model_dump(include={"VALUE"}) == {
"VALUE": "before"
}
assert service.snapshot(include={"VALUE"}) == {"VALUE": "before"}
def test_system_config_service_supports_separate_reader_and_writer() -> None:
+3 -8
View File
@@ -272,13 +272,6 @@ def _load_transmission_module():
def get(self, *_args, **_kwargs):
return None
class _RuntimeSettingsCompat:
"""隔离测试用动态配置代理,保持生产模块的兼容读取语义。"""
def __getattr__(self, key):
"""从测试提供的旧 Settings 桩读取配置项。"""
return getattr(config_module.settings, key)
transmission_client_module.Transmission = object
cache_module.FileCache = _FileCache
schema_transfer_module.TransferTorrent = _TransferTorrent
@@ -294,7 +287,9 @@ def _load_transmission_module():
"DownloaderType", {"Transmission": "Transmission"}
)
config_module.settings = SimpleNamespace(TORRENT_TAG="moviepilot-tag")
runtime_settings_module.RuntimeSettingsCompat = _RuntimeSettingsCompat
runtime_settings_module.get_runtime_setting = lambda key, default=None: getattr(
config_module.settings, key, default
)
metainfo_module.MetaInfo = _MetaInfo
log_module.logger = _Logger()
modules_module._ModuleBase = _ModuleBase
+10 -2
View File
@@ -150,7 +150,11 @@ def test_copy_falls_back_to_direct_when_disabled(tmp_path, monkeypatch):
"""
import app.adapters.system.fsproxy as fsproxy_module
monkeypatch.setattr(fsproxy_module.settings, "FS_PROXY_ENABLED", False, raising=False)
monkeypatch.setattr(
fsproxy_module,
"get_runtime_setting",
lambda key, default=None: False if key == "FS_PROXY_ENABLED" else default,
)
src = tmp_path / "a.mkv"
src.write_bytes(b"direct" * 100)
dst = tmp_path / "b.mkv"
@@ -171,7 +175,11 @@ def test_direct_copy_honours_cancel(tmp_path, monkeypatch):
"""
import app.adapters.system.fsproxy as fsproxy_module
monkeypatch.setattr(fsproxy_module.settings, "FS_PROXY_ENABLED", False, raising=False)
monkeypatch.setattr(
fsproxy_module,
"get_runtime_setting",
lambda key, default=None: False if key == "FS_PROXY_ENABLED" else default,
)
src = tmp_path / "a.mkv"
src.write_bytes(b"x" * 8192)
dst = tmp_path / "b.mkv"
+2
View File
@@ -211,6 +211,8 @@ def test_string_api_data_locator_is_confined_to_compatibility_boundary() -> None
"""字符串数据注册表只能由 startup 注入并经旧 Facade 转发。"""
importers = set()
for path in (PROJECT_ROOT / "app").rglob("*.py"):
if path.is_relative_to(PROJECT_ROOT / "app" / "plugins"):
continue
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
imported_modules = {
node.module
+8 -5
View File
@@ -5,21 +5,20 @@ from __future__ import annotations
import importlib
import os
from pathlib import Path
from types import ModuleType, SimpleNamespace
from types import ModuleType
import pytest
from app.runtime.compat import resource_imports
from app.runtime.compat.resource_imports import (
PluginResourceImportScanError,
RESOURCE_IMPORT_RULES,
PluginResourceImportScanError,
scan_plugin_resource_imports,
)
from app.runtime.extensions import plugin_manager as plugin_manager_module
from app.runtime.extensions.plugin_manager import PluginManager
from app.startup.initializers import plugins as plugins_initializer
_HEADED_CLOAKBROWSER_ENTRYPOINTS = (
"launch",
"launch_async",
@@ -317,8 +316,12 @@ def test_plugin_preparer_runs_before_import_in_non_debug_and_isolates_failures(
monkeypatch.setattr(
plugin_manager_module,
"settings",
SimpleNamespace(ROOT_PATH=tmp_path, DEBUG=False),
"get_runtime_setting",
lambda key, default=None: {
"ROOT_PATH": tmp_path,
"DEBUG": False,
"DEV": False,
}.get(key, default),
)
monkeypatch.setattr(
plugin_manager_module,
+4
View File
@@ -146,6 +146,10 @@ with stub_modules({"app.runtime.config": _config_stub, "app.runtime.log": _log_s
llm_module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(llm_module)
llm_module.settings = _config_stub.settings
llm_module.get_runtime_setting = lambda key, default=None: getattr(
_config_stub.settings, key, default
)
class _OfflineProviderManager:
+13 -15
View File
@@ -20,6 +20,16 @@ from app.modules.wechatclawbot import wechatclawbot as clawbot_module
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def _patch_ingress_settings(monkeypatch, **values):
"""通过只读运行配置端口注入回环入口测试设置。"""
settings = SimpleNamespace(**values)
monkeypatch.setattr(
ingress,
"get_runtime_setting",
lambda key: getattr(settings, key),
)
def test_forward_message_to_host_encodes_source_and_closes_response(monkeypatch):
"""统一入口必须安全编码查询参数并释放本地 HTTP 响应。"""
response = SimpleNamespace(status_code=200, close=MagicMock())
@@ -27,11 +37,7 @@ def test_forward_message_to_host_encodes_source_and_closes_response(monkeypatch)
request = MagicMock()
request.post_res = post_res
request_factory = MagicMock(return_value=request)
monkeypatch.setattr(
ingress,
"settings",
SimpleNamespace(PORT=3000, API_TOKEN="token value"),
)
_patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token value")
monkeypatch.setattr(ingress, "RequestUtils", request_factory)
assert ingress.forward_message_to_host(
@@ -60,11 +66,7 @@ def test_forward_message_to_host_rejects_unconfirmed_response(
response = SimpleNamespace(status_code=status_code, close=MagicMock())
request = MagicMock()
request.post_res.return_value = response
monkeypatch.setattr(
ingress,
"settings",
SimpleNamespace(PORT=3000, API_TOKEN="token"),
)
_patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token")
monkeypatch.setattr(ingress, "RequestUtils", MagicMock(return_value=request))
assert ingress.forward_message_to_host({}, "channel") is False
@@ -78,11 +80,7 @@ async def test_async_forward_message_to_host_uses_same_contract(monkeypatch):
request = MagicMock()
request.post_res = AsyncMock(return_value=response)
request_factory = MagicMock(return_value=request)
monkeypatch.setattr(
ingress,
"settings",
SimpleNamespace(PORT=3000, API_TOKEN="token value"),
)
_patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token value")
monkeypatch.setattr(ingress, "AsyncRequestUtils", request_factory)
assert await ingress.async_forward_message_to_host(
+3 -2
View File
@@ -46,10 +46,11 @@ def _patch_market_paths(monkeypatch, tmp_path: Path) -> tuple[Path, Path]:
plugin_root.mkdir(parents=True)
config_dir.mkdir(parents=True)
monkeypatch.setattr(market_module, "PLUGIN_DIR", plugin_root)
runtime_settings = SimpleNamespace(CONFIG_PATH=config_dir)
monkeypatch.setattr(
market_module,
"settings",
SimpleNamespace(CONFIG_PATH=config_dir),
"get_runtime_setting",
lambda key, default=None: getattr(runtime_settings, key, default),
)
monkeypatch.setattr(
market_module.SystemUtils,
+69 -20
View File
@@ -12,22 +12,62 @@ from types import ModuleType, SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import pytest
from packaging.requirements import Requirement
from packaging.version import Version
PLUGIN_ID = "DemoPlugin"
REPO_URL = "https://github.com/demo/MoviePilot-Plugins"
def _patch_catalog_settings(monkeypatch, **values) -> None:
"""通过只读端口注入插件目录测试需要的部署配置。"""
from app.runtime.extensions.plugin import catalog as catalog_module
settings = SimpleNamespace(**values)
monkeypatch.setattr(
catalog_module,
"get_runtime_setting",
lambda key: getattr(settings, key),
)
@pytest.fixture(autouse=True)
def _configure_plugin_catalog_factory(monkeypatch):
"""为直接构造 PluginManager 的测试注入真实目录用例和假持久化接缝。"""
from app.adapters.external import market as market_module
from app.adapters.external.plugin.client import PluginMarketClient
from app.application.plugin.catalog import PluginCatalogService
from app.foundation.version import compare_version
from app.runtime.extensions import plugin_manager as manager_module
original_runtime_setting = market_module.get_runtime_setting
class _SettingsStub(SimpleNamespace):
"""允许存量用例覆盖尚未显式声明的配置键。"""
def __getattr__(self, _key):
return None
market_settings = _SettingsStub(
VERSION_FLAG="v3",
ROOT_PATH=original_runtime_setting("ROOT_PATH"),
TEMP_PATH=original_runtime_setting("TEMP_PATH"),
CONFIG_PATH=original_runtime_setting("CONFIG_PATH"),
PACKAGE_CACHE_PATH=original_runtime_setting("PACKAGE_CACHE_PATH"),
PIP_PROXY=original_runtime_setting("PIP_PROXY"),
PROXY_HOST=original_runtime_setting("PROXY_HOST"),
REPO_GITHUB_HEADERS=original_runtime_setting("REPO_GITHUB_HEADERS"),
PLUGIN_LOCAL_REPO_PATHS="",
)
monkeypatch.setattr(market_module, "settings", market_settings, raising=False)
monkeypatch.setattr(
market_module,
"get_runtime_setting",
lambda key, default=None: (
getattr(market_module.settings, key)
if hasattr(market_module.settings, key)
else original_runtime_setting(key, default)
),
)
def build_catalog(manager):
"""按生产组合方式连接目录服务,但保留测试可替换的依赖。"""
@@ -231,8 +271,8 @@ class TestPluginHelper:
插件库强制刷新时远端索引 URL 也要变化避免命中镜像或代理缓存
"""
try:
from app.runtime.cache import fresh
from app.adapters.external.market import PluginHelper
from app.runtime.cache import fresh
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
@@ -342,8 +382,8 @@ class TestPluginHelper:
插件市场强制刷新时 Release 列表请求也要绕过 GitHub 镜像或代理缓存
"""
try:
from app.runtime.cache import fresh
from app.adapters.external.market import PluginHelper
from app.runtime.cache import fresh
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
@@ -442,8 +482,8 @@ class TestPluginHelper:
同一仓库的并发强制刷新共享一个请求任务避免缓存失效瞬间放大 GitHub 请求
"""
try:
from app.runtime.cache import async_fresh
from app.adapters.external.market import PluginHelper
from app.runtime.cache import async_fresh
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
@@ -484,8 +524,8 @@ class TestPluginHelper:
def test_async_forced_release_refresh_does_not_reuse_normal_read_task(self, monkeypatch):
"""强刷等待在途普通读取后再请求,最终缓存必须保留强刷结果。"""
try:
from app.runtime.cache import async_fresh
from app.adapters.external.market import PluginHelper
from app.runtime.cache import async_fresh
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
@@ -594,8 +634,8 @@ class TestPluginHelper:
def test_async_normal_release_read_does_not_wait_for_pending_force_refresh(self, monkeypatch):
"""普通读取遇到后台强刷时仍优先返回已有缓存,避免页面响应被强刷阻塞。"""
try:
from app.runtime.cache import async_fresh
from app.adapters.external.market import PluginHelper
from app.runtime.cache import async_fresh
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
@@ -697,8 +737,8 @@ class TestPluginHelper:
def test_failed_forced_release_refresh_preserves_cached_repository_payload(self, monkeypatch):
"""GitHub 强刷失败时不以空值覆盖该仓库已有 Release 缓存。"""
try:
from app.runtime.cache import fresh
from app.adapters.external.market import PluginHelper
from app.runtime.cache import fresh
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
@@ -730,8 +770,8 @@ class TestPluginHelper:
插件市场 labels 为列表时应转换为字符串避免响应模型序列化异常
"""
try:
from app.runtime.extensions.plugin_manager import PluginManager
from app.adapters.external.market import PluginHelper
from app.runtime.extensions.plugin_manager import PluginManager
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
@@ -747,7 +787,7 @@ class TestPluginHelper:
plugin_manager = PluginManager()
monkeypatch.setattr(plugin_manager, "_plugins", {})
monkeypatch.setattr(plugin_manager, "_running_plugins", {})
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", SimpleNamespace(VERSION_FLAG="v2"))
_patch_catalog_settings(monkeypatch, VERSION_FLAG="v2")
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.get_plugin_storage",
lambda: SimpleNamespace(read=lambda _key: []),
@@ -770,8 +810,8 @@ class TestPluginHelper:
package.v2.json 中的 v2 原生插件并过滤掉未声明任何版本兼容的 v1 插件
"""
try:
from app.runtime.extensions.plugin_manager import PluginManager
from app.adapters.external.market import PluginHelper
from app.runtime.extensions.plugin_manager import PluginManager
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
@@ -807,9 +847,10 @@ class TestPluginHelper:
plugin_manager = PluginManager()
monkeypatch.setattr(plugin_manager, "_plugins", {})
monkeypatch.setattr(plugin_manager, "_running_plugins", {})
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.settings",
SimpleNamespace(VERSION_FLAG="v3", PLUGIN_MARKET=REPO_URL),
_patch_catalog_settings(
monkeypatch,
VERSION_FLAG="v3",
PLUGIN_MARKET=REPO_URL,
)
monkeypatch.setattr("app.adapters.external.market.settings", SimpleNamespace(VERSION_FLAG="v3"))
monkeypatch.setattr(
@@ -968,13 +1009,17 @@ class TestPluginHelper:
全市场刷新不清理 Release 缓存Release 接口按请求仓库协调刷新两类数据
"""
try:
from app.runtime.extensions.plugin_manager import PluginManager
from app.adapters.external.market import PluginHelper
from app.runtime.extensions.plugin_manager import PluginManager
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
clear_calls = []
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings.PLUGIN_MARKET", "https://github.com/demo/plugins")
_patch_catalog_settings(
monkeypatch,
PLUGIN_MARKET="https://github.com/demo/plugins",
VERSION_FLAG="v3",
)
monkeypatch.setattr(PluginManager, "get_plugins_from_market", lambda *_args, **_kwargs: [])
PluginManager().get_online_plugins(force=True)
@@ -996,7 +1041,11 @@ class TestPluginHelper:
async def fake_market(*_args, **_kwargs):
return []
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings.PLUGIN_MARKET", "https://github.com/demo/plugins")
_patch_catalog_settings(
monkeypatch,
PLUGIN_MARKET="https://github.com/demo/plugins",
VERSION_FLAG="v3",
)
monkeypatch.setattr(PluginManager, "async_get_plugins_from_market", fake_market)
asyncio.run(PluginManager().async_get_online_plugins(force=True))
@@ -1788,8 +1837,8 @@ demo = { index = "private" }
with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \
patch.dict(os.environ, {}, clear=True), \
patch("app.adapters.external.market.settings.CONFIG_DIR", str(root / "config")), \
patch("app.adapters.external.market.settings.PACKAGE_CACHE_ROOT", str(root / "custom-package-cache")), \
patch("app.adapters.external.market.settings.CONFIG_PATH", root / "config"), \
patch("app.adapters.external.market.settings.PACKAGE_CACHE_PATH", root / "custom-package-cache"), \
patch("app.adapters.external.market.settings.PIP_PROXY", "https://user:pass@mirror.example/simple"), \
patch("app.adapters.external.market.settings.PROXY_HOST", "http://proxy.example:7890"), \
patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
@@ -3425,8 +3474,8 @@ demo = { index = "private" }
异步 release zip 带顶层插件目录时剥离该层后写入运行目录
"""
try:
from app.runtime.config import settings
from app.adapters.external.market import PluginHelper
from app.runtime.config import settings
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
+50 -7
View File
@@ -21,6 +21,30 @@ from app.schemas.types import EventType, SystemConfigKey
def plugin_manager(monkeypatch) -> Iterator[PluginManager]:
"""构造隔离的插件管理器实例,避免单例状态污染其它用例。"""
system = get_plugin_system()
from app.adapters.external import market as market_module
original_runtime_setting = market_module.get_runtime_setting
class _SettingsStub(SimpleNamespace):
"""允许存量用例覆盖尚未显式声明的配置键。"""
def __getattr__(self, _key):
return None
market_settings = _SettingsStub(
VERSION_FLAG="v2",
REPO_GITHUB_HEADERS=original_runtime_setting("REPO_GITHUB_HEADERS"),
PLUGIN_LOCAL_REPO_PATHS="",
)
monkeypatch.setattr(market_module, "settings", market_settings, raising=False)
monkeypatch.setattr(
market_module,
"get_runtime_setting",
lambda key, default=None: (
getattr(market_module.settings, key)
if hasattr(market_module.settings, key)
else original_runtime_setting(key, default)
),
)
def install_local(**kwargs) -> tuple[bool, str]:
"""用测试包适配器模拟已通过来源准入的本地 Gateway。"""
@@ -73,6 +97,22 @@ def _build_local_plugin_repo(tmp_path: Path) -> tuple[Path, Path]:
return repo_path, source_file
def _patch_plugin_runtime_settings(monkeypatch, settings) -> None:
"""以只读键值端口注入插件运行配置。"""
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.get_runtime_setting",
lambda key: getattr(settings, key),
)
def _patch_package_runtime_settings(monkeypatch, settings) -> None:
"""为插件包文件适配器注入隔离路径配置。"""
monkeypatch.setattr(
"app.adapters.system.plugin.package.get_runtime_setting",
lambda key: getattr(settings, key),
)
def _configure_local_watcher(
monkeypatch,
tmp_path: Path,
@@ -91,9 +131,9 @@ def _configure_local_watcher(
CONFIG_PATH=tmp_path / "config",
VERSION_FLAG="v2",
)
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub)
_patch_plugin_runtime_settings(monkeypatch, settings_stub)
monkeypatch.setattr("app.adapters.external.market.settings", settings_stub)
monkeypatch.setattr("app.adapters.system.plugin.package.settings", settings_stub)
_patch_package_runtime_settings(monkeypatch, settings_stub)
monkeypatch.setattr("app.runtime.extensions.plugin_manager.watch", lambda *_args, **_kwargs: iter([changes]))
@@ -169,8 +209,8 @@ def test_dev_local_plugin_candidate_keeps_hot_sync_allowed_when_system_version_l
TEMP_PATH=tmp_path / "temp",
CONFIG_PATH=tmp_path / "config",
)
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub)
monkeypatch.setattr("app.adapters.system.plugin.package.settings", settings_stub)
_patch_plugin_runtime_settings(monkeypatch, settings_stub)
_patch_package_runtime_settings(monkeypatch, settings_stub)
monkeypatch.setattr("app.adapters.external.market.settings.PLUGIN_LOCAL_REPO_PATHS", str(repo_path))
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10"))
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
@@ -193,7 +233,10 @@ def test_local_plugin_candidate_keeps_system_version_gate_outside_dev(
"""非 DEV 本地候选继续受主系统版本门禁保护,避免自动热加载绕过安装约束。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", SimpleNamespace(DEV=False, ROOT_PATH=tmp_path))
_patch_plugin_runtime_settings(
monkeypatch,
SimpleNamespace(DEV=False, ROOT_PATH=tmp_path),
)
monkeypatch.setattr("app.adapters.external.market.settings.PLUGIN_LOCAL_REPO_PATHS", str(repo_path))
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10"))
@@ -219,7 +262,7 @@ def test_local_plugin_sync_without_candidate_respects_system_version_gate(
PLUGIN_LOCAL_REPO_PATHS=str(repo_path),
)
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub)
_patch_plugin_runtime_settings(monkeypatch, settings_stub)
monkeypatch.setattr("app.adapters.external.market.settings", settings_stub)
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10"))
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
@@ -374,7 +417,7 @@ def test_local_federated_asset_reads_running_render_mode_for_each_batch(
ROOT_PATH=tmp_path,
VERSION_FLAG="v2",
)
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub)
_patch_plugin_runtime_settings(monkeypatch, settings_stub)
monkeypatch.setattr("app.adapters.external.market.settings", settings_stub)
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.watch",
+12 -10
View File
@@ -14,14 +14,15 @@ async def test_sync_and_async_github_requests_share_fallback_policy(
) -> None:
"""同步与异步请求必须使用相同镜像、代理、直连顺序和参数。"""
proxy = {"all": "http://proxy.example:7890"}
monkeypatch.setattr(
market,
"settings",
SimpleNamespace(
runtime_settings = SimpleNamespace(
GITHUB_PROXY="https://mirror.example",
PROXY_HOST="http://proxy.example:7890",
PROXY=proxy,
),
)
monkeypatch.setattr(
market,
"get_runtime_setting",
lambda key, default=None: getattr(runtime_settings, key, default),
)
sync_requests: list[tuple[dict, str]] = []
async_requests: list[tuple[dict, str]] = []
@@ -88,14 +89,15 @@ async def test_sync_and_async_github_requests_share_fallback_policy(
def test_github_api_request_policy_skips_content_mirror(monkeypatch) -> None:
"""GitHub API 请求必须跳过只用于 raw 内容的镜像站。"""
monkeypatch.setattr(
market,
"settings",
SimpleNamespace(
runtime_settings = SimpleNamespace(
GITHUB_PROXY="https://mirror.example",
PROXY_HOST=None,
PROXY=None,
),
)
monkeypatch.setattr(
market,
"get_runtime_setting",
lambda key, default=None: getattr(runtime_settings, key, default),
)
strategies = PluginHelper._build_github_request_strategies(
+51 -57
View File
@@ -30,6 +30,16 @@ def _reset_plugin_manager() -> None:
Singleton._instances.pop((PluginManager, (), frozenset()), None)
def _patch_runtime_settings(monkeypatch, **values) -> None:
"""按键注入插件运行时配置,避免测试恢复模块级 Settings 代理。"""
settings = SimpleNamespace(**values)
monkeypatch.setattr(
plugin_manager_module,
"get_runtime_setting",
lambda key: getattr(settings, key),
)
@pytest.mark.parametrize(
("dev", "auto_reload"),
((True, False), (False, True)),
@@ -43,13 +53,11 @@ def test_plugin_manager_constructor_does_not_start_monitor_before_runtime(
_reset_plugin_manager()
reset_plugin_system()
start = MagicMock()
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.settings",
SimpleNamespace(
DEV=dev,
PLUGIN_AUTO_RELOAD=auto_reload,
ROOT_PATH=MagicMock(),
),
_patch_runtime_settings(
monkeypatch,
DEV=dev,
PLUGIN_AUTO_RELOAD=auto_reload,
ROOT_PATH=MagicMock(),
)
monkeypatch.setattr(PluginMonitorController, "start", start)
@@ -422,13 +430,11 @@ def test_start_monitor_respects_runtime_configuration(
"""首次启动只在开发模式或插件自动重载启用时创建监控线程。"""
_reset_plugin_manager()
reset_plugin_system()
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.settings",
SimpleNamespace(
DEV=dev,
PLUGIN_AUTO_RELOAD=auto_reload,
ROOT_PATH=MagicMock(),
),
_patch_runtime_settings(
monkeypatch,
DEV=dev,
PLUGIN_AUTO_RELOAD=auto_reload,
ROOT_PATH=MagicMock(),
)
manager = PluginManager()
start = MagicMock()
@@ -444,13 +450,11 @@ def test_plugin_monitor_waits_until_dependency_settlement(monkeypatch) -> None:
"""后台依赖收敛期间不启动文件监控,避免源码写入触发重复重载。"""
_reset_plugin_manager()
reset_plugin_system()
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.settings",
SimpleNamespace(
DEV=True,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
),
_patch_runtime_settings(
monkeypatch,
DEV=True,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
)
manager = PluginManager()
start = MagicMock()
@@ -589,13 +593,11 @@ def test_plugin_manager_start_monitor_can_reopen_new_lifespan(monkeypatch) -> No
"""新应用生命周期可显式解除封口,再按运行配置启动监控。"""
_reset_plugin_manager()
reset_plugin_system()
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.settings",
SimpleNamespace(
DEV=True,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
),
_patch_runtime_settings(
monkeypatch,
DEV=True,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
)
manager = PluginManager()
reopen = MagicMock(return_value=True)
@@ -648,13 +650,11 @@ async def test_quiesce_timeout_retains_future_owner_until_worker_finishes(
"""同步插件 hook 超时后必须保留 Future,且未结束前拒绝卸载实例。"""
_reset_plugin_manager()
reset_plugin_system()
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.settings",
SimpleNamespace(
DEV=False,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
),
_patch_runtime_settings(
monkeypatch,
DEV=False,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
)
manager = PluginManager()
started = threading.Event()
@@ -703,14 +703,12 @@ async def test_quiesce_seals_runtime_until_new_lifespan_reopens(monkeypatch) ->
"""屏障前封口后 start/reload/config 不能重开 producer,新 lifespan 可显式恢复。"""
_reset_plugin_manager()
reset_plugin_system()
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.settings",
SimpleNamespace(
DEBUG=False,
DEV=False,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
),
_patch_runtime_settings(
monkeypatch,
DEBUG=False,
DEV=False,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
)
manager = PluginManager()
manager._plugin_lifecycle.quiesce_handlers = MagicMock(return_value=True)
@@ -831,13 +829,11 @@ def test_plugin_monitor_suppression_is_reference_counted(monkeypatch) -> None:
"""同一插件的重叠写入必须等最后一个事务退出后才解除监控抑制。"""
_reset_plugin_manager()
reset_plugin_system()
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.settings",
SimpleNamespace(
DEV=False,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
),
_patch_runtime_settings(
monkeypatch,
DEV=False,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
)
manager = PluginManager()
@@ -855,13 +851,11 @@ def test_config_change_reloads_monitor(monkeypatch) -> None:
"""配置热更新继续使用重建语义,不复用首次启动入口。"""
_reset_plugin_manager()
reset_plugin_system()
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.settings",
SimpleNamespace(
DEV=False,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
),
_patch_runtime_settings(
monkeypatch,
DEV=False,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
)
manager = PluginManager()
reload_monitor = MagicMock()
+7 -6
View File
@@ -10,13 +10,14 @@ from app.adapters.system.plugin.package import PluginPackageManager
def _manager(monkeypatch, tmp_path: Path) -> PluginPackageManager:
"""构造使用隔离运行目录和事务目录的插件包管理器。"""
settings = SimpleNamespace(
ROOT_PATH=tmp_path,
TEMP_PATH=tmp_path / "temp",
CONFIG_PATH=tmp_path / "config",
)
monkeypatch.setattr(
"app.adapters.system.plugin.package.settings",
SimpleNamespace(
ROOT_PATH=tmp_path,
TEMP_PATH=tmp_path / "temp",
CONFIG_PATH=tmp_path / "config",
),
"app.adapters.system.plugin.package.get_runtime_setting",
lambda key: getattr(settings, key),
)
return PluginPackageManager(helper=Mock())
@@ -23,10 +23,11 @@ def test_package_version_candidates_have_one_canonical_order(
expected: tuple[str, ...],
) -> None:
"""显式版本、向后兼容版本和基础索引必须由一个有序事实源产生。"""
runtime_settings = SimpleNamespace(VERSION_FLAG=configured_version)
monkeypatch.setattr(
market,
"settings",
SimpleNamespace(VERSION_FLAG=configured_version),
"get_runtime_setting",
lambda key, default=None: getattr(runtime_settings, key, default),
)
assert PluginHelper._package_version_candidates(requested_version) == expected
@@ -37,10 +38,11 @@ async def test_sync_and_async_package_resolution_visit_same_candidates(
monkeypatch,
) -> None:
"""同步与异步安装必须按相同顺序停止在首个兼容插件索引。"""
runtime_settings = SimpleNamespace(VERSION_FLAG="v3")
monkeypatch.setattr(
market,
"settings",
SimpleNamespace(VERSION_FLAG="v3"),
"get_runtime_setting",
lambda key, default=None: getattr(runtime_settings, key, default),
)
helper = PluginHelper.__new__(PluginHelper)
indexes = {
+9 -7
View File
@@ -6,6 +6,7 @@ from fastapi import HTTPException
from app import schemas
from app.api.endpoints.plugin import plugin_rating, plugin_ratings, rate_plugin
from app.adapters.external import server as server_module
from app.adapters.external.server import MoviePilotServerHelper
@@ -13,8 +14,13 @@ def test_server_helper_uses_plugin_rating_endpoints() -> None:
"""评分辅助方法应使用独立中心端路径并传递评分载荷。"""
async def run_scenario() -> None:
runtime_setting = server_module.get_runtime_setting
with (
patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"),
patch.object(
server_module,
"get_runtime_setting",
side_effect=lambda key: "https://movie-pilot.org" if key == "MP_SERVER_HOST" else runtime_setting(key),
),
patch.object(
MoviePilotServerHelper,
"_async_get",
@@ -30,16 +36,12 @@ def test_server_helper_uses_plugin_rating_endpoints() -> None:
await MoviePilotServerHelper.async_plugin_rating("Demo Plugin")
await MoviePilotServerHelper.async_rate_plugin("Demo Plugin", 4.5)
assert get_request.await_args_list[0].args == (
"https://movie-pilot.org/plugin/rating",
)
assert get_request.await_args_list[0].args == ("https://movie-pilot.org/plugin/rating",)
assert get_request.await_args_list[0].kwargs == {
"params": {"plugin_ids": "DemoPlugin,OtherPlugin"},
"timeout": 10,
}
assert get_request.await_args_list[1].args == (
"https://movie-pilot.org/plugin/rating/Demo%20Plugin",
)
assert get_request.await_args_list[1].args == ("https://movie-pilot.org/plugin/rating/Demo%20Plugin",)
assert post_request.await_args.args == (
"https://movie-pilot.org/plugin/rating/Demo%20Plugin",
{"rating": 4.5},
+10 -2
View File
@@ -52,8 +52,16 @@ def test_main_rejects_topology_before_startup_side_effects(monkeypatch):
"""主入口应在注册信号、迁移数据库和启动服务器前拒绝错误拓扑。"""
from app import main
monkeypatch.setattr(main.settings, "API_WORKERS", 2)
monkeypatch.setattr(main.settings, "MOVIEPILOT_SAFE_MODE", False)
original_setting = main.get_runtime_setting
topology_settings = {
"API_WORKERS": 2,
"MOVIEPILOT_SAFE_MODE": False,
}
monkeypatch.setattr(
main,
"get_runtime_setting",
lambda key: topology_settings[key] if key in topology_settings else original_setting(key),
)
signal_handler = MagicMock()
start_tray = MagicMock()
server_run = MagicMock()
+3 -8
View File
@@ -84,13 +84,6 @@ def _load_qbittorrent_modules():
def get(self, *_args, **_kwargs):
return None
class _RuntimeSettingsCompat:
"""隔离测试用动态配置代理,保持生产模块的兼容读取语义。"""
def __getattr__(self, key):
"""从测试提供的旧 Settings 桩读取配置项。"""
return getattr(config_module.settings, key)
class _MetaInfo:
def __init__(self, name):
self.name = name
@@ -195,7 +188,9 @@ def _load_qbittorrent_modules():
log_module.logger = _Logger()
cache_module.FileCache = _FileCache
config_module.settings = types.SimpleNamespace(TORRENT_TAG="moviepilot-tag")
runtime_settings_module.RuntimeSettingsCompat = _RuntimeSettingsCompat
runtime_settings_module.get_runtime_setting = lambda key, default=None: getattr(
config_module.settings, key, default
)
metainfo_module.MetaInfo = _MetaInfo
schema_dashboard_module.DownloaderInfo = object
schema_transfer_module.TransferTorrent = object
+254 -197
View File
@@ -1,195 +1,237 @@
from __future__ import annotations
import unittest
from contextlib import contextmanager
from unittest.mock import AsyncMock, Mock, patch
import pytest
from app.adapters.external import server as server_module
from app.adapters.external.server import (
MoviePilotServerHelper,
configure_server_application_services,
)
from app.application.server.report import ServerReportService
from app.application.server.share import ServerSharingService
from app.runtime.config import settings
from app.schemas.types import MediaSource
class MoviePilotServerHelperTests(unittest.TestCase):
@contextmanager
def _runtime_settings(**values):
"""按键覆盖中心服务测试配置,其余读取继续委托真实只读端口。"""
original = server_module.get_runtime_setting
with patch.object(
server_module,
"get_runtime_setting",
side_effect=lambda key: values[key] if key in values else original(key),
):
yield
@pytest.fixture(autouse=True)
def _configure_server_services() -> None:
"""
MoviePilot 服务端请求辅助工具测试
清理安装用户 ID 缓存避免不同用例之间互相影响
"""
MoviePilotServerHelper._user_uid = None
configure_server_application_services(
report_service=ServerReportService(
config_reader=Mock(return_value=None),
config_writer=Mock(),
installed_plugins_provider=Mock(return_value=[]),
subscribes_provider=Mock(return_value=[]),
async_subscribes_provider=AsyncMock(return_value=[]),
plugin_report_sender=Mock(),
async_plugin_report_sender=AsyncMock(),
subscribe_report_sender=Mock(),
async_subscribe_report_sender=AsyncMock(),
async_config_writer=AsyncMock(),
repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url,
),
sharing_service=ServerSharingService(
subscribe_provider=Mock(return_value=None),
async_subscribe_provider=AsyncMock(return_value=None),
workflow_provider=Mock(return_value=None),
async_workflow_provider=AsyncMock(return_value=None),
user_uuid_provider=Mock(return_value="user-1"),
subscribe_sender=Mock(),
async_subscribe_sender=AsyncMock(),
workflow_sender=Mock(),
async_workflow_sender=AsyncMock(),
response_handler=Mock(return_value=(True, "")),
subscribe_cache_clearer=Mock(),
workflow_cache_clearer=Mock(),
),
)
def setUp(self) -> None:
"""
清理安装用户 ID 缓存避免不同用例之间互相影响
"""
MoviePilotServerHelper._user_uid = None
configure_server_application_services(
report_service=ServerReportService(
config_reader=Mock(return_value=None),
config_writer=Mock(),
installed_plugins_provider=Mock(return_value=[]),
subscribes_provider=Mock(return_value=[]),
async_subscribes_provider=AsyncMock(return_value=[]),
plugin_report_sender=Mock(),
async_plugin_report_sender=AsyncMock(),
subscribe_report_sender=Mock(),
async_subscribe_report_sender=AsyncMock(),
async_config_writer=AsyncMock(),
repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url,
),
sharing_service=ServerSharingService(
subscribe_provider=Mock(return_value=None),
async_subscribe_provider=AsyncMock(return_value=None),
workflow_provider=Mock(return_value=None),
async_workflow_provider=AsyncMock(return_value=None),
user_uuid_provider=Mock(return_value="user-1"),
subscribe_sender=Mock(),
async_subscribe_sender=AsyncMock(),
workflow_sender=Mock(),
async_workflow_sender=AsyncMock(),
response_handler=Mock(return_value=(True, "")),
subscribe_cache_clearer=Mock(),
workflow_cache_clearer=Mock(),
),
def test_server_request_adds_user_uid_header():
"""
发往 MoviePilot 服务端的请求会自动携带安装用户 ID
"""
with (
patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"),
_runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"),
):
headers = MoviePilotServerHelper.build_headers(
"https://movie-pilot.org/plugin/install",
headers={"Content-Type": "application/json"},
)
def test_server_request_adds_user_uid_header(self):
"""
发往 MoviePilot 服务端的请求会自动携带安装用户 ID
"""
with patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), \
patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"):
headers = MoviePilotServerHelper.build_headers(
"https://movie-pilot.org/plugin/install",
headers={"Content-Type": "application/json"},
)
assert headers["X-MoviePilot-User-Uid"] == "uid-1"
assert headers["Content-Type"] == "application/json"
self.assertEqual(headers["X-MoviePilot-User-Uid"], "uid-1")
self.assertEqual(headers["Content-Type"], "application/json")
def test_non_server_request_does_not_add_user_uid_header(self):
"""
发往其他域名的请求不会携带安装用户 ID
"""
with patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), \
patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"):
headers = MoviePilotServerHelper.build_headers(
"https://example.com/plugin/install",
headers={"Content-Type": "application/json"},
)
self.assertNotIn("X-MoviePilot-User-Uid", headers)
def test_existing_user_uid_header_is_preserved(self):
"""
调用方显式传入的安装用户 ID 请求头不被覆盖
"""
with patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), \
patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"):
headers = MoviePilotServerHelper.build_headers(
"https://movie-pilot.org/plugin/install",
headers={
"Content-Type": "application/json",
"X-MoviePilot-User-Uid": "custom-uid",
},
)
self.assertEqual(headers["X-MoviePilot-User-Uid"], "custom-uid")
def test_existing_user_uid_header_is_detected_case_insensitively(self):
"""
调用方使用不同大小写的安装用户 ID 请求头时不会重复注入
"""
with patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), \
patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"):
headers = MoviePilotServerHelper.build_headers(
"https://movie-pilot.org/plugin/install",
headers={
"Content-Type": "application/json",
"x-moviepilot-user-uid": "custom-uid",
},
)
self.assertNotIn("X-MoviePilot-User-Uid", headers)
self.assertEqual(headers["x-moviepilot-user-uid"], "custom-uid")
def test_content_type_can_be_added(self):
"""
构建 JSON 请求头时会补充 Content-Type
"""
with patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"), \
patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"):
headers = MoviePilotServerHelper.build_headers(
"https://movie-pilot.org/plugin/install",
content_type="application/json",
)
self.assertEqual(headers["Content-Type"], "application/json")
def test_subscribe_fork_uses_fork_endpoint(self):
"""
订阅复用请求使用服务端 fork 接口
"""
with patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"), \
patch.object(MoviePilotServerHelper, "_get", return_value=None) as request:
MoviePilotServerHelper.subscribe_fork(9)
request.assert_called_once_with(
"https://movie-pilot.org/subscribe/fork/9",
timeout=5,
def test_non_server_request_does_not_add_user_uid_header():
"""
发往其他域名的请求不会携带安装用户 ID
"""
with (
patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"),
_runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"),
):
headers = MoviePilotServerHelper.build_headers(
"https://example.com/plugin/install",
headers={"Content-Type": "application/json"},
)
def test_workflow_fork_uses_fork_endpoint(self):
"""
工作流复用请求使用服务端 fork 接口
"""
with patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"), \
patch.object(MoviePilotServerHelper, "_get", return_value=None) as request:
MoviePilotServerHelper.workflow_fork(9)
assert "X-MoviePilot-User-Uid" not in headers
request.assert_called_once_with(
"https://movie-pilot.org/workflow/fork/9",
timeout=5,
def test_existing_user_uid_header_is_preserved():
"""
调用方显式传入的安装用户 ID 请求头不被覆盖
"""
with (
patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"),
_runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"),
):
headers = MoviePilotServerHelper.build_headers(
"https://movie-pilot.org/plugin/install",
headers={
"Content-Type": "application/json",
"X-MoviePilot-User-Uid": "custom-uid",
},
)
def test_user_permissions_uses_server_endpoint(self):
"""
用户权限请求使用服务端权限接口
"""
with patch("app.adapters.external.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"), \
patch.object(MoviePilotServerHelper, "_get", return_value=None) as request:
MoviePilotServerHelper.user_permissions("jxxghp")
assert headers["X-MoviePilot-User-Uid"] == "custom-uid"
request.assert_called_once_with(
"https://movie-pilot.org/user/permissions",
params={"github_user": "jxxghp"},
include_user_uid=False,
timeout=5,
def test_existing_user_uid_header_is_detected_case_insensitively():
"""
调用方使用不同大小写的安装用户 ID 请求头时不会重复注入
"""
with (
patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"),
_runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"),
):
headers = MoviePilotServerHelper.build_headers(
"https://movie-pilot.org/plugin/install",
headers={
"Content-Type": "application/json",
"x-moviepilot-user-uid": "custom-uid",
},
)
def test_is_admin_user_uses_server_permissions(self):
"""
共享管理权限由服务端权限结果决定
"""
response = Mock(status_code=200)
response.json.return_value = {"is_admin": True}
with patch.object(MoviePilotServerHelper, "get_github_user", return_value="jxxghp"), \
patch.object(MoviePilotServerHelper, "user_permissions", return_value=response):
self.assertTrue(MoviePilotServerHelper.is_admin_user())
assert "X-MoviePilot-User-Uid" not in headers
assert headers["x-moviepilot-user-uid"] == "custom-uid"
def test_is_admin_user_returns_false_without_server_permission(self):
"""
服务端未返回管理权限时不授予共享管理权限
"""
response = Mock(status_code=200)
response.json.return_value = {"is_admin": False}
with patch.object(MoviePilotServerHelper, "get_github_user", return_value="user"), \
patch.object(MoviePilotServerHelper, "user_permissions", return_value=response):
self.assertFalse(MoviePilotServerHelper.is_admin_user())
def test_subscribe_statistic_payload_only_keeps_server_contract(self):
"""订阅统计载荷应删除本地运行列和所有旧专用媒体 ID。"""
payload = MoviePilotServerHelper._build_subscribe_statistic_payload({
def test_content_type_can_be_added():
"""
构建 JSON 请求头时会补充 Content-Type
"""
with (
patch.object(MoviePilotServerHelper, "get_user_uid", return_value="uid-1"),
_runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"),
):
headers = MoviePilotServerHelper.build_headers(
"https://movie-pilot.org/plugin/install",
content_type="application/json",
)
assert headers["Content-Type"] == "application/json"
def test_subscribe_fork_uses_fork_endpoint():
"""
订阅复用请求使用服务端 fork 接口
"""
with (
_runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"),
patch.object(MoviePilotServerHelper, "_get", return_value=None) as request,
):
MoviePilotServerHelper.subscribe_fork(9)
request.assert_called_once_with(
"https://movie-pilot.org/subscribe/fork/9",
timeout=5,
)
def test_workflow_fork_uses_fork_endpoint():
"""
工作流复用请求使用服务端 fork 接口
"""
with (
_runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"),
patch.object(MoviePilotServerHelper, "_get", return_value=None) as request,
):
MoviePilotServerHelper.workflow_fork(9)
request.assert_called_once_with(
"https://movie-pilot.org/workflow/fork/9",
timeout=5,
)
def test_user_permissions_uses_server_endpoint():
"""
用户权限请求使用服务端权限接口
"""
with (
_runtime_settings(MP_SERVER_HOST="https://movie-pilot.org"),
patch.object(MoviePilotServerHelper, "_get", return_value=None) as request,
):
MoviePilotServerHelper.user_permissions("jxxghp")
request.assert_called_once_with(
"https://movie-pilot.org/user/permissions",
params={"github_user": "jxxghp"},
include_user_uid=False,
timeout=5,
)
def test_is_admin_user_uses_server_permissions():
"""
共享管理权限由服务端权限结果决定
"""
response = Mock(status_code=200)
response.json.return_value = {"is_admin": True}
with (
patch.object(MoviePilotServerHelper, "get_github_user", return_value="jxxghp"),
patch.object(MoviePilotServerHelper, "user_permissions", return_value=response),
):
assert MoviePilotServerHelper.is_admin_user()
def test_is_admin_user_returns_false_without_server_permission():
"""
服务端未返回管理权限时不授予共享管理权限
"""
response = Mock(status_code=200)
response.json.return_value = {"is_admin": False}
with (
patch.object(MoviePilotServerHelper, "get_github_user", return_value="user"),
patch.object(MoviePilotServerHelper, "user_permissions", return_value=response),
):
assert not MoviePilotServerHelper.is_admin_user()
def test_subscribe_statistic_payload_only_keeps_server_contract():
"""订阅统计载荷应删除本地运行列和所有旧专用媒体 ID。"""
payload = MoviePilotServerHelper._build_subscribe_statistic_payload(
{
"id": 1,
"name": "Test",
"type": "电影",
@@ -198,18 +240,21 @@ class MoviePilotServerHelperTests(unittest.TestCase):
"tmdbid": 99,
"state": "N",
"username": "tester",
})
}
)
self.assertEqual(payload, {
"name": "Test",
"type": "电影",
"media_source": MediaSource.Douban.value,
"media_id": "42",
})
assert payload == {
"name": "Test",
"type": "电影",
"media_source": MediaSource.Douban.value,
"media_id": "42",
}
def test_subscribe_share_payload_only_keeps_server_contract(self):
"""订阅分享载荷应保留分享配置并剔除本地下载状态。"""
payload = MoviePilotServerHelper._build_subscribe_share_payload({
def test_subscribe_share_payload_only_keeps_server_contract():
"""订阅分享载荷应保留分享配置并剔除本地下载状态。"""
payload = MoviePilotServerHelper._build_subscribe_share_payload(
{
"share_title": "Share",
"share_user": "tester",
"name": "Test",
@@ -220,40 +265,52 @@ class MoviePilotServerHelperTests(unittest.TestCase):
"audio_quality": "lossless",
"downloader": "default",
"bangumiid": 7,
})
}
)
self.assertEqual(payload, {
"share_title": "Share",
"share_user": "tester",
"name": "Test",
"type": "电视剧",
"media_source": MediaSource.Bangumi.value,
"media_id": "7",
"include": "WEB-DL",
})
assert payload == {
"share_title": "Share",
"share_user": "tester",
"name": "Test",
"type": "电视剧",
"media_source": MediaSource.Bangumi.value,
"media_id": "7",
"include": "WEB-DL",
}
def test_subscribe_payload_rejects_incomplete_unified_identity(self):
"""中心服务载荷不得再从旧专用 ID 推导主身份。"""
self.assertIsNone(
MoviePilotServerHelper._build_subscribe_statistic_payload({
def test_subscribe_payload_rejects_incomplete_unified_identity():
"""中心服务载荷不得再从旧专用 ID 推导主身份。"""
assert (
MoviePilotServerHelper._build_subscribe_statistic_payload(
{
"name": "Legacy",
"type": "电影",
"tmdbid": 99,
})
}
)
is None
)
def test_durable_subscribe_report_treats_disabled_sharing_as_success(self):
"""用户关闭统计分享时,durable intent 应视为无需远端投递。"""
with patch.object(settings, "SUBSCRIBE_STATISTIC_SHARE", False), patch.object(
def test_durable_subscribe_report_treats_disabled_sharing_as_success():
"""用户关闭统计分享时,durable intent 应视为无需远端投递。"""
with (
_runtime_settings(SUBSCRIBE_STATISTIC_SHARE=False),
patch.object(
MoviePilotServerHelper,
"sub_reg",
) as reporter:
self.assertTrue(MoviePilotServerHelper.sub_reg_durable({"media_id": "1"}))
reporter.assert_not_called()
) as reporter,
):
assert MoviePilotServerHelper.sub_reg_durable({"media_id": "1"})
reporter.assert_not_called()
with patch.object(settings, "SUBSCRIBE_STATISTIC_SHARE", False), patch.object(
with (
_runtime_settings(SUBSCRIBE_STATISTIC_SHARE=False),
patch.object(
MoviePilotServerHelper,
"sub_done",
) as reporter:
self.assertTrue(MoviePilotServerHelper.sub_done_durable({"media_id": "1"}))
reporter.assert_not_called()
) as reporter,
):
assert MoviePilotServerHelper.sub_done_durable({"media_id": "1"})
reporter.assert_not_called()
+3 -3
View File
@@ -14,6 +14,7 @@ ensure_optional_stub("aioshutil")
ensure_optional_stub("pyquery", PyQuery=object)
from app.chain.message import MessageChain
from app.application.configuration import get_runtime_settings # noqa: E402 - optional stubs must be installed first
from app.application.messaging.interaction import InteractionContext
from app.application.messaging.skill import SkillInteractionHandler
from app.application.messaging.skill import skill_interaction_manager
@@ -21,7 +22,6 @@ from app.agent.skills.registry import (
SkillHelper,
SkillInfo,
SkillMarketSource,
settings as skill_settings,
)
from app.schemas.types import NotificationChannel
@@ -358,8 +358,8 @@ class TestSkillsCommand(unittest.TestCase):
"get_market_sources",
return_value=["https://github.com/openai/skills"],
), patch.object(
type(skill_settings),
"update_setting",
get_runtime_settings(),
"update",
return_value=(True, ""),
) as update_setting:
success, message = helper.add_custom_market_source("acme/custom-skills")