Merge pull request #6666 from Aqr-K/feat/plugin-instance-target-enablement

feat(plugin): 插件实例默认调用目标与启停,未指定实例时不再兜底取第一个
This commit is contained in:
Aqr-K
2026-09-13 16:37:27 +08:00
committed by GitHub
parent 30d6d9e146
commit d44480d739
54 changed files with 2853 additions and 73 deletions

View File

@@ -0,0 +1,137 @@
"""插件实例启停接口与依赖分类装载过滤测试。"""
from __future__ import annotations
import inspect
from types import SimpleNamespace
from app.api.dependencies.auth import get_current_active_superuser
from app.api.endpoints import plugintarget as plugintarget_endpoint
from app.api.endpoints.plugintarget import set_plugin_instance_enabled
from app.runtime.extensions.plugin.dependency import PluginDependencyService
from app.schemas.exception import PluginMutationRejectedError
from app.schemas.plugin import PluginInstance, PluginInstanceEnabledRequest
def _manager(**methods):
"""按方法名快速拼装一个鸭子类型的 Manager 替身。"""
return type("Manager", (), methods)()
def test_endpoint_requires_superuser_dependency():
"""启停端点要求超级管理员。"""
depends = inspect.signature(set_plugin_instance_enabled).parameters["_"].default
assert depends.dependency is get_current_active_superuser
def test_endpoint_delegates_to_manager_and_reports_success(monkeypatch):
"""请求原样转交给 Manager,状态确实变化时返回成功。"""
calls: list = []
manager = _manager(
set_plugin_instance_enabled=lambda self, *a: (calls.append(a), True)[1]
)
monkeypatch.setattr(plugintarget_endpoint, "get_plugin_manager", lambda: manager)
result = set_plugin_instance_enabled(
"DemoPluginWork", PluginInstanceEnabledRequest(enabled=False), None
)
assert result.success is True
assert calls == [("DemoPluginWork", False)]
def test_endpoint_reports_unchanged_state_without_pretending_success(monkeypatch):
"""实例不存在或已处于目标状态时如实回报失败,并给出完整句子而非中文片段。"""
manager = _manager(set_plugin_instance_enabled=lambda self, *a: False)
monkeypatch.setattr(plugintarget_endpoint, "get_plugin_manager", lambda: manager)
disabled = set_plugin_instance_enabled(
"Missing", PluginInstanceEnabledRequest(enabled=False), None
)
enabled = set_plugin_instance_enabled(
"Missing", PluginInstanceEnabledRequest(enabled=True), None
)
assert disabled.success is False
assert disabled.message == "实例 Missing 不存在或已处于停用状态"
assert enabled.message == "实例 Missing 不存在或已处于启用状态"
def test_endpoint_reports_mutation_rejection_as_a_failed_response(monkeypatch):
"""处于停机准入窗口时返回失败响应而不是抛出。"""
def _raise(*_a):
raise PluginMutationRejectedError("插件正在停机")
manager = _manager(set_plugin_instance_enabled=_raise)
monkeypatch.setattr(plugintarget_endpoint, "get_plugin_manager", lambda: manager)
result = set_plugin_instance_enabled(
"DemoPluginWork", PluginInstanceEnabledRequest(enabled=True), None
)
assert result.success is False
assert "插件正在停机" in result.message
def test_router_registers_the_enabled_path():
"""路由器暴露启停路径,注册在插件前缀下。"""
paths = {route.path for route in plugintarget_endpoint.router.routes}
assert "/instance/{instance_id}/enabled" in paths
# --------------------------------------------------------------------------- #
# 依赖分类必须按装载判据过滤
# --------------------------------------------------------------------------- #
def test_classification_drops_hosts_that_are_not_loadable():
"""分类结果会被逐个 start(),停用的本体必须在这里就被剔除。
物理插件那一层按安装清单划分,而安装清单只回答「包在不在磁盘上」;不过滤的话
停用的插件会在开机与配置热重载时被重新拉起来,启用位形同虚设。
"""
system = SimpleNamespace(
dependency=SimpleNamespace(
classify_plugins=lambda: (
["PluginA", "PluginB"],
["PluginC"],
["PluginD"],
)
)
)
service = PluginDependencyService(
system=lambda: system,
instances=lambda: {
"PluginAx2": PluginInstance(
instance_id="PluginAx2", source_plugin_id="PluginA"
)
},
loadable_hosts=lambda: {"PluginA"},
log=SimpleNamespace(info=lambda *_a: None, error=lambda *_a: None),
)
classification = service.classify_plugins()
assert classification.ready == ("PluginA", "PluginAx2")
assert classification.missing_dependencies == ()
assert classification.missing_source == ()
def test_classification_without_a_loadable_port_keeps_every_plugin():
"""未装配装载判据端口时保持原分类,测试与旧调用方不因此丢掉插件。"""
system = SimpleNamespace(
dependency=SimpleNamespace(
classify_plugins=lambda: (["PluginA"], ["PluginC"], ["PluginD"])
)
)
service = PluginDependencyService(
system=lambda: system,
log=SimpleNamespace(info=lambda *_a: None, error=lambda *_a: None),
)
classification = service.classify_plugins()
assert classification.ready == ("PluginA",)
assert classification.missing_dependencies == ("PluginC",)
assert classification.missing_source == ("PluginD",)