Files
MoviePilot/tests/test_plugin_install_gateway.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

654 lines
22 KiB
Python

"""
统一插件安装 Gateway 测试。
"""
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from unittest.mock import AsyncMock, Mock, patch
import pytest
from app.application.plugin import gateway as gateway_module
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.source import (
CandidateInventory,
LocalCandidateRead,
MarketRead,
PluginLocalCandidate,
PluginMarketCandidate,
)
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins"
def _inventory() -> CandidateInventory:
"""
构造仅含官方候选的完整库存。
"""
return CandidateInventory((
MarketRead.present(
REPO_URL,
(
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},
),
),
package_generation="v3",
),
))
@pytest.mark.asyncio
async def test_gateway_freezes_admission_before_executing_transaction() -> None:
"""
Gateway 只把已选中的候选交给事务执行器。
"""
executor = AsyncMock()
executor.execute.return_value = type(
"Result",
(),
{"success": True, "message": ""},
)()
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=_inventory()),
identity=AsyncMock(return_value=None),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: NOW,
source_plugin_id=lambda plugin_id: plugin_id,
)
result = await gateway.install(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
package_version="v3",
explicit_source=True,
)
assert result.success is True
admission = executor.execute.await_args.kwargs["admission"]
assert admission.candidate.repo_url == REPO_URL
assert admission.expected_revision is None
@pytest.mark.asyncio
async def test_force_install_reuses_cached_candidate_inventory() -> None:
"""
强制覆盖载荷时不得连带强刷全部远程插件仓库。
"""
inventory = AsyncMock(return_value=_inventory())
executor = AsyncMock()
executor.execute.return_value = type(
"Result",
(),
{"success": True, "message": ""},
)()
gateway = PluginInstallGateway(
inventory=inventory,
identity=AsyncMock(return_value=None),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: NOW,
source_plugin_id=lambda plugin_id: plugin_id,
)
result = await gateway.install(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
package_version="v3",
force=True,
explicit_source=True,
)
assert result.success is True
inventory.assert_awaited_once_with(False)
assert executor.execute.await_args.kwargs["force"] is True
@pytest.mark.asyncio
async def test_local_only_requires_explicit_online_binding() -> None:
"""
本地专属身份即使发现唯一在线来源,也只能由管理员显式绑定。
"""
online = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:jxxghp/moviepilot-plugins",
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url=REPO_URL,
package_generation="v3",
plugin_version="9.0.0",
dto={"v3": True},
)
local = PluginLocalCandidate(
plugin_id="DemoPlugin",
repo_url="local://DemoPlugin?version=v3",
package_generation="v3",
plugin_version="1.0.0",
dto={"v3": True},
)
identity = PluginIdentity(
plugin_id="DemoPlugin",
normalized_plugin_id="demoplugin",
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
trusted_source_key=None,
binding_basis=PluginBindingBasis.LOCAL_ONLY,
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
declared_version="1.0.0",
package_generation="v3",
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo local", "v3": True},
declaration_version="1.0.0",
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "1" * 64,
revision=2,
created_at=NOW,
updated_at=NOW,
bound_at=None,
payload_applied_at=NOW,
)
executor = AsyncMock()
executor.execute.return_value = type(
"Result",
(),
{"success": True, "message": ""},
)()
gateway = PluginInstallGateway(
inventory=AsyncMock(
return_value=CandidateInventory(
(MarketRead.present(REPO_URL, (online,)),),
(local,),
local_read=LocalCandidateRead.present((local,)),
)
),
identity=AsyncMock(return_value=identity),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: NOW,
source_plugin_id=lambda plugin_id: plugin_id,
)
automatic = await gateway.install(
plugin_id="DemoPlugin",
repo_url=None,
package_version="v3",
)
assert automatic.success is True
automatic_admission = executor.execute.await_args.kwargs["admission"]
assert automatic_admission.candidate is local
assert automatic_admission.binding_basis is PluginBindingBasis.LOCAL_ONLY
assert automatic_admission.trusted_source_key is None
hinted = await gateway.install(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
package_version="v3",
)
assert hinted.success is True
hinted_admission = executor.execute.await_args.kwargs["admission"]
assert hinted_admission.candidate is local
assert hinted_admission.binding_basis is PluginBindingBasis.LOCAL_ONLY
assert hinted_admission.trusted_source_key is None
explicit = await gateway.install(
plugin_id="DemoPlugin",
repo_url=REPO_URL,
package_version="v3",
explicit_source=True,
)
assert explicit.success is True
explicit_admission = executor.execute.await_args.kwargs["admission"]
assert explicit_admission.candidate is online
assert explicit_admission.binding_basis is PluginBindingBasis.EXPLICIT_INSTALL
assert explicit_admission.trusted_source_key == online.source_key
@pytest.mark.asyncio
async def test_explicit_local_sync_uses_local_candidate_and_execution_mode() -> None:
"""
文件监控明确指定本地候选时必须同步本地载荷。
"""
online = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:jxxghp/moviepilot-plugins",
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url=REPO_URL,
package_generation="v3",
plugin_version="9.0.0",
dto={"v3": True},
)
local = PluginLocalCandidate(
plugin_id="DemoPlugin",
repo_url="local://DemoPlugin?version=v3",
package_generation="v3",
plugin_version="1.0.0",
dto={"v3": True},
)
identity = PluginIdentity(
plugin_id="DemoPlugin",
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.OFFICIAL,
payload_source_key="github:jxxghp/moviepilot-plugins",
declared_version="9.0.0",
package_generation="v3",
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo online", "v3": True},
declaration_version="9.0.0",
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "1" * 64,
revision=2,
created_at=NOW,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW,
)
executor = AsyncMock()
executor.execute.return_value = type(
"Result",
(),
{"success": True, "message": ""},
)()
gateway = PluginInstallGateway(
inventory=AsyncMock(
return_value=CandidateInventory(
(MarketRead.present(REPO_URL, (online,)),),
(local,),
local_read=LocalCandidateRead.present((local,)),
)
),
identity=AsyncMock(return_value=identity),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: NOW,
source_plugin_id=lambda plugin_id: plugin_id,
)
result = await gateway.install(
plugin_id="DemoPlugin",
repo_url=local.repo_url,
package_version="v3",
force=True,
local_sync=True,
explicit_source=True,
)
assert result.success is True
assert executor.execute.await_args.kwargs["admission"].candidate is local
assert executor.execute.await_args.kwargs["local_sync"] is True
@pytest.mark.asyncio
async def test_gateway_rejects_source_conflict_before_package_execution() -> None:
"""
来源准入失败时不进入文件和数据库事务。
"""
other = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:example/moviepilot-plugins",
source_type=TrustedPluginSourceType.THIRD_PARTY,
repo_url="https://github.com/example/moviepilot-plugins",
package_generation="v3",
plugin_version="2.0.0",
dto={"v3": True},
)
executor = AsyncMock()
gateway = PluginInstallGateway(
inventory=AsyncMock(
return_value=CandidateInventory((
MarketRead.present(REPO_URL, (_inventory().online_candidates[0], other)),
))
),
identity=AsyncMock(return_value=None),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: NOW,
source_plugin_id=lambda plugin_id: plugin_id,
)
result = await gateway.install(
plugin_id="DemoPlugin",
repo_url=None,
)
assert result.success is False
assert result.failure_stage == "source_admission"
executor.execute.assert_not_awaited()
@pytest.mark.asyncio
async def test_gateway_checks_compatibility_on_final_trusted_candidate() -> None:
"""
跨仓聚合不能替代最终可信候选的系统版本兼容门禁。
"""
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.2.0",
dto={"v3": True, "system_version": ">=99"},
)
competing = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:example/moviepilot-plugins",
source_type=TrustedPluginSourceType.THIRD_PARTY,
repo_url="https://github.com/example/moviepilot-plugins",
package_generation="v3",
plugin_version="9.9.10",
dto={"v3": True},
)
identity = PluginIdentity(
plugin_id="DemoPlugin",
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.9",
package_generation="v3",
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo local", "v3": True},
declaration_version="9.9.9",
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "1" * 64,
revision=3,
created_at=NOW,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW,
)
compatibility = Mock(return_value=(False, "当前版本不满足插件要求"))
executor = AsyncMock()
gateway = PluginInstallGateway(
inventory=AsyncMock(
return_value=CandidateInventory((
MarketRead.present(REPO_URL, (official,)),
MarketRead.present(competing.repo_url, (competing,)),
))
),
identity=AsyncMock(return_value=identity),
candidate_compatibility=compatibility,
executor=executor,
clock=lambda: NOW,
source_plugin_id=lambda plugin_id: plugin_id,
)
result = await gateway.install(
plugin_id="DemoPlugin",
repo_url=None,
package_version="v3",
)
assert result.success is False
assert result.failure_stage == "source_admission"
assert result.message == "当前版本不满足插件要求"
compatibility.assert_called_once_with(official)
executor.execute.assert_not_awaited()
@pytest.mark.asyncio
async def test_gateway_source_inspection_preserves_sources_without_local_path() -> None:
"""
来源查询按在线仓归并版本,但不返回本地仓库路径。
"""
official_v3 = _inventory().online_candidates[0]
official_v2 = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:jxxghp/moviepilot-plugins",
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url=REPO_URL,
package_generation="v2",
plugin_version="9.0.0",
dto={"v2": True},
)
third_party = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:example/moviepilot-plugins",
source_type=TrustedPluginSourceType.THIRD_PARTY,
repo_url="https://github.com/example/moviepilot-plugins",
package_generation="v3",
plugin_version="2.0.0",
dto={"v3": True},
)
local = PluginLocalCandidate(
plugin_id="DemoPlugin",
repo_url="local://DemoPlugin?path=/private/plugins&version=v3",
package_generation="v3",
plugin_version="3.0.0-dev",
dto={"path": "/private/plugins", "v3": True},
)
inventory = CandidateInventory(
(
MarketRead.present(
third_party.repo_url,
(third_party,),
package_generation="v3",
),
MarketRead.present(REPO_URL, (official_v3,), package_generation="v3"),
MarketRead.present(REPO_URL, (official_v2,), package_generation="v2"),
),
(local,),
local_read=LocalCandidateRead.present((local,)),
)
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=inventory),
identity=AsyncMock(return_value=None),
candidate_compatibility=lambda _candidate: (True, ""),
executor=AsyncMock(),
clock=lambda: NOW,
source_plugin_id=lambda plugin_id: plugin_id,
)
inspection = await gateway.inspect_source(plugin_id="DemoPlugin")
assert [candidate.source_key for candidate in inspection.online_candidates] == [
"github:jxxghp/moviepilot-plugins",
"github:example/moviepilot-plugins",
]
assert inspection.online_candidates[0].package_generation == "v3"
assert inspection.local_candidate is local
public_repo_url = inspection.local_candidate.public_dict()["repo_url"]
assert public_repo_url == "local://DemoPlugin?version=v3"
assert "/private/plugins" not in public_repo_url
@pytest.mark.asyncio
async def test_gateway_forwards_explicit_source_change_revision() -> None:
"""
显式换源的目标来源和 revision 必须冻结到事务准入结果。
"""
current = PluginIdentity(
plugin_id="DemoPlugin",
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.OFFICIAL,
payload_source_key="github:jxxghp/moviepilot-plugins",
declared_version="1.0.0",
package_generation="v3",
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True},
declaration_version="1.0.0",
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "0" * 64,
revision=4,
created_at=NOW,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW,
)
candidate = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:example/moviepilot-plugins",
source_type=TrustedPluginSourceType.THIRD_PARTY,
repo_url="https://github.com/example/moviepilot-plugins",
package_generation="v3",
plugin_version="2.0.0",
dto={"v3": True},
)
executor = AsyncMock()
executor.execute.return_value = type(
"Result",
(),
{"success": True, "message": ""},
)()
gateway = PluginInstallGateway(
inventory=AsyncMock(
return_value=CandidateInventory((MarketRead.present(REPO_URL, (candidate,)),))
),
identity=AsyncMock(return_value=current),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: NOW,
source_plugin_id=lambda plugin_id: plugin_id,
)
result = await gateway.install(
plugin_id="DemoPlugin",
repo_url=candidate.repo_url,
explicit_source=True,
source_change=True,
expected_revision=4,
)
assert result.success is True
admission = executor.execute.await_args.kwargs["admission"]
assert admission.identity_before == current
assert admission.expected_revision == 4
assert admission.binding_basis is PluginBindingBasis.EXPLICIT_SOURCE_CHANGE
assert admission.trusted_source_key == candidate.source_key
@pytest.mark.asyncio
async def test_inspect_source_resolves_clone_id_to_its_source_plugin() -> None:
"""
分身勘察来源必须先归一到源插件,否则查不到任何安装包。
"""
identity = AsyncMock(return_value=None)
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=_inventory()),
identity=identity,
candidate_compatibility=lambda _candidate: (True, ""),
executor=AsyncMock(),
clock=lambda: NOW,
source_plugin_id=lambda plugin_id: (
"DemoPlugin" if plugin_id == "DemoPluginwork" else plugin_id
),
)
inspection = await gateway.inspect_source(plugin_id="DemoPluginwork")
assert [candidate.plugin_id for candidate in inspection.online_candidates] == [
"DemoPlugin"
]
assert inspection.selection.candidate is not None
assert inspection.plugin_id == "DemoPlugin"
identity.assert_awaited_once_with("DemoPlugin")
@pytest.mark.asyncio
async def test_install_resolves_clone_id_to_its_source_plugin() -> None:
"""
分身安装必须先归一到源插件,否则准入阶段就找不到安装包。
"""
executor = AsyncMock()
executor.execute.return_value = type(
"Result",
(),
{"success": True, "message": ""},
)()
identity = AsyncMock(return_value=None)
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=_inventory()),
identity=identity,
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: NOW,
source_plugin_id=lambda plugin_id: (
"DemoPlugin" if plugin_id == "DemoPluginwork" else plugin_id
),
)
result = await gateway.install(
plugin_id="DemoPluginwork",
repo_url=REPO_URL,
package_version="v3",
explicit_source=True,
)
assert result.success is True
admission = executor.execute.await_args.kwargs["admission"]
assert admission.candidate.plugin_id == "DemoPlugin"
identity.assert_awaited_once_with("DemoPlugin")
@pytest.mark.asyncio
async def test_install_holds_lifecycle_on_the_source_plugin_for_clones() -> None:
"""
分身安装的生命周期占位必须落在源插件上,同源分身才不会并发改写同一份载荷。
"""
held: list[str] = []
@asynccontextmanager
async def _record_hold(plugin_id: str, _startup_token=None):
"""
记录安装期间实际占位的插件 ID。
"""
held.append(plugin_id)
yield
executor = AsyncMock()
executor.execute.return_value = type(
"Result",
(),
{"success": True, "message": ""},
)()
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=_inventory()),
identity=AsyncMock(return_value=None),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: NOW,
source_plugin_id=lambda plugin_id: (
"DemoPlugin" if plugin_id == "DemoPluginwork" else plugin_id
),
)
with patch.object(gateway_module.plugin_lifecycle, "hold", _record_hold):
result = await gateway.install(
plugin_id="DemoPluginwork",
repo_url=REPO_URL,
package_version="v3",
explicit_source=True,
)
assert result.success is True
assert held == ["DemoPlugin"]