Files
MoviePilot/tests/test_transfer_execution_runner.py
T

404 lines
14 KiB
Python

"""验证 TransferChain 步骤 runner 与文件执行器的崩溃恢复边界。"""
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import Mock
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.application.transfer.execution import (
TransferExecutionCommand,
TransferExecutionState,
TransferOperationObservation,
TransferOperationObservationState,
TransferStepIntent,
TransferStepResult,
build_transfer_checkpoint_fingerprint,
)
from app.application.transfer.workflow import (
TransferPlanCheckpoint,
TransferPlanItem,
TransferPlanningInput,
)
from app.chain import transfer as transfer_chain_module
from app.db.adapters.transfer.execution import (
TransactionalTransferExecutionRepository,
)
from app.db.base import Base
from app.db.models.transferexecutionstep import TransferExecutionStep
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.modules.filemanager.transhandler import TransHandler
from app.schemas.workflow import FileItem
def _runner_plan_checkpoint() -> TransferPlanCheckpoint:
"""构造 runner fixture 使用的完整冻结计划。"""
planning_input = TransferPlanningInput(
source_fileitem={
"storage": "local",
"path": "/source.mkv",
"type": "file",
},
target_storage="local",
target_path="/target.mkv",
requested_transfer_type="copy",
)
return TransferPlanCheckpoint(
planning_input=planning_input,
target_storage="local",
root_target_path="/",
final_target_path="/target.mkv",
resolved_transfer_type="copy",
items=(TransferPlanItem(
sequence=0,
source_fileitem=planning_input.source_fileitem,
target_storage="local",
target_path="/target.mkv",
),),
)
@pytest.fixture
def execution_repository():
"""构造带有效 pending 租约的独立执行仓储。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
TransferPending.__table__,
TransferHistory.__table__,
TransferExecutionStep.__table__,
],
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
plan_checkpoint = _runner_plan_checkpoint()
planning_input = plan_checkpoint.planning_input
with factory() as session:
session.add(TransferPending(
task_id="task-runner",
storage="local",
src_path="/source.mkv",
created_at="2026-08-27 09:00:00",
state="planned",
updated_at="2026-08-27 09:00:00",
input_version=1,
planning_input=planning_input.to_payload(),
input_fingerprint=planning_input.fingerprint,
checkpoint_version=1,
checkpoint_payload=plan_checkpoint.to_payload(),
planned_at="2026-08-27 09:00:00",
lease_owner="worker",
lease_token="lease",
lease_expires_at="2099-01-01 00:00:00.000000",
heartbeat_at="2026-08-27 01:00:00.000000",
attempt_count=1,
execution_state="not_started",
retry_generation=0,
retry_count=0,
settlement_revision=0,
))
session.commit()
repository = TransactionalTransferExecutionRepository(
factory,
local_clock=lambda: datetime(2026, 8, 27, 9, 30, 0),
lease_clock=lambda: datetime(2026, 8, 27, 1, 30, 0, tzinfo=timezone.utc),
)
try:
yield repository
finally:
engine.dispose()
def _runner(repository):
"""构造绑定固定任务、租约与计划身份的 durable runner。"""
return transfer_chain_module._DurableTransferStepRunner(
task_id="task-runner",
lease_token="lease",
checkpoint_fingerprint=_runner_plan_fingerprint(),
repository=repository,
)
def _runner_plan_fingerprint() -> str:
"""返回 runner fixture 中完整冻结计划的 canonical 指纹。"""
return build_transfer_checkpoint_fingerprint(
_runner_plan_checkpoint().to_payload()
)
def _runner_step_payload() -> dict:
"""返回由 runner 冻结计划唯一叶操作导出的目标落地参数。"""
checkpoint = _runner_plan_checkpoint()
item = checkpoint.items[0]
return {
"source": item.source_fileitem,
"target_storage": item.target_storage,
"target_path": item.target_path,
"transfer_type": checkpoint.resolved_transfer_type,
}
def test_runner_replay_returns_persisted_result_without_repeating_side_effect(
execution_repository,
):
"""成功步骤重放只能回读结果,不能再次调用外部执行函数。"""
calls = []
first = _runner(execution_repository).run(
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
execute=lambda: calls.append("executed") or TransferStepResult(
payload={"item": {"path": "/target.mkv"}}
),
observe=lambda: pytest.fail("新步骤不应执行恢复探测"),
)
second = _runner(execution_repository).run(
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
execute=lambda: pytest.fail("已成功步骤不得重复执行"),
observe=lambda: pytest.fail("已成功步骤不得执行恢复探测"),
)
assert first == second
assert calls == ["executed"]
def test_runner_routes_unknown_orphaned_attempt_to_manual_review(
execution_repository,
):
"""遗留 STARTED 无法严格判断时必须隔离,不能再次执行副作用。"""
command = TransferExecutionCommand(execution_repository)
prepared = command.prepare(
task_id="task-runner",
lease_token="lease",
intent=TransferStepIntent.create(
task_id="task-runner",
checkpoint_fingerprint=_runner_plan_fingerprint(),
ordinal=0,
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
),
)
command.begin(
task_id="task-runner",
lease_token="lease",
operation_id=prepared.operation_id,
)
with pytest.raises(
transfer_chain_module._TransferManualReviewRequired,
match="禁止自动重放",
):
_runner(execution_repository).run(
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
execute=lambda: pytest.fail("未知遗留步骤不得重放"),
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={"receipt": None}),
),
)
snapshot = execution_repository.get_snapshot(task_id="task-runner")
assert snapshot is not None
assert snapshot.state is TransferExecutionState.MANUAL_REVIEW
def test_runner_observes_applied_after_execute_error_and_completes_step(
execution_repository,
) -> None:
"""execute 抛错后若外部事实已生效,必须以观察证据完成而非重放。"""
evidence = TransferStepResult(payload={"target": "/target.mkv"})
def execute() -> TransferStepResult:
"""模拟副作用成功后调用方在返回前崩溃。"""
raise OSError("connection reset after apply")
result = _runner(execution_repository).run(
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
execute=execute,
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.APPLIED,
evidence=evidence,
),
)
snapshot = execution_repository.get_snapshot(task_id="task-runner")
assert result == evidence
assert snapshot is not None
assert snapshot.state is TransferExecutionState.RUNNING
assert snapshot.steps[0].result == evidence
assert snapshot.steps[0].state.value == "succeeded"
def test_runner_defers_after_execute_error_observed_not_applied(
execution_repository,
) -> None:
"""execute 抛错且确认未生效时只能进入持久退避,不得误判成功。"""
evidence = TransferStepResult(payload={"target_exists": False})
def execute() -> TransferStepResult:
"""模拟外部操作在应用前失败。"""
raise OSError("write rejected")
with pytest.raises(transfer_chain_module._TransferRetryDeferred):
_runner(execution_repository).run(
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
execute=execute,
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=evidence,
),
)
snapshot = execution_repository.get_snapshot(task_id="task-runner")
assert snapshot is not None
assert snapshot.state is TransferExecutionState.RETRY_WAIT
assert snapshot.steps[0].result == evidence
assert snapshot.steps[0].state.value == "failed"
@pytest.mark.parametrize(
"observation_state",
[
TransferOperationObservationState.UNKNOWN,
TransferOperationObservationState.CONFLICT,
],
)
def test_runner_freezes_uncertain_execute_error_for_manual_review(
execution_repository,
observation_state,
) -> None:
"""execute 异常后的未知或冲突结果必须冻结,禁止自动重放。"""
evidence = TransferStepResult(payload={"receipt": "ambiguous"})
def execute() -> TransferStepResult:
"""模拟外部结果未知的执行异常。"""
raise TimeoutError("provider timeout")
with pytest.raises(
transfer_chain_module._TransferManualReviewRequired,
match=observation_state.value,
):
_runner(execution_repository).run(
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
execute=execute,
observe=lambda: TransferOperationObservation(
state=observation_state,
evidence=evidence,
),
)
snapshot = execution_repository.get_snapshot(task_id="task-runner")
assert snapshot is not None
assert snapshot.state is TransferExecutionState.MANUAL_REVIEW
assert snapshot.steps[0].result == evidence
assert snapshot.steps[0].state.value == "manual_review"
def test_runner_freezes_when_observer_errors_after_execute_error(
execution_repository,
) -> None:
"""execute 与 observer 同时失败时必须保留双重证据并进入人工复核。"""
def execute() -> TransferStepResult:
"""模拟调用结果未知的执行超时。"""
raise TimeoutError("execute timeout")
def observe() -> TransferOperationObservation:
"""模拟外部状态查询端点同时不可用。"""
raise ConnectionError("observer unavailable")
with pytest.raises(
transfer_chain_module._TransferManualReviewRequired,
match="unknown",
):
_runner(execution_repository).run(
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
execute=execute,
observe=observe,
)
snapshot = execution_repository.get_snapshot(task_id="task-runner")
assert snapshot is not None
assert snapshot.state is TransferExecutionState.MANUAL_REVIEW
assert snapshot.steps[0].result is not None
assert snapshot.steps[0].result.payload == {
"execute_error": "execute timeout",
"observe_error": "observer unavailable",
}
class _ImmediateStepRunner:
"""记录 TransHandler 拆分顺序并立即执行步骤的测试 runner。"""
def __init__(self) -> None:
"""初始化步骤记录。"""
self.steps = []
def run(self, *, phase, kind, payload, execute, observe):
"""记录稳定意图并直接执行,不触发恢复探测。"""
self.steps.append((phase, kind, payload))
return execute()
def test_cross_storage_move_materializes_before_independent_source_delete(tmp_path):
"""跨存储 move 必须先复制目标,再用独立步骤删除源。"""
source_path = tmp_path / "source.mkv"
source_path.write_bytes(b"movie")
source_item = FileItem(
storage="local",
path=source_path.as_posix(),
name=source_path.name,
type="file",
size=source_path.stat().st_size,
extension="mkv",
)
target_item = FileItem(
storage="remote",
path="/library/source.mkv",
name="source.mkv",
type="file",
size=source_item.size,
extension="mkv",
)
source_oper = Mock()
source_oper.delete.return_value = True
target_oper = Mock()
target_oper.get_folder.return_value = FileItem(
storage="remote", path="/library", name="library", type="dir"
)
target_oper.upload.return_value = target_item
runner = _ImmediateStepRunner()
result, error = TransHandler._TransHandler__execute_transfer_with_steps(
step_runner=runner,
fileitem=source_item,
target_storage="remote",
source_oper=source_oper,
target_oper=target_oper,
target_file=Path("/library/source.mkv"),
transfer_type="move",
)
assert error == ""
assert result == target_item
assert [kind for _phase, kind, _payload in runner.steps] == [
"materialize_target",
"delete_move_source",
]
assert runner.steps[0][2]["transfer_type"] == "copy"
target_oper.upload.assert_called_once()
source_oper.delete.assert_called_once_with(source_item)