fix(plugin): 按绑定仓库处理插件更新 (#6472)

This commit is contained in:
InfinityPacer
2026-08-26 21:35:16 +08:00
committed by GitHub
parent 0a5b6a637d
commit d24c52ea97
14 changed files with 287 additions and 52 deletions
+1 -1
View File
@@ -325,7 +325,7 @@ def test_install_plugin_reports_source_conflict_before_retry() -> None:
"app.agent.tools.impl.install_plugin.inspect_plugin_sources",
new=AsyncMock(return_value={
"selection_status": "conflict",
"selection_reason": "未安装插件存在多个在线来源,不能静默选择",
"selection_reason": "插件存在多个仓库,请选择仓库",
"inventory_complete": True,
"candidates": source_candidates,
}),
+128 -8
View File
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from app import schemas
from app.api.endpoints import plugin as plugin_endpoint
from app.api.endpoints.plugin import (
_prepare_update_candidates,
plugin_history,
plugin_releases,
plugin_static_file,
@@ -40,21 +41,28 @@ SOURCE_URL = "https://github.com/demo/plugins"
def _plugin_identity(
*,
metadata: PluginDeclaredMetadata | None = None,
plugin_id: str = "DemoPlugin",
source_type: TrustedPluginSourceType = TrustedPluginSourceType.THIRD_PARTY,
source_key: str = SOURCE_KEY,
) -> PluginIdentity:
"""构造绑定到测试仓库的插件身份。"""
has_payload = metadata is not None
return PluginIdentity(
plugin_id="DemoPlugin",
normalized_plugin_id="demoplugin",
trusted_source_type=TrustedPluginSourceType.THIRD_PARTY,
trusted_source_key=SOURCE_KEY,
plugin_id=plugin_id,
normalized_plugin_id=plugin_id.lower(),
trusted_source_type=source_type,
trusted_source_key=source_key,
binding_basis=PluginBindingBasis.EXPLICIT_INSTALL,
payload_source_type=(
PluginPayloadSourceType.THIRD_PARTY
if has_payload
else PluginPayloadSourceType.UNKNOWN
PluginPayloadSourceType.UNKNOWN
if not has_payload
else (
PluginPayloadSourceType.OFFICIAL
if source_type is TrustedPluginSourceType.OFFICIAL
else PluginPayloadSourceType.THIRD_PARTY
)
),
payload_source_key=SOURCE_KEY if has_payload else None,
payload_source_key=source_key if has_payload else None,
declared_version="1.0.0" if has_payload else None,
package_generation="v3" if has_payload else None,
declared_metadata=metadata,
@@ -67,6 +75,118 @@ def _plugin_identity(
)
def test_update_candidates_report_source_and_binding_relationship():
"""市场更新候选应标明仓库类型,并仅把当前绑定仓库视为可直接更新。"""
plugins = [
schemas.Plugin(
id="OfficialBound",
plugin_version="2.0.0",
repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
has_update=True,
),
schemas.Plugin(
id="ThirdPartyBound",
plugin_version="2.0.0",
repo_url="https://github.com/example/plugins",
has_update=True,
),
schemas.Plugin(
id="AlternativeOfficial",
plugin_version="2.0.0",
repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
has_update=True,
),
schemas.Plugin(
id="LocalOnly",
plugin_version="2.0.0",
repo_url="local:///plugins",
has_update=True,
),
]
persistence = MagicMock()
persistence.list_identities = AsyncMock(
return_value=[
_plugin_identity(
plugin_id="OfficialBound",
source_type=TrustedPluginSourceType.OFFICIAL,
source_key="github:jxxghp/moviepilot-plugins",
),
_plugin_identity(
plugin_id="ThirdPartyBound",
source_key="github:example/plugins",
),
_plugin_identity(
plugin_id="AlternativeOfficial",
source_key="github:example/plugins",
),
]
)
plugin_manager = MagicMock()
plugin_manager.process_plugins_list.side_effect = lambda higher, _base: higher
with patch(
"app.api.endpoints.plugin.get_plugin_persistence",
return_value=persistence,
):
result = asyncio.run(
_prepare_update_candidates(
plugin_manager,
plugins,
plugins,
[plugin.id for plugin in plugins],
)
)
assert result[0].update_candidate is not None
assert result[0].update_candidate.source_type == "official"
assert result[0].update_candidate.is_bound is True
assert result[1].update_candidate is not None
assert result[1].update_candidate.source_type == "third_party"
assert result[1].update_candidate.is_bound is True
assert result[2].update_candidate is not None
assert result[2].update_candidate.source_type == "official"
assert result[2].update_candidate.is_bound is False
assert result[3].update_candidate is None
def test_bound_repository_update_precedes_a_higher_alternative():
"""绑定仓库仍有更新时先完成可信更新,下一轮再提示其他仓库版本。"""
bound_update = schemas.Plugin(
id="DemoPlugin",
plugin_version="2.0.0",
repo_url=SOURCE_URL,
has_update=True,
)
alternative_update = schemas.Plugin(
id="DemoPlugin",
plugin_version="3.0.0",
repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
has_update=True,
)
persistence = MagicMock()
persistence.list_identities = AsyncMock(return_value=[_plugin_identity()])
plugin_manager = MagicMock()
plugin_manager.process_plugins_list.return_value = [bound_update]
with patch(
"app.api.endpoints.plugin.get_plugin_persistence",
return_value=persistence,
):
result = asyncio.run(
_prepare_update_candidates(
plugin_manager,
[bound_update, alternative_update],
[alternative_update],
["DemoPlugin"],
)
)
assert result == [bound_update]
assert result[0].update_candidate is not None
assert result[0].update_candidate.version == "2.0.0"
assert result[0].update_candidate.is_bound is True
def _persistence(identity: PluginIdentity) -> MagicMock:
"""构造只暴露身份读取合同的异步持久化替身。"""
persistence = MagicMock()
@@ -384,7 +384,7 @@ async def test_http_source_options_return_sanitized_candidates(monkeypatch) -> N
identity=identity,
selection=SimpleNamespace(
status=SimpleNamespace(value="conflict"),
reason="未安装插件存在多个在线来源,不能静默选择",
reason="插件存在多个仓库,请选择仓库",
),
online_candidates=(
SimpleNamespace(
+3 -3
View File
@@ -184,7 +184,7 @@ def test_first_online_binding_uses_payload_commit_time() -> None:
def test_force_semantics_cannot_authorize_source_change() -> None:
"""普通安装即使替换载荷,也不能选择不同于已绑定来源的仓库。"""
with pytest.raises(PluginSourceAdmissionError, match="普通安装不能改变"):
with pytest.raises(PluginSourceAdmissionError, match="已绑定其他仓库"):
admit_plugin_install(
_inventory(
_online_candidate(),
@@ -207,7 +207,7 @@ def test_force_semantics_cannot_authorize_source_change() -> None:
@pytest.mark.parametrize("revision", [None, 2, 4])
def test_source_change_requires_exact_identity_revision(revision: int | None) -> None:
"""显式换源必须携带当前身份的精确 revision。"""
with pytest.raises(PluginSourceAdmissionError, match="revision"):
with pytest.raises(PluginSourceAdmissionError, match="状态已变化"):
admit_plugin_install(
_inventory(
_online_candidate(
@@ -410,7 +410,7 @@ def test_source_change_rejects_local_payload_reference() -> None:
with pytest.raises(
PluginSourceAdmissionError,
match="显式换源只接受在线插件仓库",
match="只能更换为在线插件仓库",
):
admit_plugin_install(
_inventory(local_candidates=(local,)),
+2 -2
View File
@@ -1,9 +1,9 @@
from types import SimpleNamespace
from app.chain import subscribe as subscribe_module
from app.chain.subscribe import SubscribeChain
from app.agent.tools.impl._filter_rule_utils import normalize_media_type
from app.application.rules import RuleHelper
from app.chain import subscribe as subscribe_module
from app.chain.subscribe import SubscribeChain
from app.domain.context import MediaInfo, MusicInfo, TorrentInfo
from app.modules.filter import FilterModule
from app.runtime.events import Event