Files
MoviePilot/tests/test_agent_plugin_tools.py

376 lines
12 KiB
Python

import asyncio
import json
from types import SimpleNamespace
from typing import Optional
from unittest.mock import AsyncMock, MagicMock, patch
from app.agent.tools.impl._plugin_tool_utils import install_plugin_runtime
from app.agent.tools.impl.install_plugin import InstallPluginTool
from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool
from app.agent.tools.impl.query_market_plugins import QueryMarketPluginsTool
from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool
from app.agent.tools.impl.query_plugin_data import QueryPluginDataTool
from app.agent.tools.impl.reload_plugin import ReloadPluginTool
from app.agent.tools.impl.uninstall_plugin import UninstallPluginTool
from app.agent.tools.impl.update_plugin_config import UpdatePluginConfigTool
def _plugin_snapshot(state: bool = True) -> dict:
"""
构造插件运行态快照。
"""
return {
"plugin_id": "DemoPlugin",
"plugin_name": "Demo Plugin",
"plugin_version": "1.0.0",
"state": state,
}
def _market_plugin(
plugin_id: str,
plugin_name: str,
installed: bool = False,
repo_url: Optional[str] = "https://example.com/market",
) -> SimpleNamespace:
"""
构造插件市场或已安装插件摘要对象。
"""
return SimpleNamespace(
id=plugin_id,
plugin_name=plugin_name,
plugin_desc=f"{plugin_name} description",
plugin_version="1.0.0",
plugin_author="author",
installed=installed,
has_update=False,
state=installed,
repo_url=repo_url,
add_time=1,
)
def test_query_market_plugins_filters_candidates() -> None:
"""
查询插件市场时会按关键字返回匹配候选。
"""
tool = QueryMarketPluginsTool(session_id="session-1", user_id="10001")
plugins = [
_market_plugin("DemoPlugin", "Demo Plugin"),
_market_plugin("OtherPlugin", "Other Plugin"),
]
with patch(
"app.agent.tools.impl.query_market_plugins.load_market_plugins",
new=AsyncMock(return_value=plugins),
):
result = asyncio.run(tool.run(query="demo"))
payload = json.loads(result)
assert payload["success"]
assert payload["match_count"] == 1
assert payload["plugins"][0]["id"] == "DemoPlugin"
def test_query_installed_plugins_filters_candidates() -> None:
"""
查询已安装插件时会按关键字返回匹配候选。
"""
tool = QueryInstalledPluginsTool(session_id="session-1", user_id="10001")
plugins = [
_market_plugin("DemoPlugin", "Demo Plugin", installed=True),
_market_plugin("OtherPlugin", "Other Plugin", installed=True),
]
with patch(
"app.agent.tools.impl.query_installed_plugins.list_installed_plugins",
return_value=plugins,
):
result = asyncio.run(tool.run(query="demo"))
payload = json.loads(result)
assert payload["success"]
assert payload["match_count"] == 1
assert payload["plugins"][0]["id"] == "DemoPlugin"
def test_query_installed_plugins_fills_missing_repo_url_from_market() -> None:
"""
已安装插件缺少来源地址时,会从插件市场元数据补齐 repo_url。
"""
tool = QueryInstalledPluginsTool(session_id="session-1", user_id="10001")
installed_plugin = _market_plugin(
"DemoPlugin", "Demo Plugin", installed=True, repo_url=None
)
market_plugin = _market_plugin(
"DemoPlugin",
"Demo Plugin",
installed=True,
repo_url="https://github.com/demo/plugins",
)
plugin_manager = MagicMock()
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
with (
patch(
"app.agent.tools.impl.query_installed_plugins.list_installed_plugins",
return_value=[installed_plugin],
),
patch(
"app.agent.tools.impl._plugin_tool_utils.PluginManager",
return_value=plugin_manager,
),
):
result = asyncio.run(tool.run(query="demo"))
payload = json.loads(result)
assert payload["success"]
assert payload["plugins"][0]["repo_url"] == "https://github.com/demo/plugins"
plugin_manager.async_get_online_plugins.assert_awaited_once_with(force=False)
def test_query_plugin_config_returns_saved_config_and_default_model() -> None:
"""
查询插件配置会返回保存值和默认配置模型。
"""
tool = QueryPluginConfigTool(session_id="session-1", user_id="10001")
plugin_manager = MagicMock()
plugin_manager.get_plugin_config.return_value = {"enabled": True}
plugin_instance = MagicMock()
plugin_instance.get_form.return_value = (None, {"enabled": False, "interval": 10})
plugin_manager.running_plugins = {"DemoPlugin": plugin_instance}
with (
patch(
"app.agent.tools.impl.query_plugin_config.get_plugin_snapshot",
return_value=_plugin_snapshot(),
),
patch(
"app.agent.tools.impl.query_plugin_config.PluginManager",
return_value=plugin_manager,
),
):
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
payload = json.loads(result)
assert payload["success"]
assert payload["config"] == {"enabled": True}
assert payload["default_model"] == {"enabled": False, "interval": 10}
def test_update_plugin_config_merges_and_removes_keys_without_reloading() -> None:
"""
更新插件配置会合并新增键并移除指定旧键。
"""
tool = UpdatePluginConfigTool(session_id="session-1", user_id="10001")
plugin_manager = MagicMock()
plugin_manager.get_plugin_config.return_value = {
"enabled": False,
"interval": 30,
"token": "legacy-token",
}
plugin_manager.async_save_plugin_config = AsyncMock(return_value=True)
with (
patch(
"app.agent.tools.impl.update_plugin_config.get_plugin_snapshot",
return_value=_plugin_snapshot(),
),
patch(
"app.agent.tools.impl.update_plugin_config.PluginManager",
return_value=plugin_manager,
),
):
result = asyncio.run(
tool.run(
plugin_id="DemoPlugin",
updates={"enabled": True},
remove_keys=["token"],
)
)
payload = json.loads(result)
assert payload["success"]
assert payload["config_requires_reload"]
assert payload["saved_config"] == {"enabled": True, "interval": 30}
plugin_manager.async_save_plugin_config.assert_awaited_once_with(
"DemoPlugin",
{"enabled": True, "interval": 30},
)
def test_reload_plugin_triggers_runtime_refresh() -> None:
"""
重载插件工具会调用运行态刷新流程。
"""
tool = ReloadPluginTool(session_id="session-1", user_id="10001")
with (
patch(
"app.agent.tools.impl.reload_plugin.get_plugin_snapshot",
side_effect=[_plugin_snapshot(), _plugin_snapshot(state=False)],
),
patch(
"app.agent.tools.impl.reload_plugin.reload_plugin_runtime"
) as reload_plugin_runtime,
):
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
payload = json.loads(result)
assert payload["success"]
assert not payload["state"]
reload_plugin_runtime.assert_called_once_with("DemoPlugin")
def test_install_plugin_installs_market_candidate() -> None:
"""
安装插件工具会使用市场候选携带的仓库地址。
"""
tool = InstallPluginTool(session_id="session-1", user_id="10001")
candidate = _market_plugin("DemoPlugin", "Demo Plugin")
with (
patch(
"app.agent.tools.impl.install_plugin.load_market_plugins",
new=AsyncMock(return_value=[candidate]),
),
patch(
"app.agent.tools.impl.install_plugin.install_plugin_runtime",
new=AsyncMock(return_value=(True, "插件安装完成", False)),
) as install_runtime,
patch(
"app.agent.tools.impl.install_plugin.get_plugin_snapshot",
return_value=_plugin_snapshot(),
),
):
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
payload = json.loads(result)
assert payload["success"]
assert payload["plugin"]["id"] == "DemoPlugin"
install_runtime.assert_awaited_once_with(
"DemoPlugin", "https://example.com/market", force=False
)
def test_install_plugin_runtime_reloads_in_threadpool() -> None:
"""
已存在插件刷新加载时会通过插件线程池执行重载。
"""
plugin_manager = MagicMock()
plugin_manager.get_plugin_ids.return_value = ["DemoPlugin"]
plugin_helper = MagicMock()
config_oper = MagicMock()
config_oper.get.return_value = ["DemoPlugin"]
calls = []
async def fake_run_agent_blocking(bucket, func, *args, **kwargs) -> None:
calls.append((bucket, func, args, kwargs))
return None
with (
patch(
"app.agent.tools.impl._plugin_tool_utils.SystemConfigOper",
return_value=config_oper,
),
patch(
"app.agent.tools.impl._plugin_tool_utils.PluginManager",
return_value=plugin_manager,
),
patch(
"app.agent.tools.impl._plugin_tool_utils.PluginHelper",
return_value=plugin_helper,
),
patch(
"app.agent.tools.impl._plugin_tool_utils.reload_plugin_runtime",
) as reload_runtime,
patch(
"app.agent.tools.impl._plugin_tool_utils.MoviePilotServerHelper.async_install_plugin_reg",
AsyncMock(return_value=True),
) as install_reg,
patch(
"app.agent.tools.base.run_agent_blocking",
side_effect=fake_run_agent_blocking,
),
):
success, message, refreshed_only = asyncio.run(
install_plugin_runtime(
"DemoPlugin",
"https://example.com/market",
force=False,
)
)
assert success
assert message == "插件已存在,已刷新加载"
assert refreshed_only
install_reg.assert_awaited_once_with(
plugin_id="DemoPlugin",
repo_url="https://example.com/market",
)
assert len(calls) == 1
assert calls[0][0] == "plugin"
assert calls[0][1] == reload_runtime
assert calls[0][2] == ("DemoPlugin",)
assert calls[0][3] == {}
def test_uninstall_plugin_uninstalls_installed_candidate() -> None:
"""
卸载插件工具会按已安装候选执行卸载流程。
"""
tool = UninstallPluginTool(session_id="session-1", user_id="10001")
installed_plugin = _market_plugin(
"DemoPlugin", "Demo Plugin", installed=True
)
with (
patch(
"app.agent.tools.impl.uninstall_plugin.list_installed_plugins",
return_value=[installed_plugin],
),
patch(
"app.agent.tools.impl.uninstall_plugin.uninstall_plugin_runtime",
new=AsyncMock(
return_value={"was_clone": False, "clone_files_removed": False}
),
) as uninstall_runtime,
):
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
payload = json.loads(result)
assert payload["success"]
assert payload["plugin"]["id"] == "DemoPlugin"
uninstall_runtime.assert_awaited_once_with("DemoPlugin")
def test_query_plugin_data_truncates_large_payload() -> None:
"""
查询插件数据会截断超长内容并返回预览。
"""
tool = QueryPluginDataTool(session_id="session-1", user_id="10001")
plugin_data_oper = MagicMock()
plugin_data_oper.async_get_data_all = AsyncMock(return_value=[
SimpleNamespace(key="payload", value={"text": "x" * 5000})
])
with (
patch(
"app.agent.tools.impl.query_plugin_data.get_plugin_snapshot",
return_value=_plugin_snapshot(),
),
patch(
"app.agent.tools.impl.query_plugin_data.PluginDataOper",
return_value=plugin_data_oper,
),
):
result = asyncio.run(tool.run(plugin_id="DemoPlugin", max_chars=200))
payload = json.loads(result)
assert payload["success"]
assert payload["truncated"]
assert "data_preview" in payload
assert "data" not in payload
assert "已截断" in payload["data_preview"]