Files
MoviePilot/tests/test_plugin_endpoint.py
Aqr-K 6610a8c008 fix: retain plugin lifecycle ownership and recover installation state (#7146)
* fix: retain plugin lifecycle ownership and recover installation state

* test: cover plugin rollback and uninstall lifecycle boundaries

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 07:44:07 +08:00

1713 lines
62 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
验证插件接口的状态查询、配置与生命周期委托。
"""
import asyncio
from collections.abc import Callable
from contextlib import asynccontextmanager, nullcontext
from dataclasses import replace
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from starlette.responses import Response
from app import schemas
from app.api.endpoints import plugin as plugin_endpoint
from app.api.endpoints import pluginclone as plugin_clone_endpoint
from app.api.endpoints import pluginfolder as plugin_folders_endpoint
from app.api.endpoints.plugin import (
plugin_capabilities,
plugin_data_summary,
plugin_history,
plugin_releases,
plugin_static_file,
reload_plugin,
reset_plugin,
runtime_status,
uninstall_plugin,
)
from app.api.endpoints.system import sync_plugin_market_from_wiki
from app.application.plugin import release as release_module
from app.application.plugin.catalog import PluginCatalogQuery
from app.application.plugin.config import PluginConfigCommand
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.application.plugin.release import PluginReleaseService
from app.foundation.singleton import Singleton
from app.runtime.config import settings
from app.runtime.extensions.plugin.admission import PluginMutationAdmission
from app.runtime.extensions.plugin.manager import PluginManager
from app.runtime.tasks import TaskRegistry
from app.schemas.event import PluginDataResetEventData
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
from app.schemas.types import ChainEventType, SystemConfigKey
NOW = datetime(2026, 8, 26, 12, 0, tzinfo=timezone.utc)
SOURCE_KEY = "github:demo/plugins"
SOURCE_URL = "https://github.com/demo/plugins"
def _plugin_identity(
*,
metadata: PluginDeclaredMetadata | None = None,
plugin_id: str = "DemoPlugin",
source_type: TrustedPluginSourceType = TrustedPluginSourceType.THIRD_PARTY,
source_key: str = SOURCE_KEY,
) -> PluginIdentity:
"""
构造绑定到测试仓库的插件身份。
"""
has_payload = metadata is not None
return PluginIdentity(
plugin_id=plugin_id,
normalized_plugin_id=plugin_id.lower(),
trusted_source_type=source_type,
trusted_source_key=source_key,
binding_basis=PluginBindingBasis.EXPLICIT_INSTALL,
payload_source_type=(
PluginPayloadSourceType.UNKNOWN
if not has_payload
else (
PluginPayloadSourceType.OFFICIAL
if source_type is TrustedPluginSourceType.OFFICIAL
else PluginPayloadSourceType.THIRD_PARTY
)
),
payload_source_key=source_key if has_payload else None,
declared_version="1.0.0" if has_payload else None,
package_generation="v3" if has_payload else None,
declared_metadata=metadata,
payload_receipt="sha256:" + "0" * 64 if has_payload else None,
revision=1,
created_at=NOW,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW if has_payload else None,
)
def _catalog_query(plugin_manager: MagicMock, persistence: MagicMock) -> PluginCatalogQuery:
"""
用运行态和身份端口替身构造插件目录查询。
"""
return PluginCatalogQuery(
installed_plugins=plugin_manager.get_installed_plugins,
local_plugins=plugin_manager.get_local_plugins,
local_repo_plugins=plugin_manager.get_local_repo_plugins,
online_candidates=plugin_manager.async_get_online_plugin_candidates,
process_plugins=plugin_manager.process_plugins_list,
identities=persistence.list_identities,
free_threaded=lambda: False,
)
def test_update_candidates_report_source_and_binding_relationship():
"""
市场更新候选应标明仓库类型,并仅把当前绑定仓库视为可直接更新。
"""
plugins = [
schemas.Plugin(
id="OfficialBound",
plugin_version="2.0.0",
repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
has_update=True,
),
schemas.Plugin(
id="ThirdPartyBound",
plugin_version="2.0.0",
repo_url="https://github.com/example/plugins",
has_update=True,
),
schemas.Plugin(
id="AlternativeOfficial",
plugin_version="2.0.0",
repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
has_update=True,
),
schemas.Plugin(
id="LocalOnly",
plugin_version="2.0.0",
repo_url="local:///plugins",
has_update=True,
),
]
persistence = MagicMock()
persistence.list_identities = AsyncMock(
return_value=[
_plugin_identity(
plugin_id="OfficialBound",
source_type=TrustedPluginSourceType.OFFICIAL,
source_key="github:jxxghp/moviepilot-plugins",
),
_plugin_identity(
plugin_id="ThirdPartyBound",
source_key="github:example/plugins",
),
_plugin_identity(
plugin_id="AlternativeOfficial",
source_key="github:example/plugins",
),
]
)
plugin_manager = MagicMock()
plugin_manager.process_plugins_list.side_effect = lambda higher, _base: higher
result = asyncio.run(
_catalog_query(plugin_manager, persistence).project_update_candidates(
plugins,
plugins,
[plugin.id for plugin in plugins],
)
)
assert result[0].update_candidate is not None
assert result[0].update_candidate.source_type == "official"
assert result[0].update_candidate.is_bound is True
assert result[0].has_update is True
assert result[1].update_candidate is not None
assert result[1].update_candidate.source_type == "third_party"
assert result[1].update_candidate.is_bound is True
assert result[1].has_update is True
assert result[2].update_candidate is not None
assert result[2].update_candidate.source_type == "official"
assert result[2].update_candidate.is_bound is False
assert result[2].has_update is False
assert plugins[2].has_update is True
assert result[3].update_candidate is None
def test_bound_repository_update_precedes_a_higher_alternative():
"""
绑定仓库存在更新时只提示可信更新,不受其他仓库更高版本影响。
"""
bound_update = schemas.Plugin(
id="DemoPlugin",
plugin_version="2.0.0",
repo_url=SOURCE_URL,
has_update=True,
)
alternative_update = schemas.Plugin(
id="DemoPlugin",
plugin_version="3.0.0",
repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
has_update=True,
)
persistence = MagicMock()
persistence.list_identities = AsyncMock(return_value=[_plugin_identity()])
plugin_manager = MagicMock()
plugin_manager.process_plugins_list.return_value = [bound_update]
result = asyncio.run(
_catalog_query(plugin_manager, persistence).project_update_candidates(
[bound_update, alternative_update],
[alternative_update],
["DemoPlugin"],
)
)
assert [plugin.id for plugin in result] == ["DemoPlugin"]
assert result[0].update_candidate is not None
assert result[0].update_candidate.version == "2.0.0"
assert result[0].update_candidate.is_bound is True
assert result[0].has_update is True
@pytest.mark.parametrize("state", ["market", "all"])
@pytest.mark.parametrize("bound_version", [None, "1.0.0", "0.9.0"])
def test_catalog_excludes_alternative_update_when_bound_source_has_no_update(
state, bound_version,
):
"""
绑定源版本持平、较低或缺失时,其他仓库不能产生市场更新条目。
"""
installed = schemas.Plugin(
id="DemoPlugin", plugin_version="1.0.0", installed=True,
)
alternative = schemas.Plugin(
id="DemoPlugin",
plugin_version="3.0.0",
repo_url="https://github.com/alternative/plugins",
has_update=True,
)
online = [alternative]
if bound_version is not None:
online.append(schemas.Plugin(
id="DemoPlugin",
plugin_version=bound_version,
repo_url=SOURCE_URL,
has_update=False,
))
plugin_manager = MagicMock()
plugin_manager.get_installed_plugins.return_value = [installed]
plugin_manager.get_local_plugins.return_value = [installed]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_online_plugin_candidates = AsyncMock(return_value=online)
plugin_manager.process_plugins_list.side_effect = lambda higher, base: [
max(higher + base, key=lambda plugin: plugin.plugin_version)
]
persistence = MagicMock()
persistence.list_identities = AsyncMock(return_value=[_plugin_identity()])
with patch(
"app.api.endpoints.plugin.get_plugin_catalog_query",
return_value=_catalog_query(plugin_manager, persistence),
):
response = Response()
result = asyncio.run(plugin_endpoint.all_plugins(
None, state, False, response=response,
))
assert [plugin.id for plugin in result] == (["DemoPlugin"] if state == "all" else [])
assert response.headers["X-Total-Count"] == str(len(result))
assert all(not plugin.has_update for plugin in result)
assert all(plugin.plugin_version == "1.0.0" for plugin in result)
assert alternative.has_update is True
assert alternative.update_candidate is None
@pytest.mark.parametrize("has_identity", [False, True])
def test_unbound_plugin_keeps_market_candidate_for_source_selection(has_identity):
"""
尚未绑定来源的插件仍能发现市场候选,供用户显式选择来源。
"""
candidate = schemas.Plugin(
id="DemoPlugin", plugin_version="2.0.0", repo_url=SOURCE_URL, has_update=True,
)
identity = replace(
_plugin_identity(),
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
trusted_source_key=None,
binding_basis=PluginBindingBasis.LEGACY_UNBOUND,
bound_at=None,
)
persistence = MagicMock()
persistence.list_identities = AsyncMock(return_value=[identity] if has_identity else [])
result = asyncio.run(
_catalog_query(MagicMock(), persistence).project_update_candidates(
[candidate], [candidate], ["DemoPlugin"],
)
)
assert result[0].has_update is True
assert result[0].update_candidate is not None
assert result[0].update_candidate.is_bound is False
def test_market_endpoint_reads_source_preserving_candidates_for_bound_update():
"""
市场接口必须在全局版本合并前保留绑定仓库候选。
"""
installed = schemas.Plugin(
id="DemoPlugin",
plugin_version="1.0.0",
installed=True,
)
bound_update = schemas.Plugin(
id="DemoPlugin",
plugin_version="2.0.0",
repo_url=SOURCE_URL,
has_update=True,
)
alternative_update = schemas.Plugin(
id="DemoPlugin",
plugin_version="3.0.0",
repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
has_update=True,
)
plugin_manager = MagicMock()
plugin_manager.get_installed_plugins.return_value = [installed]
plugin_manager.get_local_plugins.return_value = []
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_online_plugin_candidates = AsyncMock(return_value=[bound_update, alternative_update])
plugin_manager.process_plugins_list.side_effect = lambda higher, base: [
max(
higher + base,
key=lambda plugin: tuple(int(part) for part in plugin.plugin_version.split(".")),
)
]
persistence = MagicMock()
persistence.list_identities = AsyncMock(return_value=[_plugin_identity()])
query = _catalog_query(plugin_manager, persistence)
with patch(
"app.api.endpoints.plugin.get_plugin_catalog_query",
return_value=query,
):
response = Response()
result = asyncio.run(
plugin_endpoint.all_plugins(
None,
"market",
False,
max_results=1,
response=response,
)
)
assert [plugin.id for plugin in result] == ["DemoPlugin"]
assert response.headers["X-Total-Count"] == "1"
assert result[0].update_candidate is not None
assert result[0].update_candidate.version == "2.0.0"
assert result[0].update_candidate.is_bound is True
plugin_manager.async_get_online_plugin_candidates.assert_awaited_once_with(False)
def test_all_plugins_explicit_page_count_overrides_legacy_max_results() -> None:
"""
插件列表显式 page/count 应分页,并优先于显式 max_results 限量。
"""
catalog = MagicMock()
catalog.query = AsyncMock(
return_value=[schemas.Plugin(id=f"Plugin{index}", plugin_version="1.0.0") for index in range(1, 4)]
)
with patch(
"app.api.endpoints.plugin.get_plugin_catalog_query",
return_value=catalog,
):
response = Response()
result = asyncio.run(
plugin_endpoint.all_plugins(
None,
"all",
False,
max_results=1,
page=2,
count=1,
response=response,
)
)
assert [plugin.id for plugin in result] == ["Plugin2"]
assert response.headers["X-Total-Count"] == "3"
def test_all_plugins_without_pagination_or_limit_returns_complete_catalog() -> None:
"""
插件列表省略分页和限量参数时应返回完整目录。
"""
catalog = MagicMock()
catalog.query = AsyncMock(
return_value=[schemas.Plugin(id=f"Plugin{index}", plugin_version="1.0.0") for index in range(1, 52)]
)
with patch(
"app.api.endpoints.plugin.get_plugin_catalog_query",
return_value=catalog,
):
response = Response()
result = asyncio.run(
plugin_endpoint.all_plugins(
None,
"all",
False,
response=response,
)
)
assert len(result) == 51
assert result[0].id == "Plugin1"
assert result[-1].id == "Plugin51"
assert response.headers["X-Total-Count"] == "51"
def _persistence(identity: PluginIdentity) -> MagicMock:
"""
构造只暴露身份读取合同的异步持久化替身。
"""
persistence = MagicMock()
persistence.get_identity = AsyncMock(return_value=identity)
return persistence
def _no_clone_source_plugin_id(plugin_id: str) -> str:
"""
未建分身时的源身份归一:任何 ID 都是它自己的源插件。
"""
return plugin_id
def _release_service(
plugin_manager: MagicMock,
*,
persistence: MagicMock | None = None,
plugin_helper: MagicMock | None = None,
source_plugin_id: Callable[[str], str] = _no_clone_source_plugin_id,
) -> PluginReleaseService:
"""
用端口替身构造与启动组合根等价的 Release 查询服务。
"""
if persistence is None:
persistence = MagicMock()
persistence.get_identity = AsyncMock(return_value=None)
if plugin_helper is None:
plugin_helper = MagicMock()
plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False)
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[])
return PluginReleaseService(
source_plugin_id=source_plugin_id,
installed_plugins=plugin_manager.get_installed_plugins,
local_repo_plugins=plugin_manager.get_local_repo_plugins,
market_plugins=plugin_manager.async_get_plugins_from_market,
local_version=plugin_manager.get_local_plugin_version,
identity=persistence.get_identity,
version_flag=lambda: settings.VERSION_FLAG,
compatible_flags=lambda flag: ["v2"] if flag == "v3" else [],
has_release_cache=plugin_helper.async_has_plugin_release_cache,
releases=plugin_helper.async_get_plugin_release_versions,
refresh_releases=plugin_helper.async_get_plugin_release_versions,
free_threaded=lambda: False,
)
def test_plugin_release_service_reset_isolates_lifespans(monkeypatch):
"""
停机清理后不得继续复用上一 lifespan 的 Release 端口。
"""
manager = MagicMock()
service = _release_service(manager)
monkeypatch.setattr(release_module, "_release_service", service)
release_module.reset_plugin_release_service()
with pytest.raises(RuntimeError, match="尚未完成初始化"):
release_module.get_plugin_release_service()
def test_plugin_history_merges_remote_metadata():
"""
已安装插件点击更新说明时,接口会按需合并远端仓库中的更新记录。
"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
history={},
)
market_plugin = schemas.Plugin(
id="DemoPlugin",
repo_url=SOURCE_URL,
history={"v1.1.0": "- 新增更新说明"},
system_version=">=2.0.0",
system_version_compatible=True,
has_update=True,
)
plugin_manager = MagicMock()
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
persistence = _persistence(_plugin_identity())
release_service = _release_service(plugin_manager, persistence=persistence)
with patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
assert result.repo_url == "https://github.com/demo/plugins"
assert result.history == {"v1.1.0": "- 新增更新说明"}
assert result.system_version == ">=2.0.0"
assert result.has_update
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(SOURCE_URL, settings.VERSION_FLAG, True)
def test_plugin_history_uses_payload_source_when_local_candidate_also_exists():
"""
在线运行载荷不得被同 ID 的本地仓库历史覆盖。
"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
history={},
)
local_plugin = schemas.Plugin(
id="DemoPlugin",
repo_url="local://DemoPlugin?path=/plugins&version=v3",
history={"v0.9.0": "本地旧版说明"},
)
market_plugin = schemas.Plugin(
id="DemoPlugin",
repo_url=SOURCE_URL,
history={"v1.1.0": "在线新版说明"},
)
plugin_manager = MagicMock()
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = [local_plugin]
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
metadata = PluginDeclaredMetadata.from_package(
{"name": "Demo Plugin", "v3": True},
declaration_version="1.0.0",
manifest_matches_payload=True,
)
identity = _plugin_identity(metadata=metadata)
release_service = _release_service(plugin_manager, persistence=_persistence(identity))
result = asyncio.run(release_service.history("DemoPlugin", force=True))
assert result is not None
assert result.repo_url == SOURCE_URL
assert result.history == {"v1.1.0": "在线新版说明"}
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(SOURCE_URL, settings.VERSION_FLAG, True)
def test_plugin_history_uses_local_metadata_for_local_payload():
"""
本地运行载荷继续读取同一本地仓库的历史。
"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
history={},
)
local_plugin = schemas.Plugin(
id="DemoPlugin",
repo_url="local://DemoPlugin?path=/plugins&version=v3",
history={"v1.1.0": "本地新版说明"},
)
plugin_manager = MagicMock()
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = [local_plugin]
plugin_manager.async_get_plugins_from_market = AsyncMock()
metadata = PluginDeclaredMetadata.from_package(
{"name": "Demo Plugin", "v3": True},
declaration_version="1.0.0",
manifest_matches_payload=True,
)
identity = _plugin_identity(metadata=metadata)
identity = replace(
identity,
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
)
release_service = _release_service(plugin_manager, persistence=_persistence(identity))
result = asyncio.run(release_service.history("DemoPlugin", force=True))
assert result is not None
assert result.repo_url == local_plugin.repo_url
assert result.history == {"v1.1.0": "本地新版说明"}
plugin_manager.async_get_plugins_from_market.assert_not_awaited()
def test_plugin_history_falls_back_to_backward_compatible_package():
"""
已绑定来源的插件在当前索引缺失时仍读取向后兼容代际的历史。
"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
history={},
)
market_plugin = schemas.Plugin(
id="DemoPlugin",
repo_url=SOURCE_URL,
history={"v2.0.0": "兼容代际更新说明"},
)
plugin_manager = MagicMock()
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_plugins_from_market = AsyncMock(side_effect=[[], [market_plugin]])
persistence = _persistence(_plugin_identity())
release_service = _release_service(plugin_manager, persistence=persistence)
with patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
assert result.history == {"v2.0.0": "兼容代际更新说明"}
assert plugin_manager.async_get_plugins_from_market.await_args_list == [
((SOURCE_URL, settings.VERSION_FLAG, True), {}),
((SOURCE_URL, "v2", True), {}),
]
def test_runtime_status_reports_pending_and_terminal_counts():
"""
插件页摘要区分后台收敛、准备态和终态失败。
"""
plugin_manager = MagicMock()
plugin_manager.get_plugin_runtime_statuses.return_value = {
"SourcePending": PluginRuntimeStatus.SOURCE_MISSING,
"DependencyPending": PluginRuntimeStatus.DEPENDENCY_PENDING,
"ActivePlugin": PluginRuntimeStatus.ACTIVE,
"FailedPlugin": PluginRuntimeStatus.LOAD_FAILED,
}
plugin_manager.is_plugin_settling.return_value = True
plugin_manager.get_plugin_runtime_generation.return_value = 7
plugin_manager.get_plugin_restart_requirements.return_value = {
"NativePlugin": ("native-demo",),
"RemovedPlugin": ("native-removed",),
}
plugin_manager.get_plugin_gil_fallbacks.return_value = ["NativePlugin", "RemovedPlugin"]
config = MagicMock()
config.get.return_value = ["NativePlugin"]
with (
patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager),
patch(
"app.api.endpoints.plugin.get_configured_system_config",
return_value=config,
),
):
result = asyncio.run(runtime_status(None))
assert result.ready is False
assert result.generation == 7
assert result.pending_count == 2
assert result.failed_count == 1
assert result.restart_required_plugin_ids == ["NativePlugin"]
assert result.gil_enabled_plugin_ids == ["NativePlugin"]
def test_reload_endpoint_reports_load_failure(monkeypatch):
"""
插件重载失败时接口返回失败,并委托应用服务维护注册一致性。
"""
reload_runtime = MagicMock(return_value=PluginRuntimeStatus.LOAD_FAILED)
monkeypatch.setattr(plugin_endpoint, "reload_plugin_runtime", reload_runtime)
result = reload_plugin("DemoPlugin", None)
assert result.success is False
assert result.message == "插件加载失败,请查看插件日志"
reload_runtime.assert_called_once_with("DemoPlugin")
def test_reload_endpoint_uses_post_for_process_side_effect():
"""
插件进程内重载只允许通过 POST 暴露。
"""
reload_routes = [
route for route in plugin_endpoint.router.routes if getattr(route, "path", None) == "/reload/{plugin_id}"
]
assert len(reload_routes) == 1
assert reload_routes[0].methods == {"POST"}
def test_plugin_capabilities_return_only_safe_runtime_metadata(monkeypatch):
"""
插件能力接口不得暴露插件自由定义的数据、函数或定时参数。
"""
plugin_manager = MagicMock()
plugin_manager.get_plugin_commands.return_value = [
{
"cmd": "/demo",
"desc": "执行演示命令",
"pid": "DemoPlugin",
"data": {"token": "secret"},
"event": object(),
},
{"desc": "缺少命令标识"},
]
plugin_manager.get_plugin_actions.return_value = [
{
"plugin_id": "DemoPlugin",
"plugin_name": "演示插件",
"actions": [
{
"id": "refresh",
"name": "刷新数据",
"kwargs": {"token": "secret"},
"func": object(),
},
{"name": "缺少动作标识"},
],
}
]
plugin_manager.get_plugin_services.return_value = [
{
"id": "demo-service",
"name": "演示任务",
"trigger": "cron[hour='1']",
"kwargs": {"token": "secret"},
"func_kwargs": {"path": "/private"},
"func": object(),
},
{"name": "缺少服务标识"},
]
monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: plugin_manager)
result = asyncio.run(plugin_capabilities("DemoPlugin", None))
assert result.success is True
assert result.data is not None
assert result.data.model_dump() == {
"commands": [
{
"cmd": "/demo",
"desc": "执行演示命令",
"plugin_id": "DemoPlugin",
}
],
"actions": [
{
"plugin_id": "DemoPlugin",
"plugin_name": "演示插件",
"actions": [{"id": "refresh", "name": "刷新数据"}],
}
],
"services": [
{
"id": "demo-service",
"name": "演示任务",
"trigger": "cron[hour='1']",
}
],
}
plugin_manager.get_plugin_commands.assert_called_once_with(pid="DemoPlugin")
plugin_manager.get_plugin_actions.assert_called_once_with(pid="DemoPlugin")
plugin_manager.get_plugin_services.assert_called_once_with(pid="DemoPlugin")
def test_plugin_data_summary_endpoint_never_returns_persisted_values(monkeypatch):
"""
插件数据摘要接口只返回键级诊断元数据。
"""
repository = MagicMock()
repository.list = AsyncMock(
return_value={
"api_token": "secret-token",
"history": [{"id": 1}],
}
)
runtime = SimpleNamespace(agent=SimpleNamespace(plugin_data=repository))
monkeypatch.setattr(
plugin_endpoint,
"get_plugin_snapshot",
lambda _plugin_id: {
"plugin_id": "DemoPlugin",
"plugin_name": "演示插件",
"plugin_version": "1.0.0",
"state": True,
},
)
result = asyncio.run(plugin_data_summary("DemoPlugin", None, runtime))
assert result.success is True
assert result.data is not None
payload = result.data.model_dump()
assert payload["count"] == 2
assert payload["keys"][0] == {
"key": "api_token",
"value_type": "string",
"serialized_chars": len('"secret-token"'),
"sensitive": True,
}
assert "secret-token" not in repr(payload)
repository.list.assert_awaited_once_with("DemoPlugin")
def test_plugin_history_returns_installed_plugin_when_remote_missing():
"""
远端仓库不可用时,接口仍返回本地已安装插件信息,前端可继续展示兜底状态。
"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
)
plugin_manager = MagicMock()
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[])
persistence = _persistence(_plugin_identity())
release_service = _release_service(plugin_manager, persistence=persistence)
with patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
assert result.id == "DemoPlugin"
assert result.history == {}
def test_plugin_history_uses_bound_repo_without_refreshing_all_markets():
"""
更新说明只读取持久化绑定仓库,不信任运行态 DTO 中可漂移的来源地址。
"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
repo_url="https://github.com/attacker/plugins",
installed=True,
)
market_plugin = schemas.Plugin(
id="DemoPlugin",
repo_url=SOURCE_URL,
history={"v1.1.0": "- 新增更新说明"},
)
plugin_manager = MagicMock()
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[])
persistence = _persistence(_plugin_identity())
release_service = _release_service(plugin_manager, persistence=persistence)
with patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
assert result.history == {"v1.1.0": "- 新增更新说明"}
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(SOURCE_URL, settings.VERSION_FLAG, True)
plugin_manager.async_get_online_plugins.assert_not_awaited()
def test_plugin_history_uses_declared_metadata_when_bound_market_is_unavailable():
"""
加载失败且绑定市场不可用时,详情仍返回已提交载荷的展示信息。
"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="DemoPlugin",
installed=True,
runtime_status=PluginRuntimeStatus.LOAD_FAILED,
)
metadata = PluginDeclaredMetadata.from_package(
{
"name": "Saved Demo",
"description": "Saved description",
"author": "Saved author",
"v3": True,
},
declaration_version="1.0.0",
manifest_matches_payload=True,
)
plugin_manager = MagicMock()
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[])
persistence = _persistence(_plugin_identity(metadata=metadata))
release_service = _release_service(plugin_manager, persistence=persistence)
with patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
assert result.plugin_name == "Saved Demo"
assert result.plugin_desc == "Saved description"
assert result.plugin_author == "Saved author"
assert result.plugin_version == "1.0.0"
assert result.runtime_status is PluginRuntimeStatus.LOAD_FAILED
def test_plugin_releases_returns_supported_versions_with_latest_and_current(monkeypatch):
"""
release 列表接口返回可安装版本,并标记当前 package 最新版本与本地已安装版本。
"""
market_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_version="1.2.3",
repo_url="https://github.com/demo/plugins",
release=True,
)
plugin_manager = MagicMock()
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugin_version.return_value = "1.2.0"
plugin_helper = MagicMock()
plugin_helper.async_get_plugin_release_versions = AsyncMock(
return_value=[
{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"},
{"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0", "asset_name": "demoplugin_v1.2.0.zip"},
]
)
release_service = _release_service(plugin_manager, plugin_helper=plugin_helper)
with patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
):
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False))
assert result["release_supported"] is True
assert result["latest_version"] == "1.2.3"
assert result["current_version"] == "1.2.0"
assert result["items"][0]["is_latest"] is True
assert result["items"][0]["is_current"] is False
assert result["items"][1]["is_latest"] is False
assert result["items"][1]["is_current"] is True
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(
"https://github.com/demo/plugins", settings.VERSION_FLAG, False
)
plugin_manager.async_get_online_plugins.assert_not_awaited()
plugin_manager.get_local_plugins.assert_not_called()
def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch):
"""
接口标记当前/最新版本时不能修改 helper 返回对象,避免污染缓存中的 release 列表。
"""
market_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_version="1.2.3",
repo_url="https://github.com/demo/plugins",
release=True,
)
release_items = [
{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"},
]
plugin_manager = MagicMock()
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugin_version.return_value = "1.2.0"
plugin_helper = MagicMock()
plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False)
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=release_items)
release_service = _release_service(plugin_manager, plugin_helper=plugin_helper)
with patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
):
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False))
assert result["items"][0]["is_latest"] is True
assert "is_latest" not in release_items[0]
assert "is_current" not in release_items[0]
def test_plugin_releases_falls_back_to_compatible_base_package(monkeypatch):
"""
当前版本和向后兼容 package 未包含插件时,再读取基础 package 兼容项。
"""
market_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_version="1.2.3",
repo_url="https://github.com/demo/plugins",
release=True,
)
plugin_manager = MagicMock()
plugin_manager.async_get_plugins_from_market = AsyncMock(side_effect=[[], [], [market_plugin]])
plugin_manager.get_local_plugin_version.return_value = None
plugin_helper = MagicMock()
plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False)
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[])
release_service = _release_service(plugin_manager, plugin_helper=plugin_helper)
with patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
):
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False))
assert result["latest_version"] == "1.2.3"
assert plugin_manager.async_get_plugins_from_market.await_args_list == [
(("https://github.com/demo/plugins", settings.VERSION_FLAG, False), {}),
(("https://github.com/demo/plugins", "v2", False), {}),
(("https://github.com/demo/plugins", None, False), {}),
]
def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch):
"""
release 列表接口沿用插件市场的 force 语义,供前端手动刷新时绕过缓存。
"""
market_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_version="1.2.3",
repo_url="https://github.com/demo/plugins",
release=True,
)
plugin_manager = MagicMock()
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugin_version.return_value = None
plugin_helper = MagicMock()
plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False)
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[])
release_service = _release_service(plugin_manager, plugin_helper=plugin_helper)
with patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
):
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", True))
assert result["release_supported"] is False
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(
"https://github.com/demo/plugins", settings.VERSION_FLAG, True
)
assert plugin_helper.async_get_plugin_release_versions.await_args.args == (
"DemoPlugin",
"https://github.com/demo/plugins",
)
def test_plugin_releases_force_uses_cached_release_response_and_schedules_refresh(monkeypatch):
"""
手动刷新时 package 元数据仍强刷,但 Release 明细先读缓存并后台刷新,避免弹窗阻塞。
"""
from app.runtime.cache import is_fresh
market_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_version="1.2.3",
repo_url="https://github.com/demo/plugins",
release=True,
)
plugin_manager = MagicMock()
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugin_version.return_value = None
fresh_states = []
plugin_helper = MagicMock()
plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=True)
async def fake_releases(*_args):
fresh_states.append(is_fresh())
return [
{
"version": "1.2.3",
"tag_name": "DemoPlugin_v1.2.3",
"asset_name": "demoplugin_v1.2.3.zip",
}
]
plugin_helper.async_get_plugin_release_versions = fake_releases
release_service = _release_service(plugin_manager, plugin_helper=plugin_helper)
scheduled = []
def fake_schedule(plugin_id, repo_url, task_registry):
scheduled.append((plugin_id, repo_url, task_registry))
with (
patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
),
patch.object(plugin_endpoint, "_schedule_plugin_release_refresh", fake_schedule),
):
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", True))
assert result["release_supported"] is True
assert fresh_states == [False]
assert len(scheduled) == 1
assert scheduled[0][:2] == (
"DemoPlugin",
"https://github.com/demo/plugins",
)
assert isinstance(scheduled[0][2], TaskRegistry)
plugin_helper.async_has_plugin_release_cache.assert_awaited_once_with("https://github.com/demo/plugins")
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(
"https://github.com/demo/plugins", settings.VERSION_FLAG, True
)
def test_plugin_releases_force_skips_background_refresh_without_release_cache(monkeypatch):
"""
冷缓存 force 请求已在响应路径读取 Release,不能马上再启动一次重复强刷。
"""
market_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_version="1.2.3",
repo_url="https://github.com/demo/plugins",
release=True,
)
plugin_manager = MagicMock()
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugin_version.return_value = None
plugin_helper = MagicMock()
plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False)
plugin_helper.async_get_plugin_release_versions = AsyncMock(
return_value=[
{
"version": "1.2.3",
"tag_name": "DemoPlugin_v1.2.3",
"asset_name": "demoplugin_v1.2.3.zip",
}
]
)
release_service = _release_service(plugin_manager, plugin_helper=plugin_helper)
scheduled = []
def fake_schedule(plugin_id, repo_url):
scheduled.append((plugin_id, repo_url))
with (
patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
),
patch.object(plugin_endpoint, "_schedule_plugin_release_refresh", fake_schedule),
):
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", True))
assert result["release_supported"] is True
assert scheduled == []
plugin_helper.async_has_plugin_release_cache.assert_awaited_once_with("https://github.com/demo/plugins")
def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release(monkeypatch):
"""
接口是否支持 Release 安装要与当前 package 的 release 声明保持一致。
"""
market_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_version="1.2.3",
repo_url="https://github.com/demo/plugins",
release=False,
)
plugin_manager = MagicMock()
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugin_version.return_value = None
plugin_helper = MagicMock()
plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False)
plugin_helper.async_get_plugin_release_versions = AsyncMock(
return_value=[
{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"},
]
)
release_service = _release_service(plugin_manager, plugin_helper=plugin_helper)
with patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
):
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False))
assert result["release_supported"] is False
assert result["items"] == []
plugin_helper.async_get_plugin_release_versions.assert_not_awaited()
def test_sync_plugin_market_from_wiki_merges_and_deduplicates_repos():
"""
Wiki 同步会提取标记区域内的 GitHub 仓库地址,并与本地配置合并去重后写入。
"""
markdown = """
<!-- plugin-market-repos:start -->
- https://github.com/local/existing/
- https://github.com/wiki/new-repo/
- https://github.com/wiki/new-repo
<!-- plugin-market-repos:end -->
- https://github.com/wiki/ignored-outside-marker
"""
response = MagicMock(status_code=200, text=markdown)
request_utils = MagicMock()
request_utils.get_res = AsyncMock(return_value=response)
runtime_settings = MagicMock()
runtime_settings.get.return_value = "https://github.com/local/existing"
runtime_settings.update.return_value = (True, "")
@asynccontextmanager
async def rule_group_mutation():
"""
提供本用例不会进入的规则组事务替身。
"""
yield SimpleNamespace()
from app.startup.composition.system import compose_system_service
runtime = SimpleNamespace(
system=compose_system_service(
settings=runtime_settings,
system_config=MagicMock(),
rule_group_mutation=rule_group_mutation,
)
)
with (
patch(
"app.startup.composition.system.AsyncRequestUtils",
return_value=request_utils,
),
patch(
"app.startup.composition.system.eventmanager.async_send_event_strict",
new=AsyncMock(),
) as send_event,
):
result = asyncio.run(sync_plugin_market_from_wiki(None, None, runtime))
assert result.success
assert result.data["repos"] == [
"https://github.com/local/existing",
"https://github.com/wiki/new-repo",
]
assert result.data["added_count"] == 1
assert result.data["total_count"] == 2
runtime_settings.update.assert_called_once_with(
"PLUGIN_MARKET",
"https://github.com/local/existing,https://github.com/wiki/new-repo",
)
send_event.assert_awaited_once()
def test_reset_plugin_sends_pre_reset_chain_event_before_deleting_data():
"""
插件重置会先触发同步链式事件,让插件在数据被清空前完成自有状态补偿。
"""
plugin_manager = MagicMock()
calls = []
def delete_config(plugin_id, force=False):
calls.append(("delete_config", plugin_id, force))
return True
def delete_data(plugin_id, force=False):
calls.append(("delete_data", plugin_id, force))
return True
def stop_plugin(plugin_id):
calls.append(("stop", plugin_id))
return True
plugin_manager.stop.side_effect = stop_plugin
plugin_manager.delete_plugin_config.side_effect = delete_config
plugin_manager.delete_plugin_data.side_effect = delete_data
def publish_reset(plugin_id):
"""
记录重置前事件,验证应用用例保留补偿时序。
"""
calls.append(
(
"event",
ChainEventType.PluginDataReset,
PluginDataResetEventData(
plugin_id=plugin_id,
reset_config=True,
reset_data=True,
),
)
)
command = PluginConfigCommand(
save_config=plugin_manager.save_plugin_config,
initialize=plugin_manager.init_plugin,
stop=plugin_manager.stop,
delete_config=plugin_manager.delete_plugin_config,
delete_data=plugin_manager.delete_plugin_data,
reload_runtime=plugin_manager.reload_plugin,
publish_reset=publish_reset,
refresh_registrations=lambda _plugin_id: None,
mutation=lambda _operation: nullcontext(),
delete_plugin_data_rows=plugin_manager.delete_plugin_data_rows,
destroy_own_database=plugin_manager.destroy_plugin_own_database,
delete_data_directory=plugin_manager.delete_plugin_data_directory,
purge_instance=plugin_manager.purge_plugin_instance,
is_clone=plugin_manager.is_plugin_clone,
)
result = reset_plugin("SubscribeAssistantEnhanced", None, command)
assert result.success is True
assert len(calls) == 4
event_call = calls[0]
assert event_call[0] == "event"
assert event_call[1] is ChainEventType.PluginDataReset
assert isinstance(event_call[2], PluginDataResetEventData)
assert event_call[2].plugin_id == "SubscribeAssistantEnhanced"
assert event_call[2].reset_config is True
assert event_call[2].reset_data is True
assert calls[1:] == [
("stop", "SubscribeAssistantEnhanced"),
("delete_config", "SubscribeAssistantEnhanced", True),
("delete_data", "SubscribeAssistantEnhanced", True),
]
plugin_manager.reload_plugin.assert_called_once_with("SubscribeAssistantEnhanced")
def test_delete_plugin_config_can_force_delete_after_plugin_is_stopped():
"""
重置入口会先停止插件;配置删除需要能处理运行态注册已清理的插件 ID。
"""
Singleton._instances.pop((PluginManager, (), frozenset()), None)
manager = PluginManager()
storage = MagicMock()
storage.delete_config.return_value = True
with patch("app.runtime.extensions.plugin.storage._plugin_storage", storage):
assert manager.delete_plugin_config("DemoPlugin", force=True) is True
storage.delete_config.assert_called_once_with("DemoPlugin")
Singleton._instances.pop((PluginManager, (), frozenset()), None)
def test_virtual_instance_static_file_reads_from_source_directory(tmp_path, monkeypatch):
"""
实例 URL 保持独立,但静态内容直接读取共享的源插件目录。
"""
source_file = tmp_path / "app/plugins/demoplugin/dist/remoteEntry.js"
source_file.parent.mkdir(parents=True)
source_file.write_text("export default 'shared'", encoding="utf-8")
plugin_manager = MagicMock()
plugin_manager.get_plugin_source_id.return_value = "DemoPlugin"
monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: plugin_manager)
monkeypatch.setattr(
plugin_endpoint,
"get_api_runtime_config_snapshot",
lambda: MagicMock(root_path=tmp_path),
)
response = asyncio.run(plugin_static_file("DemoPluginwork", "dist/remoteEntry.js", None))
async def read_body() -> bytes:
"""
读取流式响应的全部测试内容。
"""
return b"".join([chunk async for chunk in response.body_iterator])
assert asyncio.run(read_body()) == b"export default 'shared'"
assert response.media_type == "application/javascript"
assert response.headers["cache-control"] == "no-cache, must-revalidate"
plugin_manager.get_plugin_source_id.assert_called_once_with("DemoPluginwork")
def test_uninstall_virtual_instance_never_removes_source_package(monkeypatch):
"""
卸载虚拟实例只清理实例状态,不触碰源插件安装清单或目录。
"""
plugin_manager = MagicMock()
plugin_manager.get_plugin_instance.return_value = PluginInstance(
instance_id="DemoPluginwork",
source_plugin_id="DemoPlugin",
)
plugin_manager.get_plugin_source_instances.return_value = []
config = MagicMock()
config.get.return_value = ["DemoPlugin"]
monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: plugin_manager)
monkeypatch.setattr(plugin_endpoint, "get_configured_system_config", lambda: config)
monkeypatch.setattr(plugin_endpoint, "remove_plugin_api", MagicMock())
monkeypatch.setattr(plugin_endpoint, "remove_plugin_job", MagicMock())
monkeypatch.setattr(plugin_endpoint, "remove_plugin_from_folders", MagicMock())
result = uninstall_plugin("DemoPluginwork", None)
assert result.success is True
config.update_atomically.assert_called_once()
key, mutation = config.update_atomically.call_args.args
assert key is SystemConfigKey.UserInstalledPlugins
assert mutation(["DemoPlugin"]) == (None, ["DemoPlugin"])
plugin_manager.delete_plugin_config.assert_called_once_with(
"DemoPluginwork",
force=True,
)
plugin_manager.delete_plugin_data.assert_called_once_with(
"DemoPluginwork",
force=True,
)
plugin_manager.delete_plugin_instance.assert_called_once_with("DemoPluginwork")
plugin_manager.remove_plugin.assert_called_once_with("DemoPluginwork")
plugin_manager.remove_plugin_package.assert_not_called()
def test_uninstall_clone_delegates_physical_removal_to_package_owner(monkeypatch):
"""
HTTP 分身卸载不得自行拼接路径或直接删除目录。
"""
plugin_manager = MagicMock()
plugin_manager.get_plugin_instance.return_value = None
plugin_manager.get_plugin_source_instances.return_value = []
plugin_manager.plugins = {"DemoPluginwork": MagicMock(is_clone=True)}
plugin_manager.remove_plugin_package.return_value = True
config = MagicMock()
config.get.return_value = ["DemoPluginwork"]
monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: plugin_manager)
monkeypatch.setattr(plugin_endpoint, "get_configured_system_config", lambda: config)
monkeypatch.setattr(plugin_endpoint, "remove_plugin_api", MagicMock())
monkeypatch.setattr(plugin_endpoint, "remove_plugin_job", MagicMock())
monkeypatch.setattr(plugin_endpoint, "remove_plugin_from_folders", MagicMock())
result = uninstall_plugin("DemoPluginwork", None)
assert result.success is True
plugin_manager.remove_plugin_package.assert_called_once_with("DemoPluginwork")
assert "DemoPluginwork" not in plugin_manager.plugins
def test_sealed_http_uninstall_rejects_before_first_side_effect(monkeypatch):
"""
HTTP 卸载在封口后明确失败,且不读取或写入插件持久化状态。
"""
admission = PluginMutationAdmission()
admission.seal()
plugin_manager = MagicMock()
plugin_manager.mutation.side_effect = admission.hold
config_provider = MagicMock()
remove_api = MagicMock()
remove_job = MagicMock()
monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: plugin_manager)
monkeypatch.setattr(
plugin_endpoint,
"get_configured_system_config",
config_provider,
)
monkeypatch.setattr(plugin_endpoint, "remove_plugin_api", remove_api)
monkeypatch.setattr(plugin_endpoint, "remove_plugin_job", remove_job)
result = uninstall_plugin("DemoPlugin", None)
assert result.success is False
assert "停机阶段" in result.message
plugin_manager.get_plugin_instance.assert_not_called()
config_provider.assert_not_called()
remove_api.assert_not_called()
remove_job.assert_not_called()
def test_sealed_http_clone_rejects_before_runtime_and_registration(monkeypatch):
"""
HTTP 分身事务未获 admission 时不创建实例、不刷新注册或文件夹。
"""
admission = PluginMutationAdmission()
admission.seal()
plugin_manager = MagicMock()
plugin_manager.mutation.side_effect = admission.hold
register = MagicMock()
add_to_folder = MagicMock()
monkeypatch.setattr(plugin_clone_endpoint, "get_plugin_manager", lambda: plugin_manager)
monkeypatch.setattr(plugin_clone_endpoint, "register_plugin", register)
monkeypatch.setattr(plugin_clone_endpoint, "add_clone_to_plugin_folder", add_to_folder)
result = plugin_clone_endpoint.clone_plugin(
"DemoPlugin",
schemas.PluginCloneRequest(suffix="Work"),
None,
)
assert result.success is False
assert "停机阶段" in result.message
plugin_manager.clone_plugin.assert_not_called()
register.assert_not_called()
add_to_folder.assert_not_called()
def test_sealed_http_folder_update_rejects_before_config_access(monkeypatch):
"""
插件文件夹写入口在封口后不读取或改写持久化配置。
"""
admission = PluginMutationAdmission()
admission.seal()
plugin_manager = MagicMock()
plugin_manager.mutation.side_effect = admission.hold
config_provider = MagicMock()
monkeypatch.setattr(
"app.application.plugin.folders.get_plugin_manager",
lambda: plugin_manager,
)
monkeypatch.setattr(
"app.application.plugin.folders.get_configured_system_config",
config_provider,
)
result = asyncio.run(plugin_folders_endpoint.update_folder_plugins("常用", ["DemoPlugin"], None))
assert result.success is False
assert "停机阶段" in result.message
config_provider.assert_not_called()
def test_plugin_folder_incremental_endpoints_delegate_structured_payloads(monkeypatch):
"""
插件文件夹增量 HTTP 入口应保留字段存在性并委托单目标用例。
"""
service = MagicMock()
service.update_folder = AsyncMock(return_value=SimpleNamespace(success=True, message=""))
service.update_plugins = AsyncMock(return_value=SimpleNamespace(success=True, message=""))
service.assign_plugin = AsyncMock(return_value=SimpleNamespace(success=True, message=""))
service.remove_plugin_from_folder = AsyncMock(return_value=SimpleNamespace(success=True, message=""))
monkeypatch.setattr(
plugin_folders_endpoint,
"get_plugin_folder_service",
lambda: service,
)
patch_result = asyncio.run(
plugin_folders_endpoint.update_plugin_folder(
"常用",
schemas.PluginFolderUpdateRequest(
new_name="工具",
color="#ff0000",
showIcon=False,
),
None,
)
)
members_result = asyncio.run(
plugin_folders_endpoint.update_folder_plugins(
"工具",
schemas.PluginFolderPluginsUpdateRequest(
plugins=["DemoPlugin"],
expected_plugins=[],
),
None,
)
)
assign_result = asyncio.run(plugin_folders_endpoint.assign_plugin_to_folder("工具", "DemoPlugin", None))
remove_result = asyncio.run(plugin_folders_endpoint.remove_plugin_from_folder("工具", "DemoPlugin", None))
assert all(result.success for result in (patch_result, members_result, assign_result, remove_result))
service.update_folder.assert_awaited_once_with(
"常用",
new_name="工具",
changes={"color": "#ff0000", "showIcon": False},
)
service.update_plugins.assert_awaited_once_with(
"工具",
["DemoPlugin"],
[],
)
service.assign_plugin.assert_awaited_once_with("工具", "DemoPlugin")
service.remove_plugin_from_folder.assert_awaited_once_with(
"工具",
"DemoPlugin",
)
def test_plugin_folder_member_endpoint_keeps_legacy_array_request(monkeypatch):
"""
旧客户端发送裸插件数组时仍应按无条件替换语义工作。
"""
service = MagicMock()
service.update_plugins = AsyncMock(return_value=SimpleNamespace(success=True, message=""))
monkeypatch.setattr(
plugin_folders_endpoint,
"get_plugin_folder_service",
lambda: service,
)
result = asyncio.run(
plugin_folders_endpoint.update_folder_plugins(
"常用",
["DemoPlugin"],
None,
)
)
assert result.success is True
service.update_plugins.assert_awaited_once_with(
"常用",
["DemoPlugin"],
None,
)
def test_delete_plugin_data_can_force_delete_after_plugin_is_stopped():
"""
重置入口会先停止插件;插件数据删除不能依赖运行态注册仍存在。
"""
Singleton._instances.pop((PluginManager, (), frozenset()), None)
manager = PluginManager()
calls = []
storage = MagicMock()
storage.delete_data.side_effect = lambda pid: calls.append(pid)
with patch("app.runtime.extensions.plugin.storage._plugin_storage", storage):
assert manager.delete_plugin_data("DemoPlugin", force=True) is True
assert calls == ["DemoPlugin"]
Singleton._instances.pop((PluginManager, (), frozenset()), None)
def _clone_source_plugin_id(plugin_id: str) -> str:
"""
把测试用分身 ID 归一到源插件,其余 ID 保持原值。
"""
return "DemoPlugin" if plugin_id == "DemoPluginwork" else plugin_id
def test_plugin_history_resolves_clone_id_to_its_source_plugin():
"""
分身查更新说明必须归一到源插件,否则安装清单里查不到而报 404。
"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
history={},
)
market_plugin = schemas.Plugin(
id="DemoPlugin",
repo_url=SOURCE_URL,
history={"v1.1.0": "- 新增更新说明"},
)
plugin_manager = MagicMock()
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_plugins_from_market = AsyncMock(
return_value=[market_plugin]
)
persistence = _persistence(_plugin_identity())
release_service = _release_service(
plugin_manager,
persistence=persistence,
source_plugin_id=_clone_source_plugin_id,
)
with patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
):
result = asyncio.run(plugin_history("DemoPluginwork", None, True))
assert result.id == "DemoPlugin"
assert result.history == {"v1.1.0": "- 新增更新说明"}
persistence.get_identity.assert_awaited_once_with("DemoPlugin")
def test_plugin_releases_resolves_clone_id_to_its_source_plugin():
"""
分身查 Release 必须归一到源插件,否则市场索引与本地版本一并落空。
"""
market_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_version="1.2.3",
repo_url=SOURCE_URL,
release=True,
)
plugin_manager = MagicMock()
plugin_manager.async_get_plugins_from_market = AsyncMock(
return_value=[market_plugin]
)
plugin_manager.get_local_plugin_version.return_value = "1.2.0"
plugin_helper = MagicMock()
plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False)
plugin_helper.async_get_plugin_release_versions = AsyncMock(
return_value=[{"version": "1.2.3"}, {"version": "1.2.0"}]
)
release_service = _release_service(
plugin_manager,
plugin_helper=plugin_helper,
source_plugin_id=_clone_source_plugin_id,
)
with patch(
"app.api.endpoints.plugin.get_plugin_release_service",
return_value=release_service,
):
result = asyncio.run(
plugin_releases("DemoPluginwork", None, SOURCE_URL, False)
)
assert result["release_supported"] is True
assert result["latest_version"] == "1.2.3"
assert result["current_version"] == "1.2.0"
plugin_manager.get_local_plugin_version.assert_called_once_with("DemoPlugin")
plugin_helper.async_get_plugin_release_versions.assert_awaited_once_with(
"DemoPlugin", SOURCE_URL
)