Files
MoviePilot/tests/test_plugin_sync_service.py
T
InfinityPacer 011ebfbe5f fix: 协调本地插件与绑定仓库的版本选择 (#6482)
* fix(plugin): reconcile local and bound payload versions

* fix(plugin): preserve bound source on updates

* fix(plugin): normalize source reconciliation ids

* fix(plugin): defer local payload activation

* fix(plugin): centralize startup source admission

* fix(plugin): derive sync mode from admitted source
2026-08-27 20:03:30 +08:00

610 lines
21 KiB
Python

"""插件市场同步服务用例。"""
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from packaging.version import Version
from app.application.plugin.catalog import PluginCatalogService
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.gateway import PluginInstallGateway
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.application.plugin.install import PluginInstallResult
from app.application.plugin.lifecycle import plugin_lifecycle
from app.application.plugin.source import (
CandidateInventory,
LocalCandidateRead,
MarketRead,
PluginLocalCandidate,
PluginMarketCandidate,
)
from app.runtime.config import global_vars
from app.runtime.extensions.plugin.sync import LocalPluginSyncService, PluginSyncService
from app.startup.initializers import plugins as plugins_initializer
REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins"
def _merge_catalog_plugins(higher, base, markets):
"""通过生产目录服务合并启动同步候选。"""
service = PluginCatalogService(
market_loader=Mock(return_value={}),
async_market_loader=AsyncMock(return_value={}),
installed_plugins_provider=Mock(return_value=[]),
plugin_mapper=Mock(),
is_local_repo=lambda value: str(value).startswith("local://"),
version_compare=lambda left, operator, right: (
operator == ">" and Version(left) > Version(right)
),
warning=Mock(),
error=Mock(),
)
return service.merge(higher, base, markets)
def test_market_sync_keeps_install_rollback_enabled() -> None:
"""自动更新插件时保留旧版本,失败后可由安装器恢复。"""
plugin = SimpleNamespace(
id="DemoPlugin",
repo_url="https://example.com/plugins",
plugin_name="Demo",
plugin_version="1.0.0",
system_version_compatible=True,
)
install = Mock(return_value=(True, ""))
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [plugin.id],
online_plugins=lambda: [plugin],
local_plugins=lambda: [],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: False,
install=install,
log=Mock(),
)
assert service.sync() == [plugin.id]
install.assert_called_once_with(plugin.id, None, False, None)
def test_market_sync_restores_trusted_online_payload_after_local_source_removed() -> None:
"""本地高版本来源消失后,启动同步仍恢复已绑定的在线载荷。"""
plugin = SimpleNamespace(
id="DemoPlugin",
repo_url=REPO_URL,
plugin_name="Demo",
plugin_version="1.2.0",
system_version_compatible=False,
)
install = Mock(return_value=(True, ""))
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [plugin.id],
online_plugins=lambda: [plugin],
local_plugins=lambda: [],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: True,
install=install,
log=Mock(),
)
assert service.sync(
online_restore_plugins={"demoplugin"},
) == [plugin.id]
install.assert_called_once_with(plugin.id, None, False, None)
def test_market_sync_reconciles_existing_local_candidate_through_gateway() -> None:
"""本地候选对应插件已延后激活,必须经 Gateway 协调后再启动。"""
online = SimpleNamespace(
id="DemoPlugin",
repo_url=REPO_URL,
plugin_name="Demo",
plugin_version="1.2.0",
system_version_compatible=True,
)
local = SimpleNamespace(
id="DemoPlugin",
repo_url="local://DemoPlugin?package_version=v3",
plugin_name="Demo Local",
plugin_version="9.9.10",
system_version_compatible=True,
)
install = Mock(return_value=(True, ""))
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [online.id],
online_plugins=lambda: [online],
local_plugins=lambda: [local],
merge_plugins=lambda items, *_args: [online],
plugin_exists=lambda *_args: True,
install=install,
log=Mock(),
)
assert service.sync(online_restore_plugins={"demoplugin"}) == [online.id]
install.assert_called_once_with(online.id, None, False, None)
def test_market_sync_defers_source_selection_to_gateway() -> None:
"""启动目录只能决定同步目标,不能把本地候选升级为选源授权。"""
local = SimpleNamespace(
id="DemoPlugin",
repo_url="local://DemoPlugin?path=/private/plugins&version=v3",
plugin_name="Demo Local",
plugin_version="3.3.2",
system_version_compatible=True,
)
install = Mock(return_value=(True, ""))
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [local.id],
online_plugins=lambda: [],
local_plugins=lambda: [local],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: False,
install=install,
log=Mock(),
)
assert service.sync() == [local.id]
install.assert_called_once_with(local.id, None, False, None)
def test_market_sync_reports_local_install_failure() -> None:
"""本地载荷安装失败必须阻止启动编排继续激活旧代码。"""
local = SimpleNamespace(
id="DemoPlugin",
repo_url="local://DemoPlugin?path=/private/plugins&version=v3",
plugin_name="Demo Local",
plugin_version="3.3.2",
system_version_compatible=True,
)
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [local.id],
online_plugins=lambda: [],
local_plugins=lambda: [local],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: False,
install=Mock(return_value=(False, "copy failed")),
log=Mock(),
)
with pytest.raises(
RuntimeError,
match="延后激活的插件同步未完成:DemoPlugin",
):
service.sync()
def test_local_sync_matches_installed_plugin_id_case_insensitively() -> None:
"""本地热同步应把大小写不同的索引 ID 识别为同一已安装插件。"""
candidate = {
"repo_url": "local://downloadcenter?package_version=v3",
"package_version": "v3",
"compatible": True,
}
system = Mock()
system.install_plugin.return_value = (True, "")
recent_sync: dict[str, float] = {}
service = LocalPluginSyncService(
installed_plugins=lambda: ["DownloadCenter"],
candidate=Mock(return_value=candidate),
system=lambda: system,
recent_sync=recent_sync,
log=Mock(),
)
assert service.sync("DownloadCenter", candidate)
system.install_plugin.assert_called_once_with(
plugin_id="DownloadCenter",
repo_url=candidate["repo_url"],
package_version="v3",
force=True,
local_sync=True,
explicit_source=True,
)
assert "downloadcenter" in recent_sync
@pytest.mark.asyncio
async def test_market_sync_preserves_generation_priority_through_gateway(
monkeypatch,
) -> None:
"""目录中的高版本 V2 不能绕过 Gateway 覆盖绑定仓库的 V3。"""
online = PluginMarketCandidate(
plugin_id="DownloadCenter",
source_key="github:jxxghp/moviepilot-plugins",
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url=REPO_URL,
package_generation="v3",
plugin_version="1.0.0",
dto={"v3": True},
)
local = PluginLocalCandidate(
plugin_id="downloadcenter",
repo_url="local://downloadcenter?path=/private/plugins&version=v2",
package_generation="v2",
plugin_version="9.0.0",
dto={"v3": True},
)
identity = PluginIdentity(
plugin_id="DownloadCenter",
normalized_plugin_id="downloadcenter",
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key=online.source_key,
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
payload_source_key=online.source_key,
declared_version=online.plugin_version,
package_generation="v3",
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True},
declaration_version=online.plugin_version,
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "2" * 64,
revision=2,
created_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
bound_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
payload_applied_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
)
inventory = CandidateInventory(
(MarketRead.present(REPO_URL, (online,)),),
(local,),
local_read=LocalCandidateRead.present((local,)),
)
executor = AsyncMock()
executor.execute.return_value = PluginInstallResult(success=True)
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=inventory),
identity=AsyncMock(return_value=identity),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
)
monkeypatch.setattr(
global_vars,
"CURRENT_EVENT_LOOP",
asyncio.get_running_loop(),
)
def install(
plugin_id: str,
repo_url: str | None,
force: bool,
startup_token: object | None,
) -> tuple[bool, str]:
"""按生产兼容入口让 Gateway 完成最终来源准入。"""
return plugins_initializer._run_plugin_install_sync(
gateway,
plugin_id=plugin_id,
repo_url=repo_url or "",
package_version="v3",
release_version=None,
force=force,
local_sync=True,
explicit_source=False,
startup_token=startup_token,
)
merged_local = SimpleNamespace(
id=local.plugin_id,
repo_url=local.repo_url,
plugin_name="Demo Local",
plugin_version=local.plugin_version,
system_version_compatible=True,
)
merged_online = SimpleNamespace(
id=online.plugin_id,
repo_url=online.repo_url,
plugin_name="Download Center",
plugin_version=online.plugin_version,
system_version_compatible=True,
)
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [online.plugin_id],
online_plugins=lambda: [merged_online],
local_plugins=lambda: [merged_local],
merge_plugins=_merge_catalog_plugins,
plugin_exists=lambda *_args: False,
install=install,
log=Mock(),
)
async with plugin_lifecycle.hold_startup() as startup_token:
synced = await asyncio.wait_for(
asyncio.to_thread(service.sync, startup_token),
timeout=2,
)
assert synced == [local.plugin_id]
executor.execute.assert_awaited_once()
assert executor.execute.await_args.kwargs["local_sync"] is False
admission = executor.execute.await_args.kwargs["admission"]
assert admission.candidate is online
assert admission.trusted_source_key == online.source_key
@pytest.mark.asyncio
async def test_market_sync_blocks_activation_when_gateway_selected_local_fails(
monkeypatch,
) -> None:
"""目录预选在线候选时,Gateway 改选本地失败仍必须阻止旧载荷激活。"""
competing_repo_url = "https://github.com/example/MoviePilot-Plugins"
official = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:jxxghp/moviepilot-plugins",
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url=REPO_URL,
package_generation="v3",
plugin_version="1.0.0",
dto={"v3": True},
)
competing = PluginMarketCandidate(
plugin_id=official.plugin_id,
source_key="github:example/moviepilot-plugins",
source_type=TrustedPluginSourceType.THIRD_PARTY,
repo_url=competing_repo_url,
package_generation="v3",
plugin_version="9.0.0",
dto={"v3": True},
)
local = PluginLocalCandidate(
plugin_id=official.plugin_id,
repo_url="local://DemoPlugin?path=/private/plugins&version=v3",
package_generation="v3",
plugin_version="2.0.0",
dto={"v3": True},
)
identity = PluginIdentity(
plugin_id=official.plugin_id,
normalized_plugin_id="demoplugin",
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key=official.source_key,
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
payload_source_key=official.source_key,
declared_version=official.plugin_version,
package_generation="v3",
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True},
declaration_version=official.plugin_version,
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "3" * 64,
revision=2,
created_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
bound_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
payload_applied_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
)
inventory = CandidateInventory(
(
MarketRead.present(REPO_URL, (official,)),
MarketRead.present(competing_repo_url, (competing,)),
),
(local,),
local_read=LocalCandidateRead.present((local,)),
)
executor = AsyncMock(
**{"execute.return_value": PluginInstallResult(
success=False,
message="copy failed",
)}
)
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=inventory),
identity=AsyncMock(return_value=identity),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
)
monkeypatch.setattr(
global_vars,
"CURRENT_EVENT_LOOP",
asyncio.get_running_loop(),
)
def install(
plugin_id: str,
repo_url: str | None,
force: bool,
startup_token: object | None,
) -> tuple[bool, str]:
return plugins_initializer._run_plugin_install_sync(
gateway,
plugin_id=plugin_id,
repo_url=repo_url or "",
package_version="v3",
release_version=None,
force=force,
local_sync=False,
explicit_source=False,
startup_token=startup_token,
)
merged_official = SimpleNamespace(
id=official.plugin_id,
repo_url=official.repo_url,
plugin_name="Demo Official",
plugin_version=official.plugin_version,
system_version_compatible=True,
)
merged_competing = SimpleNamespace(
id=competing.plugin_id,
repo_url=competing.repo_url,
plugin_name="Demo Competing",
plugin_version=competing.plugin_version,
system_version_compatible=True,
)
merged_local = SimpleNamespace(
id=local.plugin_id,
repo_url=local.repo_url,
plugin_name="Demo Local",
plugin_version=local.plugin_version,
system_version_compatible=True,
)
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [official.plugin_id],
online_plugins=lambda: [merged_official, merged_competing],
local_plugins=lambda: [merged_local],
merge_plugins=_merge_catalog_plugins,
plugin_exists=lambda *_args: False,
install=install,
log=Mock(),
)
async with plugin_lifecycle.hold_startup() as startup_token:
with pytest.raises(
RuntimeError,
match="延后激活的插件同步未完成:DemoPlugin",
):
await asyncio.wait_for(
asyncio.to_thread(service.sync, startup_token),
timeout=2,
)
executor.execute.assert_awaited_once()
assert executor.execute.await_args.kwargs["local_sync"] is True
admission = executor.execute.await_args.kwargs["admission"]
assert admission.candidate is local
@pytest.mark.asyncio
async def test_market_sync_reuses_startup_lease_through_real_gateway(
monkeypatch,
) -> None:
"""启动自动安装跨线程进入 Gateway 时必须复用同一个 startup lease。"""
competing_repo_url = "https://github.com/example/MoviePilot-Plugins"
plugin = SimpleNamespace(
id="DemoPlugin",
repo_url=competing_repo_url,
plugin_name="Demo",
plugin_version="9.0.0",
system_version_compatible=True,
)
official_candidate = PluginMarketCandidate(
plugin_id=plugin.id,
source_key="github:jxxghp/moviepilot-plugins",
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url=REPO_URL,
package_generation="v3",
plugin_version="1.1.0",
dto={"v3": True},
)
competing_candidate = PluginMarketCandidate(
plugin_id=plugin.id,
source_key="github:example/moviepilot-plugins",
source_type=TrustedPluginSourceType.THIRD_PARTY,
repo_url=competing_repo_url,
package_generation="v3",
plugin_version=plugin.plugin_version,
dto={"v3": True},
)
inventory = CandidateInventory((
MarketRead.present(
REPO_URL,
(official_candidate,),
package_generation="v3",
),
MarketRead.present(
competing_repo_url,
(competing_candidate,),
package_generation="v3",
),
))
identity = PluginIdentity(
plugin_id=plugin.id,
normalized_plugin_id="demoplugin",
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
declared_version="9.9.10",
package_generation="v3",
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo local", "v3": True},
declaration_version="9.9.10",
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "0" * 64,
revision=1,
created_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
bound_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
payload_applied_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
)
executor = AsyncMock()
executor.execute.return_value = PluginInstallResult(success=True)
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=inventory),
identity=AsyncMock(return_value=identity),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
)
monkeypatch.setattr(
global_vars,
"CURRENT_EVENT_LOOP",
asyncio.get_running_loop(),
)
def install(
plugin_id: str,
repo_url: str | None,
force: bool,
startup_token: object | None,
) -> tuple[bool, str]:
"""复用生产同步包装层,把线程池安装提交回宿主事件循环。"""
return plugins_initializer._run_plugin_install_sync(
gateway,
plugin_id=plugin_id,
repo_url=repo_url,
package_version="v3",
release_version=None,
force=force,
local_sync=False,
explicit_source=False,
startup_token=startup_token,
)
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [plugin.id],
online_plugins=lambda: [plugin],
local_plugins=lambda: [],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: True,
install=install,
log=Mock(),
)
async with plugin_lifecycle.hold_startup() as startup_token:
synced = await asyncio.wait_for(
asyncio.to_thread(
service.sync,
startup_token,
online_restore_plugins={"demoplugin"},
),
timeout=2,
)
assert synced == [plugin.id]
executor.execute.assert_awaited_once()
admission = executor.execute.await_args.kwargs["admission"]
assert admission.candidate.repo_url == REPO_URL