mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
1108 lines
37 KiB
Python
1108 lines
37 KiB
Python
"""插件安装事务用例的端到端副作用顺序与补偿测试。"""
|
|
|
|
import asyncio
|
|
from contextlib import nullcontext
|
|
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.admission import (
|
|
PluginInstallAdmission,
|
|
PluginInstallAdmissionRequest,
|
|
admit_plugin_install,
|
|
)
|
|
from app.application.plugin.declaration import PluginDeclaredMetadata
|
|
from app.application.plugin.identity import (
|
|
PluginBindingBasis,
|
|
PluginIdentity,
|
|
PluginPayloadSourceType,
|
|
TrustedPluginSourceType,
|
|
)
|
|
from app.application.plugin.install import PluginInstallCommand
|
|
from app.application.plugin.recovery import PluginInstallationRecoveryService
|
|
from app.application.plugin.source import (
|
|
CandidateInventory,
|
|
MarketRead,
|
|
PluginMarketCandidate,
|
|
)
|
|
from app.application.plugin.transaction import (
|
|
PluginInstallationConflictError,
|
|
PluginInstallationPhase,
|
|
PluginInstallationRecord,
|
|
)
|
|
from app.schemas.exception import (
|
|
DatabaseWorkerClosedError,
|
|
PersistenceUnavailableError,
|
|
PluginMutationRejectedError,
|
|
)
|
|
from app.schemas.plugin import PluginRuntimeStatus
|
|
|
|
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
|
|
REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins"
|
|
SOURCE_KEY = "github:jxxghp/moviepilot-plugins"
|
|
RECEIPT = "sha256:" + "a" * 64
|
|
|
|
|
|
def _identity(*, version: str = "1.0.0", revision: int = 1) -> PluginIdentity:
|
|
"""构造与市场候选匹配的已提交来源身份。"""
|
|
return PluginIdentity(
|
|
plugin_id="DemoPlugin",
|
|
normalized_plugin_id="demoplugin",
|
|
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
|
trusted_source_key=SOURCE_KEY,
|
|
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
|
payload_source_type=PluginPayloadSourceType.OFFICIAL,
|
|
payload_source_key=SOURCE_KEY,
|
|
declared_version=version,
|
|
package_generation="v3",
|
|
declared_metadata=PluginDeclaredMetadata.from_package(
|
|
{"name": "Demo", "v3": True, "v3t": False},
|
|
declaration_version=version,
|
|
manifest_matches_payload=True,
|
|
),
|
|
payload_receipt=RECEIPT,
|
|
revision=revision,
|
|
created_at=NOW,
|
|
updated_at=NOW,
|
|
bound_at=NOW,
|
|
payload_applied_at=NOW,
|
|
)
|
|
|
|
|
|
def _admission(
|
|
*,
|
|
identity: PluginIdentity | None = None,
|
|
version: str = "1.0.0",
|
|
) -> PluginInstallAdmission:
|
|
"""冻结一个可供安装用例消费的官方 V3 候选。"""
|
|
candidate = PluginMarketCandidate(
|
|
plugin_id="DemoPlugin",
|
|
source_key=SOURCE_KEY,
|
|
source_type=TrustedPluginSourceType.OFFICIAL,
|
|
repo_url=REPO_URL,
|
|
package_generation="v3",
|
|
plugin_version=version,
|
|
dto={"v3": True},
|
|
)
|
|
inventory = CandidateInventory(
|
|
(
|
|
MarketRead.present(
|
|
REPO_URL,
|
|
(candidate,),
|
|
package_generation="v3",
|
|
),
|
|
)
|
|
)
|
|
return admit_plugin_install(
|
|
inventory,
|
|
request=PluginInstallAdmissionRequest(
|
|
plugin_id="DemoPlugin",
|
|
generations=("v3", "v2", "v1"),
|
|
requested_repo_url=REPO_URL,
|
|
explicit_source=identity is None,
|
|
),
|
|
identity=identity,
|
|
now=NOW,
|
|
)
|
|
|
|
|
|
class _PersistenceSpy:
|
|
"""记录安装 journal 的异步状态转移,并允许注入持久化失败。"""
|
|
|
|
def __init__(
|
|
self,
|
|
calls: list[str],
|
|
*,
|
|
create_error: Exception | None = None,
|
|
target_error: Exception | None = None,
|
|
commit_error: Exception | None = None,
|
|
delete_error: Exception | None = None,
|
|
identity: PluginIdentity | None = None,
|
|
) -> None:
|
|
self.calls = calls
|
|
self.records: dict[str, PluginInstallationRecord] = {}
|
|
self.create_error = create_error
|
|
self.target_error = target_error
|
|
self.commit_error = commit_error
|
|
self.delete_error = delete_error
|
|
self.identity = identity
|
|
|
|
async def create_installation(
|
|
self,
|
|
record: PluginInstallationRecord,
|
|
) -> PluginInstallationRecord:
|
|
"""保存 PREPARED journal。"""
|
|
self.calls.append("journal_create")
|
|
if self.create_error:
|
|
raise self.create_error
|
|
if any(
|
|
existing.plugin_id.lower() == record.plugin_id.lower()
|
|
and existing.phase
|
|
in {
|
|
PluginInstallationPhase.PREPARED,
|
|
PluginInstallationPhase.COMMITTED,
|
|
}
|
|
for existing in self.records.values()
|
|
):
|
|
raise PluginInstallationConflictError(
|
|
f"插件 {record.plugin_id} 存在未收尾安装事务"
|
|
)
|
|
self.records[record.transaction_id] = record
|
|
return record
|
|
|
|
async def get_installation(
|
|
self,
|
|
transaction_id: str,
|
|
) -> PluginInstallationRecord | None:
|
|
"""返回 journal 创建结果,模拟超时后的确认读取。"""
|
|
self.calls.append("journal_get")
|
|
return self.records.get(transaction_id)
|
|
|
|
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 set_installation_target(
|
|
self,
|
|
transaction_id: str,
|
|
*,
|
|
membership_target: bool,
|
|
identity_target: PluginIdentity | None,
|
|
) -> PluginInstallationRecord:
|
|
"""保存最终 membership 与身份 revision。"""
|
|
self.calls.append("journal_target")
|
|
if self.target_error:
|
|
raise self.target_error
|
|
record = self.records[transaction_id]
|
|
record = replace(
|
|
record,
|
|
membership_target=membership_target,
|
|
identity_target_revision=(
|
|
identity_target.revision if identity_target else None
|
|
),
|
|
)
|
|
self.records[transaction_id] = record
|
|
return record
|
|
|
|
async def commit_installation(
|
|
self,
|
|
transaction_id: str,
|
|
*,
|
|
identity_target: PluginIdentity | None,
|
|
) -> PluginInstallationRecord:
|
|
"""把 journal 推进到 COMMITTED。"""
|
|
self.calls.append("journal_commit")
|
|
if self.commit_error:
|
|
raise self.commit_error
|
|
if identity_target is not None:
|
|
self.identity = identity_target
|
|
record = self.records[transaction_id]
|
|
record = replace(
|
|
record,
|
|
phase=PluginInstallationPhase.COMMITTED,
|
|
membership_target=True,
|
|
identity_target_revision=(
|
|
identity_target.revision if identity_target else None
|
|
),
|
|
)
|
|
self.records[transaction_id] = record
|
|
return record
|
|
|
|
async def delete_installation(
|
|
self,
|
|
transaction_id: str,
|
|
*,
|
|
expected_phase: PluginInstallationPhase,
|
|
) -> bool:
|
|
"""按 phase 删除已补偿或已收尾 journal。"""
|
|
self.calls.append("journal_delete")
|
|
if self.delete_error:
|
|
raise self.delete_error
|
|
record = self.records.get(transaction_id)
|
|
if record is None or record.phase is not expected_phase:
|
|
return False
|
|
del self.records[transaction_id]
|
|
return True
|
|
|
|
|
|
def _command(
|
|
*,
|
|
persistence: _PersistenceSpy | None = None,
|
|
installed: list[str] | None = None,
|
|
plugin_ids: list[str] | None = None,
|
|
installer=None,
|
|
checkpointer=None,
|
|
package_restore=None,
|
|
package_rollback=None,
|
|
package_cleanup=None,
|
|
package_stage_backup=None,
|
|
package_activate_backup=None,
|
|
package_finalize_backup=None,
|
|
package_commit=None,
|
|
payload_receipt=None,
|
|
reporter=None,
|
|
target_reloader=None,
|
|
rollback_reloader=None,
|
|
registration_refresher=None,
|
|
mutation=None,
|
|
package_write_guard=None,
|
|
transaction_id: str = "txn-demo",
|
|
) -> tuple[PluginInstallCommand, _PersistenceSpy, list[str]]:
|
|
"""构造只含窄端口的安装命令,并返回可观测调用记录。"""
|
|
calls: list[str] = persistence.calls if persistence else []
|
|
persistence = persistence or _PersistenceSpy(calls)
|
|
checkpoint = SimpleNamespace(
|
|
plugin_existed=False,
|
|
persistent_backup_existed=False,
|
|
)
|
|
|
|
async def default_checkpoint(_plugin_id: str, _transaction_id: str):
|
|
"""返回事务级文件快照。"""
|
|
calls.append("checkpoint")
|
|
return checkpoint
|
|
|
|
async def default_installer(**_kwargs):
|
|
"""表示包载荷安装成功。"""
|
|
calls.append("package")
|
|
return True, "installed"
|
|
|
|
async def default_receipt(_plugin_id: str):
|
|
"""返回稳定的已落盘载荷收据。"""
|
|
calls.append("receipt")
|
|
return RECEIPT
|
|
|
|
async def default_reporter(_plugin_id: str, _repo_url: str | None):
|
|
"""表示远程安装上报成功。"""
|
|
calls.append("report")
|
|
return True
|
|
|
|
packages = SimpleNamespace(
|
|
async_checkpoint=checkpointer or default_checkpoint,
|
|
async_install=installer or default_installer,
|
|
async_restore=package_restore or AsyncMock(),
|
|
async_cleanup=package_cleanup or AsyncMock(),
|
|
async_stage_persistent_backup=package_stage_backup or AsyncMock(),
|
|
async_activate_persistent_backup=package_activate_backup or AsyncMock(),
|
|
async_finalize_persistent_backup=package_finalize_backup or AsyncMock(),
|
|
async_commit=package_commit or AsyncMock(),
|
|
async_payload_receipt=payload_receipt or default_receipt,
|
|
)
|
|
command = PluginInstallCommand(
|
|
persistence=persistence,
|
|
installed_plugins_reader=lambda: installed or [],
|
|
plugin_ids_provider=lambda: plugin_ids or [],
|
|
packages=packages,
|
|
install_reporter=reporter or default_reporter,
|
|
target_reloader=target_reloader
|
|
or AsyncMock(return_value=PluginRuntimeStatus.ACTIVE),
|
|
rollback_reloader=rollback_reloader or AsyncMock(),
|
|
registration_refresher=registration_refresher or AsyncMock(),
|
|
mutation=mutation or (lambda _operation: nullcontext()),
|
|
package_write_guard=package_write_guard
|
|
or (lambda _plugin_id: nullcontext()),
|
|
clock=lambda: NOW,
|
|
transaction_id_factory=lambda: transaction_id,
|
|
)
|
|
return command, persistence, calls
|
|
|
|
|
|
def _journal_record(
|
|
phase: PluginInstallationPhase,
|
|
*,
|
|
transaction_id: str,
|
|
) -> PluginInstallationRecord:
|
|
"""构造占用同一物理插件槽位的旧安装 journal。"""
|
|
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,
|
|
)
|
|
|
|
|
|
async def _execute(
|
|
command: PluginInstallCommand,
|
|
admission=None,
|
|
*,
|
|
release_version: str | None = None,
|
|
force: bool = False,
|
|
**kwargs,
|
|
):
|
|
"""执行测试安装并默认使用当前冻结候选。"""
|
|
return await command.execute(
|
|
admission=admission or _admission(),
|
|
release_version=release_version,
|
|
force=force,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_success_commits_journal_before_report_and_cleans_package_snapshot():
|
|
"""成功路径按快照、journal、运行态、数据库提交、清理和上报顺序执行。"""
|
|
calls: list[str] = []
|
|
|
|
def mark(name: str):
|
|
async def action(_value):
|
|
calls.append(name)
|
|
|
|
return action
|
|
|
|
async def target_reload(_plugin_id):
|
|
calls.append("target_reload")
|
|
return PluginRuntimeStatus.ACTIVE
|
|
|
|
async def installer(**_kwargs):
|
|
calls.append("package")
|
|
return True, "installed"
|
|
|
|
async def receipt(_plugin_id):
|
|
calls.append("receipt")
|
|
return RECEIPT
|
|
|
|
async def report(_plugin_id, _repo_url):
|
|
calls.append("report")
|
|
return True
|
|
|
|
persistence = _PersistenceSpy(calls)
|
|
command, _, _ = _command(
|
|
persistence=persistence,
|
|
installer=installer,
|
|
payload_receipt=receipt,
|
|
package_stage_backup=mark("stage_backup"),
|
|
package_activate_backup=mark("activate_backup"),
|
|
target_reloader=target_reload,
|
|
registration_refresher=mark("registrations"),
|
|
package_finalize_backup=mark("finalize_backup"),
|
|
package_commit=mark("package_commit"),
|
|
reporter=report,
|
|
)
|
|
|
|
result = await _execute(command)
|
|
|
|
assert result.success is True
|
|
assert result.package_installed is True
|
|
assert result.installed_list_persisted is True
|
|
assert result.runtime_reloaded is True
|
|
assert result.registrations_refreshed is True
|
|
assert result.reported is True
|
|
assert calls == [
|
|
"checkpoint",
|
|
"journal_create",
|
|
"package",
|
|
"receipt",
|
|
"journal_target",
|
|
"stage_backup",
|
|
"activate_backup",
|
|
"target_reload",
|
|
"registrations",
|
|
"journal_commit",
|
|
"finalize_backup",
|
|
"package_commit",
|
|
"journal_delete",
|
|
"report",
|
|
]
|
|
assert persistence.records == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_active_runtime_status_compensates_before_database_commit():
|
|
"""运行态重载未激活时,安装必须在数据库提交前补偿并返回失败。"""
|
|
package_restore = AsyncMock()
|
|
package_cleanup = AsyncMock()
|
|
rollback_reloader = AsyncMock(return_value=PluginRuntimeStatus.ACTIVE)
|
|
registration_refresher = AsyncMock()
|
|
target_reloader = AsyncMock(return_value=PluginRuntimeStatus.LOAD_FAILED)
|
|
reporter = AsyncMock()
|
|
|
|
command, persistence, calls = _command(
|
|
package_restore=package_restore,
|
|
package_cleanup=package_cleanup,
|
|
target_reloader=target_reloader,
|
|
rollback_reloader=rollback_reloader,
|
|
registration_refresher=registration_refresher,
|
|
reporter=reporter,
|
|
)
|
|
|
|
result = await _execute(command)
|
|
|
|
assert result.success is False
|
|
assert result.failure_stage == "runtime_reload"
|
|
assert result.package_installed is True
|
|
assert result.installed_list_persisted is False
|
|
assert result.runtime_reloaded is False
|
|
assert result.registrations_refreshed is False
|
|
assert result.reported is False
|
|
package_restore.assert_awaited_once()
|
|
rollback_reloader.assert_awaited_once_with("DemoPlugin")
|
|
registration_refresher.assert_awaited_once_with("DemoPlugin")
|
|
reporter.assert_not_awaited()
|
|
assert "journal_commit" not in calls
|
|
assert persistence.records == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_plugin_with_non_active_runtime_status_is_not_reported_successfully():
|
|
"""已有载荷刷新失败时不得伪装成运行态成功或发送安装上报。"""
|
|
target_reloader = AsyncMock(return_value=PluginRuntimeStatus.LOAD_FAILED)
|
|
reporter = AsyncMock()
|
|
command, persistence, _ = _command(
|
|
installed=["DemoPlugin"],
|
|
plugin_ids=["DemoPlugin"],
|
|
target_reloader=target_reloader,
|
|
reporter=reporter,
|
|
)
|
|
|
|
result = await _execute(command, admission=_admission(identity=_identity()))
|
|
|
|
assert result.success is False
|
|
assert result.refreshed_only is True
|
|
assert result.failure_stage == "runtime_reload"
|
|
assert result.runtime_reloaded is False
|
|
assert result.registrations_refreshed is False
|
|
assert result.reported is False
|
|
target_reloader.assert_awaited_once_with("DemoPlugin")
|
|
reporter.assert_not_awaited()
|
|
assert persistence.records == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"phase",
|
|
[PluginInstallationPhase.PREPARED, PluginInstallationPhase.COMMITTED],
|
|
)
|
|
async def test_unfinished_journal_blocks_follow_up_before_payload_write(
|
|
phase: PluginInstallationPhase,
|
|
) -> None:
|
|
"""旧 journal 未收尾时,新安装不得写载荷或推进身份 revision。"""
|
|
persistence = _PersistenceSpy([])
|
|
old_record = _journal_record(phase, transaction_id=f"old-{phase.value}")
|
|
persistence.records[old_record.transaction_id] = old_record
|
|
package_install = AsyncMock()
|
|
package_restore = AsyncMock()
|
|
package_cleanup = AsyncMock()
|
|
command, _, _ = _command(
|
|
persistence=persistence,
|
|
installer=package_install,
|
|
package_restore=package_restore,
|
|
package_cleanup=package_cleanup,
|
|
)
|
|
|
|
admission = (
|
|
_admission(identity=_identity(revision=2))
|
|
if phase is PluginInstallationPhase.COMMITTED
|
|
else _admission()
|
|
)
|
|
result = await _execute(command, admission=admission, force=True)
|
|
|
|
assert result.success is False
|
|
assert result.failure_stage == "journal_prepare_conflict"
|
|
assert "未收尾安装事务" in result.message
|
|
package_install.assert_not_awaited()
|
|
package_restore.assert_awaited_once()
|
|
package_cleanup.assert_awaited_once()
|
|
assert persistence.records == {old_record.transaction_id: old_record}
|
|
|
|
if phase is PluginInstallationPhase.COMMITTED:
|
|
checkpoint = SimpleNamespace(
|
|
plugin_existed=True,
|
|
persistent_backup_existed=True,
|
|
)
|
|
recovery_packages = SimpleNamespace(
|
|
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(),
|
|
)
|
|
persistence.identity = _identity(revision=2)
|
|
recovery = PluginInstallationRecoveryService(
|
|
persistence=persistence,
|
|
packages=recovery_packages,
|
|
)
|
|
|
|
recovery_result = await recovery.replay()
|
|
|
|
assert recovery_result.finalized == 1
|
|
assert persistence.records == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_package_rejection_restores_files_and_removes_prepared_journal():
|
|
"""包安装返回失败时不得切换运行态,且必须删除 PREPARED journal。"""
|
|
package_restore = AsyncMock()
|
|
package_cleanup = AsyncMock()
|
|
target_reloader = AsyncMock()
|
|
reporter = AsyncMock()
|
|
|
|
async def installer(**_kwargs):
|
|
return False, "download failed"
|
|
|
|
command, persistence, _ = _command(
|
|
installer=installer,
|
|
package_restore=package_restore,
|
|
package_cleanup=package_cleanup,
|
|
target_reloader=target_reloader,
|
|
reporter=reporter,
|
|
)
|
|
|
|
result = await _execute(command)
|
|
|
|
assert result.success is False
|
|
assert result.failure_stage == "package_install"
|
|
assert result.rollback.file_restored is True
|
|
assert result.rollback.journal_deleted is True
|
|
package_restore.assert_awaited_once()
|
|
package_cleanup.assert_awaited_once()
|
|
target_reloader.assert_not_awaited()
|
|
reporter.assert_not_awaited()
|
|
assert persistence.records == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_journal_create_failure_uses_legacy_rollback_without_journal_cleanup():
|
|
"""journal 创建失败时使用兼容回滚,不尝试删除不存在的 journal。"""
|
|
package_restore = AsyncMock()
|
|
package_cleanup = AsyncMock()
|
|
persistence = _PersistenceSpy(
|
|
[],
|
|
create_error=RuntimeError("database unavailable"),
|
|
)
|
|
command, _, _ = _command(
|
|
persistence=persistence,
|
|
package_restore=package_restore,
|
|
package_cleanup=package_cleanup,
|
|
)
|
|
|
|
result = await _execute(command)
|
|
|
|
assert result.success is False
|
|
assert result.failure_stage == "journal_prepare"
|
|
assert result.rollback.file_restored is True
|
|
package_restore.assert_awaited_once()
|
|
package_cleanup.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persistence_unavailable_during_journal_prepare_is_rethrown():
|
|
"""数据库 worker 暂不可用时完成无 journal 回滚后保留异常语义。"""
|
|
package_restore = AsyncMock()
|
|
package_cleanup = AsyncMock()
|
|
persistence = _PersistenceSpy(
|
|
[],
|
|
create_error=DatabaseWorkerClosedError("worker closed"),
|
|
)
|
|
command, _, _ = _command(
|
|
persistence=persistence,
|
|
package_restore=package_restore,
|
|
package_cleanup=package_cleanup,
|
|
)
|
|
|
|
with pytest.raises(PersistenceUnavailableError):
|
|
await _execute(command)
|
|
|
|
package_restore.assert_awaited_once()
|
|
package_cleanup.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("failure", "failure_stage", "runtime_touched"),
|
|
[
|
|
("receipt", "payload_receipt", False),
|
|
("target", "payload_receipt", False),
|
|
("stage", "persistent_backup_stage", False),
|
|
("activate", "persistent_backup_activate", False),
|
|
("reload", "runtime_reload", True),
|
|
("registrations", "registration_refresh", True),
|
|
],
|
|
)
|
|
async def test_precommit_failure_restores_files_and_runtime(
|
|
failure: str,
|
|
failure_stage: str,
|
|
runtime_touched: bool,
|
|
):
|
|
"""每个提交前阶段失败都恢复文件,运行态失败还要恢复旧运行态。"""
|
|
package_restore = AsyncMock()
|
|
rollback_reloader = AsyncMock()
|
|
registration_refresher = AsyncMock()
|
|
|
|
async def failing_receipt(_plugin_id):
|
|
raise RuntimeError("receipt failed")
|
|
|
|
async def failing_stage(_value):
|
|
raise RuntimeError("backup stage failed")
|
|
|
|
async def failing_activate(_value):
|
|
raise RuntimeError("backup activation failed")
|
|
|
|
async def failing_reload(_plugin_id):
|
|
raise RuntimeError("reload failed")
|
|
|
|
registration_attempts = 0
|
|
|
|
async def failing_registrations(_plugin_id):
|
|
nonlocal registration_attempts
|
|
registration_attempts += 1
|
|
if registration_attempts == 1:
|
|
raise RuntimeError("registration failed")
|
|
|
|
persistence = _PersistenceSpy(
|
|
[],
|
|
target_error=RuntimeError("target failed") if failure == "target" else None,
|
|
)
|
|
command, _, _ = _command(
|
|
persistence=persistence,
|
|
package_restore=package_restore,
|
|
payload_receipt=(failing_receipt if failure == "receipt" else None),
|
|
package_stage_backup=(failing_stage if failure == "stage" else None),
|
|
package_activate_backup=(
|
|
failing_activate if failure == "activate" else None
|
|
),
|
|
target_reloader=failing_reload if failure == "reload" else None,
|
|
rollback_reloader=rollback_reloader,
|
|
registration_refresher=(
|
|
failing_registrations
|
|
if failure == "registrations"
|
|
else registration_refresher
|
|
),
|
|
)
|
|
|
|
result = await _execute(command)
|
|
|
|
assert result.success is False
|
|
assert result.failure_stage == failure_stage
|
|
assert result.rollback.file_restored is True
|
|
assert result.rollback.journal_deleted is True
|
|
if runtime_touched:
|
|
rollback_reloader.assert_awaited_once_with("DemoPlugin")
|
|
else:
|
|
rollback_reloader.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_precommit_failure_keeps_previous_declared_metadata_snapshot():
|
|
"""提交前失败不得把新声明快照写成当前身份事实。"""
|
|
previous = _identity(version="1.0.0", revision=3)
|
|
persistence = _PersistenceSpy([], identity=previous)
|
|
rollback_reloader = AsyncMock()
|
|
target_reloader = AsyncMock(side_effect=RuntimeError("reload failed"))
|
|
command, _, _ = _command(
|
|
persistence=persistence,
|
|
target_reloader=target_reloader,
|
|
rollback_reloader=rollback_reloader,
|
|
)
|
|
|
|
result = await _execute(
|
|
command,
|
|
admission=_admission(identity=previous),
|
|
)
|
|
|
|
assert result.success is False
|
|
assert result.failure_stage == "runtime_reload"
|
|
assert result.rollback.journal_deleted is True
|
|
assert persistence.identity is previous
|
|
assert persistence.identity.declared_version == "1.0.0"
|
|
assert persistence.identity.declared_metadata is previous.declared_metadata
|
|
rollback_reloader.assert_awaited_once_with("DemoPlugin")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_database_commit_failure_restores_runtime_before_deleting_journal():
|
|
"""数据库最终提交失败时,文件、运行态和 PREPARED journal 必须一起补偿。"""
|
|
package_restore = AsyncMock()
|
|
rollback_reloader = AsyncMock()
|
|
persistence = _PersistenceSpy(
|
|
[],
|
|
commit_error=RuntimeError("commit failed"),
|
|
)
|
|
command, _, _ = _command(
|
|
persistence=persistence,
|
|
package_restore=package_restore,
|
|
rollback_reloader=rollback_reloader,
|
|
)
|
|
|
|
result = await _execute(command)
|
|
|
|
assert result.success is False
|
|
assert result.failure_stage == "database_commit"
|
|
assert result.rollback.file_restored is True
|
|
assert result.rollback.runtime_restored is True
|
|
assert result.rollback.journal_deleted is True
|
|
package_restore.assert_awaited_once()
|
|
rollback_reloader.assert_awaited_once_with("DemoPlugin")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_failure_keeps_committed_journal_for_replay():
|
|
"""数据库已提交但清理失败时不能回滚载荷,journal 必须保留供启动重放。"""
|
|
package_finalize = AsyncMock(side_effect=RuntimeError("cleanup unavailable"))
|
|
rollback_reloader = AsyncMock()
|
|
command, persistence, _ = _command(
|
|
package_finalize_backup=package_finalize,
|
|
rollback_reloader=rollback_reloader,
|
|
)
|
|
|
|
result = await _execute(command)
|
|
|
|
assert result.success is True
|
|
assert result.checkpoint_cleanup_error == "cleanup unavailable"
|
|
assert not result.rollback.file_attempted
|
|
assert persistence.records["txn-demo"].phase is PluginInstallationPhase.COMMITTED
|
|
rollback_reloader.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_report_failure_does_not_rollback_committed_install():
|
|
"""远程安装上报失败属于非关键副作用,不得撤销已提交本地安装。"""
|
|
package_restore = AsyncMock()
|
|
reporter = AsyncMock(side_effect=RuntimeError("server unavailable"))
|
|
command, persistence, _ = _command(
|
|
package_restore=package_restore,
|
|
reporter=reporter,
|
|
)
|
|
|
|
result = await _execute(command)
|
|
|
|
assert result.success is True
|
|
assert result.reported is False
|
|
assert result.report_error == "server unavailable"
|
|
assert "不影响本地安装" in result.message
|
|
package_restore.assert_not_awaited()
|
|
assert persistence.records == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_matching_payload_only_refreshes_runtime():
|
|
"""同一来源、代际和版本已提交时只刷新运行态,不重复写包或 journal。"""
|
|
checkpointer = AsyncMock()
|
|
installer = AsyncMock()
|
|
reloader = AsyncMock(return_value=PluginRuntimeStatus.ACTIVE)
|
|
refresher = AsyncMock()
|
|
reporter = AsyncMock(return_value=True)
|
|
command, persistence, _ = _command(
|
|
installed=["DemoPlugin"],
|
|
plugin_ids=["DemoPlugin"],
|
|
checkpointer=checkpointer,
|
|
installer=installer,
|
|
target_reloader=reloader,
|
|
registration_refresher=refresher,
|
|
reporter=reporter,
|
|
)
|
|
|
|
result = await _execute(command, admission=_admission(identity=_identity()))
|
|
|
|
assert result.success is True
|
|
assert result.refreshed_only is True
|
|
assert result.package_installed is False
|
|
assert result.runtime_reloaded is True
|
|
checkpointer.assert_not_awaited()
|
|
installer.assert_not_awaited()
|
|
reloader.assert_awaited_once_with("DemoPlugin")
|
|
refresher.assert_awaited_once_with("DemoPlugin")
|
|
reporter.assert_awaited_once_with("DemoPlugin", REPO_URL)
|
|
assert persistence.records == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_force_install_replaces_matching_payload_and_local_sync_skips_report():
|
|
"""强制安装和本地同步都绕过刷新短路,本地同步还禁止远程上报。"""
|
|
installer = AsyncMock(return_value=(True, "synced"))
|
|
reporter = AsyncMock(return_value=True)
|
|
command, _, _ = _command(
|
|
installed=["DemoPlugin"],
|
|
plugin_ids=["DemoPlugin"],
|
|
installer=installer,
|
|
reporter=reporter,
|
|
)
|
|
|
|
result = await _execute(
|
|
command,
|
|
admission=_admission(identity=_identity()),
|
|
force=True,
|
|
local_sync=True,
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.refreshed_only is False
|
|
installer.assert_awaited_once_with(
|
|
plugin_id="DemoPlugin",
|
|
repo_url=REPO_URL,
|
|
package_version="v3",
|
|
release_version=None,
|
|
force_install=True,
|
|
)
|
|
reporter.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mutation_rejection_happens_before_package_write_guard():
|
|
"""运行时封口后拒绝安装,不能进入包写入抑制或文件快照。"""
|
|
checkpointer = AsyncMock()
|
|
package_guard = Mock(return_value=nullcontext())
|
|
rejected = Mock(side_effect=PluginMutationRejectedError("安装插件 DemoPlugin"))
|
|
|
|
command, _, _ = _command(
|
|
checkpointer=checkpointer,
|
|
mutation=rejected,
|
|
package_write_guard=package_guard,
|
|
)
|
|
|
|
result = await _execute(command)
|
|
|
|
assert result.success is False
|
|
assert result.failure_stage == "admission"
|
|
package_guard.assert_not_called()
|
|
checkpointer.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_package_install_waits_for_compensation():
|
|
"""包安装被取消时等待文件恢复和 journal 清理完成后再传播取消。"""
|
|
started = asyncio.Event()
|
|
release = asyncio.Event()
|
|
package_restore = AsyncMock()
|
|
package_cleanup = AsyncMock()
|
|
|
|
async def installer(**_kwargs):
|
|
started.set()
|
|
await release.wait()
|
|
|
|
command, persistence, _ = _command(
|
|
installer=installer,
|
|
package_restore=package_restore,
|
|
package_cleanup=package_cleanup,
|
|
)
|
|
task = asyncio.create_task(_execute(command))
|
|
await started.wait()
|
|
task.cancel()
|
|
release.set()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
package_restore.assert_awaited_once()
|
|
package_cleanup.assert_awaited_once()
|
|
assert persistence.records == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_journal_create_resolves_persisted_record_before_rollback():
|
|
"""PREPARED 已写入但调用被取消时,必须确认记录并完成补偿。"""
|
|
started = asyncio.Event()
|
|
release = asyncio.Event()
|
|
package_restore = AsyncMock()
|
|
package_cleanup = AsyncMock()
|
|
persistence = _PersistenceSpy([])
|
|
|
|
async def create(record: PluginInstallationRecord):
|
|
persistence.calls.append("journal_create")
|
|
persistence.records[record.transaction_id] = record
|
|
started.set()
|
|
await release.wait()
|
|
return record
|
|
|
|
persistence.create_installation = create
|
|
command, _, _ = _command(
|
|
persistence=persistence,
|
|
package_restore=package_restore,
|
|
package_cleanup=package_cleanup,
|
|
)
|
|
task = asyncio.create_task(_execute(command))
|
|
await started.wait()
|
|
task.cancel()
|
|
release.set()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
package_restore.assert_awaited_once()
|
|
package_cleanup.assert_awaited_once()
|
|
assert persistence.records == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_prepared_commit_rolls_back_after_outcome_check():
|
|
"""提交任务确定仍为 PREPARED 时,取消必须先完成旧载荷补偿。"""
|
|
started = asyncio.Event()
|
|
release = asyncio.Event()
|
|
package_restore = AsyncMock()
|
|
rollback_reloader = AsyncMock()
|
|
persistence = _PersistenceSpy([])
|
|
|
|
async def commit(
|
|
transaction_id: str,
|
|
*,
|
|
identity_target: PluginIdentity | None,
|
|
) -> PluginInstallationRecord:
|
|
del transaction_id, identity_target
|
|
persistence.calls.append("journal_commit")
|
|
started.set()
|
|
await release.wait()
|
|
raise RuntimeError("commit failed")
|
|
|
|
persistence.commit_installation = commit
|
|
command, _, _ = _command(
|
|
persistence=persistence,
|
|
package_restore=package_restore,
|
|
rollback_reloader=rollback_reloader,
|
|
)
|
|
task = asyncio.create_task(_execute(command))
|
|
await started.wait()
|
|
task.cancel()
|
|
release.set()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
package_restore.assert_awaited_once()
|
|
rollback_reloader.assert_awaited_once_with("DemoPlugin")
|
|
assert persistence.records == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_committed_install_keeps_journal_for_startup_cleanup():
|
|
"""数据库已提交后发生取消时不得回滚新载荷,journal 留给启动收尾。"""
|
|
started = asyncio.Event()
|
|
release = asyncio.Event()
|
|
package_restore = AsyncMock()
|
|
package_finalize = AsyncMock()
|
|
persistence = _PersistenceSpy([])
|
|
|
|
async def commit(
|
|
transaction_id: str,
|
|
*,
|
|
identity_target: PluginIdentity | None,
|
|
) -> PluginInstallationRecord:
|
|
record = replace(
|
|
persistence.records[transaction_id],
|
|
phase=PluginInstallationPhase.COMMITTED,
|
|
membership_target=True,
|
|
identity_target_revision=(
|
|
identity_target.revision if identity_target else None
|
|
),
|
|
)
|
|
persistence.records[transaction_id] = record
|
|
started.set()
|
|
await release.wait()
|
|
return record
|
|
|
|
persistence.commit_installation = commit
|
|
command, _, _ = _command(
|
|
persistence=persistence,
|
|
package_restore=package_restore,
|
|
package_finalize_backup=package_finalize,
|
|
)
|
|
task = asyncio.create_task(_execute(command))
|
|
await started.wait()
|
|
task.cancel()
|
|
release.set()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
package_restore.assert_not_awaited()
|
|
package_finalize.assert_not_awaited()
|
|
assert persistence.records["txn-demo"].phase is PluginInstallationPhase.COMMITTED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_ack_failure_uses_committed_journal_as_final_fact():
|
|
"""提交回执丢失但 journal 已为 COMMITTED 时继续完成新载荷收尾。"""
|
|
package_restore = AsyncMock()
|
|
persistence = _PersistenceSpy([])
|
|
|
|
async def commit(
|
|
transaction_id: str,
|
|
*,
|
|
identity_target: PluginIdentity | None,
|
|
) -> PluginInstallationRecord:
|
|
record = replace(
|
|
persistence.records[transaction_id],
|
|
phase=PluginInstallationPhase.COMMITTED,
|
|
membership_target=True,
|
|
identity_target_revision=(
|
|
identity_target.revision if identity_target else None
|
|
),
|
|
)
|
|
persistence.records[transaction_id] = record
|
|
raise RuntimeError("commit acknowledgement lost")
|
|
|
|
persistence.commit_installation = commit
|
|
command, _, _ = _command(
|
|
persistence=persistence,
|
|
package_restore=package_restore,
|
|
)
|
|
|
|
result = await _execute(command)
|
|
|
|
assert result.success is True
|
|
package_restore.assert_not_awaited()
|
|
assert persistence.records == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_commit_result_preserves_current_payload_and_journal():
|
|
"""数据库最终状态无法读取时不得猜测回滚,必须留待启动恢复。"""
|
|
package_restore = AsyncMock()
|
|
rollback_reloader = AsyncMock()
|
|
persistence = _PersistenceSpy([], commit_error=RuntimeError("commit failed"))
|
|
persistence.get_installation = AsyncMock(
|
|
side_effect=RuntimeError("database unavailable")
|
|
)
|
|
command, _, _ = _command(
|
|
persistence=persistence,
|
|
package_restore=package_restore,
|
|
rollback_reloader=rollback_reloader,
|
|
)
|
|
|
|
result = await _execute(command)
|
|
|
|
assert result.success is False
|
|
assert result.failure_stage == "database_commit_unknown"
|
|
package_restore.assert_not_awaited()
|
|
rollback_reloader.assert_not_awaited()
|
|
assert persistence.records["txn-demo"].phase is PluginInstallationPhase.PREPARED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_compensation_failure_keeps_prepared_journal():
|
|
"""旧运行态未恢复完整时不得删除 PREPARED journal 和恢复材料。"""
|
|
package_restore = AsyncMock()
|
|
package_cleanup = AsyncMock()
|
|
target_reloader = AsyncMock(side_effect=RuntimeError("reload failed"))
|
|
rollback_reloader = AsyncMock(side_effect=RuntimeError("rollback failed"))
|
|
command, persistence, _ = _command(
|
|
package_restore=package_restore,
|
|
package_cleanup=package_cleanup,
|
|
target_reloader=target_reloader,
|
|
rollback_reloader=rollback_reloader,
|
|
)
|
|
|
|
result = await _execute(command)
|
|
|
|
assert result.success is False
|
|
assert result.failure_stage == "runtime_reload"
|
|
assert result.rollback.file_restored is True
|
|
assert result.rollback.runtime_restored is False
|
|
assert result.rollback.journal_deleted is False
|
|
assert result.rollback.errors == ("插件运行态恢复失败:rollback failed",)
|
|
package_cleanup.assert_not_awaited()
|
|
assert persistence.records["txn-demo"].phase is PluginInstallationPhase.PREPARED
|