Files
MoviePilot/tests/test_plugin_install_command.py
T

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