fix: avoid blocking plugin release history refresh (#6084)

This commit is contained in:
qqcomeup
2026-07-08 12:51:24 +08:00
committed by GitHub
parent f3ac69669c
commit 099ef7d5bf
5 changed files with 367 additions and 43 deletions

View File

@@ -1,6 +1,7 @@
import asyncio
from unittest.mock import ANY, AsyncMock, MagicMock, patch
from app.api.endpoints import plugin as plugin_endpoint
from app import schemas
from app.api.endpoints.plugin import plugin_history
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 == {}
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):
"""
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_helper = MagicMock()
plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False)
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[])
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.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=[])
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):
"""
接口是否支持 Release 安装要与当前 package 的 release 声明保持一致。

View File

@@ -499,6 +499,109 @@ class TestPluginHelper:
assert [item["version"] for item in cached_result] == ["1.2.3"]
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):
"""GitHub 强刷失败时不以空值覆盖该仓库已有 Release 缓存。"""
try: