mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-13 00:59:01 +08:00
293 lines
9.6 KiB
Python
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
|