mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +08:00
fix: avoid blocking plugin release history refresh (#6084)
This commit is contained in:
+82
-25
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import shutil
|
import shutil
|
||||||
from typing import Annotated, Any, List, Optional
|
from typing import Annotated, Any, List, Optional
|
||||||
@@ -39,6 +40,67 @@ PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/
|
|||||||
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_market_plugin_from_repo(
|
||||||
|
plugin_manager: PluginManager,
|
||||||
|
plugin_id: str,
|
||||||
|
repo_url: str,
|
||||||
|
force: bool,
|
||||||
|
) -> Optional[schemas.Plugin]:
|
||||||
|
"""
|
||||||
|
只读取指定插件仓库的市场元数据,避免单插件详情触发全部市场刷新。
|
||||||
|
"""
|
||||||
|
market_plugins = await plugin_manager.async_get_plugins_from_market(
|
||||||
|
repo_url, settings.VERSION_FLAG, force
|
||||||
|
)
|
||||||
|
market_plugin = next(
|
||||||
|
(
|
||||||
|
plugin
|
||||||
|
for plugin in market_plugins or []
|
||||||
|
if plugin.id == plugin_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if market_plugin or not settings.VERSION_FLAG:
|
||||||
|
return market_plugin
|
||||||
|
|
||||||
|
compatible_plugins = await plugin_manager.async_get_plugins_from_market(
|
||||||
|
repo_url, None, force
|
||||||
|
)
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
plugin
|
||||||
|
for plugin in compatible_plugins or []
|
||||||
|
if plugin.id == plugin_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_plugin_release_versions(plugin_id: str, repo_url: str) -> None:
|
||||||
|
"""
|
||||||
|
后台强制刷新 Release 缓存,接口响应路径优先返回已有缓存。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with async_fresh(True):
|
||||||
|
await PluginHelper().async_get_plugin_release_versions(plugin_id, repo_url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"后台刷新插件 {plugin_id} Release 列表失败:{e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_plugin_release_refresh(plugin_id: str, repo_url: str) -> None:
|
||||||
|
"""
|
||||||
|
保留后台任务引用,避免任务被回收,同时让 helper 负责同仓库强刷合并。
|
||||||
|
"""
|
||||||
|
task = asyncio.create_task(_refresh_plugin_release_versions(plugin_id, repo_url))
|
||||||
|
_plugin_release_refresh_tasks.add(task)
|
||||||
|
|
||||||
|
def _discard_task(completed_task: asyncio.Task) -> None:
|
||||||
|
_plugin_release_refresh_tasks.discard(completed_task)
|
||||||
|
|
||||||
|
task.add_done_callback(_discard_task)
|
||||||
|
|
||||||
|
|
||||||
def register_plugin_api(plugin_id: Optional[str] = None):
|
def register_plugin_api(plugin_id: Optional[str] = None):
|
||||||
@@ -239,6 +301,15 @@ async def _get_plugin_history_detail(
|
|||||||
if local_repo_plugin:
|
if local_repo_plugin:
|
||||||
return _merge_plugin_market_metadata(installed_plugin, local_repo_plugin)
|
return _merge_plugin_market_metadata(installed_plugin, local_repo_plugin)
|
||||||
|
|
||||||
|
if installed_plugin.repo_url:
|
||||||
|
market_plugin = await _get_market_plugin_from_repo(
|
||||||
|
plugin_manager, plugin_id, installed_plugin.repo_url, force
|
||||||
|
)
|
||||||
|
if not market_plugin:
|
||||||
|
logger.debug(f"插件 {plugin_id} 未从来源仓库获取到更新说明,返回本地插件信息")
|
||||||
|
return installed_plugin
|
||||||
|
return _merge_plugin_market_metadata(installed_plugin, market_plugin)
|
||||||
|
|
||||||
market_plugin = next(
|
market_plugin = next(
|
||||||
(
|
(
|
||||||
plugin
|
plugin
|
||||||
@@ -359,30 +430,9 @@ async def plugin_releases(
|
|||||||
}
|
}
|
||||||
|
|
||||||
plugin_manager = PluginManager()
|
plugin_manager = PluginManager()
|
||||||
market_plugins = await plugin_manager.async_get_plugins_from_market(
|
market_plugin = await _get_market_plugin_from_repo(
|
||||||
repo_url, settings.VERSION_FLAG, force
|
plugin_manager, plugin_id, repo_url, force
|
||||||
)
|
)
|
||||||
market_plugin = next(
|
|
||||||
(
|
|
||||||
plugin
|
|
||||||
for plugin in market_plugins or []
|
|
||||||
if plugin.id == plugin_id
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not market_plugin and settings.VERSION_FLAG:
|
|
||||||
compatible_plugins = await plugin_manager.async_get_plugins_from_market(
|
|
||||||
repo_url, None, force
|
|
||||||
)
|
|
||||||
market_plugin = next(
|
|
||||||
(
|
|
||||||
plugin
|
|
||||||
for plugin in compatible_plugins or []
|
|
||||||
if plugin.id == plugin_id
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
latest_version = market_plugin.plugin_version if market_plugin else None
|
latest_version = market_plugin.plugin_version if market_plugin else None
|
||||||
current_version = plugin_manager.get_local_plugin_version(plugin_id)
|
current_version = plugin_manager.get_local_plugin_version(plugin_id)
|
||||||
if not getattr(market_plugin, "release", False):
|
if not getattr(market_plugin, "release", False):
|
||||||
@@ -393,8 +443,15 @@ async def plugin_releases(
|
|||||||
"items": [],
|
"items": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
async with async_fresh(force):
|
plugin_helper = PluginHelper()
|
||||||
release_items = await PluginHelper().async_get_plugin_release_versions(plugin_id, repo_url)
|
has_release_cache = (
|
||||||
|
await plugin_helper.async_has_plugin_release_cache(repo_url)
|
||||||
|
if force
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
release_items = await plugin_helper.async_get_plugin_release_versions(plugin_id, repo_url)
|
||||||
|
if force and has_release_cache:
|
||||||
|
_schedule_plugin_release_refresh(plugin_id, repo_url)
|
||||||
items = []
|
items = []
|
||||||
for item in release_items:
|
for item in release_items:
|
||||||
version = item.get("version")
|
version = item.get("version")
|
||||||
|
|||||||
@@ -1214,8 +1214,19 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
|
|||||||
"""
|
"""
|
||||||
await cache_backend.clear(region=cache_region)
|
await cache_backend.clear(region=cache_region)
|
||||||
|
|
||||||
|
async def cache_exists(*args, **kwargs) -> bool:
|
||||||
|
"""
|
||||||
|
判断当前参数对应的有效缓存是否存在。
|
||||||
|
"""
|
||||||
|
cache_key = __get_cache_key(args, kwargs)
|
||||||
|
cached_value = await cache_backend.get(cache_key, region=cache_region)
|
||||||
|
return should_cache(cached_value) and await async_is_valid_cache_value(
|
||||||
|
cache_key, cached_value, cache_region
|
||||||
|
)
|
||||||
|
|
||||||
async_wrapper.cache_region = cache_region
|
async_wrapper.cache_region = cache_region
|
||||||
async_wrapper.cache_clear = cache_clear
|
async_wrapper.cache_clear = cache_clear
|
||||||
|
async_wrapper.cache_exists = cache_exists
|
||||||
return async_wrapper
|
return async_wrapper
|
||||||
else:
|
else:
|
||||||
# 同步函数使用同步缓存后端
|
# 同步函数使用同步缓存后端
|
||||||
@@ -1246,8 +1257,19 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
|
|||||||
"""
|
"""
|
||||||
cache_backend.clear(region=cache_region)
|
cache_backend.clear(region=cache_region)
|
||||||
|
|
||||||
|
def cache_exists(*args, **kwargs) -> bool:
|
||||||
|
"""
|
||||||
|
判断当前参数对应的有效缓存是否存在。
|
||||||
|
"""
|
||||||
|
cache_key = __get_cache_key(args, kwargs)
|
||||||
|
cached_value = cache_backend.get(cache_key, region=cache_region)
|
||||||
|
return should_cache(cached_value) and is_valid_cache_value(
|
||||||
|
cache_key, cached_value, cache_region
|
||||||
|
)
|
||||||
|
|
||||||
wrapper.cache_region = cache_region
|
wrapper.cache_region = cache_region
|
||||||
wrapper.cache_clear = cache_clear
|
wrapper.cache_clear = cache_clear
|
||||||
|
wrapper.cache_exists = cache_exists
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|||||||
+16
-3
@@ -2218,11 +2218,12 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
normal_task_key = (loop, normalized_repo_url, False)
|
normal_task_key = (loop, normalized_repo_url, False)
|
||||||
force_task_key = (loop, normalized_repo_url, True)
|
force_task_key = (loop, normalized_repo_url, True)
|
||||||
with self._release_task_lock:
|
with self._release_task_lock:
|
||||||
|
if is_fresh():
|
||||||
force_task = self._release_tasks.get(force_task_key)
|
force_task = self._release_tasks.get(force_task_key)
|
||||||
if force_task and not force_task.done():
|
if force_task and not force_task.done():
|
||||||
task_key = force_task_key
|
task_key = force_task_key
|
||||||
task = force_task
|
task = force_task
|
||||||
elif is_fresh():
|
else:
|
||||||
pending_normal_task = self._release_tasks.get(normal_task_key)
|
pending_normal_task = self._release_tasks.get(normal_task_key)
|
||||||
if pending_normal_task and pending_normal_task.done():
|
if pending_normal_task and pending_normal_task.done():
|
||||||
pending_normal_task = None
|
pending_normal_task = None
|
||||||
@@ -2236,17 +2237,29 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
task_key = normal_task_key
|
task_key = normal_task_key
|
||||||
task = self._release_tasks.get(task_key)
|
pending_normal_task = self._release_tasks.get(normal_task_key)
|
||||||
if task is None or task.done():
|
if pending_normal_task is None or pending_normal_task.done():
|
||||||
task = loop.create_task(self._async_get_plugin_repo_releases(normalized_repo_url))
|
task = loop.create_task(self._async_get_plugin_repo_releases(normalized_repo_url))
|
||||||
self._release_tasks[task_key] = task
|
self._release_tasks[task_key] = task
|
||||||
task.add_done_callback(
|
task.add_done_callback(
|
||||||
lambda completed_task: self._remove_release_task(task_key, completed_task)
|
lambda completed_task: self._remove_release_task(task_key, completed_task)
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
task = pending_normal_task
|
||||||
|
|
||||||
payload = await asyncio.shield(task)
|
payload = await asyncio.shield(task)
|
||||||
return self.__parse_plugin_release_response(pid, payload)
|
return self.__parse_plugin_release_response(pid, payload)
|
||||||
|
|
||||||
|
async def async_has_plugin_release_cache(self, repo_url: str) -> bool:
|
||||||
|
"""
|
||||||
|
判断指定仓库的 Release 列表缓存是否已经存在。
|
||||||
|
"""
|
||||||
|
if not repo_url:
|
||||||
|
return False
|
||||||
|
return await self._async_get_plugin_repo_releases.cache_exists(
|
||||||
|
self, repo_url.rstrip("/")
|
||||||
|
)
|
||||||
|
|
||||||
async def _async_refresh_plugin_repo_releases(
|
async def _async_refresh_plugin_repo_releases(
|
||||||
self,
|
self,
|
||||||
repo_url: str,
|
repo_url: str,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from app.api.endpoints import plugin as plugin_endpoint
|
||||||
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 plugin_releases
|
||||||
@@ -68,6 +69,38 @@ def test_plugin_history_returns_installed_plugin_when_remote_missing():
|
|||||||
assert result.history == {}
|
assert result.history == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_history_uses_installed_repo_without_refreshing_all_markets():
|
||||||
|
"""
|
||||||
|
已安装插件记录了来源仓库时,更新说明只刷新该仓库,避免弹窗触发全市场慢刷新。
|
||||||
|
"""
|
||||||
|
installed_plugin = schemas.Plugin(
|
||||||
|
id="DemoPlugin",
|
||||||
|
plugin_name="Demo Plugin",
|
||||||
|
plugin_version="1.0.0",
|
||||||
|
repo_url="https://github.com/demo/plugins",
|
||||||
|
installed=True,
|
||||||
|
)
|
||||||
|
market_plugin = schemas.Plugin(
|
||||||
|
id="DemoPlugin",
|
||||||
|
repo_url="https://github.com/demo/plugins",
|
||||||
|
history={"v1.1.0": "- 新增更新说明"},
|
||||||
|
)
|
||||||
|
plugin_manager = MagicMock()
|
||||||
|
plugin_manager.get_local_plugins.return_value = [installed_plugin]
|
||||||
|
plugin_manager.get_local_repo_plugins.return_value = []
|
||||||
|
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
|
||||||
|
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
with patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager):
|
||||||
|
result = asyncio.run(plugin_history("DemoPlugin", None, True))
|
||||||
|
|
||||||
|
assert result.history == {"v1.1.0": "- 新增更新说明"}
|
||||||
|
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(
|
||||||
|
"https://github.com/demo/plugins", settings.VERSION_FLAG, True
|
||||||
|
)
|
||||||
|
plugin_manager.async_get_online_plugins.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
def test_plugin_releases_returns_supported_versions_with_latest_and_current(monkeypatch):
|
def test_plugin_releases_returns_supported_versions_with_latest_and_current(monkeypatch):
|
||||||
"""
|
"""
|
||||||
release 列表接口返回可安装版本,并标记当前 package 最新版本与本地已安装版本。
|
release 列表接口返回可安装版本,并标记当前 package 最新版本与本地已安装版本。
|
||||||
@@ -154,6 +187,7 @@ def test_plugin_releases_falls_back_to_compatible_base_package(monkeypatch):
|
|||||||
)
|
)
|
||||||
plugin_manager.get_local_plugin_version.return_value = None
|
plugin_manager.get_local_plugin_version.return_value = None
|
||||||
plugin_helper = MagicMock()
|
plugin_helper = MagicMock()
|
||||||
|
plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False)
|
||||||
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[])
|
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[])
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -185,6 +219,7 @@ def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch):
|
|||||||
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
|
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
|
||||||
plugin_manager.get_local_plugin_version.return_value = None
|
plugin_manager.get_local_plugin_version.return_value = None
|
||||||
plugin_helper = MagicMock()
|
plugin_helper = MagicMock()
|
||||||
|
plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False)
|
||||||
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[])
|
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[])
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -203,6 +238,100 @@ def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_releases_force_uses_cached_release_response_and_schedules_refresh(monkeypatch):
|
||||||
|
"""
|
||||||
|
手动刷新时 package 元数据仍强刷,但 Release 明细先读缓存并后台刷新,避免弹窗阻塞。
|
||||||
|
"""
|
||||||
|
from app.core.cache import is_fresh
|
||||||
|
|
||||||
|
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_plugins_from_market = AsyncMock(return_value=[market_plugin])
|
||||||
|
plugin_manager.get_local_plugin_version.return_value = None
|
||||||
|
fresh_states = []
|
||||||
|
plugin_helper = MagicMock()
|
||||||
|
plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
async def fake_releases(*_args):
|
||||||
|
fresh_states.append(is_fresh())
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"version": "1.2.3",
|
||||||
|
"tag_name": "DemoPlugin_v1.2.3",
|
||||||
|
"asset_name": "demoplugin_v1.2.3.zip",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
plugin_helper.async_get_plugin_release_versions = fake_releases
|
||||||
|
scheduled = []
|
||||||
|
|
||||||
|
def fake_schedule(plugin_id, repo_url):
|
||||||
|
scheduled.append((plugin_id, repo_url))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager),
|
||||||
|
patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper),
|
||||||
|
patch.object(plugin_endpoint, "_schedule_plugin_release_refresh", fake_schedule),
|
||||||
|
):
|
||||||
|
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", True))
|
||||||
|
|
||||||
|
assert result["release_supported"] is True
|
||||||
|
assert fresh_states == [False]
|
||||||
|
assert scheduled == [("DemoPlugin", "https://github.com/demo/plugins")]
|
||||||
|
plugin_helper.async_has_plugin_release_cache.assert_awaited_once_with(
|
||||||
|
"https://github.com/demo/plugins"
|
||||||
|
)
|
||||||
|
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(
|
||||||
|
"https://github.com/demo/plugins", settings.VERSION_FLAG, True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_releases_force_skips_background_refresh_without_release_cache(monkeypatch):
|
||||||
|
"""
|
||||||
|
冷缓存 force 请求已在响应路径读取 Release,不能马上再启动一次重复强刷。
|
||||||
|
"""
|
||||||
|
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_plugins_from_market = AsyncMock(return_value=[market_plugin])
|
||||||
|
plugin_manager.get_local_plugin_version.return_value = None
|
||||||
|
plugin_helper = MagicMock()
|
||||||
|
plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False)
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
])
|
||||||
|
scheduled = []
|
||||||
|
|
||||||
|
def fake_schedule(plugin_id, repo_url):
|
||||||
|
scheduled.append((plugin_id, repo_url))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager),
|
||||||
|
patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper),
|
||||||
|
patch.object(plugin_endpoint, "_schedule_plugin_release_refresh", fake_schedule),
|
||||||
|
):
|
||||||
|
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", True))
|
||||||
|
|
||||||
|
assert result["release_supported"] is True
|
||||||
|
assert scheduled == []
|
||||||
|
plugin_helper.async_has_plugin_release_cache.assert_awaited_once_with(
|
||||||
|
"https://github.com/demo/plugins"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release(monkeypatch):
|
def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release(monkeypatch):
|
||||||
"""
|
"""
|
||||||
接口是否支持 Release 安装要与当前 package 的 release 声明保持一致。
|
接口是否支持 Release 安装要与当前 package 的 release 声明保持一致。
|
||||||
|
|||||||
@@ -499,6 +499,109 @@ class TestPluginHelper:
|
|||||||
assert [item["version"] for item in cached_result] == ["1.2.3"]
|
assert [item["version"] for item in cached_result] == ["1.2.3"]
|
||||||
assert request_count == 2
|
assert request_count == 2
|
||||||
|
|
||||||
|
def test_async_normal_release_read_does_not_wait_for_pending_force_refresh(self, monkeypatch):
|
||||||
|
"""普通读取遇到后台强刷时仍优先返回已有缓存,避免页面响应被强刷阻塞。"""
|
||||||
|
try:
|
||||||
|
from app.core.cache import async_fresh
|
||||||
|
from app.helper.plugin import PluginHelper
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
pytest.skip(f"missing dependency: {exc}")
|
||||||
|
|
||||||
|
old_payload = [{
|
||||||
|
"tag_name": "DemoPlugin_v1.2.2",
|
||||||
|
"assets": [{"name": "demoplugin_v1.2.2.zip", "id": 1}],
|
||||||
|
}]
|
||||||
|
fresh_payload = [{
|
||||||
|
"tag_name": "DemoPlugin_v1.2.3",
|
||||||
|
"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 2}],
|
||||||
|
}]
|
||||||
|
force_request_started = asyncio.Event()
|
||||||
|
release_force_request = asyncio.Event()
|
||||||
|
request_count = 0
|
||||||
|
|
||||||
|
async def fake_request(*_args, **_kwargs):
|
||||||
|
nonlocal request_count
|
||||||
|
request_count += 1
|
||||||
|
if request_count == 1:
|
||||||
|
return _FakeTextResponse(200, old_payload)
|
||||||
|
force_request_started.set()
|
||||||
|
await release_force_request.wait()
|
||||||
|
return _FakeTextResponse(200, fresh_payload)
|
||||||
|
|
||||||
|
async def run_test():
|
||||||
|
helper = PluginHelper()
|
||||||
|
await helper.async_get_plugin_release_versions.cache_clear()
|
||||||
|
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
|
||||||
|
initial = await helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL)
|
||||||
|
async with async_fresh(True):
|
||||||
|
force_task = asyncio.create_task(
|
||||||
|
helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL)
|
||||||
|
)
|
||||||
|
await force_request_started.wait()
|
||||||
|
normal_task = asyncio.create_task(
|
||||||
|
helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL)
|
||||||
|
)
|
||||||
|
normal_before_force_finished = await asyncio.wait_for(normal_task, timeout=1)
|
||||||
|
force_done_before_normal_finished = force_task.done()
|
||||||
|
release_force_request.set()
|
||||||
|
force_result = await force_task
|
||||||
|
cached_result = await helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL)
|
||||||
|
return (
|
||||||
|
initial,
|
||||||
|
force_done_before_normal_finished,
|
||||||
|
normal_before_force_finished,
|
||||||
|
force_result,
|
||||||
|
cached_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
initial,
|
||||||
|
force_done_before_normal_finished,
|
||||||
|
normal_before_force_finished,
|
||||||
|
force_result,
|
||||||
|
cached_result,
|
||||||
|
) = asyncio.run(run_test())
|
||||||
|
|
||||||
|
assert [item["version"] for item in initial] == ["1.2.2"]
|
||||||
|
assert force_done_before_normal_finished is False
|
||||||
|
assert [item["version"] for item in normal_before_force_finished] == ["1.2.2"]
|
||||||
|
assert [item["version"] for item in force_result] == ["1.2.3"]
|
||||||
|
assert [item["version"] for item in cached_result] == ["1.2.3"]
|
||||||
|
assert request_count == 2
|
||||||
|
|
||||||
|
def test_async_has_plugin_release_cache_reflects_repository_cache(self, monkeypatch):
|
||||||
|
"""Release 缓存探针只判断仓库级缓存是否已经存在,不触发网络请求。"""
|
||||||
|
try:
|
||||||
|
from app.helper.plugin import PluginHelper
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
pytest.skip(f"missing dependency: {exc}")
|
||||||
|
|
||||||
|
payload = [{
|
||||||
|
"tag_name": "DemoPlugin_v1.2.3",
|
||||||
|
"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 1}],
|
||||||
|
}]
|
||||||
|
request_count = 0
|
||||||
|
|
||||||
|
async def fake_request(*_args, **_kwargs):
|
||||||
|
nonlocal request_count
|
||||||
|
request_count += 1
|
||||||
|
return _FakeTextResponse(200, payload)
|
||||||
|
|
||||||
|
async def run_test():
|
||||||
|
helper = PluginHelper()
|
||||||
|
await helper.async_get_plugin_release_versions.cache_clear()
|
||||||
|
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
|
||||||
|
before = await helper.async_has_plugin_release_cache(REPO_URL)
|
||||||
|
await helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL)
|
||||||
|
after = await helper.async_has_plugin_release_cache(REPO_URL)
|
||||||
|
return before, after
|
||||||
|
|
||||||
|
before, after = asyncio.run(run_test())
|
||||||
|
|
||||||
|
assert before is False
|
||||||
|
assert after is True
|
||||||
|
assert request_count == 1
|
||||||
|
|
||||||
def test_failed_forced_release_refresh_preserves_cached_repository_payload(self, monkeypatch):
|
def test_failed_forced_release_refresh_preserves_cached_repository_payload(self, monkeypatch):
|
||||||
"""GitHub 强刷失败时不以空值覆盖该仓库已有 Release 缓存。"""
|
"""GitHub 强刷失败时不以空值覆盖该仓库已有 Release 缓存。"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user