mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
refactor(config): retire RuntimeSettingsCompat host usage
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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("你好")
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]] = {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user