mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
295 lines
10 KiB
Python
295 lines
10 KiB
Python
"""插件市场、包安装和依赖安装同步异步一致性测试。"""
|
||
|
||
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",
|
||
)
|