mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-08 08:11:31 +08:00
feat(plugin): support installing release versions (#5964)
This commit is contained in:
@@ -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 与文件列表都失败时返回文件列表错误,并保持失败清理顺序稳定。
|
||||
|
||||
Reference in New Issue
Block a user