mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
* fix: retain plugin lifecycle ownership and recover installation state * test: cover plugin rollback and uninstall lifecycle boundaries --------- Co-authored-by: jxxghp <jxxghp@gmail.com>
963 lines
28 KiB
Python
963 lines
28 KiB
Python
"""
|
||
插件包安装的外部调用边界测试。
|
||
"""
|
||
|
||
import asyncio
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, Mock, call
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from pydantic import ValidationError
|
||
|
||
from app.adapters.external import market
|
||
from app.adapters.external.market import PluginHelper
|
||
from app.adapters.system.plugin.package import PluginPackageManager
|
||
from app.api.endpoints import plugin as plugin_endpoint
|
||
from app.application.plugin import management as plugin_management
|
||
from app.runtime.config import global_vars
|
||
from app.schemas.plugin import (
|
||
PluginInstance,
|
||
PluginRuntimeStatus,
|
||
PluginSourceChangeRequest,
|
||
PluginSourceIdentity,
|
||
PluginSourceInstallRequest,
|
||
PluginSourceOptions,
|
||
)
|
||
from app.startup.initializers import plugins as plugins_initializer
|
||
|
||
REPO_URL = "https://github.com/example/moviepilot-plugins"
|
||
|
||
|
||
def test_package_manager_sync_preserves_external_install_contract() -> None:
|
||
"""
|
||
同步包适配器必须调用包级入口,不能再次进入公开 Gateway。
|
||
"""
|
||
manager = PluginPackageManager(source=Mock())
|
||
manager.install_raw = Mock(return_value=(True, "installed"))
|
||
|
||
result = manager.install(
|
||
plugin_id="DemoPlugin",
|
||
repo_url=REPO_URL,
|
||
package_version="v3",
|
||
release_version="1.2.3",
|
||
force_install=False,
|
||
)
|
||
|
||
assert result == (True, "installed")
|
||
manager.install_raw.assert_called_once_with(
|
||
pid="DemoPlugin",
|
||
repo_url=REPO_URL,
|
||
package_version="v3",
|
||
release_version="1.2.3",
|
||
force_install=False,
|
||
before_dependency_install=None,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_package_manager_async_preserves_external_install_contract() -> None:
|
||
"""
|
||
异步包适配器必须调用包级入口,不能再次进入公开 Gateway。
|
||
"""
|
||
manager = PluginPackageManager(source=Mock())
|
||
manager.async_install_raw = AsyncMock(return_value=(True, "installed"))
|
||
|
||
result = await manager.async_install(
|
||
plugin_id="DemoPlugin",
|
||
repo_url=REPO_URL,
|
||
package_version="v3",
|
||
release_version="1.2.3",
|
||
force_install=False,
|
||
)
|
||
|
||
assert result == (True, "installed")
|
||
manager.async_install_raw.assert_awaited_once_with(
|
||
pid="DemoPlugin",
|
||
repo_url=REPO_URL,
|
||
package_version="v3",
|
||
release_version="1.2.3",
|
||
force_install=False,
|
||
before_dependency_install=None,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_package_manager_captures_native_state_only_when_dependency_install_starts(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
包适配器只在安装器确认存在依赖清单后记录原生载荷。
|
||
"""
|
||
checkpoint = SimpleNamespace(native_dependencies=None)
|
||
baseline = object()
|
||
capture = Mock(return_value=baseline)
|
||
|
||
async def install_with_dependency(**kwargs):
|
||
kwargs["before_dependency_install"]()
|
||
return True, "installed"
|
||
|
||
monkeypatch.setattr(
|
||
"app.adapters.system.plugin.package.capture_loaded_native_dependencies",
|
||
capture,
|
||
)
|
||
manager = PluginPackageManager(source=Mock())
|
||
manager.async_install_raw = AsyncMock(side_effect=install_with_dependency)
|
||
|
||
result = await manager.async_install(
|
||
plugin_id="DemoPlugin",
|
||
repo_url=REPO_URL,
|
||
checkpoint=checkpoint,
|
||
)
|
||
|
||
assert result == (True, "installed")
|
||
assert checkpoint.native_dependencies is baseline
|
||
capture.assert_called_once_with()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize(
|
||
("filename", "content"),
|
||
[
|
||
("requirements.txt", "demo>=1\n"),
|
||
(
|
||
"pyproject.toml",
|
||
'[project]\nname = "demo"\nversion = "1.0.0"\ndependencies = ["demo>=1"]\n',
|
||
),
|
||
],
|
||
)
|
||
async def test_dependency_manifest_is_observed_before_install(
|
||
tmp_path,
|
||
monkeypatch,
|
||
filename,
|
||
content,
|
||
) -> None:
|
||
"""
|
||
两类插件依赖清单共用同一安装前观察边界。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
plugin_dir = plugin_root / "demoplugin"
|
||
plugin_dir.mkdir(parents=True)
|
||
manifest = plugin_dir / filename
|
||
manifest.write_text(content, encoding="utf-8")
|
||
manager = PluginPackageManager(plugin_root=plugin_root)
|
||
calls = []
|
||
|
||
async def install(path, _find_links=None):
|
||
calls.append(("install", path))
|
||
return True, ""
|
||
|
||
monkeypatch.setattr(
|
||
manager._health,
|
||
"async_install_packages_with_fallback",
|
||
install,
|
||
)
|
||
|
||
result = await manager._PluginPackageManager__async_install_dependencies_if_required(
|
||
"DemoPlugin",
|
||
lambda: calls.append(("observe", None)),
|
||
)
|
||
|
||
assert result == (True, True, "")
|
||
assert calls == [("observe", None), ("install", manifest)]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dependency_observer_failure_does_not_block_install(
|
||
tmp_path,
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
原生状态观察异常不应改变插件依赖安装结果。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
plugin_dir = plugin_root / "demoplugin"
|
||
plugin_dir.mkdir(parents=True)
|
||
requirements_file = plugin_dir / "requirements.txt"
|
||
requirements_file.write_text("demo>=1\n", encoding="utf-8")
|
||
manager = PluginPackageManager(plugin_root=plugin_root)
|
||
install = AsyncMock(return_value=(True, ""))
|
||
|
||
def failing_observer():
|
||
raise OSError("probe unavailable")
|
||
|
||
monkeypatch.setattr(
|
||
manager._health,
|
||
"async_install_packages_with_fallback",
|
||
install,
|
||
)
|
||
|
||
result = await manager._PluginPackageManager__async_install_dependencies_if_required(
|
||
"DemoPlugin",
|
||
failing_observer,
|
||
)
|
||
|
||
assert result == (True, True, "")
|
||
install.assert_awaited_once_with(requirements_file, None)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dependency_observer_is_not_called_without_manifest(
|
||
tmp_path,
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
未声明依赖的插件不承担原生环境快照成本。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
(plugin_root / "demoplugin").mkdir(parents=True)
|
||
manager = PluginPackageManager(plugin_root=plugin_root)
|
||
observer = Mock()
|
||
result = await manager._PluginPackageManager__async_install_dependencies_if_required(
|
||
"DemoPlugin",
|
||
observer,
|
||
)
|
||
|
||
assert result == (False, False, "不存在依赖")
|
||
observer.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize(
|
||
("filename", "content"),
|
||
[
|
||
("requirements.txt", "\n# no dependencies\n"),
|
||
(
|
||
"pyproject.toml",
|
||
'[project]\nname = "demo"\nversion = "1.0.0"\ndependencies = []\n',
|
||
),
|
||
],
|
||
)
|
||
async def test_empty_dependency_manifest_does_not_trigger_native_snapshot(
|
||
tmp_path,
|
||
monkeypatch,
|
||
filename,
|
||
content,
|
||
) -> None:
|
||
"""
|
||
空清单保持既有安装兼容,但不枚举宿主原生发行包。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
plugin_dir = plugin_root / "demoplugin"
|
||
plugin_dir.mkdir(parents=True)
|
||
manifest = plugin_dir / filename
|
||
manifest.write_text(content, encoding="utf-8")
|
||
manager = PluginPackageManager(plugin_root=plugin_root)
|
||
observer = Mock()
|
||
install = AsyncMock(return_value=(True, ""))
|
||
monkeypatch.setattr(
|
||
manager._health,
|
||
"async_install_packages_with_fallback",
|
||
install,
|
||
)
|
||
|
||
result = await manager._PluginPackageManager__async_install_dependencies_if_required(
|
||
"DemoPlugin",
|
||
observer,
|
||
)
|
||
|
||
assert result == (True, True, "")
|
||
observer.assert_not_called()
|
||
install.assert_awaited_once_with(manifest, None)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_legacy_unparsed_requirement_still_triggers_native_snapshot(
|
||
tmp_path,
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
历史 VCS 或选项式依赖不能因结构化解析为空而漏过观察。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
plugin_dir = plugin_root / "demoplugin"
|
||
plugin_dir.mkdir(parents=True)
|
||
manifest = plugin_dir / "requirements.txt"
|
||
manifest.write_text(
|
||
"git+https://example.invalid/demo.git@fixed\n",
|
||
encoding="utf-8",
|
||
)
|
||
manager = PluginPackageManager(plugin_root=plugin_root)
|
||
calls = []
|
||
|
||
async def install(path, _find_links=None):
|
||
calls.append(("install", path))
|
||
return True, ""
|
||
|
||
monkeypatch.setattr(
|
||
manager._health,
|
||
"async_install_packages_with_fallback",
|
||
install,
|
||
)
|
||
|
||
result = await manager._PluginPackageManager__async_install_dependencies_if_required(
|
||
"DemoPlugin",
|
||
lambda: calls.append(("observe", None)),
|
||
)
|
||
|
||
assert result == (True, True, "")
|
||
assert calls == [("observe", None), ("install", manifest)]
|
||
|
||
|
||
def test_external_sync_helper_rejects_until_gateway_is_configured(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
外部同步入口在宿主未装配来源门禁时不得直接写入插件包。
|
||
"""
|
||
helper = PluginHelper()
|
||
monkeypatch.setattr(
|
||
market,
|
||
"_plugin_install_gateway",
|
||
market._unconfigured_plugin_install_gateway,
|
||
)
|
||
|
||
success, message = helper.install(
|
||
"DemoPlugin",
|
||
REPO_URL,
|
||
"v3",
|
||
"1.2.3",
|
||
True,
|
||
)
|
||
assert success is False
|
||
assert message
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_external_async_helper_rejects_until_gateway_is_configured(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
外部异步入口在宿主未装配来源门禁时不得直接写入插件包。
|
||
"""
|
||
helper = PluginHelper()
|
||
monkeypatch.setattr(
|
||
market,
|
||
"_async_plugin_install_gateway",
|
||
market._unconfigured_async_plugin_install_gateway,
|
||
)
|
||
|
||
success, message = await helper.async_install(
|
||
"DemoPlugin",
|
||
REPO_URL,
|
||
"v3",
|
||
"1.2.3",
|
||
True,
|
||
)
|
||
assert success is False
|
||
assert message
|
||
|
||
|
||
def test_external_sync_helper_uses_configured_gateway(monkeypatch) -> None:
|
||
"""
|
||
外部同步调用必须把所有参数交给宿主统一安装用例。
|
||
"""
|
||
gateway = Mock(return_value=(False, "source conflict"))
|
||
monkeypatch.setattr(market, "_plugin_install_gateway", gateway)
|
||
|
||
result = PluginHelper().install("DemoPlugin", REPO_URL, "v3", "1.2.3", True)
|
||
|
||
assert result == (False, "source conflict")
|
||
gateway.assert_called_once_with("DemoPlugin", REPO_URL, "v3", "1.2.3", True)
|
||
|
||
|
||
def test_sync_gateway_returns_failure_when_runtime_loop_is_unavailable(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
主事件循环释放后,同步兼容入口应稳定返回失败结果。
|
||
"""
|
||
gateway = Mock()
|
||
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", None)
|
||
|
||
result = plugins_initializer._run_plugin_install_sync(
|
||
gateway,
|
||
plugin_id="DemoPlugin",
|
||
repo_url=REPO_URL,
|
||
package_version="v3",
|
||
release_version="1.2.3",
|
||
force=False,
|
||
local_sync=False,
|
||
explicit_source=True,
|
||
)
|
||
|
||
assert result == (False, "插件安装服务当前不可用")
|
||
gateway.install.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_external_async_helper_uses_configured_gateway(monkeypatch) -> None:
|
||
"""
|
||
外部异步调用必须把所有参数交给宿主统一安装用例。
|
||
"""
|
||
|
||
async def gateway(*args):
|
||
"""
|
||
返回统一 Gateway 的结果。
|
||
"""
|
||
seen.append(args)
|
||
return False, "source conflict"
|
||
|
||
seen = []
|
||
monkeypatch.setattr(market, "_async_plugin_install_gateway", gateway)
|
||
|
||
result = await PluginHelper().async_install(
|
||
"DemoPlugin",
|
||
REPO_URL,
|
||
"v3",
|
||
"1.2.3",
|
||
True,
|
||
)
|
||
|
||
assert result == (False, "source conflict")
|
||
assert seen == [("DemoPlugin", REPO_URL, "v3", "1.2.3", True)]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_external_async_helper_preserves_failure_tuple_on_gateway_error(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
公开异步 Helper 在持久化等内部异常下仍返回兼容二元组。
|
||
"""
|
||
gateway = Mock()
|
||
gateway.install = AsyncMock(side_effect=RuntimeError("persistence unavailable"))
|
||
|
||
async def install(*args):
|
||
"""
|
||
按组合根的真实参数映射进入公开异步兼容包装层。
|
||
"""
|
||
plugin_id, repo_url, package_version, release_version, force = args
|
||
return await plugins_initializer._run_plugin_install_async(
|
||
gateway,
|
||
plugin_id=plugin_id,
|
||
repo_url=repo_url,
|
||
package_version=package_version,
|
||
release_version=release_version,
|
||
force=force,
|
||
local_sync=False,
|
||
explicit_source=bool(repo_url),
|
||
)
|
||
|
||
monkeypatch.setattr(market, "_async_plugin_install_gateway", install)
|
||
|
||
result = await PluginHelper().async_install(
|
||
"DemoPlugin",
|
||
REPO_URL,
|
||
"v3",
|
||
"1.2.3",
|
||
True,
|
||
)
|
||
|
||
assert result == (False, "persistence unavailable")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_http_install_forwards_repo_url_without_granting_source_authority(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
普通更新保留绑定仓库提示,但不能把它升级为选源授权。
|
||
"""
|
||
gateway = Mock()
|
||
gateway.install = AsyncMock(
|
||
return_value=SimpleNamespace(
|
||
success=True,
|
||
message="installed",
|
||
restart_required=True,
|
||
)
|
||
)
|
||
monkeypatch.setattr(
|
||
plugin_endpoint,
|
||
"get_plugin_install_service",
|
||
lambda: gateway,
|
||
)
|
||
|
||
result = await plugin_endpoint.install(
|
||
"DemoPlugin",
|
||
REPO_URL,
|
||
"1.2.3",
|
||
False,
|
||
None,
|
||
)
|
||
|
||
assert result.success is True
|
||
assert result.data.restart_required is True
|
||
gateway.install.assert_awaited_once_with(
|
||
plugin_id="DemoPlugin",
|
||
repo_url=REPO_URL,
|
||
release_version="1.2.3",
|
||
force=False,
|
||
explicit_source=False,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_http_explicit_source_install_uses_explicit_gateway_mode(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
专用来源安装入口必须把管理员选择传给统一 Gateway。
|
||
"""
|
||
gateway = Mock()
|
||
gateway.install = AsyncMock(
|
||
return_value=SimpleNamespace(
|
||
success=True,
|
||
message="installed",
|
||
restart_required=True,
|
||
)
|
||
)
|
||
monkeypatch.setattr(
|
||
plugin_endpoint,
|
||
"get_plugin_install_service",
|
||
lambda: gateway,
|
||
)
|
||
|
||
result = await plugin_endpoint.install_plugin_from_source(
|
||
"DemoPlugin",
|
||
PluginSourceInstallRequest(
|
||
repo_url=REPO_URL,
|
||
release_version="1.2.3",
|
||
force=True,
|
||
),
|
||
None,
|
||
)
|
||
|
||
assert result.success is True
|
||
assert result.data.restart_required is True
|
||
gateway.install.assert_awaited_once_with(
|
||
plugin_id="DemoPlugin",
|
||
repo_url=REPO_URL,
|
||
release_version="1.2.3",
|
||
force=True,
|
||
explicit_source=True,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_http_source_change_requires_revision_and_explicit_gateway_mode(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
管理员换源入口必须把目标仓库和精确 revision 交给统一 Gateway。
|
||
"""
|
||
gateway = Mock()
|
||
gateway.install = AsyncMock(
|
||
return_value=SimpleNamespace(
|
||
success=True,
|
||
message="installed",
|
||
restart_required=True,
|
||
)
|
||
)
|
||
monkeypatch.setattr(
|
||
plugin_endpoint,
|
||
"get_plugin_install_service",
|
||
lambda: gateway,
|
||
)
|
||
|
||
result = await plugin_endpoint.change_plugin_source(
|
||
"DemoPlugin",
|
||
PluginSourceChangeRequest(
|
||
repo_url=REPO_URL,
|
||
expected_revision=7,
|
||
release_version="1.2.3",
|
||
),
|
||
None,
|
||
)
|
||
|
||
assert result.success is True
|
||
assert result.data.restart_required is True
|
||
gateway.install.assert_awaited_once_with(
|
||
plugin_id="DemoPlugin",
|
||
repo_url=REPO_URL,
|
||
release_version="1.2.3",
|
||
force=True,
|
||
explicit_source=True,
|
||
source_change=True,
|
||
expected_revision=7,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_http_source_identity_returns_current_cas_evidence(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
来源查询只公开确认和显式换源所需的最小身份字段。
|
||
"""
|
||
identity = SimpleNamespace(
|
||
plugin_id="DemoPlugin",
|
||
trusted_source_type=SimpleNamespace(value="official"),
|
||
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
||
binding_basis=SimpleNamespace(value="official_default"),
|
||
payload_source_type=SimpleNamespace(value="local"),
|
||
payload_source_key=None,
|
||
revision=7,
|
||
)
|
||
persistence = Mock()
|
||
persistence.get_identity = AsyncMock(return_value=identity)
|
||
monkeypatch.setattr(
|
||
plugin_endpoint,
|
||
"get_plugin_persistence",
|
||
lambda: persistence,
|
||
)
|
||
|
||
result = await plugin_endpoint.get_plugin_source_identity(
|
||
"DemoPlugin",
|
||
None,
|
||
)
|
||
|
||
assert result.success is True
|
||
assert isinstance(result.data, PluginSourceIdentity)
|
||
assert result.data.plugin_id == "DemoPlugin"
|
||
assert result.data.trusted_source_key == "github:jxxghp/moviepilot-plugins"
|
||
assert result.data.payload_source_type == "local"
|
||
assert result.data.revision == 7
|
||
|
||
|
||
def test_source_change_schema_rejects_invalid_revision_and_blank_repo() -> None:
|
||
"""
|
||
显式换源请求在进入业务层前拒绝无来源或无效 revision。
|
||
"""
|
||
with pytest.raises(ValidationError):
|
||
PluginSourceChangeRequest(repo_url=" ", expected_revision=1)
|
||
with pytest.raises(ValidationError):
|
||
PluginSourceChangeRequest(repo_url=REPO_URL, expected_revision=0)
|
||
with pytest.raises(ValidationError):
|
||
PluginSourceChangeRequest(
|
||
repo_url="local://DemoPlugin",
|
||
expected_revision=1,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_http_source_options_return_sanitized_candidates(monkeypatch) -> None:
|
||
"""
|
||
来源候选接口保留在线选择信息,但本地候选不公开路径。
|
||
"""
|
||
identity = SimpleNamespace(
|
||
plugin_id="DemoPlugin",
|
||
trusted_source_type=SimpleNamespace(value="official"),
|
||
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
||
binding_basis=SimpleNamespace(value="official_default"),
|
||
payload_source_type=SimpleNamespace(value="local"),
|
||
payload_source_key=None,
|
||
revision=7,
|
||
)
|
||
inspection = SimpleNamespace(
|
||
plugin_id="DemoPlugin",
|
||
inventory_complete=True,
|
||
identity=identity,
|
||
selection=SimpleNamespace(
|
||
status=SimpleNamespace(value="conflict"),
|
||
reason="该插件存在于多个仓库,请选择仓库",
|
||
),
|
||
online_candidates=(
|
||
SimpleNamespace(
|
||
public_dict=lambda: {
|
||
"plugin_id": "DemoPlugin",
|
||
"source_type": "official",
|
||
"source_key": "github:jxxghp/moviepilot-plugins",
|
||
"repo_url": "https://github.com/jxxghp/MoviePilot-Plugins",
|
||
"package_generation": "v3",
|
||
"plugin_version": "1.0.0",
|
||
}
|
||
),
|
||
),
|
||
local_candidate=SimpleNamespace(
|
||
public_dict=lambda: {
|
||
"plugin_id": "DemoPlugin",
|
||
"source_type": "local",
|
||
"package_generation": "v3",
|
||
"plugin_version": "2.0.0-dev",
|
||
}
|
||
),
|
||
)
|
||
gateway = Mock()
|
||
gateway.inspect_source = AsyncMock(return_value=inspection)
|
||
monkeypatch.setattr(
|
||
plugin_endpoint,
|
||
"get_plugin_install_service",
|
||
lambda: gateway,
|
||
)
|
||
|
||
result = await plugin_endpoint.get_plugin_source_options(
|
||
"DemoPlugin",
|
||
None,
|
||
)
|
||
|
||
assert result.success is True
|
||
assert isinstance(result.data, PluginSourceOptions)
|
||
assert result.data.identity is not None
|
||
assert result.data.identity.revision == 7
|
||
assert [candidate.source_type for candidate in result.data.candidates] == [
|
||
"official",
|
||
"local",
|
||
]
|
||
assert result.data.candidates[1].repo_url is None
|
||
assert "/private/" not in result.model_dump_json()
|
||
|
||
|
||
def test_source_api_openapi_uses_structured_contracts() -> None:
|
||
"""
|
||
来源查询、初始选源和换源 API 必须公开稳定结构模型。
|
||
"""
|
||
app = FastAPI()
|
||
app.include_router(plugin_endpoint.router, prefix="/api/v1/plugin")
|
||
|
||
paths = app.openapi()["paths"]
|
||
change_operation = paths["/api/v1/plugin/source/{plugin_id}"]["post"]
|
||
install_operation = paths["/api/v1/plugin/source/{plugin_id}/install"]["post"]
|
||
options_operation = paths["/api/v1/plugin/source/{plugin_id}/options"]["get"]
|
||
|
||
change_schema = change_operation["requestBody"]["content"]["application/json"]["schema"]
|
||
install_schema = install_operation["requestBody"]["content"]["application/json"]["schema"]
|
||
change_response_schema = change_operation["responses"]["200"]["content"]["application/json"]["schema"]
|
||
install_response_schema = install_operation["responses"]["200"]["content"]["application/json"]["schema"]
|
||
options_schema = options_operation["responses"]["200"]["content"]["application/json"]["schema"]
|
||
|
||
assert change_schema["$ref"].endswith("/PluginSourceChangeRequest")
|
||
assert install_schema["$ref"].endswith("/PluginSourceInstallRequest")
|
||
assert change_response_schema["$ref"].endswith("/Response_PluginInstallOutcome_")
|
||
assert install_response_schema["$ref"].endswith("/Response_PluginInstallOutcome_")
|
||
assert options_schema["$ref"].endswith("/Response_PluginSourceOptions_")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_install_uses_application_gateway(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""
|
||
Agent 安装入口只能转发到唯一 Application Gateway。
|
||
"""
|
||
gateway = Mock()
|
||
gateway.install = AsyncMock(
|
||
return_value=SimpleNamespace(
|
||
success=True,
|
||
message="installed",
|
||
refreshed_only=False,
|
||
)
|
||
)
|
||
monkeypatch.setattr(
|
||
plugin_management,
|
||
"get_plugin_install_service",
|
||
lambda: gateway,
|
||
)
|
||
|
||
result = await plugin_management.install_plugin_runtime(
|
||
"DemoPlugin",
|
||
REPO_URL,
|
||
force=False,
|
||
)
|
||
|
||
assert result == (True, "installed", False)
|
||
gateway.install.assert_awaited_once_with(
|
||
plugin_id="DemoPlugin",
|
||
repo_url=REPO_URL,
|
||
force=False,
|
||
explicit_source=False,
|
||
)
|
||
|
||
|
||
def test_startup_composition_configures_external_helper_gateway(monkeypatch) -> None:
|
||
"""
|
||
启动组合根必须装配独立 owner,并向公开 Helper 发布同一 Gateway。
|
||
"""
|
||
transport = Mock()
|
||
market_client = Mock()
|
||
package_manager = Mock()
|
||
health = Mock()
|
||
dependency = Mock()
|
||
market_composition = Mock(return_value=SimpleNamespace(
|
||
transport=transport,
|
||
client=market_client,
|
||
package=package_manager,
|
||
health=health,
|
||
dependency=dependency,
|
||
))
|
||
gateway_calls = []
|
||
application_calls = []
|
||
gateway = Mock()
|
||
sync_runner = Mock(return_value=(True, "installed"))
|
||
async_runner = AsyncMock(return_value=(True, "installed"))
|
||
command_factory = Mock(return_value=Mock())
|
||
recovery_factory = Mock(return_value=Mock())
|
||
|
||
monkeypatch.setattr(
|
||
plugins_initializer,
|
||
"compose_plugin_market",
|
||
market_composition,
|
||
)
|
||
monkeypatch.setattr(
|
||
plugins_initializer,
|
||
"PluginCandidateInventoryReader",
|
||
lambda **_kwargs: Mock(),
|
||
)
|
||
monkeypatch.setattr(
|
||
plugins_initializer,
|
||
"PluginInstallCommand",
|
||
command_factory,
|
||
)
|
||
monkeypatch.setattr(
|
||
plugins_initializer,
|
||
"PluginInstallGateway",
|
||
lambda **_kwargs: gateway,
|
||
)
|
||
monkeypatch.setattr(
|
||
plugins_initializer,
|
||
"PluginInstallationRecoveryService",
|
||
recovery_factory,
|
||
)
|
||
monkeypatch.setattr(
|
||
plugins_initializer,
|
||
"PluginIdentityMigrationService",
|
||
lambda **_kwargs: Mock(),
|
||
)
|
||
monkeypatch.setattr(plugins_initializer, "get_plugin_manager", Mock())
|
||
monkeypatch.setattr(plugins_initializer, "get_plugin_persistence", Mock())
|
||
monkeypatch.setattr(
|
||
plugins_initializer,
|
||
"configure_plugin_install_service",
|
||
application_calls.append,
|
||
)
|
||
monkeypatch.setattr(
|
||
plugins_initializer,
|
||
"configure_plugin_installation_recovery",
|
||
Mock(),
|
||
)
|
||
monkeypatch.setattr(
|
||
plugins_initializer,
|
||
"configure_plugin_identity_migration",
|
||
Mock(),
|
||
)
|
||
monkeypatch.setattr(
|
||
plugins_initializer,
|
||
"_run_plugin_install_sync",
|
||
sync_runner,
|
||
)
|
||
monkeypatch.setattr(
|
||
plugins_initializer,
|
||
"_run_plugin_install_async",
|
||
async_runner,
|
||
)
|
||
for name in (
|
||
"configure_plugin_legacy_import_services",
|
||
"configure_plugin_resource_import_preparer",
|
||
"configure_site_auth_level_provider",
|
||
"configure_installed_plugins_provider",
|
||
"configure_plugin_catalog_factory",
|
||
"configure_plugin_runtime_factory",
|
||
"configure_plugin_route_refresher",
|
||
"configure_plugin_runtime_owners",
|
||
"configure_plugin_system",
|
||
"configure_plugin_storage",
|
||
):
|
||
monkeypatch.setattr(plugins_initializer, name, Mock())
|
||
|
||
def configure_gateway(**kwargs) -> None:
|
||
"""
|
||
记录组合根提供给外部 Helper 的同步/异步端口。
|
||
"""
|
||
gateway_calls.append(kwargs)
|
||
|
||
monkeypatch.setattr(
|
||
plugins_initializer,
|
||
"configure_plugin_install_gateway",
|
||
configure_gateway,
|
||
raising=False,
|
||
)
|
||
|
||
plugins_initializer.configure_plugin_services()
|
||
|
||
manager = plugins_initializer.get_plugin_manager()
|
||
manager.reload_plugin_tree.return_value = PluginRuntimeStatus.ACTIVE
|
||
reloader = command_factory.call_args.kwargs["target_reloader"]
|
||
restorer = recovery_factory.call_args.kwargs["restore_host_enabled"]
|
||
assert asyncio.run(reloader("DemoPlugin")) is PluginRuntimeStatus.ACTIVE
|
||
manager.reload_plugin_tree.assert_called_once_with("DemoPlugin")
|
||
for enabled in (True, False):
|
||
asyncio.run(restorer("DemoPlugin", enabled))
|
||
assert manager.restore_plugin_host_enabled.call_args_list == [
|
||
call("DemoPlugin", True), call("DemoPlugin", False),
|
||
]
|
||
manager.restore_plugin_host_enabled.side_effect = RuntimeError("候选资源未停止")
|
||
with pytest.raises(RuntimeError, match="候选资源未停止"):
|
||
asyncio.run(restorer("DemoPlugin", False))
|
||
|
||
monkeypatch.setattr(
|
||
plugins_initializer,
|
||
"get_plugin_instance_directory",
|
||
lambda: SimpleNamespace(list_enabled=lambda: [
|
||
PluginInstance(instance_id="Enabled", source_plugin_id="Enabled"),
|
||
PluginInstance(instance_id="Clone", source_plugin_id="DisabledHost"),
|
||
]),
|
||
)
|
||
assert market_composition.call_args.kwargs["enabled_plugins_provider"]() == {
|
||
"Enabled", "DisabledHost"
|
||
}
|
||
|
||
assert application_calls == [gateway]
|
||
assert len(gateway_calls) == 1
|
||
assert callable(gateway_calls[0]["install"])
|
||
assert callable(gateway_calls[0]["async_install"])
|
||
|
||
assert gateway_calls[0]["install"](
|
||
"DemoPlugin",
|
||
REPO_URL,
|
||
"v3",
|
||
"1.2.3",
|
||
False,
|
||
) == (True, "installed")
|
||
assert asyncio.run(
|
||
gateway_calls[0]["async_install"](
|
||
"DemoPlugin",
|
||
REPO_URL,
|
||
"v3",
|
||
"1.2.3",
|
||
False,
|
||
)
|
||
) == (True, "installed")
|
||
local_repo_url = "local://DemoPlugin?path=/private/plugins&version=v3"
|
||
assert gateway_calls[0]["install"](
|
||
"DemoPlugin",
|
||
local_repo_url,
|
||
"v3",
|
||
None,
|
||
True,
|
||
) == (True, "installed")
|
||
assert asyncio.run(
|
||
gateway_calls[0]["async_install"](
|
||
"DemoPlugin",
|
||
local_repo_url,
|
||
"v3",
|
||
None,
|
||
True,
|
||
)
|
||
) == (True, "installed")
|
||
online_expected = {
|
||
"plugin_id": "DemoPlugin",
|
||
"repo_url": "",
|
||
"package_version": "v3",
|
||
"release_version": "1.2.3",
|
||
"force": False,
|
||
"local_sync": False,
|
||
"explicit_source": False,
|
||
}
|
||
local_expected = {
|
||
"plugin_id": "DemoPlugin",
|
||
"repo_url": local_repo_url,
|
||
"package_version": "v3",
|
||
"release_version": None,
|
||
"force": True,
|
||
"local_sync": True,
|
||
"explicit_source": True,
|
||
}
|
||
assert sync_runner.call_args_list == [
|
||
call(gateway, **online_expected),
|
||
call(gateway, **local_expected),
|
||
]
|
||
assert async_runner.await_args_list == [
|
||
call(gateway, **online_expected),
|
||
call(gateway, **local_expected),
|
||
]
|