Files
MoviePilot/tests/test_plugin_adapter_parity.py

295 lines
10 KiB
Python
Raw 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.

"""插件市场、包安装和依赖安装同步异步一致性测试。"""
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from app.adapters.external.plugin.client import PluginMarketTransport
from app.adapters.system.plugin.dependency import PluginDependencyInstaller
from app.adapters.system.plugin.package import PluginPackageManager
REPO_URL = "https://github.com/example/moviepilot-plugins"
SYNC_INDEX_REQUEST = "_PluginMarketTransport__request_plugin_index_with_fallback"
ASYNC_INDEX_REQUEST = (
"_PluginMarketTransport__async_request_plugin_index_with_fallback"
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("response", "expected", "error"),
[
((404, ""), None, None),
((200, '{"DemoPlugin": {"version": "1.2.3"}}'),
{"DemoPlugin": {"version": "1.2.3"}}, None),
(None, None, "插件索引请求失败:连接失败"),
((500, "upstream failed"), None, "插件索引请求失败HTTP 500"),
((200, "not-json"), None, "插件索引响应格式无效"),
],
)
async def test_plugin_index_sync_async_result_contract_is_identical(
monkeypatch,
response: tuple[int, str] | None,
expected: dict | None,
error: str | None,
) -> None:
"""同步与异步索引传输必须经过同一响应分类和错误语义。"""
transport = PluginMarketTransport()
def request(_url: str, *, headers: dict) -> tuple[int, str] | None:
return response
async def async_request(
_url: str, *, headers: dict
) -> tuple[int, str] | None:
return response
monkeypatch.setattr(transport, SYNC_INDEX_REQUEST, request)
monkeypatch.setattr(transport, ASYNC_INDEX_REQUEST, async_request)
transport.get_plugin_index_result.cache_clear()
await transport.async_get_plugin_index_result.cache_clear()
if error is not None:
with pytest.raises(RuntimeError, match=error):
transport.get_plugin_index_result(REPO_URL, "v3")
with pytest.raises(RuntimeError, match=error):
await transport.async_get_plugin_index_result(REPO_URL, "v3")
return
sync_result = transport.get_plugin_index_result(REPO_URL, "v3")
async_result = await transport.async_get_plugin_index_result(REPO_URL, "v3")
assert sync_result == async_result == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
("plugin_id", "repo_url", "frozen", "repo_info", "selected", "expected"),
[
("DemoPlugin", REPO_URL, True, ("example", "repo"), "v3",
(False, "可执行文件模式下,只能安装本地插件")),
("", REPO_URL, False, ("example", "repo"), "v3",
(False, "参数错误")),
("DemoPlugin", "invalid", False, (None, None), "v3",
(False, "不支持的插件仓库地址格式")),
("DemoPlugin", REPO_URL, False, ("example", "repo"), None,
(False, "DemoPlugin 没有找到适用于当前版本的插件")),
],
)
async def test_plugin_package_sync_async_preflight_failures_are_identical(
monkeypatch,
plugin_id: str,
repo_url: str,
frozen: bool,
repo_info: tuple[str | None, str | None],
selected: str | None,
expected: tuple[bool, str],
) -> None:
"""远端包安装的环境、参数、仓库和代际失败必须保持同一结果。"""
manager = PluginPackageManager(source=Mock())
monkeypatch.setattr(manager, "is_local_repo_url", lambda _repo_url: False)
monkeypatch.setattr(
"app.adapters.system.plugin.package.SystemUtils.is_frozen",
lambda: frozen,
)
monkeypatch.setattr(manager, "get_repo_info", lambda _repo_url: repo_info)
monkeypatch.setattr(
manager,
"get_plugin_package_version",
lambda *_args: selected,
)
async def async_get_plugin_package_version(*_args) -> str | None:
return selected
monkeypatch.setattr(
manager,
"async_get_plugin_package_version",
async_get_plugin_package_version,
)
sync_result = manager.install_raw(plugin_id, repo_url, package_version="v3")
async_result = await manager.async_install_raw(
plugin_id, repo_url, package_version="v3"
)
assert sync_result == async_result == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
("metadata", "release_version", "release_items", "expected_calls"),
[
({"version": "1.2.3", "release": False}, None, [], ["filelist"]),
(
{"version": "1.2.3", "release": True},
None,
[],
["release", "filelist"],
),
(
{"version": "1.2.3", "release": True},
"1.2.3",
[{"version": "1.2.3"}],
["release"],
),
],
)
async def test_plugin_package_sync_async_execute_same_selected_strategy(
monkeypatch,
tmp_path: Path,
metadata: dict,
release_version: str | None,
release_items: list[dict],
expected_calls: list[str],
) -> None:
"""共享安装选择必须让同步与异步外壳执行相同 Release 回退路径。"""
manager = PluginPackageManager(source=Mock())
sync_calls: list[str] = []
async_calls: list[str] = []
monkeypatch.setattr(manager, "is_local_repo_url", lambda _repo_url: False)
monkeypatch.setattr(
"app.adapters.system.plugin.package.SystemUtils.is_frozen",
lambda: False,
)
monkeypatch.setattr(
manager, "get_repo_info", lambda _repo_url: ("example", "repo")
)
monkeypatch.setattr(
manager, "get_plugin_package_version", lambda *_args: "v3"
)
monkeypatch.setattr(
manager, "get_plugin_release_versions", lambda *_args: release_items
)
monkeypatch.setattr(
manager, "_PluginPackageManager__get_plugin_meta", lambda *_args: metadata
)
async def selected_version(*_args) -> str:
return "v3"
async def releases(*_args) -> list[dict]:
return release_items
async def meta(*_args) -> dict:
return metadata
def install_release(*_args) -> tuple[bool, str]:
sync_calls.append("release")
return (True, "installed") if release_version else (False, "missing asset")
async def async_install_release(*_args) -> tuple[bool, str]:
async_calls.append("release")
return (True, "installed") if release_version else (False, "missing asset")
def prepare_filelist(*_args) -> tuple[bool, str]:
sync_calls.append("filelist")
return True, "installed"
async def async_prepare_filelist(*_args) -> tuple[bool, str]:
async_calls.append("filelist")
return True, "installed"
def remove(*_args) -> None:
sync_calls.append("remove")
async def async_remove(*_args) -> None:
async_calls.append("remove")
def install_flow(_pid, _force, prepare, _repo_url, _before):
return prepare(tmp_path / "staging")
async def async_install_flow(_pid, _force, prepare, _repo_url, _before):
return await prepare(tmp_path / "staging")
monkeypatch.setattr(manager, "async_get_plugin_package_version", selected_version)
monkeypatch.setattr(manager, "async_get_plugin_release_versions", releases)
monkeypatch.setattr(manager, "_PluginPackageManager__async_get_plugin_meta", meta)
monkeypatch.setattr(manager, "_PluginPackageManager__install_from_release", install_release)
monkeypatch.setattr(manager, "_PluginPackageManager__async_install_from_release", async_install_release)
monkeypatch.setattr(manager, "_PluginPackageManager__prepare_content_via_filelist_sync", prepare_filelist)
monkeypatch.setattr(manager, "_PluginPackageManager__prepare_content_via_filelist_async", async_prepare_filelist)
monkeypatch.setattr(manager, "_PluginPackageManager__remove_old_plugin", remove)
monkeypatch.setattr(manager, "_PluginPackageManager__async_remove_old_plugin", async_remove)
monkeypatch.setattr(manager, "_PluginPackageManager__install_flow_sync", install_flow)
monkeypatch.setattr(manager, "_PluginPackageManager__install_flow_async", async_install_flow)
sync_result = manager.install_raw(
"DemoPlugin",
REPO_URL,
package_version="v3",
release_version=release_version,
)
async_result = await manager.async_install_raw(
"DemoPlugin",
REPO_URL,
package_version="v3",
release_version=release_version,
)
assert sync_result == async_result == (True, "installed")
assert sync_calls == async_calls == expected_calls
@pytest.mark.asyncio
async def test_dependency_install_sync_async_share_request_and_result(
tmp_path: Path, monkeypatch
) -> None:
"""依赖安装必须向同步与异步包端口提交同一份规范请求。"""
manifest_path = tmp_path / "requirements.txt"
wheels_dir = tmp_path / "wheels"
packages = SimpleNamespace(
install_packages_with_fallback=Mock(return_value=(True, "installed")),
async_install_packages_with_fallback=AsyncMock(
return_value=(True, "installed")
),
)
installer = PluginDependencyInstaller(packages, plugin_dir=tmp_path)
monkeypatch.setattr(
installer,
"_plugin_manifests",
lambda: [SimpleNamespace(path=manifest_path)],
)
monkeypatch.setattr(installer, "_wheels_dirs", lambda: [wheels_dir])
sync_result = installer.install(["demo>=1"])
async_result = await installer.async_install(["demo>=1"])
assert sync_result == async_result == (True, "installed")
packages.install_packages_with_fallback.assert_called_once_with(
[manifest_path], [wheels_dir]
)
packages.async_install_packages_with_fallback.assert_awaited_once_with(
[manifest_path], [wheels_dir]
)
@pytest.mark.asyncio
async def test_dependency_install_sync_async_share_error_mapping(
tmp_path: Path, monkeypatch
) -> None:
"""清单准备和包端口异常必须映射为完全一致的公开失败结果。"""
packages = SimpleNamespace(
install_packages_with_fallback=Mock(side_effect=OSError("disk full")),
async_install_packages_with_fallback=AsyncMock(
side_effect=OSError("disk full")
),
)
installer = PluginDependencyInstaller(packages, plugin_dir=tmp_path)
monkeypatch.setattr(
installer,
"_plugin_manifests",
lambda: [SimpleNamespace(path=tmp_path / "requirements.txt")],
)
monkeypatch.setattr(installer, "_wheels_dirs", lambda: [])
sync_result = installer.install(["demo>=1"])
async_result = await installer.async_install(["demo>=1"])
assert sync_result == async_result == (
False,
"安装依赖项时发生错误disk full",
)