mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
559 lines
18 KiB
Python
559 lines
18 KiB
Python
"""存量插件身份启动迁移的来源和顺序合同测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from contextlib import nullcontext
|
|
from dataclasses import replace
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from app.application.plugin.declaration import PluginDeclaredMetadata
|
|
from app.application.plugin.identity import (
|
|
PluginBindingBasis,
|
|
PluginIdentity,
|
|
PluginIdentityConflictError,
|
|
PluginPayloadSourceType,
|
|
TrustedPluginSourceType,
|
|
)
|
|
from app.application.plugin.identity_migration import PluginIdentityMigrationService
|
|
from app.application.plugin.source import (
|
|
CandidateInventory,
|
|
LocalCandidateRead,
|
|
MarketRead,
|
|
PluginMarketCandidate,
|
|
)
|
|
from app.runtime.extensions.plugin.dependency import PluginDependencyInstallResult
|
|
from app.startup.initializers import plugins as plugins_initializer
|
|
|
|
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
|
|
OFFICIAL_REPO = "https://github.com/jxxghp/MoviePilot-Plugins"
|
|
OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins"
|
|
THIRD_PARTY_REPO = "https://github.com/example/MoviePilot-Plugins"
|
|
THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins"
|
|
|
|
|
|
def _metadata(version: str, *, matches_payload: bool = True) -> PluginDeclaredMetadata:
|
|
"""构造测试用 package 声明快照。"""
|
|
return PluginDeclaredMetadata.from_package(
|
|
{"name": "Demo", "v3": True, "v3t": False},
|
|
declaration_version=version,
|
|
manifest_matches_payload=matches_payload,
|
|
)
|
|
|
|
|
|
class _Persistence:
|
|
"""提供可观察 CAS 竞争的内存迁移持久化端口。"""
|
|
|
|
def __init__(self, identities: tuple[PluginIdentity, ...] = ()) -> None:
|
|
self.identities = {
|
|
identity.normalized_plugin_id: identity for identity in identities
|
|
}
|
|
self.fail_create = False
|
|
self.fail_bind = False
|
|
|
|
async def get_identity(self, plugin_id: str) -> PluginIdentity | None:
|
|
"""按规范物理 ID 返回当前身份。"""
|
|
return self.identities.get(plugin_id.lower())
|
|
|
|
async def migrate_identity(
|
|
self,
|
|
identity: PluginIdentity,
|
|
*,
|
|
expected_revision: int | None,
|
|
) -> PluginIdentity:
|
|
"""模拟首次身份 CAS。"""
|
|
assert expected_revision is None
|
|
if self.fail_create or identity.normalized_plugin_id in self.identities:
|
|
raise PluginIdentityConflictError("create conflict")
|
|
self.identities[identity.normalized_plugin_id] = identity
|
|
return identity
|
|
|
|
async def bind_online_identity(
|
|
self,
|
|
identity: PluginIdentity,
|
|
*,
|
|
expected_revision: int,
|
|
) -> PluginIdentity:
|
|
"""模拟未绑定身份的 revision CAS。"""
|
|
current = self.identities.get(identity.normalized_plugin_id)
|
|
if (
|
|
self.fail_bind
|
|
or current is None
|
|
or current.revision != expected_revision
|
|
):
|
|
raise PluginIdentityConflictError("bind conflict")
|
|
self.identities[identity.normalized_plugin_id] = identity
|
|
return identity
|
|
|
|
|
|
def _candidate(
|
|
plugin_id: str,
|
|
*,
|
|
source_type: TrustedPluginSourceType,
|
|
source_key: str,
|
|
repo_url: str,
|
|
) -> PluginMarketCandidate:
|
|
"""构造一个 V3 在线候选。"""
|
|
return PluginMarketCandidate(
|
|
plugin_id=plugin_id,
|
|
source_key=source_key,
|
|
source_type=source_type,
|
|
repo_url=repo_url,
|
|
package_generation="v3",
|
|
plugin_version="1.0.0",
|
|
)
|
|
|
|
|
|
def _inventory(
|
|
*candidates: PluginMarketCandidate,
|
|
failed_market: bool = False,
|
|
) -> CandidateInventory:
|
|
"""构造完整或部分失败的市场库存。"""
|
|
reads = [
|
|
MarketRead.present(
|
|
OFFICIAL_REPO,
|
|
candidates,
|
|
package_generation="v3",
|
|
)
|
|
]
|
|
expected_markets = [OFFICIAL_REPO]
|
|
if failed_market:
|
|
failed_repo = "https://github.com/unavailable/MoviePilot-Plugins"
|
|
reads.append(
|
|
MarketRead.failure(
|
|
failed_repo,
|
|
"unavailable",
|
|
package_generation="v3",
|
|
)
|
|
)
|
|
expected_markets.append(failed_repo)
|
|
return CandidateInventory(
|
|
market_reads=tuple(reads),
|
|
expected_markets=tuple(expected_markets),
|
|
expected_generations=("v3",),
|
|
local_read=LocalCandidateRead.absent(),
|
|
)
|
|
|
|
|
|
def _legacy(plugin_id: str = "DemoPlugin") -> PluginIdentity:
|
|
"""构造尚未绑定在线来源的存量身份。"""
|
|
return PluginIdentity(
|
|
plugin_id=plugin_id,
|
|
normalized_plugin_id=plugin_id.lower(),
|
|
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
|
|
trusted_source_key=None,
|
|
binding_basis=PluginBindingBasis.LEGACY_UNBOUND,
|
|
payload_source_type=PluginPayloadSourceType.UNKNOWN,
|
|
payload_source_key=None,
|
|
declared_version=None,
|
|
package_generation=None,
|
|
declared_metadata=None,
|
|
payload_receipt=None,
|
|
revision=1,
|
|
created_at=NOW,
|
|
updated_at=NOW,
|
|
bound_at=None,
|
|
payload_applied_at=None,
|
|
)
|
|
|
|
|
|
def _service(
|
|
persistence: _Persistence,
|
|
inventory: CandidateInventory,
|
|
installed: list[str],
|
|
*,
|
|
virtual: set[str] | None = None,
|
|
) -> PluginIdentityMigrationService:
|
|
"""装配固定库存和安装清单的迁移服务。"""
|
|
virtual_ids = virtual or set()
|
|
return PluginIdentityMigrationService(
|
|
persistence=persistence,
|
|
inventory=AsyncMock(return_value=inventory),
|
|
installed_plugins=lambda: installed,
|
|
is_virtual_instance=lambda plugin_id: plugin_id in virtual_ids,
|
|
clock=lambda: NOW,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migration_binds_official_and_unique_third_party_sources() -> None:
|
|
"""官方默认和完整库存中的唯一第三方来源都可建立更新绑定。"""
|
|
persistence = _Persistence()
|
|
inventory = _inventory(
|
|
_candidate(
|
|
"OfficialPlugin",
|
|
source_type=TrustedPluginSourceType.OFFICIAL,
|
|
source_key=OFFICIAL_SOURCE,
|
|
repo_url=OFFICIAL_REPO,
|
|
),
|
|
_candidate(
|
|
"ThirdPartyPlugin",
|
|
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
|
source_key=THIRD_PARTY_SOURCE,
|
|
repo_url=THIRD_PARTY_REPO,
|
|
),
|
|
)
|
|
|
|
result = await _service(
|
|
persistence,
|
|
inventory,
|
|
["OfficialPlugin", "ThirdPartyPlugin", "VirtualPlugin"],
|
|
virtual={"VirtualPlugin"},
|
|
).migrate()
|
|
|
|
assert result.created == 2
|
|
assert result.bound == 2
|
|
assert result.unbound == 0
|
|
assert result.skipped == 1
|
|
official = persistence.identities["officialplugin"]
|
|
third_party = persistence.identities["thirdpartyplugin"]
|
|
assert official.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT
|
|
assert official.payload_source_type is PluginPayloadSourceType.UNKNOWN
|
|
assert third_party.binding_basis is PluginBindingBasis.TOFU
|
|
assert third_party.payload_source_type is PluginPayloadSourceType.UNKNOWN
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("failed_market", (False, True))
|
|
async def test_migration_keeps_ambiguous_or_incomplete_third_party_unbound(
|
|
failed_market: bool,
|
|
) -> None:
|
|
"""多来源或库存读取失败时不得猜测第三方更新来源。"""
|
|
candidates = (
|
|
_candidate(
|
|
"DemoPlugin",
|
|
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
|
source_key=THIRD_PARTY_SOURCE,
|
|
repo_url=THIRD_PARTY_REPO,
|
|
),
|
|
)
|
|
if not failed_market:
|
|
candidates += (
|
|
_candidate(
|
|
"DemoPlugin",
|
|
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
|
source_key="github:second/moviepilot-plugins",
|
|
repo_url="https://github.com/second/MoviePilot-Plugins",
|
|
),
|
|
)
|
|
persistence = _Persistence()
|
|
|
|
result = await _service(
|
|
persistence,
|
|
_inventory(*candidates, failed_market=failed_market),
|
|
["DemoPlugin"],
|
|
).migrate()
|
|
|
|
assert result.created == 1
|
|
assert result.bound == 0
|
|
assert result.unbound == 1
|
|
identity = persistence.identities["demoplugin"]
|
|
assert identity.binding_basis is PluginBindingBasis.LEGACY_UNBOUND
|
|
assert identity.trusted_source_key is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migration_later_binds_legacy_identity_without_rewriting_payload() -> None:
|
|
"""后续市场证据充分时只升级可信来源,不改写未知存量载荷。"""
|
|
legacy = _legacy("DemoPlugin")
|
|
persistence = _Persistence((legacy,))
|
|
inventory = _inventory(
|
|
_candidate(
|
|
"demoplugin",
|
|
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
|
source_key=THIRD_PARTY_SOURCE,
|
|
repo_url=THIRD_PARTY_REPO,
|
|
)
|
|
)
|
|
|
|
result = await _service(
|
|
persistence,
|
|
inventory,
|
|
["demoplugin"],
|
|
).migrate()
|
|
|
|
assert result.bound == 1
|
|
identity = persistence.identities["demoplugin"]
|
|
assert identity.plugin_id == "DemoPlugin"
|
|
assert identity.created_at == legacy.created_at
|
|
assert identity.revision == 2
|
|
assert identity.binding_basis is PluginBindingBasis.TOFU
|
|
assert identity.payload_source_type is PluginPayloadSourceType.UNKNOWN
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migration_accepts_concurrent_create_winner() -> None:
|
|
"""首次身份 CAS 竞争已有赢家时,迁移跳过而不覆盖最终身份。"""
|
|
persistence = _Persistence()
|
|
inventory = _inventory(
|
|
_candidate(
|
|
"DemoPlugin",
|
|
source_type=TrustedPluginSourceType.OFFICIAL,
|
|
source_key=OFFICIAL_SOURCE,
|
|
repo_url=OFFICIAL_REPO,
|
|
)
|
|
)
|
|
|
|
async def create_conflict(
|
|
identity: PluginIdentity,
|
|
*,
|
|
expected_revision: int | None,
|
|
) -> PluginIdentity:
|
|
assert expected_revision is None
|
|
persistence.identities[identity.normalized_plugin_id] = identity
|
|
raise PluginIdentityConflictError("concurrent create")
|
|
|
|
persistence.migrate_identity = create_conflict # type: ignore[method-assign]
|
|
|
|
result = await _service(persistence, inventory, ["DemoPlugin"]).migrate()
|
|
|
|
assert result.created == 0
|
|
assert result.skipped == 1
|
|
assert persistence.identities["demoplugin"].trusted_source_key == OFFICIAL_SOURCE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migration_accepts_concurrent_bind_winner() -> None:
|
|
"""存量绑定 CAS 已由其他执行者推进时,迁移保留赢家并幂等结束。"""
|
|
persistence = _Persistence((_legacy("DemoPlugin"),))
|
|
inventory = _inventory(
|
|
_candidate(
|
|
"DemoPlugin",
|
|
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
|
source_key=THIRD_PARTY_SOURCE,
|
|
repo_url=THIRD_PARTY_REPO,
|
|
)
|
|
)
|
|
|
|
async def bind_conflict(
|
|
identity: PluginIdentity,
|
|
*,
|
|
expected_revision: int,
|
|
) -> PluginIdentity:
|
|
assert expected_revision == 1
|
|
persistence.identities[identity.normalized_plugin_id] = identity
|
|
raise PluginIdentityConflictError("concurrent bind")
|
|
|
|
persistence.bind_online_identity = bind_conflict # type: ignore[method-assign]
|
|
|
|
result = await _service(persistence, inventory, ["DemoPlugin"]).migrate()
|
|
|
|
assert result.bound == 0
|
|
assert result.skipped == 1
|
|
winner = persistence.identities["demoplugin"]
|
|
assert winner.revision == 2
|
|
assert winner.trusted_source_key == THIRD_PARTY_SOURCE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migration_does_not_replace_existing_bound_or_local_identity() -> None:
|
|
"""重复启动不得覆盖已绑定在线来源或本地开发身份。"""
|
|
bound = replace(
|
|
_legacy("BoundPlugin"),
|
|
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
|
trusted_source_key=OFFICIAL_SOURCE,
|
|
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
|
bound_at=NOW,
|
|
)
|
|
local = replace(
|
|
_legacy("LocalPlugin"),
|
|
binding_basis=PluginBindingBasis.LOCAL_ONLY,
|
|
payload_source_type=PluginPayloadSourceType.LOCAL,
|
|
declared_version="1.0.0-dev",
|
|
package_generation="v3",
|
|
declared_metadata=_metadata("1.0.0-dev"),
|
|
payload_receipt="sha256:" + "1" * 64,
|
|
payload_applied_at=NOW,
|
|
)
|
|
persistence = _Persistence((bound, local))
|
|
inventory = _inventory(
|
|
_candidate(
|
|
"BoundPlugin",
|
|
source_type=TrustedPluginSourceType.OFFICIAL,
|
|
source_key=OFFICIAL_SOURCE,
|
|
repo_url=OFFICIAL_REPO,
|
|
),
|
|
_candidate(
|
|
"LocalPlugin",
|
|
source_type=TrustedPluginSourceType.OFFICIAL,
|
|
source_key=OFFICIAL_SOURCE,
|
|
repo_url=OFFICIAL_REPO,
|
|
),
|
|
)
|
|
|
|
result = await _service(
|
|
persistence,
|
|
inventory,
|
|
["BoundPlugin", "LocalPlugin", "BOUNDPLUGIN"],
|
|
).migrate()
|
|
|
|
assert result.created == 0
|
|
assert result.bound == 0
|
|
assert result.skipped == 3
|
|
assert persistence.identities["boundplugin"] == bound
|
|
assert persistence.identities["localplugin"] == local
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_collect_online_restore_plugins_requires_trust_and_local_payload() -> None:
|
|
"""仅在线可信来源仍绑定的本地载荷需要进入启动恢复候选。"""
|
|
trusted_local = replace(
|
|
_legacy("TrustedLocal"),
|
|
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
|
trusted_source_key=OFFICIAL_SOURCE,
|
|
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
|
payload_source_type=PluginPayloadSourceType.LOCAL,
|
|
declared_version="9.9.10",
|
|
package_generation="v3",
|
|
declared_metadata=_metadata("9.9.10"),
|
|
payload_receipt="sha256:" + "2" * 64,
|
|
bound_at=NOW,
|
|
payload_applied_at=NOW,
|
|
)
|
|
local_only = replace(
|
|
_legacy("LocalOnly"),
|
|
binding_basis=PluginBindingBasis.LOCAL_ONLY,
|
|
payload_source_type=PluginPayloadSourceType.LOCAL,
|
|
declared_version="1.0.0-dev",
|
|
package_generation="v3",
|
|
declared_metadata=_metadata("1.0.0-dev"),
|
|
payload_receipt="sha256:" + "3" * 64,
|
|
payload_applied_at=NOW,
|
|
)
|
|
online = replace(
|
|
_legacy("OnlinePayload"),
|
|
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
|
trusted_source_key=OFFICIAL_SOURCE,
|
|
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
|
payload_source_type=PluginPayloadSourceType.OFFICIAL,
|
|
payload_source_key=OFFICIAL_SOURCE,
|
|
declared_version="1.2.0",
|
|
package_generation="v3",
|
|
declared_metadata=_metadata("1.2.0"),
|
|
payload_receipt="sha256:" + "4" * 64,
|
|
bound_at=NOW,
|
|
payload_applied_at=NOW,
|
|
)
|
|
persistence = _Persistence((trusted_local, local_only, online))
|
|
|
|
result = await plugins_initializer._collect_online_restore_plugins(
|
|
persistence,
|
|
["TrustedLocal", "TRUSTEDLOCAL", "LocalOnly", "OnlinePayload", "bad-id"],
|
|
)
|
|
|
|
assert result == {"trustedlocal"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_runs_identity_migration_before_automatic_install(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""启动自动同步必须在存量来源迁移完成后才能读取和替换载荷。"""
|
|
order: list[str] = []
|
|
manager = MagicMock()
|
|
manager.mutation.return_value = nullcontext()
|
|
|
|
def sync(_token, *, online_restore_plugins):
|
|
order.append("sync")
|
|
assert online_restore_plugins == {"demoplugin"}
|
|
return []
|
|
|
|
manager.sync.side_effect = sync
|
|
manager.async_install_plugin_missing_dependencies_with_status = AsyncMock(
|
|
return_value=PluginDependencyInstallResult(missing=[], success=True)
|
|
)
|
|
manager.get_plugin_runtime_statuses.return_value = {}
|
|
manager.classify_plugins.return_value = MagicMock(ready=())
|
|
manager.running_plugins = {}
|
|
migration = MagicMock()
|
|
|
|
async def migrate() -> None:
|
|
order.append("migrate")
|
|
|
|
migration.migrate = migrate
|
|
identity = replace(
|
|
_legacy(),
|
|
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
|
trusted_source_key=OFFICIAL_SOURCE,
|
|
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
|
payload_source_type=PluginPayloadSourceType.LOCAL,
|
|
declared_version="9.9.10",
|
|
package_generation="v3",
|
|
declared_metadata=_metadata("9.9.10"),
|
|
payload_receipt="sha256:" + "5" * 64,
|
|
bound_at=NOW,
|
|
payload_applied_at=NOW,
|
|
)
|
|
persistence = MagicMock()
|
|
|
|
async def get_identity(_plugin_id: str) -> PluginIdentity:
|
|
order.append("identity")
|
|
return identity
|
|
|
|
persistence.get_identity = get_identity
|
|
config = MagicMock()
|
|
config.get.return_value = ["DemoPlugin"]
|
|
|
|
async def execute(_loop, task, _name):
|
|
return task()
|
|
|
|
monkeypatch.setattr(
|
|
plugins_initializer.global_vars,
|
|
"CURRENT_EVENT_LOOP",
|
|
asyncio.get_running_loop(),
|
|
)
|
|
monkeypatch.setattr(
|
|
plugins_initializer,
|
|
"configure_plugin_services",
|
|
lambda: order.append("configure"),
|
|
)
|
|
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
|
|
monkeypatch.setattr(
|
|
plugins_initializer,
|
|
"get_plugin_identity_migration",
|
|
lambda: migration,
|
|
)
|
|
monkeypatch.setattr(
|
|
plugins_initializer,
|
|
"get_plugin_persistence",
|
|
lambda: persistence,
|
|
)
|
|
monkeypatch.setattr(
|
|
plugins_initializer,
|
|
"get_configured_system_config",
|
|
lambda: config,
|
|
)
|
|
monkeypatch.setattr(plugins_initializer, "execute_task", execute)
|
|
|
|
assert await plugins_initializer.sync_plugins() is False
|
|
assert order == ["configure", "migrate", "identity", "sync"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_stops_before_automatic_install_when_identity_migration_fails(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""存量身份无法持久化时,启动同步不得继续读取或替换插件载荷。"""
|
|
manager = MagicMock()
|
|
manager.mutation.return_value = nullcontext()
|
|
migration = MagicMock()
|
|
migration.migrate = AsyncMock(side_effect=RuntimeError("database unavailable"))
|
|
|
|
monkeypatch.setattr(
|
|
plugins_initializer,
|
|
"configure_plugin_services",
|
|
lambda: None,
|
|
)
|
|
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
|
|
monkeypatch.setattr(
|
|
plugins_initializer,
|
|
"get_plugin_identity_migration",
|
|
lambda: migration,
|
|
)
|
|
|
|
assert await plugins_initializer.sync_plugins() is False
|
|
manager.sync.assert_not_called()
|