mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-02 22:17:08 +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
|