mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat(plugin): support installing release versions (#5964)
This commit is contained in:
@@ -11,6 +11,7 @@ from starlette.responses import StreamingResponse
|
|||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.command import Command
|
from app.command import Command
|
||||||
|
from app.core.cache import async_fresh
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.event import eventmanager
|
from app.core.event import eventmanager
|
||||||
from app.core.plugin import PluginManager
|
from app.core.plugin import PluginManager
|
||||||
@@ -161,6 +162,7 @@ def _merge_plugin_market_metadata(
|
|||||||
"""
|
"""
|
||||||
plugin.repo_url = market_plugin.repo_url or plugin.repo_url
|
plugin.repo_url = market_plugin.repo_url or plugin.repo_url
|
||||||
plugin.history = market_plugin.history or {}
|
plugin.history = market_plugin.history or {}
|
||||||
|
plugin.release = market_plugin.release
|
||||||
plugin.has_update = market_plugin.has_update
|
plugin.has_update = market_plugin.has_update
|
||||||
plugin.system_version = market_plugin.system_version or plugin.system_version
|
plugin.system_version = market_plugin.system_version or plugin.system_version
|
||||||
plugin.system_version_compatible = market_plugin.system_version_compatible
|
plugin.system_version_compatible = market_plugin.system_version_compatible
|
||||||
@@ -336,6 +338,80 @@ async def plugin_history(
|
|||||||
return plugin
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/releases/{plugin_id}", summary="获取插件Release版本", response_model=dict)
|
||||||
|
async def plugin_releases(
|
||||||
|
plugin_id: str,
|
||||||
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
|
repo_url: Optional[str] = "",
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
查询指定插件可直接安装的 GitHub Release 版本。
|
||||||
|
"""
|
||||||
|
if not repo_url:
|
||||||
|
return {
|
||||||
|
"release_supported": False,
|
||||||
|
"latest_version": None,
|
||||||
|
"current_version": None,
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin_manager = PluginManager()
|
||||||
|
online_plugins = await plugin_manager.async_get_online_plugins(force=force)
|
||||||
|
market_plugin = next(
|
||||||
|
(
|
||||||
|
plugin
|
||||||
|
for plugin in online_plugins
|
||||||
|
if plugin.id == plugin_id and plugin.repo_url == repo_url
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not market_plugin:
|
||||||
|
market_plugin = next(
|
||||||
|
(
|
||||||
|
plugin
|
||||||
|
for plugin in online_plugins
|
||||||
|
if plugin.id == plugin_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
installed_plugin = next(
|
||||||
|
(
|
||||||
|
plugin
|
||||||
|
for plugin in plugin_manager.get_local_plugins()
|
||||||
|
if plugin.id == plugin_id and plugin.installed
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
latest_version = market_plugin.plugin_version if market_plugin else None
|
||||||
|
current_version = installed_plugin.plugin_version if installed_plugin else None
|
||||||
|
if not getattr(market_plugin, "release", False):
|
||||||
|
return {
|
||||||
|
"release_supported": False,
|
||||||
|
"latest_version": latest_version,
|
||||||
|
"current_version": current_version,
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
async with async_fresh(force):
|
||||||
|
release_items = await PluginHelper().async_get_plugin_release_versions(plugin_id, repo_url)
|
||||||
|
items = []
|
||||||
|
for item in release_items:
|
||||||
|
version = item.get("version")
|
||||||
|
copied_item = item.copy()
|
||||||
|
copied_item["is_latest"] = bool(latest_version and version == latest_version)
|
||||||
|
copied_item["is_current"] = bool(current_version and version == current_version)
|
||||||
|
items.append(copied_item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"release_supported": bool(items),
|
||||||
|
"latest_version": latest_version,
|
||||||
|
"current_version": current_version,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/statistic", summary="插件安装统计", response_model=dict)
|
@router.get("/statistic", summary="插件安装统计", response_model=dict)
|
||||||
async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -364,6 +440,7 @@ def reload_plugin(
|
|||||||
async def install(
|
async def install(
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
repo_url: Optional[str] = "",
|
repo_url: Optional[str] = "",
|
||||||
|
release_version: Optional[str] = None,
|
||||||
force: Optional[bool] = False,
|
force: Optional[bool] = False,
|
||||||
_: User = Depends(get_current_active_superuser_async),
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -386,7 +463,7 @@ async def install(
|
|||||||
# 插件不存在或需要强制安装,下载安装并注册插件
|
# 插件不存在或需要强制安装,下载安装并注册插件
|
||||||
if repo_url:
|
if repo_url:
|
||||||
state, msg = await plugin_helper.async_install(
|
state, msg = await plugin_helper.async_install(
|
||||||
pid=plugin_id, repo_url=repo_url, force_install=force
|
pid=plugin_id, repo_url=repo_url, release_version=release_version, force_install=force
|
||||||
)
|
)
|
||||||
# 安装失败则直接响应
|
# 安装失败则直接响应
|
||||||
if not state:
|
if not state:
|
||||||
|
|||||||
@@ -1199,6 +1199,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
if not settings.PLUGIN_MARKET:
|
if not settings.PLUGIN_MARKET:
|
||||||
return []
|
return []
|
||||||
|
if force:
|
||||||
|
PluginHelper().get_plugin_release_versions.cache_clear()
|
||||||
|
|
||||||
# 用于存储高于 v1 版本的插件(如 v2, v3 等)
|
# 用于存储高于 v1 版本的插件(如 v2, v3 等)
|
||||||
higher_version_plugins = []
|
higher_version_plugins = []
|
||||||
@@ -1546,6 +1548,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
# 更新历史
|
# 更新历史
|
||||||
if plugin_info.get("history"):
|
if plugin_info.get("history"):
|
||||||
plugin.history = plugin_info.get("history")
|
plugin.history = plugin_info.get("history")
|
||||||
|
# Release 能力位来自插件市场索引,用于前端展示和后端安装入口双重校验。
|
||||||
|
plugin.release = bool(plugin_info.get("release"))
|
||||||
# 仓库链接
|
# 仓库链接
|
||||||
plugin.repo_url = market
|
plugin.repo_url = market
|
||||||
# 本地标志
|
# 本地标志
|
||||||
@@ -1562,6 +1566,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
if not settings.PLUGIN_MARKET:
|
if not settings.PLUGIN_MARKET:
|
||||||
return []
|
return []
|
||||||
|
if force:
|
||||||
|
await PluginHelper().async_get_plugin_release_versions.cache_clear()
|
||||||
|
|
||||||
# 用于存储高于 v1 版本的插件(如 v2, v3 等)
|
# 用于存储高于 v1 版本的插件(如 v2, v3 等)
|
||||||
higher_version_plugins = []
|
higher_version_plugins = []
|
||||||
|
|||||||
+174
-1
@@ -409,6 +409,55 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __build_plugin_release_item(pid: str, release_info: dict) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
从 GitHub release 响应中提取可安装版本,仅接受规范 tag 与同名 zip 资产。
|
||||||
|
"""
|
||||||
|
if not isinstance(release_info, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
tag_name = release_info.get("tag_name")
|
||||||
|
if not isinstance(tag_name, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
tag_prefix = f"{pid}_v"
|
||||||
|
if not tag_name.startswith(tag_prefix):
|
||||||
|
return None
|
||||||
|
|
||||||
|
version = tag_name[len(tag_prefix):]
|
||||||
|
if not version:
|
||||||
|
return None
|
||||||
|
|
||||||
|
asset_name = f"{tag_name.lower()}.zip"
|
||||||
|
assets = release_info.get("assets") or []
|
||||||
|
if not any(isinstance(asset, dict) and asset.get("name") == asset_name for asset in assets):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": version,
|
||||||
|
"tag_name": tag_name,
|
||||||
|
"name": release_info.get("name") or tag_name,
|
||||||
|
"published_at": release_info.get("published_at"),
|
||||||
|
"body": release_info.get("body") or "",
|
||||||
|
"asset_name": asset_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __parse_plugin_release_response(pid: str, payload) -> List[dict]:
|
||||||
|
"""
|
||||||
|
解析 GitHub release 列表,过滤出当前插件可直接安装的 release 资产。
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
releases = []
|
||||||
|
for release_info in payload:
|
||||||
|
item = PluginHelper.__build_plugin_release_item(pid, release_info)
|
||||||
|
if item:
|
||||||
|
releases.append(item)
|
||||||
|
return releases
|
||||||
|
|
||||||
@cached(maxsize=128, ttl=1800)
|
@cached(maxsize=128, ttl=1800)
|
||||||
def get_plugins(self, repo_url: str,
|
def get_plugins(self, repo_url: str,
|
||||||
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
||||||
@@ -437,6 +486,51 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
return None
|
return None
|
||||||
return self.__parse_plugin_index_response(res.text)
|
return self.__parse_plugin_index_response(res.text)
|
||||||
|
|
||||||
|
@cached(maxsize=128, ttl=1800)
|
||||||
|
def get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]:
|
||||||
|
"""
|
||||||
|
获取插件可安装的 GitHub Release 版本列表。
|
||||||
|
"""
|
||||||
|
if not pid or not repo_url:
|
||||||
|
return []
|
||||||
|
|
||||||
|
user, repo = self.get_repo_info(repo_url)
|
||||||
|
if not user or not repo:
|
||||||
|
return []
|
||||||
|
|
||||||
|
user_repo = f"{user}/{repo}"
|
||||||
|
releases = []
|
||||||
|
for page in range(1, 11):
|
||||||
|
release_api = f"https://api.github.com/repos/{user_repo}/releases?per_page=100&page={page}"
|
||||||
|
release_api = self.__append_cache_buster(release_api)
|
||||||
|
res = self.__request_with_fallback(
|
||||||
|
release_api,
|
||||||
|
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),
|
||||||
|
timeout=30,
|
||||||
|
is_api=True,
|
||||||
|
)
|
||||||
|
if res is None or res.status_code != 200:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = res.json()
|
||||||
|
if not payload:
|
||||||
|
break
|
||||||
|
releases.extend(self.__parse_plugin_release_response(pid, payload))
|
||||||
|
if len(payload) < 100:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解析插件 {pid} Release 列表失败:{e}")
|
||||||
|
break
|
||||||
|
return releases
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __has_installable_release_version(release_items: List[dict], release_version: str) -> bool:
|
||||||
|
"""
|
||||||
|
指定版本必须来自已解析出的可安装 Release 列表,避免直接拼接任意 tag。
|
||||||
|
"""
|
||||||
|
return any(item.get("version") == release_version for item in release_items)
|
||||||
|
|
||||||
def get_plugin_package_version(self, pid: str, repo_url: str,
|
def get_plugin_package_version(self, pid: str, repo_url: str,
|
||||||
package_version: Optional[str] = None) -> Optional[str]:
|
package_version: Optional[str] = None) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
@@ -485,7 +579,8 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
return None, None
|
return None, None
|
||||||
return user, repo
|
return user, repo
|
||||||
|
|
||||||
def install(self, pid: str, repo_url: str, package_version: Optional[str] = None, force_install: bool = False) \
|
def install(self, pid: str, repo_url: str, package_version: Optional[str] = None,
|
||||||
|
release_version: Optional[str] = None, force_install: bool = False) \
|
||||||
-> Tuple[bool, str]:
|
-> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略
|
安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略
|
||||||
@@ -498,6 +593,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
:param pid: 插件 ID
|
:param pid: 插件 ID
|
||||||
:param repo_url: 插件仓库地址
|
:param repo_url: 插件仓库地址
|
||||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
|
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
|
||||||
|
:param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本
|
||||||
:param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作
|
:param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作
|
||||||
:return: (是否成功, 错误信息)
|
:return: (是否成功, 错误信息)
|
||||||
"""
|
"""
|
||||||
@@ -541,6 +637,25 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
is_release = meta.get("release")
|
is_release = meta.get("release")
|
||||||
# 插件版本号
|
# 插件版本号
|
||||||
plugin_version = meta.get("version")
|
plugin_version = meta.get("version")
|
||||||
|
if release_version:
|
||||||
|
if not is_release:
|
||||||
|
return False, f"{pid} 未声明 Release 安装,无法安装指定版本"
|
||||||
|
if not self.__has_installable_release_version(
|
||||||
|
self.get_plugin_release_versions(pid, repo_url), release_version
|
||||||
|
):
|
||||||
|
return False, f"{pid} 未找到可安装的 Release 版本:{release_version}"
|
||||||
|
if release_version == plugin_version:
|
||||||
|
compatible, message = self.check_plugin_system_version(meta)
|
||||||
|
if not compatible:
|
||||||
|
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
|
||||||
|
return False, message
|
||||||
|
release_tag = f"{pid}_v{release_version}"
|
||||||
|
|
||||||
|
def prepare_selected_release() -> Tuple[bool, str]:
|
||||||
|
return self.__install_from_release(pid, user_repo, release_tag)
|
||||||
|
|
||||||
|
return self.__install_flow_sync(pid, force_install, prepare_selected_release, repo_url)
|
||||||
|
|
||||||
compatible, message = self.check_plugin_system_version(meta)
|
compatible, message = self.check_plugin_system_version(meta)
|
||||||
if not compatible:
|
if not compatible:
|
||||||
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
|
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
|
||||||
@@ -1885,6 +2000,44 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
return None
|
return None
|
||||||
return self.__parse_plugin_index_response(res.text)
|
return self.__parse_plugin_index_response(res.text)
|
||||||
|
|
||||||
|
@cached(maxsize=128, ttl=1800)
|
||||||
|
async def async_get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]:
|
||||||
|
"""
|
||||||
|
异步获取插件可安装的 GitHub Release 版本列表。
|
||||||
|
"""
|
||||||
|
if not pid or not repo_url:
|
||||||
|
return []
|
||||||
|
|
||||||
|
user, repo = self.get_repo_info(repo_url)
|
||||||
|
if not user or not repo:
|
||||||
|
return []
|
||||||
|
|
||||||
|
user_repo = f"{user}/{repo}"
|
||||||
|
releases = []
|
||||||
|
for page in range(1, 11):
|
||||||
|
release_api = f"https://api.github.com/repos/{user_repo}/releases?per_page=100&page={page}"
|
||||||
|
release_api = self.__append_cache_buster(release_api)
|
||||||
|
res = await self.__async_request_with_fallback(
|
||||||
|
release_api,
|
||||||
|
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),
|
||||||
|
timeout=30,
|
||||||
|
is_api=True,
|
||||||
|
)
|
||||||
|
if res is None or res.status_code != 200:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = res.json()
|
||||||
|
if not payload:
|
||||||
|
break
|
||||||
|
releases.extend(self.__parse_plugin_release_response(pid, payload))
|
||||||
|
if len(payload) < 100:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解析插件 {pid} Release 列表失败:{e}")
|
||||||
|
break
|
||||||
|
return releases
|
||||||
|
|
||||||
async def __async_get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \
|
async def __async_get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \
|
||||||
Tuple[Optional[list], Optional[str]]:
|
Tuple[Optional[list], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
@@ -2243,6 +2396,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
async def async_install(self, pid: str, repo_url: str, package_version: Optional[str] = None,
|
async def async_install(self, pid: str, repo_url: str, package_version: Optional[str] = None,
|
||||||
|
release_version: Optional[str] = None,
|
||||||
force_install: bool = False) -> Tuple[bool, str]:
|
force_install: bool = False) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
异步安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略
|
异步安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略
|
||||||
@@ -2255,6 +2409,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
:param pid: 插件 ID
|
:param pid: 插件 ID
|
||||||
:param repo_url: 插件仓库地址
|
:param repo_url: 插件仓库地址
|
||||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
|
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
|
||||||
|
:param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本
|
||||||
:param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作
|
:param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作
|
||||||
:return: (是否成功, 错误信息)
|
:return: (是否成功, 错误信息)
|
||||||
"""
|
"""
|
||||||
@@ -2297,6 +2452,24 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
is_release = meta.get("release")
|
is_release = meta.get("release")
|
||||||
# 插件版本号
|
# 插件版本号
|
||||||
plugin_version = meta.get("version")
|
plugin_version = meta.get("version")
|
||||||
|
if release_version:
|
||||||
|
if not is_release:
|
||||||
|
return False, f"{pid} 未声明 Release 安装,无法安装指定版本"
|
||||||
|
release_items = await self.async_get_plugin_release_versions(pid, repo_url)
|
||||||
|
if not self.__has_installable_release_version(release_items, release_version):
|
||||||
|
return False, f"{pid} 未找到可安装的 Release 版本:{release_version}"
|
||||||
|
if release_version == plugin_version:
|
||||||
|
compatible, message = self.check_plugin_system_version(meta)
|
||||||
|
if not compatible:
|
||||||
|
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
|
||||||
|
return False, message
|
||||||
|
release_tag = f"{pid}_v{release_version}"
|
||||||
|
|
||||||
|
async def prepare_selected_release() -> Tuple[bool, str]:
|
||||||
|
return await self.__async_install_from_release(pid, user_repo, release_tag)
|
||||||
|
|
||||||
|
return await self.__install_flow_async(pid, force_install, prepare_selected_release, repo_url)
|
||||||
|
|
||||||
compatible, message = self.check_plugin_system_version(meta)
|
compatible, message = self.check_plugin_system_version(meta)
|
||||||
if not compatible:
|
if not compatible:
|
||||||
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
|
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class Plugin(BaseModel):
|
|||||||
system_version_message: Optional[str] = None
|
system_version_message: Optional[str] = None
|
||||||
# 主系统版本限定范围
|
# 主系统版本限定范围
|
||||||
system_version: Optional[str] = None
|
system_version: Optional[str] = None
|
||||||
|
# 是否声明支持通过 GitHub Release 资产安装
|
||||||
|
release: Optional[bool] = False
|
||||||
# 是否本地
|
# 是否本地
|
||||||
is_local: Optional[bool] = False
|
is_local: Optional[bool] = False
|
||||||
# 仓库地址
|
# 仓库地址
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.api.endpoints.plugin import plugin_history
|
from app.api.endpoints.plugin import plugin_history
|
||||||
|
from app.api.endpoints.plugin import plugin_releases
|
||||||
from app.api.endpoints.plugin import reset_plugin
|
from app.api.endpoints.plugin import reset_plugin
|
||||||
from app.api.endpoints.system import sync_plugin_market_from_wiki
|
from app.api.endpoints.system import sync_plugin_market_from_wiki
|
||||||
from app.schemas.event import PluginDataResetEventData
|
from app.schemas.event import PluginDataResetEventData
|
||||||
@@ -64,6 +65,137 @@ def test_plugin_history_returns_installed_plugin_when_remote_missing():
|
|||||||
assert result.history == {}
|
assert result.history == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_releases_returns_supported_versions_with_latest_and_current(monkeypatch):
|
||||||
|
"""
|
||||||
|
release 列表接口返回可安装版本,并标记当前 package 最新版本与本地已安装版本。
|
||||||
|
"""
|
||||||
|
market_plugin = schemas.Plugin(
|
||||||
|
id="DemoPlugin",
|
||||||
|
plugin_version="1.2.3",
|
||||||
|
repo_url="https://github.com/demo/plugins",
|
||||||
|
release=True,
|
||||||
|
)
|
||||||
|
installed_plugin = schemas.Plugin(
|
||||||
|
id="DemoPlugin",
|
||||||
|
plugin_version="1.2.0",
|
||||||
|
installed=True,
|
||||||
|
)
|
||||||
|
plugin_manager = MagicMock()
|
||||||
|
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
|
||||||
|
plugin_manager.get_local_plugins.return_value = [installed_plugin]
|
||||||
|
plugin_helper = MagicMock()
|
||||||
|
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[
|
||||||
|
{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"},
|
||||||
|
{"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0", "asset_name": "demoplugin_v1.2.0.zip"},
|
||||||
|
])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager),
|
||||||
|
patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper),
|
||||||
|
):
|
||||||
|
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False))
|
||||||
|
|
||||||
|
assert result["release_supported"] is True
|
||||||
|
assert result["latest_version"] == "1.2.3"
|
||||||
|
assert result["current_version"] == "1.2.0"
|
||||||
|
assert result["items"][0]["is_latest"] is True
|
||||||
|
assert result["items"][0]["is_current"] is False
|
||||||
|
assert result["items"][1]["is_latest"] is False
|
||||||
|
assert result["items"][1]["is_current"] is True
|
||||||
|
plugin_manager.async_get_online_plugins.assert_awaited_once_with(force=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch):
|
||||||
|
"""
|
||||||
|
接口标记当前/最新版本时不能修改 helper 返回对象,避免污染缓存中的 release 列表。
|
||||||
|
"""
|
||||||
|
market_plugin = schemas.Plugin(
|
||||||
|
id="DemoPlugin",
|
||||||
|
plugin_version="1.2.3",
|
||||||
|
repo_url="https://github.com/demo/plugins",
|
||||||
|
release=True,
|
||||||
|
)
|
||||||
|
installed_plugin = schemas.Plugin(
|
||||||
|
id="DemoPlugin",
|
||||||
|
plugin_version="1.2.0",
|
||||||
|
installed=True,
|
||||||
|
)
|
||||||
|
release_items = [
|
||||||
|
{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"},
|
||||||
|
]
|
||||||
|
plugin_manager = MagicMock()
|
||||||
|
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
|
||||||
|
plugin_manager.get_local_plugins.return_value = [installed_plugin]
|
||||||
|
plugin_helper = MagicMock()
|
||||||
|
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=release_items)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager),
|
||||||
|
patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper),
|
||||||
|
):
|
||||||
|
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False))
|
||||||
|
|
||||||
|
assert result["items"][0]["is_latest"] is True
|
||||||
|
assert "is_latest" not in release_items[0]
|
||||||
|
assert "is_current" not in release_items[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch):
|
||||||
|
"""
|
||||||
|
release 列表接口沿用插件市场的 force 语义,供前端手动刷新时绕过缓存。
|
||||||
|
"""
|
||||||
|
market_plugin = schemas.Plugin(
|
||||||
|
id="DemoPlugin",
|
||||||
|
plugin_version="1.2.3",
|
||||||
|
repo_url="https://github.com/demo/plugins",
|
||||||
|
release=True,
|
||||||
|
)
|
||||||
|
plugin_manager = MagicMock()
|
||||||
|
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
|
||||||
|
plugin_manager.get_local_plugins.return_value = []
|
||||||
|
plugin_helper = MagicMock()
|
||||||
|
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager),
|
||||||
|
patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper),
|
||||||
|
):
|
||||||
|
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", True))
|
||||||
|
|
||||||
|
assert result["release_supported"] is False
|
||||||
|
plugin_manager.async_get_online_plugins.assert_awaited_once_with(force=True)
|
||||||
|
assert plugin_helper.async_get_plugin_release_versions.await_args.args == ("DemoPlugin", "https://github.com/demo/plugins")
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release(monkeypatch):
|
||||||
|
"""
|
||||||
|
接口是否支持 Release 安装要与当前 package 的 release 声明保持一致。
|
||||||
|
"""
|
||||||
|
market_plugin = schemas.Plugin(
|
||||||
|
id="DemoPlugin",
|
||||||
|
plugin_version="1.2.3",
|
||||||
|
repo_url="https://github.com/demo/plugins",
|
||||||
|
release=False,
|
||||||
|
)
|
||||||
|
plugin_manager = MagicMock()
|
||||||
|
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
|
||||||
|
plugin_manager.get_local_plugins.return_value = []
|
||||||
|
plugin_helper = MagicMock()
|
||||||
|
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[
|
||||||
|
{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"},
|
||||||
|
])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager),
|
||||||
|
patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper),
|
||||||
|
):
|
||||||
|
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False))
|
||||||
|
|
||||||
|
assert result["release_supported"] is False
|
||||||
|
assert result["items"] == []
|
||||||
|
plugin_helper.async_get_plugin_release_versions.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
def test_sync_plugin_market_from_wiki_merges_and_deduplicates_repos():
|
def test_sync_plugin_market_from_wiki_merges_and_deduplicates_repos():
|
||||||
"""
|
"""
|
||||||
Wiki 同步会提取标记区域内的 GitHub 仓库地址,并与本地配置合并去重后写入。
|
Wiki 同步会提取标记区域内的 GitHub 仓库地址,并与本地配置合并去重后写入。
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ class _FakeContentResponse(_FakeResponse):
|
|||||||
self.content = content
|
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:
|
def _build_zip(entries: dict[str, bytes]) -> bytes:
|
||||||
"""构造内存 zip 包,键为包内路径、值为文件内容。"""
|
"""构造内存 zip 包,键为包内路径、值为文件内容。"""
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
@@ -204,6 +216,134 @@ class TestPluginHelper:
|
|||||||
assert success
|
assert success
|
||||||
assert "" == message
|
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):
|
def test_annotate_plugin_system_version_marks_incompatible(self):
|
||||||
"""
|
"""
|
||||||
插件市场列表会带出系统版本兼容状态,供前端禁用安装入口。
|
插件市场列表会带出系统版本兼容状态,供前端禁用安装入口。
|
||||||
@@ -660,6 +800,99 @@ class TestPluginHelper:
|
|||||||
assert "MoviePilot 版本 >=9.0.0" in message
|
assert "MoviePilot 版本 >=9.0.0" in message
|
||||||
assert [] == calls
|
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):
|
def test_install_rejects_invalid_parameters_before_remote_lookup(self):
|
||||||
"""
|
"""
|
||||||
远端安装缺少插件 ID 或仓库地址时直接拒绝。
|
远端安装缺少插件 ID 或仓库地址时直接拒绝。
|
||||||
@@ -824,6 +1057,72 @@ class TestPluginHelper:
|
|||||||
assert calls[:4] == ["remove", "release", "remove", "filelist"]
|
assert calls[:4] == ["remove", "release", "remove", "filelist"]
|
||||||
assert calls[4][0] == "to_thread"
|
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):
|
def test_async_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch):
|
||||||
"""
|
"""
|
||||||
异步安装路径在 release 与文件列表都失败时返回文件列表错误,并保持失败清理顺序稳定。
|
异步安装路径在 release 与文件列表都失败时返回文件列表错误,并保持失败清理顺序稳定。
|
||||||
|
|||||||
Reference in New Issue
Block a user