"""验证整理执行证据、CAS fencing 与终态结算持久化。""" from datetime import datetime, timezone import pytest from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker from app.application.transfer.execution import ( TransferExecutionCheckpoint, TransferExecutionCommand, TransferExecutionConflictError, TransferExecutionState, TransferManualReviewDecision, TransferOperationObservation, TransferOperationObservationState, TransferStepIntent, TransferStepResult, TransferStepState, build_transfer_checkpoint_fingerprint, build_transfer_operation_id, ) 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 @pytest.fixture def execution_store(): """构造只含整理执行相关表的独立内存数据库。""" 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) try: yield factory finally: engine.dispose() def _seed_pending(factory, *, task_id: str = "task-1", lease_token: str = "lease-1"): """写入一条带有效租约与合法 planning checkpoint 的待执行任务。""" with factory() as session: session.add(TransferPending( task_id=task_id, storage="local", src_path=f"/{task_id}.mkv", created_at="2026-08-27 09:00:00", state="planned", updated_at="2026-08-27 09:00:00", input_version=1, planning_input={"schema_version": 1, "source": task_id}, input_fingerprint="input-fingerprint", checkpoint_version=1, checkpoint_payload={"schema_version": 1, "task_id": task_id}, planned_at="2026-08-27 09:00:00", lease_owner="worker-1", lease_token=lease_token, 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() def _repository(factory, token_values: list[str] | None = None): """构造固定时钟与可预测 attempt token 的执行命令。""" 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), ) values = iter(token_values or ["attempt-1", "attempt-2", "attempt-3"]) return repository, TransferExecutionCommand( repository, attempt_token_factory=lambda: next(values), ) def _intent(*, task_id: str = "task-1", ordinal: int = 0) -> TransferStepIntent: """构造稳定且可重复计算身份的测试步骤意图。""" return TransferStepIntent.create( task_id=task_id, checkpoint_fingerprint="plan-fingerprint", ordinal=ordinal, phase="transfer", kind="copy", payload={"src": f"/{task_id}.mkv", "dest": f"/media/{task_id}.mkv"}, ) def test_stable_operation_and_checkpoint_identities_are_canonical(): """字段顺序不能改变 operation ID 或执行 checkpoint 指纹。""" first = build_transfer_operation_id( task_id="task", checkpoint_fingerprint="plan", ordinal=2, phase="scrape", kind="write", intent_payload={"b": 2, "a": 1}, ) second = build_transfer_operation_id( task_id="task", checkpoint_fingerprint="plan", ordinal=2, phase="scrape", kind="write", intent_payload={"a": 1, "b": 2}, ) assert first == second assert build_transfer_checkpoint_fingerprint({"b": 2, "a": 1}) == ( build_transfer_checkpoint_fingerprint({"a": 1, "b": 2}) ) mutable_payload = {"path": "/original"} intent = TransferStepIntent.create( task_id="task", checkpoint_fingerprint="plan", ordinal=0, phase="transfer", kind="copy", payload=mutable_payload, ) mutable_payload["path"] = "/mutated" assert intent.payload == {"path": "/original"} def test_success_path_persists_steps_and_execution_checkpoint(execution_store): """成功路径应保留每步证据,并提交可供唯一 durable writer 结算的检查点。""" _seed_pending(execution_store) repository, command = _repository(execution_store) prepared = command.prepare( task_id="task-1", lease_token="lease-1", intent=_intent(), ) assert prepared.state is TransferStepState.PREPARED started = command.begin( task_id="task-1", lease_token="lease-1", operation_id=prepared.operation_id, ) assert started.attempt_token == "attempt-1" succeeded = command.complete( task_id="task-1", lease_token="lease-1", step=started, result=TransferStepResult(payload={"dest_exists": True}), ) checkpoint = TransferExecutionCheckpoint.create( payload={"dest": "/media/task-1.mkv"}, operation_ids=(succeeded.operation_id,), ) snapshot = command.checkpoint( task_id="task-1", lease_token="lease-1", checkpoint=checkpoint, ) assert snapshot.state is TransferExecutionState.SETTLING with execution_store() as session: pending = session.scalar(select(TransferPending)) assert pending is not None assert pending.execution_fingerprint == checkpoint.fingerprint step = session.scalar(select(TransferExecutionStep)) assert step is not None and step.state == "succeeded" def test_retry_wait_resumes_same_failed_operation_with_new_attempt(execution_store): """到期重试必须复用 operation ID、保留失败证据并轮换 attempt token。""" _seed_pending(execution_store) repository, command = _repository(execution_store) prepared = command.prepare( task_id="task-1", lease_token="lease-1", intent=_intent() ) started = command.begin( task_id="task-1", lease_token="lease-1", operation_id=prepared.operation_id, ) deferred = command.defer( task_id="task-1", lease_token="lease-1", step=started, error="destination unavailable", retry_due_at="2026-08-27 01:30:01.000000", evidence=TransferStepResult(payload={"applied": False}), ) assert deferred.state is TransferExecutionState.RETRY_WAIT assert deferred.retry_generation == 1 with execution_store() as session: claimed = TransferPending.claim_task( session, task_id="task-1", states=("planned",), owner_id="worker-2", lease_token="lease-2", now_time="2026-08-27 01:30:02.000000", lease_expires_at="2099-01-01 00:00:00.000000", updated_at="2026-08-27 09:30:02", ) session.commit() assert claimed == 1 resumed = command.resume_failed( task_id="task-1", lease_token="lease-2", step=deferred.steps[0], ) assert resumed.operation_id == prepared.operation_id assert resumed.attempt_token == "attempt-2" assert resumed.attempt_count == 2 assert resumed.result == TransferStepResult(payload={"applied": False}) assert resumed.last_error == "destination unavailable" def test_orphan_started_requires_observation_before_attempt_rotation(execution_store): """遗留 STARTED 只能凭 NOT_APPLIED 证据轮换 attempt,旧 attempt 随即失效。""" _seed_pending(execution_store) repository, command = _repository(execution_store) prepared = command.prepare( task_id="task-1", lease_token="lease-1", intent=_intent() ) started = command.begin( task_id="task-1", lease_token="lease-1", operation_id=prepared.operation_id, ) observation = TransferOperationObservation( state=TransferOperationObservationState.NOT_APPLIED, evidence=TransferStepResult(payload={"dest_exists": False}), ) restarted = command.restart_after_not_applied( task_id="task-1", lease_token="lease-1", step=started, evidence=observation.evidence, ) assert restarted.attempt_token == "attempt-2" assert restarted.attempt_count == 2 with pytest.raises(TransferExecutionConflictError): repository.complete_step( task_id="task-1", lease_token="lease-1", operation_id=started.operation_id, attempt_token="attempt-1", result=TransferStepResult(payload={"stale": True}), ) def test_zero_side_effect_checkpoint_is_vacuously_complete(execution_store): """纯策略拒绝可在没有步骤行时提交带 skip_reason 的确定执行结果。""" _seed_pending(execution_store) _, command = _repository(execution_store) checkpoint = TransferExecutionCheckpoint.create( payload={"preview": True, "accepted": False}, operation_ids=(), skip_reason="preview", ) snapshot = command.checkpoint( task_id="task-1", lease_token="lease-1", checkpoint=checkpoint, ) assert snapshot.state is TransferExecutionState.SETTLING assert snapshot.checkpoint == checkpoint assert snapshot.steps == () def test_exhausted_step_builds_failure_checkpoint_and_keeps_lease(execution_store): """预算耗尽应原子建立失败结算检查点,并为 durable writer 保留 lease。""" _seed_pending(execution_store) _, command = _repository(execution_store) prepared = command.prepare( task_id="task-1", lease_token="lease-1", intent=_intent() ) started = command.begin( task_id="task-1", lease_token="lease-1", operation_id=prepared.operation_id, ) snapshot = command.exhaust( task_id="task-1", lease_token="lease-1", step=started, error="retry budget exhausted", evidence=TransferStepResult(payload={"applied": False}), ) assert snapshot.state is TransferExecutionState.SETTLING assert snapshot.checkpoint is not None assert snapshot.checkpoint.payload["outcome"] == "failed" assert snapshot.checkpoint.payload["error"] == "retry budget exhausted" assert snapshot.checkpoint.operation_ids == (started.operation_id,) assert snapshot.steps[0].state is TransferStepState.FAILED with execution_store() as session: pending = session.scalar(select(TransferPending)) assert pending is not None assert pending.lease_token == "lease-1" def test_user_retry_is_single_generation_and_rejects_manual_review(execution_store): """FAILED 用户重试只递增一次世代,重复请求幂等且人工复核必须拒绝。""" _seed_pending(execution_store) with execution_store() as session: pending = session.scalar(select(TransferPending)) assert pending is not None pending.execution_state = "failed" pending.lease_owner = None pending.lease_token = None pending.lease_expires_at = None pending.terminal_history_id = 42 pending.retry_count = 3 session.commit() _, command = _repository(execution_store) first = command.request_retry( task_id="task-1", reason="用户确认目标未落地", requested_by="admin", ) repeated = command.request_retry( task_id="task-1", reason="重复点击", requested_by="admin", ) assert first.accepted and repeated.accepted assert first.retry_generation == repeated.retry_generation == 1 with execution_store() as session: pending = session.scalar(select(TransferPending)) assert pending is not None assert pending.execution_state == "retry_wait" assert pending.retry_count == 3 assert pending.terminal_history_id == 42 assert pending.retry_reason == "用户确认目标未落地" assert pending.retry_requested_by == "admin" pending.execution_state = "manual_review" session.commit() rejected = command.request_retry( task_id="task-1", reason="强制重试", requested_by="admin", ) assert not rejected.accepted assert rejected.state is TransferExecutionState.MANUAL_REVIEW assert "人工" in rejected.message def test_manual_not_applied_decision_is_audited_and_schedules_same_step( execution_store, ) -> None: """人工判定未发生应无 lease 地恢复 FAILED,并只交给唯一调度器。""" _seed_pending(execution_store) _, command = _repository(execution_store) prepared = command.prepare( task_id="task-1", lease_token="lease-1", intent=_intent() ) started = command.begin( task_id="task-1", lease_token="lease-1", operation_id=prepared.operation_id, ) manual = command.manual_review( task_id="task-1", lease_token="lease-1", step=started, error="external result unknown", ) assert manual.state is TransferExecutionState.MANUAL_REVIEW resolved = command.resolve_manual_review( task_id="task-1", operation_id=started.operation_id, decision=TransferManualReviewDecision.NOT_APPLIED, actor="admin", reason="目标与临时文件均不存在", result=TransferStepResult(payload={"dest_exists": False}), ) assert resolved.state is TransferExecutionState.RETRY_WAIT assert resolved.review_revision == 1 assert resolved.step.state is TransferStepState.FAILED with execution_store() as session: pending = session.scalar(select(TransferPending)) assert pending is not None assert pending.lease_token is None assert pending.reviewed_by == "admin" assert pending.review_decision == "not_applied" assert pending.review_reason == "目标与临时文件均不存在" with pytest.raises(TransferExecutionConflictError): command.resolve_manual_review( task_id="task-1", operation_id=started.operation_id, decision=TransferManualReviewDecision.NOT_APPLIED, actor="admin", reason="重复判定", ) def test_manual_applied_requires_result_and_failed_decision_is_rejected( execution_store, ) -> None: """人工判定已发生必须带结果证据,FAILED 不得绕过 lease durable 结算。""" _seed_pending(execution_store) _, command = _repository(execution_store) prepared = command.prepare( task_id="task-1", lease_token="lease-1", intent=_intent() ) started = command.begin( task_id="task-1", lease_token="lease-1", operation_id=prepared.operation_id, ) command.manual_review( task_id="task-1", lease_token="lease-1", step=started, error="external result unknown", ) with pytest.raises(ValueError, match="结果证据"): command.resolve_manual_review( task_id="task-1", operation_id=started.operation_id, decision=TransferManualReviewDecision.APPLIED, actor="admin", reason="已确认目标存在", ) with pytest.raises(TransferExecutionConflictError, match="durable"): command.resolve_manual_review( task_id="task-1", operation_id=started.operation_id, decision=TransferManualReviewDecision.FAILED, actor="admin", reason="确认失败", ) resolved = command.resolve_manual_review( task_id="task-1", operation_id=started.operation_id, decision=TransferManualReviewDecision.APPLIED, actor="admin", reason="目标摘要匹配", result=TransferStepResult(payload={"dest_exists": True, "hash_match": True}), ) assert resolved.step.state is TransferStepState.SUCCEEDED assert resolved.step.result == TransferStepResult( payload={"dest_exists": True, "hash_match": True} )