feat(plugin): support installing release versions (#5964)

This commit is contained in:
InfinityPacer
2026-06-18 15:47:07 +08:00
committed by GitHub
parent 80d440f6a0
commit 69ed70cc66
6 changed files with 691 additions and 2 deletions

View File

@@ -39,6 +39,18 @@ class _FakeContentResponse(_FakeResponse):
self.content = content
class _FakeTextResponse(_FakeResponse):
"""带文本正文的响应对象,用于模拟 GitHub release 列表响应。"""
def __init__(self, status_code: int, payload: list[dict] | dict):
super().__init__(status_code, payload if isinstance(payload, dict) else {})
self._payload = payload
def json(self):
"""返回构造时注入的 JSON payload。"""
return self._payload
def _build_zip(entries: dict[str, bytes]) -> bytes:
"""构造内存 zip 包,键为包内路径、值为文件内容。"""
buffer = io.BytesIO()
@@ -204,6 +216,134 @@ class TestPluginHelper:
assert success
assert "" == message
def test_get_plugin_release_versions_keeps_only_matching_zip_assets(self, monkeypatch):
"""
release 版本列表只暴露符合插件 tag 规范且存在同名 zip 资产的版本。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
payload = [
{
"tag_name": "DemoPlugin_v1.2.3",
"name": "DemoPlugin v1.2.3",
"published_at": "2026-06-01T00:00:00Z",
"body": "稳定版本",
"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 1}],
},
{
"tag_name": "DemoPlugin_v1.2.2",
"name": "missing asset",
"assets": [{"name": "other.zip", "id": 2}],
},
{
"tag_name": "OtherPlugin_v9.9.9",
"name": "other plugin",
"assets": [{"name": "otherplugin_v9.9.9.zip", "id": 3}],
},
]
helper = PluginHelper()
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: _FakeTextResponse(200, payload))
releases = helper.get_plugin_release_versions(PLUGIN_ID, REPO_URL)
assert releases == [
{
"version": "1.2.3",
"tag_name": "DemoPlugin_v1.2.3",
"name": "DemoPlugin v1.2.3",
"published_at": "2026-06-01T00:00:00Z",
"body": "稳定版本",
"asset_name": "demoplugin_v1.2.3.zip",
}
]
def test_get_plugin_release_versions_uses_cache_buster_during_fresh_context(self, monkeypatch):
"""
插件市场强制刷新时 Release 列表请求也要绕过 GitHub 镜像或代理缓存。
"""
try:
from app.core.cache import fresh
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
requested_urls = []
def fake_request(url, **_kwargs):
requested_urls.append(url)
return _FakeTextResponse(200, [])
helper = PluginHelper()
helper.get_plugin_release_versions.cache_clear()
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request)
with patch("app.helper.plugin.time.time_ns", return_value=1234567890):
with fresh(True):
helper.get_plugin_release_versions(PLUGIN_ID, REPO_URL)
assert requested_urls == [
"https://api.github.com/repos/demo/MoviePilot-Plugins/releases?per_page=100&page=1&_refresh=1234567890"
]
def test_get_plugin_release_versions_fetches_multiple_pages(self, monkeypatch):
"""
多插件共用 Release 列表时需要分页,避免目标插件历史发行版被第一页之外的数据遮蔽。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
payload_by_page = {
"1": [
{
"tag_name": f"OtherPlugin_v9.9.{index}",
"assets": [{"name": f"otherplugin_v9.9.{index}.zip", "id": index}],
}
for index in range(100)
],
"2": [{"tag_name": "DemoPlugin_v1.2.0", "assets": [{"name": "demoplugin_v1.2.0.zip", "id": 2}]}],
}
requested_pages = []
def fake_request(url, **_kwargs):
page = url.rsplit("page=", 1)[1].split("&", 1)[0]
requested_pages.append(page)
return _FakeTextResponse(200, payload_by_page[page])
helper = PluginHelper()
helper.get_plugin_release_versions.cache_clear()
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request)
releases = helper.get_plugin_release_versions(PLUGIN_ID, REPO_URL)
assert requested_pages == ["1", "2"]
assert [item["version"] for item in releases] == ["1.2.0"]
def test_get_online_plugins_force_clears_release_cache(self, monkeypatch):
"""
插件市场缓存刷新会一并清理 Release 列表缓存,覆盖定时刷新服务入口。
"""
try:
from app.core.plugin import PluginManager
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
clear_calls = []
fake_release_method = SimpleNamespace(cache_clear=lambda: clear_calls.append("clear"))
fake_helper = SimpleNamespace(get_plugin_release_versions=fake_release_method)
monkeypatch.setattr("app.core.plugin.settings.PLUGIN_MARKET", "https://github.com/demo/plugins")
monkeypatch.setattr("app.core.plugin.PluginHelper", lambda: fake_helper)
monkeypatch.setattr(PluginManager, "get_plugins_from_market", lambda *_args, **_kwargs: [])
PluginManager().get_online_plugins(force=True)
assert clear_calls == ["clear"]
def test_annotate_plugin_system_version_marks_incompatible(self):
"""
插件市场列表会带出系统版本兼容状态,供前端禁用安装入口。
@@ -660,6 +800,99 @@ class TestPluginHelper:
assert "MoviePilot 版本 >=9.0.0" in message
assert [] == calls
def test_install_rejects_latest_release_version_when_system_version_is_incompatible(self, monkeypatch):
"""
指定安装当前最新 release 时仍按当前 package 元数据校验主程序版本。
"""
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"))
monkeypatch.setattr(
helper,
"get_plugin_release_versions",
lambda *_args: [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}],
)
success, message = helper.install(
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.3", force_install=True
)
assert not success
assert "MoviePilot 版本 >=9.0.0" in message
assert [] == calls
def test_install_old_release_version_uses_release_asset_without_filelist_fallback(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"},
(False, "未找到资产文件demoplugin_v1.2.0.zip"),
(True, ""),
)
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.0.0"))
monkeypatch.setattr(
helper,
"get_plugin_release_versions",
lambda *_args: [{"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0"}],
)
success, message = helper.install(
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True
)
assert not success
assert "未找到资产文件demoplugin_v1.2.0.zip" == message
assert ["remove", "release", "remove"] == calls
def test_install_rejects_release_version_missing_from_release_list(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, "version": "1.2.3"},
(True, ""),
)
monkeypatch.setattr(
helper,
"get_plugin_release_versions",
lambda *_args: [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}],
)
success, message = helper.install(
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True
)
assert not success
assert f"{PLUGIN_ID} 未找到可安装的 Release 版本1.2.0" == message
assert [] == calls
def test_install_rejects_invalid_parameters_before_remote_lookup(self):
"""
远端安装缺少插件 ID 或仓库地址时直接拒绝。
@@ -824,6 +1057,72 @@ class TestPluginHelper:
assert calls[:4] == ["remove", "release", "remove", "filelist"]
assert calls[4][0] == "to_thread"
def test_async_install_old_release_version_uses_release_asset_without_filelist_fallback(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", "system_version": ">=9.0.0"},
(False, "未找到资产文件demoplugin_v1.2.0.zip"),
(True, ""),
)
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.0.0"))
async def fake_releases(*_args):
return [{"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0"}]
monkeypatch.setattr(helper, "async_get_plugin_release_versions", fake_releases)
success, message = asyncio.run(
helper.async_install(
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True
)
)
assert not success
assert "未找到资产文件demoplugin_v1.2.0.zip" == message
assert calls[:3] == ["remove", "release", "remove"]
def test_async_install_rejects_release_version_missing_from_release_list(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"},
(True, ""),
)
async def fake_releases(*_args):
return [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}]
monkeypatch.setattr(helper, "async_get_plugin_release_versions", fake_releases)
success, message = asyncio.run(
helper.async_install(
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True
)
)
assert not success
assert f"{PLUGIN_ID} 未找到可安装的 Release 版本1.2.0" == message
assert [] == calls
def test_async_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch):
"""
异步安装路径在 release 与文件列表都失败时返回文件列表错误,并保持失败清理顺序稳定。