Files
MoviePilot/tests/test_plugin_helper.py

1592 lines
61 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.
import asyncio
import io
import sys
import tempfile
import threading
import time
import zipfile
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
import pytest
from packaging.requirements import Requirement
from packaging.version import Version
PLUGIN_ID = "DemoPlugin"
REPO_URL = "https://github.com/demo/MoviePilot-Plugins"
class _FakeResponse:
"""模拟 requests/httpx 响应对象,覆盖插件 release 安装分支读取的最小协议。"""
def __init__(self, status_code: int, payload: dict | None = None):
self.status_code = status_code
self._payload = payload or {}
def json(self):
"""返回构造时注入的 JSON payload。"""
return self._payload
class _FakeContentResponse(_FakeResponse):
"""带二进制正文的响应对象,用于模拟 GitHub release asset 下载。"""
def __init__(self, status_code: int, content: bytes):
super().__init__(status_code)
self.content = content
def _build_zip(entries: dict[str, bytes]) -> bytes:
"""构造内存 zip 包,键为包内路径、值为文件内容。"""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
for name, content in entries.items():
zf.writestr(name, content)
return buffer.getvalue()
def _patch_sync_remote_install(helper, monkeypatch, meta: dict,
release_result: tuple[bool, str],
filelist_result: tuple[bool, str] = (True, "")):
"""隔离同步远端插件安装流程,只观察 release 与文件列表准备路径选择。"""
calls = []
monkeypatch.setattr(helper, "get_plugin_package_version", lambda *_args: "v2")
monkeypatch.setattr(helper, "_PluginHelper__get_plugin_meta", lambda *_args: meta)
monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: None)
monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: calls.append("remove"))
monkeypatch.setattr(helper, "_PluginHelper__install_dependencies_if_required", lambda _pid: (False, True, ""))
monkeypatch.setattr(helper, "refresh_persistent_plugin_backup", lambda _pid: calls.append("refresh"))
def fake_release(_pid, _user_repo, _release_tag):
calls.append("release")
return release_result
def fake_filelist(_pid, _user_repo, _package_version):
calls.append("filelist")
return filelist_result
monkeypatch.setattr(helper, "_PluginHelper__install_from_release", fake_release)
monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_sync", fake_filelist)
return calls
def _patch_async_remote_install(helper, monkeypatch, meta: dict,
release_result: tuple[bool, str],
filelist_result: tuple[bool, str] = (True, "")):
"""隔离异步远端插件安装流程,只观察 release 与文件列表准备路径选择。"""
calls = []
async def fake_package_version(*_args):
return "v2"
async def fake_meta(*_args):
return meta
async def fake_backup(_pid):
return None
async def fake_remove(_pid):
calls.append("remove")
async def fake_dependencies(_pid):
return False, True, ""
async def fake_release(_pid, _user_repo, _release_tag):
calls.append("release")
return release_result
async def fake_filelist(_pid, _user_repo, _package_version):
calls.append("filelist")
return filelist_result
async def fake_to_thread(func, *args, **kwargs):
calls.append(("to_thread", func, args, kwargs))
return None
monkeypatch.setattr(helper, "async_get_plugin_package_version", fake_package_version)
monkeypatch.setattr(helper, "_PluginHelper__async_get_plugin_meta", fake_meta)
monkeypatch.setattr(helper, "_PluginHelper__async_backup_plugin", fake_backup)
monkeypatch.setattr(helper, "_PluginHelper__async_remove_old_plugin", fake_remove)
monkeypatch.setattr(helper, "_PluginHelper__async_install_dependencies_if_required", fake_dependencies)
monkeypatch.setattr(helper, "_PluginHelper__async_install_from_release", fake_release)
monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_async", fake_filelist)
monkeypatch.setattr("app.helper.plugin.asyncio.to_thread", fake_to_thread)
return calls
class TestPluginHelper:
def test_sanitize_plugin_repo_url_keeps_remote_url(self):
"""
插件安装统计脱敏保留远端仓库地址。
"""
try:
from app.helper.server import MoviePilotServerHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
repo_url = "https://github.com/InfinityPacer/MoviePilot-Plugins"
assert repo_url == MoviePilotServerHelper.sanitize_plugin_repo_url(repo_url)
def test_sanitize_plugin_repo_url_strips_local_path(self):
"""
插件安装统计脱敏移除本地仓库绝对路径。
"""
try:
from app.helper.server import MoviePilotServerHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
repo_url = "local://TestPlugin?path=/Users/InfinityPacer/GitHub/MoviePilot/MoviePilot-Plugins&version=v2"
assert "local://TestPlugin?version=v2" == MoviePilotServerHelper.sanitize_plugin_repo_url(repo_url)
def test_append_cache_buster_only_during_fresh_context(self):
"""
插件库强制刷新时远端索引 URL 也要变化,避免命中镜像或代理缓存。
"""
try:
from app.core.cache import fresh
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
url = "https://raw.githubusercontent.com/user/repo/main/package.json"
assert url == PluginHelper._PluginHelper__append_cache_buster(url)
with patch("app.helper.plugin.time.time_ns", return_value=1234567890):
with fresh(True):
refreshed_url = PluginHelper._PluginHelper__append_cache_buster(url)
assert "https://raw.githubusercontent.com/user/repo/main/package.json?_refresh=1234567890" == refreshed_url
def test_check_plugin_system_version_allows_missing_field(self):
"""
未声明主系统版本范围时保持旧插件兼容,不做额外限制。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
success, message = PluginHelper.check_plugin_system_version({"version": "1.0.0"})
assert success
assert "" == message
def test_check_plugin_system_version_rejects_out_of_range(self):
"""
插件声明的主系统版本范围不满足当前版本时拒绝安装。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
with patch.object(PluginHelper, "get_current_system_version", return_value=Version("2.12.2")):
success, message = PluginHelper.check_plugin_system_version({"system_version": ">=2.13.0"})
assert not success
assert "MoviePilot 版本 >=2.13.0" in message
def test_check_plugin_system_version_accepts_v_prefix_specifier(self):
"""
兼容带 v 前缀的版本范围,降低插件索引维护成本。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
with patch.object(PluginHelper, "get_current_system_version", return_value=Version("2.12.2")):
success, message = PluginHelper.check_plugin_system_version({"system_version": ">=v2.12.0"})
assert success
assert "" == message
def test_annotate_plugin_system_version_marks_incompatible(self):
"""
插件市场列表会带出系统版本兼容状态,供前端禁用安装入口。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
plugin_info = {"system_version": ">=2.13.0"}
with patch.object(PluginHelper, "get_current_system_version", return_value=Version("2.12.2")):
annotated = PluginHelper.annotate_plugin_system_version(plugin_info)
assert not annotated["system_version_compatible"]
assert "当前版本" in annotated["system_version_message"]
def test_pip_install_keeps_modules_imported_during_install(self):
"""
验证依赖安装窗口内被其他任务导入的运行态模块不会被误删。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
module_names = ["app.plugins.dynamicwechat.helper", "Crypto.Cipher._mode_cbc"]
def fake_execute(_cmd):
for module_name in module_names:
sys.modules[module_name] = ModuleType(module_name)
return True, "ok"
# patch.dict 进入时快照 sys.modules、退出时整体还原替代手写逐项 save/restore
# 保证 fake_execute 在安装窗口注入的运行态模块在用例结束后被清理、不污染其他用例
with patch.dict(sys.modules):
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
assert success
assert "ok" == message
for module_name in module_names:
assert module_name in sys.modules
def test_pip_install_serializes_concurrent_calls(self):
"""
验证多个依赖安装请求会复用同一把锁串行执行 pip。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
thread_count = 2
active_installs = 0
max_active_installs = 0
state_lock = threading.Lock()
start_event = threading.Event()
errors = []
def fake_execute(_cmd):
nonlocal active_installs, max_active_installs
with state_lock:
active_installs += 1
max_active_installs = max(max_active_installs, active_installs)
time.sleep(0.05)
with state_lock:
active_installs -= 1
return True, "ok"
def worker(requirements_file: Path):
try:
start_event.wait()
PluginHelper.pip_install_with_fallback(requirements_file)
except Exception as err: # pragma: no cover - 仅用于并发测试失败诊断
errors.append(err)
with tempfile.TemporaryDirectory() as temp_dir:
requirements_files = []
for index in range(thread_count):
requirements_file = Path(temp_dir) / f"requirements-{index}.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
requirements_files.append(requirements_file)
threads = [
threading.Thread(target=worker, args=(requirements_file,))
for requirements_file in requirements_files
]
with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
for thread in threads:
thread.start()
start_event.set()
for thread in threads:
thread.join()
assert [] == errors
assert 1 == max_active_installs
def test_get_protected_runtime_packages_only_keeps_main_dependency_graph(self):
"""
验证仅主程序依赖链上的包会被纳入保护集合。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
installed_packages = {
"passlib": Version("1.7.4"),
"bcrypt": Version("4.0.1"),
"demo_package": Version("1.0"),
}
requirement_graph = {
"passlib": (Version("1.7.4"), [Requirement("bcrypt>=4")]),
"bcrypt": (Version("4.0.1"), []),
"demo_package": (Version("1.0"), []),
}
with patch.object(
PluginHelper,
"_PluginHelper__parse_project_requirement_roots",
return_value={"passlib": set()}
):
with patch.object(
PluginHelper,
"_PluginHelper__get_installed_distribution_requirements",
return_value=requirement_graph
):
protected_packages = PluginHelper._PluginHelper__get_protected_runtime_packages(installed_packages)
assert {
"passlib": Version("1.7.4"),
"bcrypt": Version("4.0.1"),
} == protected_packages
def test_pip_install_rejects_conflicting_runtime_dependency(self):
"""
验证插件如果试图覆盖主程序核心依赖,会在真正执行 pip 前被直接拒绝。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text("fastapi<0.1\n", encoding="utf-8")
with patch.object(
PluginHelper,
"_PluginHelper__get_protected_runtime_packages",
return_value={"fastapi": Version("0.115.14")}
):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
assert not success
assert "主程序核心依赖" in message
assert "fastapi" in message
def test_pip_install_allows_changing_non_runtime_dependency(self):
"""
验证非主程序依赖即便已安装,插件后续仍可调整其版本约束。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
seen_install_commands = []
def fake_execute(cmd):
if cmd[:4] == [sys.executable, "-m", "pip", "install"]:
seen_install_commands.append(cmd)
assert "-c" not in cmd
return True, "ok"
return True, "ok"
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text("demo-package>=2\n", encoding="utf-8")
with patch.object(
PluginHelper,
"_PluginHelper__get_installed_packages",
return_value={"demo_package": Version("1.0")}
):
with patch.object(
PluginHelper,
"_PluginHelper__get_protected_runtime_packages",
return_value={}
):
with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
assert success
assert "ok" == message
assert 1 == len(seen_install_commands)
def test_pip_install_uses_runtime_constraints_file(self):
"""
验证插件依赖安装会固定主程序依赖的当前版本,防止共享 venv 被改写。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
seen_constraints = []
def fake_execute(cmd):
if cmd[:4] == [sys.executable, "-m", "pip", "install"]:
constraint_index = cmd.index("-c") + 1
constraint_file = Path(cmd[constraint_index])
seen_constraints.append(constraint_file)
assert constraint_file.exists()
assert "fastapi==0.115.14" in constraint_file.read_text(encoding="utf-8")
return True, "ok"
return True, "ok"
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
with patch.object(
PluginHelper,
"_PluginHelper__get_protected_runtime_packages",
return_value={"fastapi": Version("0.115.14")}
):
with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
assert success
assert "ok" == message
assert 1 == len(seen_constraints)
assert not seen_constraints[0].exists()
def test_pip_install_repairs_runtime_when_healthcheck_fails(self):
"""
验证插件依赖安装后若破坏运行环境,会先恢复主程序依赖,再向上层返回失败。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
repair_commands = []
healthcheck_failed = False
pip_check_cmd = PluginHelper._PluginHelper__build_runtime_pip_command("check")
def fake_execute(cmd):
nonlocal healthcheck_failed
if cmd[:4] == [sys.executable, "-m", "pip", "install"]:
if "-c" not in cmd:
repair_commands.append(cmd)
return True, "repaired"
return True, "installed"
if cmd == pip_check_cmd:
if not healthcheck_failed:
healthcheck_failed = True
return False, "broken"
return True, "healthy"
if len(cmd) >= 3 and cmd[1] == "-c":
return True, "probe ok"
raise AssertionError(f"unexpected command: {cmd}")
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
with patch.object(
PluginHelper,
"_PluginHelper__get_protected_runtime_packages",
return_value={"fastapi": Version("0.115.14")}
):
with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
assert not success
assert "已自动恢复主程序依赖" in message
assert 1 == len(repair_commands)
assert "runtime-constraints-" in repair_commands[0][-1]
def test_async_pip_install_runs_in_threadpool(self):
"""
验证异步安装路径会把同步 pip 安装派发到线程池,避免阻塞事件循环。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
requirements_file = Path("/tmp/demo-requirements.txt")
find_links_dirs = [Path("/tmp/demo-wheels")]
calls = []
async def run_install():
return await helper._PluginHelper__async_pip_install_with_fallback(
requirements_file,
find_links_dirs
)
async def fake_to_thread(func, *args, **kwargs):
calls.append((func, args, kwargs))
return True, "ok"
with patch("app.helper.plugin.asyncio.to_thread", side_effect=fake_to_thread):
success, message = asyncio.run(run_install())
assert success
assert "ok" == message
assert 1 == len(calls)
assert helper.pip_install_with_fallback == calls[0][0]
assert (requirements_file, find_links_dirs) == calls[0][1]
assert {} == calls[0][2]
def test_install_uses_release_package_when_asset_is_available(self, monkeypatch):
"""
release 包可用时优先使用 zip 安装,不再额外访问文件列表。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = _patch_sync_remote_install(
helper,
monkeypatch,
{"release": True, "version": "1.2.3"},
(True, ""),
)
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert success
assert "" == message
assert ["remove", "release", "refresh"] == calls
def test_install_falls_back_to_filelist_when_release_is_missing(self, monkeypatch):
"""
release 标记存在但 tag 或 zip 尚未生成时回退文件列表安装。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = _patch_sync_remote_install(
helper,
monkeypatch,
{"release": True, "version": "1.2.3"},
(False, "获取 Release 信息失败404"),
(True, ""),
)
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert success
assert "" == message
assert ["remove", "release", "filelist", "refresh"] == calls
def test_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch):
"""
release 和文件列表都不可用时返回最终文件列表错误,便于定位真实安装阻断点。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = _patch_sync_remote_install(
helper,
monkeypatch,
{"release": True, "version": "1.2.3"},
(False, "未找到资产文件demoplugin_v1.2.3.zip"),
(False, "获取文件列表失败"),
)
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert not success
assert "获取文件列表失败" == message
assert ["remove", "release", "filelist", "remove"] == calls
def test_install_uses_filelist_when_release_flag_is_disabled(self, monkeypatch):
"""
未开启 release 标记的插件保持原有文件列表安装路径。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = _patch_sync_remote_install(
helper,
monkeypatch,
{"release": False, "version": "1.2.3"},
(False, "release should not be called"),
(True, ""),
)
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert success
assert "" == message
assert ["remove", "filelist", "refresh"] == calls
def test_install_rejects_release_without_version(self, monkeypatch):
"""
release 安装必须有插件版本号,否则无法构造稳定 tag。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = _patch_sync_remote_install(
helper,
monkeypatch,
{"release": True},
(True, ""),
)
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert not success
assert f"未在插件清单中找到 {PLUGIN_ID} 的版本号" in message
assert [] == calls
def test_install_rejects_incompatible_plugin_before_content_preparation(self, monkeypatch):
"""
系统版本不兼容时不会删除旧插件,也不会尝试 release 或文件列表安装。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = _patch_sync_remote_install(
helper,
monkeypatch,
{"release": True, "version": "1.2.3", "system_version": ">=9.0.0"},
(True, ""),
)
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.0.0"))
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert not success
assert "MoviePilot 版本 >=9.0.0" in message
assert [] == calls
def test_install_rejects_invalid_parameters_before_remote_lookup(self):
"""
远端安装缺少插件 ID 或仓库地址时直接拒绝。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
success, message = PluginHelper().install("", REPO_URL)
assert not success
assert "参数错误" == message
def test_install_rejects_invalid_repo_url(self):
"""
仓库地址无法解析出 owner/repo 时直接拒绝。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
success, message = PluginHelper().install(PLUGIN_ID, "not-a-repo-url")
assert not success
assert "不支持的插件仓库地址格式" == message
def test_install_rejects_missing_package_version(self, monkeypatch):
"""
当前系统版本找不到匹配插件索引时直接返回兼容性错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
monkeypatch.setattr(helper, "get_plugin_package_version", lambda *_args: None)
success, message = helper.install(PLUGIN_ID, REPO_URL)
assert not success
assert f"{PLUGIN_ID} 没有找到适用于当前版本的插件" == message
def test_install_uses_default_package_version_when_not_provided(self, monkeypatch):
"""
调用方未指定索引版本时使用系统版本标记继续安装。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
seen_versions = []
monkeypatch.setattr(helper, "get_plugin_package_version", lambda _pid, _repo, version: seen_versions.append(version) or "")
monkeypatch.setattr(helper, "_PluginHelper__get_plugin_meta", lambda *_args: {"release": False, "version": "1.2.3"})
monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: None)
monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: None)
monkeypatch.setattr(helper, "_PluginHelper__install_dependencies_if_required", lambda _pid: (False, True, ""))
monkeypatch.setattr(helper, "refresh_persistent_plugin_backup", lambda _pid: None)
monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_sync", lambda *_args: (True, ""))
success, message = helper.install(PLUGIN_ID, REPO_URL, force_install=True)
assert success
assert "" == message
assert seen_versions
def test_install_local_delegates_local_repo_url(self, monkeypatch):
"""
local:// 来源由本地插件安装路径处理,不访问远端仓库。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
monkeypatch.setattr(helper, "install_local", lambda pid, repo_url, force_install=False: (True, f"{pid}:{repo_url}"))
success, message = helper.install(PLUGIN_ID, f"local://{PLUGIN_ID}?path=/tmp/plugins")
assert success
assert message.startswith(f"{PLUGIN_ID}:local://{PLUGIN_ID}")
def test_install_release_download_failure_falls_back_to_filelist(self, monkeypatch):
"""
release tag 存在但 zip 下载失败时仍可回退文件列表安装。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = _patch_sync_remote_install(
helper,
monkeypatch,
{"release": True, "version": "1.2.3"},
(False, "下载资产失败502"),
(True, ""),
)
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
assert success
assert "" == message
assert ["remove", "release", "filelist", "refresh"] == calls
def test_async_install_uses_release_package_when_asset_is_available(self, monkeypatch):
"""
异步安装路径在 release 包可用时优先使用 zip 安装。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = _patch_async_remote_install(
helper,
monkeypatch,
{"release": True, "version": "1.2.3"},
(True, ""),
)
success, message = asyncio.run(
helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
)
assert success
assert "" == message
assert calls[:2] == ["remove", "release"]
assert calls[2][0] == "to_thread"
def test_async_install_falls_back_to_filelist_when_release_is_missing(self, monkeypatch):
"""
异步安装路径在 release tag 或 zip 未生成时回退文件列表安装。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = _patch_async_remote_install(
helper,
monkeypatch,
{"release": True, "version": "1.2.3"},
(False, "获取 Release 信息失败404"),
(True, ""),
)
success, message = asyncio.run(
helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
)
assert success
assert "" == message
assert calls[:3] == ["remove", "release", "filelist"]
assert calls[3][0] == "to_thread"
def test_async_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch):
"""
异步安装路径在 release 与文件列表都失败时返回文件列表错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = _patch_async_remote_install(
helper,
monkeypatch,
{"release": True, "version": "1.2.3"},
(False, "未找到资产文件demoplugin_v1.2.3.zip"),
(False, "获取文件列表失败"),
)
success, message = asyncio.run(
helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
)
assert not success
assert "获取文件列表失败" == message
assert calls == ["remove", "release", "filelist", "remove"]
def test_install_from_release_reports_missing_tag(self, monkeypatch):
"""
release tag 不存在时返回可用于降级判断的失败消息。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: _FakeResponse(404))
success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
assert not success
assert "获取 Release 信息失败404" == message
def test_install_from_release_reports_missing_asset(self, monkeypatch):
"""
release tag 存在但缺少规范 zip 资产时返回明确错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
monkeypatch.setattr(
helper,
"_PluginHelper__request_with_fallback",
lambda *_args, **_kwargs: _FakeResponse(200, {"assets": [{"name": "other.zip", "id": 1}]}),
)
success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
assert not success
assert "未找到资产文件demoplugin_v1.2.3.zip" == message
def test_install_from_release_reports_missing_asset_id(self, monkeypatch):
"""
release 资产缺少 id 时无法使用 API 下载,返回明确错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
monkeypatch.setattr(
helper,
"_PluginHelper__request_with_fallback",
lambda *_args, **_kwargs: _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip"}]}),
)
success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
assert not success
assert "资产缺少ID信息" == message
def test_install_from_release_reports_malformed_release_payload(self, monkeypatch):
"""
release API 返回无法解析的结构时返回解析错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
class BadResponse(_FakeResponse):
"""json() 抛错的响应对象。"""
def json(self):
"""模拟响应体不是合法 JSON。"""
raise ValueError("bad json")
helper = PluginHelper()
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: BadResponse(200))
success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
assert not success
assert "解析 Release 信息失败" in message
def test_install_from_release_reports_asset_download_failure(self, monkeypatch):
"""
release asset API 下载失败时返回下载错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
responses = iter([
_FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}),
_FakeResponse(502),
])
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses))
success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
assert not success
assert "下载资产失败502" == message
def test_install_from_release_extracts_zip_with_top_level_directory(self, monkeypatch, tmp_path):
"""
release zip 带顶层插件目录时剥离该层后写入运行目录。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
release_payload = {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}
zip_content = _build_zip({
"demoplugin/__init__.py": b"plugin",
"demoplugin/nested/config.json": b"{}",
})
responses = iter([
_FakeResponse(200, release_payload),
_FakeContentResponse(200, zip_content),
])
monkeypatch.setattr("app.helper.plugin.settings", SimpleNamespace(
ROOT_PATH=tmp_path,
REPO_GITHUB_HEADERS=lambda repo=None: {},
))
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses))
success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
assert success
assert "" == message
assert (tmp_path / "app" / "plugins" / "demoplugin" / "__init__.py").read_bytes() == b"plugin"
assert (tmp_path / "app" / "plugins" / "demoplugin" / "nested" / "config.json").read_bytes() == b"{}"
def test_install_from_release_creates_directory_entries(self, monkeypatch, tmp_path):
"""
release zip 内显式目录项会被创建,并继续写入后续文件。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
zf.writestr("demoplugin/assets/", b"")
zf.writestr("demoplugin/assets/icon.png", b"icon")
responses = iter([
_FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}),
_FakeContentResponse(200, buffer.getvalue()),
])
monkeypatch.setattr("app.helper.plugin.settings", SimpleNamespace(
ROOT_PATH=tmp_path,
REPO_GITHUB_HEADERS=lambda repo=None: {},
))
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses))
success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
assert success
assert "" == message
assert (tmp_path / "app" / "plugins" / "demoplugin" / "assets").is_dir()
assert (tmp_path / "app" / "plugins" / "demoplugin" / "assets" / "icon.png").read_bytes() == b"icon"
def test_install_from_release_reports_empty_zip(self, monkeypatch):
"""
release zip 为空时返回明确错误,避免安装出空插件目录。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
responses = iter([
_FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}),
_FakeContentResponse(200, _build_zip({})),
])
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses))
success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
assert not success
assert "压缩包内容为空" == message
def test_install_from_release_reports_directory_only_zip(self, monkeypatch, tmp_path):
"""
release zip 只有目录项时返回无可写入文件错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
zf.writestr("demoplugin/assets/", b"")
responses = iter([
_FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}),
_FakeContentResponse(200, buffer.getvalue()),
])
monkeypatch.setattr("app.helper.plugin.settings", SimpleNamespace(
ROOT_PATH=tmp_path,
REPO_GITHUB_HEADERS=lambda repo=None: {},
))
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses))
success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
assert not success
assert "压缩包中无可写入文件" == message
def test_install_from_release_reports_bad_zip(self, monkeypatch):
"""
release asset 不是合法 zip 时返回解压错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
responses = iter([
_FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}),
_FakeContentResponse(200, b"not a zip"),
])
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses))
success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
assert not success
assert "解压 Release 压缩包失败" in message
def test_install_flow_sync_restores_backup_when_prepare_fails(self, monkeypatch):
"""
内容准备失败时恢复备份,避免安装失败后留下半成品目录。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = []
monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: "/backup")
monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: calls.append("remove"))
monkeypatch.setattr(helper, "_PluginHelper__restore_plugin", lambda _pid, _backup: calls.append("restore"))
success, message = helper._PluginHelper__install_flow_sync(
PLUGIN_ID, False, lambda: (False, "prepare failed")
)
assert not success
assert "prepare failed" == message
assert ["remove", "restore"] == calls
def test_install_flow_sync_restores_backup_when_dependency_install_fails(self, monkeypatch):
"""
依赖安装失败时恢复备份,避免新插件内容破坏可用版本。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = []
monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: "/backup")
monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: calls.append("remove"))
monkeypatch.setattr(helper, "_PluginHelper__restore_plugin", lambda _pid, _backup: calls.append("restore"))
monkeypatch.setattr(
helper,
"_PluginHelper__install_dependencies_if_required",
lambda _pid: (True, False, "dependency failed"),
)
success, message = helper._PluginHelper__install_flow_sync(
PLUGIN_ID, False, lambda: (True, "")
)
assert not success
assert "dependency failed" == message
assert ["remove", "restore"] == calls
def test_prepare_content_via_filelist_sync_preinstalls_requirements_and_downloads(self, monkeypatch):
"""
文件列表安装会先尝试 requirements 预安装,再下载插件文件。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = []
requirements = {"name": "requirements.txt", "download_url": "https://example.com/requirements.txt"}
file_list = [requirements, {"name": "__init__.py", "download_url": "https://example.com/__init__.py"}]
monkeypatch.setattr(helper, "_PluginHelper__get_file_list", lambda *_args: (file_list, ""))
monkeypatch.setattr(
helper,
"_PluginHelper__download_and_install_requirements",
lambda *_args: calls.append("requirements") or (True, ""),
)
monkeypatch.setattr(
helper,
"_PluginHelper__download_files",
lambda *_args: calls.append("download") or (True, ""),
)
success, message = helper._PluginHelper__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2")
assert success
assert "" == message
assert ["requirements", "download"] == calls
def test_prepare_content_via_filelist_sync_continues_when_requirements_preinstall_fails(self, monkeypatch):
"""
requirements 预安装失败不阻断文件下载,最终依赖安装由统一流程兜底。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = []
file_list = [{"name": "requirements.txt"}, {"name": "__init__.py"}]
monkeypatch.setattr(helper, "_PluginHelper__get_file_list", lambda *_args: (file_list, ""))
monkeypatch.setattr(
helper,
"_PluginHelper__download_and_install_requirements",
lambda *_args: calls.append("requirements") or (False, "preinstall failed"),
)
monkeypatch.setattr(
helper,
"_PluginHelper__download_files",
lambda *_args: calls.append("download") or (True, ""),
)
success, message = helper._PluginHelper__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2")
assert success
assert "" == message
assert ["requirements", "download"] == calls
def test_prepare_content_via_filelist_sync_reports_missing_file_list(self, monkeypatch):
"""
文件列表为空时直接返回列表获取错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
monkeypatch.setattr(helper, "_PluginHelper__get_file_list", lambda *_args: ([], "list failed"))
success, message = helper._PluginHelper__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2")
assert not success
assert "list failed" == message
def test_prepare_content_via_filelist_sync_returns_download_error(self, monkeypatch):
"""
文件列表存在但文件下载失败时向上返回下载错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
monkeypatch.setattr(helper, "_PluginHelper__get_file_list", lambda *_args: ([{"name": "__init__.py"}], ""))
monkeypatch.setattr(helper, "_PluginHelper__download_files", lambda *_args: (False, "download failed"))
success, message = helper._PluginHelper__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2")
assert not success
assert "download failed" == message
def test_async_prepare_content_via_filelist_preinstalls_requirements_and_downloads(self, monkeypatch):
"""
异步文件列表安装会先尝试 requirements 预安装,再下载插件文件。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = []
requirements = {"name": "requirements.txt", "download_url": "https://example.com/requirements.txt"}
file_list = [requirements, {"name": "__init__.py", "download_url": "https://example.com/__init__.py"}]
async def fake_file_list(*_args):
return file_list, ""
async def fake_requirements(*_args):
calls.append("requirements")
return True, ""
async def fake_download(*_args):
calls.append("download")
return True, ""
monkeypatch.setattr(helper, "_PluginHelper__async_get_file_list", fake_file_list)
monkeypatch.setattr(helper, "_PluginHelper__async_download_and_install_requirements", fake_requirements)
monkeypatch.setattr(helper, "_PluginHelper__async_download_files", fake_download)
success, message = asyncio.run(
helper._PluginHelper__prepare_content_via_filelist_async("demoplugin", "demo/repo", "v2")
)
assert success
assert "" == message
assert ["requirements", "download"] == calls
def test_async_prepare_content_via_filelist_reports_missing_file_list(self, monkeypatch):
"""
异步文件列表为空时直接返回列表获取错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
async def fake_file_list(*_args):
return [], "list failed"
monkeypatch.setattr(helper, "_PluginHelper__async_get_file_list", fake_file_list)
success, message = asyncio.run(
helper._PluginHelper__prepare_content_via_filelist_async("demoplugin", "demo/repo", "v2")
)
assert not success
assert "list failed" == message
def test_async_prepare_content_via_filelist_returns_download_error(self, monkeypatch):
"""
异步文件列表下载失败时向上返回下载错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
async def fake_file_list(*_args):
return [{"name": "__init__.py"}], ""
async def fake_download(*_args):
return False, "download failed"
monkeypatch.setattr(helper, "_PluginHelper__async_get_file_list", fake_file_list)
monkeypatch.setattr(helper, "_PluginHelper__async_download_files", fake_download)
success, message = asyncio.run(
helper._PluginHelper__prepare_content_via_filelist_async("demoplugin", "demo/repo", "v2")
)
assert not success
assert "download failed" == message
def test_install_flow_async_restores_backup_when_prepare_fails(self, monkeypatch):
"""
异步内容准备失败时恢复备份。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = []
async def backup(_pid):
return "/backup"
async def remove(_pid):
calls.append("remove")
async def restore(_pid, _backup):
calls.append("restore")
async def prepare():
return False, "prepare failed"
monkeypatch.setattr(helper, "_PluginHelper__async_backup_plugin", backup)
monkeypatch.setattr(helper, "_PluginHelper__async_remove_old_plugin", remove)
monkeypatch.setattr(helper, "_PluginHelper__async_restore_plugin", restore)
success, message = asyncio.run(helper._PluginHelper__install_flow_async(PLUGIN_ID, False, prepare))
assert not success
assert "prepare failed" == message
assert ["remove", "restore"] == calls
def test_install_flow_async_restores_backup_when_dependency_install_fails(self, monkeypatch):
"""
异步依赖安装失败时恢复备份。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = []
async def backup(_pid):
return "/backup"
async def remove(_pid):
calls.append("remove")
async def restore(_pid, _backup):
calls.append("restore")
async def prepare():
return True, ""
async def dependencies(_pid):
return True, False, "dependency failed"
monkeypatch.setattr(helper, "_PluginHelper__async_backup_plugin", backup)
monkeypatch.setattr(helper, "_PluginHelper__async_remove_old_plugin", remove)
monkeypatch.setattr(helper, "_PluginHelper__async_restore_plugin", restore)
monkeypatch.setattr(helper, "_PluginHelper__async_install_dependencies_if_required", dependencies)
success, message = asyncio.run(helper._PluginHelper__install_flow_async(PLUGIN_ID, False, prepare))
assert not success
assert "dependency failed" == message
assert ["remove", "restore"] == calls
def test_async_install_from_release_reports_missing_asset(self, monkeypatch):
"""
异步 release tag 存在但缺少规范 zip 资产时返回明确错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
async def fake_request(*_args, **_kwargs):
return _FakeResponse(200, {"assets": [{"name": "other.zip", "id": 1}]})
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
success, message = asyncio.run(
helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
)
assert not success
assert "未找到资产文件demoplugin_v1.2.3.zip" == message
def test_async_install_from_release_reports_missing_tag(self, monkeypatch):
"""
异步 release tag 不存在时返回获取 release 失败。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
async def fake_request(*_args, **_kwargs):
return _FakeResponse(404)
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
success, message = asyncio.run(
helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
)
assert not success
assert "获取 Release 信息失败404" == message
def test_async_install_from_release_reports_missing_asset_id(self, monkeypatch):
"""
异步 release 资产缺少 id 时返回明确错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
async def fake_request(*_args, **_kwargs):
return _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip"}]})
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
success, message = asyncio.run(
helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
)
assert not success
assert "资产缺少ID信息" == message
def test_async_install_from_release_reports_asset_download_failure(self, monkeypatch):
"""
异步 release asset 下载失败时返回下载错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
responses = iter([
_FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}),
_FakeResponse(502),
])
async def fake_request(*_args, **_kwargs):
return next(responses)
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
success, message = asyncio.run(
helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
)
assert not success
assert "下载资产失败502" == message
def test_async_install_from_release_extracts_zip_with_top_level_directory(self, monkeypatch, tmp_path):
"""
异步 release zip 带顶层插件目录时剥离该层后写入运行目录。
"""
try:
from app.core.config import settings
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
responses = iter([
_FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}),
_FakeContentResponse(200, _build_zip({"demoplugin/__init__.py": b"plugin"})),
])
async def fake_request(*_args, **_kwargs):
return next(responses)
monkeypatch.setattr("app.helper.plugin.settings", SimpleNamespace(
ROOT_PATH=tmp_path,
REPO_GITHUB_HEADERS=lambda repo=None: {},
))
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
success, message = asyncio.run(
helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
)
assert success
assert "" == message
assert (tmp_path / "app" / "plugins" / "demoplugin" / "__init__.py").read_bytes() == b"plugin"
def test_async_install_from_release_reports_empty_zip(self, monkeypatch):
"""
异步 release zip 为空时返回明确错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
responses = iter([
_FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}),
_FakeContentResponse(200, _build_zip({})),
])
async def fake_request(*_args, **_kwargs):
return next(responses)
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
success, message = asyncio.run(
helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
)
assert not success
assert "压缩包内容为空" == message
def test_async_install_from_release_reports_bad_zip(self, monkeypatch):
"""
异步 release asset 不是合法 zip 时返回解压错误。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
responses = iter([
_FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}),
_FakeContentResponse(200, b"not a zip"),
])
async def fake_request(*_args, **_kwargs):
return next(responses)
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
success, message = asyncio.run(
helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
)
assert not success
assert "解压 Release 压缩包失败" in message
def test_install_local_rejects_mismatched_local_repo_id(self):
"""
本地插件来源中的插件 ID 必须与安装目标一致。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
success, message = PluginHelper().install("DemoPlugin", "local://OtherPlugin?path=/tmp/plugins")
assert not success
assert "本地插件来源与插件ID不匹配" == message