Files
MoviePilot/tests/test_plugin_installation_recovery.py
T

293 lines
9.6 KiB
Python

"""插件安装 journal 启动重放与阻断边界测试。"""
from dataclasses import replace
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.application.plugin.recovery import (
PluginInstallationRecoveryError,
PluginInstallationRecoveryService,
)
from app.application.plugin.transaction import (
PluginInstallationPhase,
PluginInstallationRecord,
)
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
RECEIPT = "sha256:" + "1" * 64
def _identity(*, revision: int = 2, receipt: str = RECEIPT) -> PluginIdentity:
"""构造一份已提交载荷身份。"""
return 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="2.0.0",
package_generation="v3",
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True, "v3t": True},
declaration_version="2.0.0",
manifest_matches_payload=True,
),
payload_receipt=receipt,
revision=revision,
created_at=NOW,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW,
)
def _record(
*,
phase: PluginInstallationPhase,
transaction_id: str = "txn-demo",
) -> PluginInstallationRecord:
"""构造 PREPARED 或 COMMITTED 恢复记录。"""
committed = phase is PluginInstallationPhase.COMMITTED
return PluginInstallationRecord(
transaction_id=transaction_id,
plugin_id="DemoPlugin",
phase=phase,
membership_before=True,
membership_target=True if committed else None,
identity_before_revision=1,
identity_target_revision=2 if committed else None,
package_existed=True,
persistent_backup_existed=True,
created_at=NOW,
updated_at=NOW,
)
class _Persistence:
"""保存恢复测试所需 journal、身份和删除故障。"""
def __init__(
self,
records: list[PluginInstallationRecord],
*,
identity: PluginIdentity | None = None,
delete_errors: list[Exception | None] | None = None,
) -> None:
self.records = {record.transaction_id: record for record in records}
self.identity = identity
self.delete_errors = list(delete_errors or [])
self.delete_calls: list[tuple[str, PluginInstallationPhase]] = []
async def list_installations(self) -> list[PluginInstallationRecord]:
"""按创建顺序返回当前 journal。"""
return list(self.records.values())
async def get_identity(self, _plugin_id: str) -> PluginIdentity | None:
"""返回已提交身份。"""
return self.identity
async def delete_installation(
self,
transaction_id: str,
*,
expected_phase: PluginInstallationPhase,
) -> bool:
"""按 phase 删除 journal,并可注入一次性错误。"""
self.delete_calls.append((transaction_id, expected_phase))
if self.delete_errors:
error = self.delete_errors.pop(0)
if error is not None:
raise error
record = self.records.get(transaction_id)
if record is None:
return False
assert record.phase is expected_phase
del self.records[transaction_id]
return True
def _packages(**overrides):
"""构造恢复服务消费的单一包事务端口。"""
checkpoint = SimpleNamespace(
plugin_existed=True,
persistent_backup_existed=True,
)
values = {
"restore_checkpoint": Mock(return_value=checkpoint),
"async_restore": AsyncMock(),
"async_cleanup": AsyncMock(),
"async_committed_payload_receipt": AsyncMock(return_value=RECEIPT),
"async_finalize_persistent_backup": AsyncMock(),
"async_commit": AsyncMock(),
}
values.update(overrides)
return SimpleNamespace(**values)
@pytest.mark.asyncio
async def test_prepared_replay_restores_before_releasing_journal() -> None:
"""PREPARED 必须先恢复旧载荷,再删除 journal 和恢复材料。"""
persistence = _Persistence([_record(phase=PluginInstallationPhase.PREPARED)])
packages = _packages()
service = PluginInstallationRecoveryService(
persistence=persistence,
packages=packages,
)
result = await service.replay()
assert result.restored == 1
assert persistence.records == {}
packages.async_restore.assert_awaited_once()
packages.async_cleanup.assert_awaited_once()
assert persistence.delete_calls == [
("txn-demo", PluginInstallationPhase.PREPARED)
]
@pytest.mark.asyncio
async def test_prepared_delete_failure_keeps_replayable_journal() -> None:
"""恢复完成但 journal 删除失败时,下次启动仍可幂等重放。"""
persistence = _Persistence(
[_record(phase=PluginInstallationPhase.PREPARED)],
delete_errors=[RuntimeError("database unavailable"), None],
)
packages = _packages()
service = PluginInstallationRecoveryService(
persistence=persistence,
packages=packages,
)
with pytest.raises(PluginInstallationRecoveryError, match="未提交安装恢复失败"):
await service.replay()
assert "txn-demo" in persistence.records
packages.async_cleanup.assert_not_awaited()
result = await service.replay()
assert result.restored == 1
assert persistence.records == {}
assert packages.async_restore.await_count == 2
packages.async_cleanup.assert_awaited_once()
@pytest.mark.asyncio
async def test_prepared_restore_failure_blocks_plugin_import() -> None:
"""旧载荷无法恢复时必须保留 journal,并让启动阶段失败。"""
persistence = _Persistence([_record(phase=PluginInstallationPhase.PREPARED)])
packages = _packages(
async_restore=AsyncMock(side_effect=RuntimeError("snapshot missing"))
)
service = PluginInstallationRecoveryService(
persistence=persistence,
packages=packages,
)
with pytest.raises(PluginInstallationRecoveryError, match="snapshot missing"):
await service.replay()
assert "txn-demo" in persistence.records
assert persistence.delete_calls == []
packages.async_cleanup.assert_not_awaited()
@pytest.mark.asyncio
async def test_committed_replay_verifies_identity_and_receipt_before_cleanup() -> None:
"""COMMITTED 只在身份 revision 和载荷收据一致时完成幂等收尾。"""
persistence = _Persistence(
[_record(phase=PluginInstallationPhase.COMMITTED)],
identity=_identity(),
)
packages = _packages()
service = PluginInstallationRecoveryService(
persistence=persistence,
packages=packages,
)
result = await service.replay()
assert result.finalized == 1
assert persistence.records == {}
packages.async_committed_payload_receipt.assert_awaited_once()
packages.async_finalize_persistent_backup.assert_awaited_once()
packages.async_commit.assert_awaited_once()
assert persistence.delete_calls == [
("txn-demo", PluginInstallationPhase.COMMITTED)
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("identity", "receipt", "message"),
[
(replace(_identity(), revision=3), RECEIPT, "身份与安装 journal 不一致"),
(_identity(), "sha256:" + "2" * 64, "载荷收据不一致"),
],
)
async def test_committed_fact_mismatch_blocks_plugin_import(
identity: PluginIdentity,
receipt: str,
message: str,
) -> None:
"""已提交数据库事实与可恢复载荷不一致时不得继续加载插件。"""
persistence = _Persistence(
[_record(phase=PluginInstallationPhase.COMMITTED)],
identity=identity,
)
packages = _packages(
async_committed_payload_receipt=AsyncMock(return_value=receipt)
)
service = PluginInstallationRecoveryService(
persistence=persistence,
packages=packages,
)
with pytest.raises(PluginInstallationRecoveryError, match=message):
await service.replay()
assert "txn-demo" in persistence.records
packages.async_finalize_persistent_backup.assert_not_awaited()
packages.async_commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_committed_cleanup_failure_is_retried_without_rollback() -> None:
"""COMMITTED 收尾失败只保留 journal,下一次启动继续清理。"""
persistence = _Persistence(
[_record(phase=PluginInstallationPhase.COMMITTED)],
identity=_identity(),
)
package_commit = AsyncMock(
side_effect=[RuntimeError("snapshot busy"), None]
)
packages = _packages(async_commit=package_commit)
service = PluginInstallationRecoveryService(
persistence=persistence,
packages=packages,
)
first = await service.replay()
assert first.cleanup_pending == 1
assert "txn-demo" in persistence.records
assert persistence.delete_calls == []
second = await service.replay()
assert second.finalized == 1
assert persistence.records == {}
assert packages.async_finalize_persistent_backup.await_count == 2
assert package_commit.await_count == 2