"""Tests for Pipeline Saga compensation pattern""" import asyncio from unittest.mock import AsyncMock import pytest from agentkit.orchestrator.compensation import ( CompletedStep, CompensationResult, SagaOrchestrator, ) from agentkit.orchestrator.pipeline_engine import PipelineEngine from agentkit.orchestrator.pipeline_schema import ( Pipeline, PipelineStage, StageStatus, ) from agentkit.orchestrator.retry import StepRetryPolicy from agentkit.skills.geo_pipeline import ( GEO_PIPELINE_COMPENSATIONS, PipelineStep, create_geo_pipeline_steps, ) class TestCompletedStep: """CompletedStep dataclass tests""" def test_creation_with_compensation(self): step = CompletedStep( step_name="optimize", result={"changes": 5}, compensate_action="revert_optimization", ) assert step.step_name == "optimize" assert step.result == {"changes": 5} assert step.compensate_action == "revert_optimization" def test_creation_without_compensation(self): step = CompletedStep(step_name="detect", result={"found": 3}) assert step.compensate_action is None class TestCompensationResult: """CompensationResult dataclass tests""" def test_success_result(self): result = CompensationResult(step_name="optimize", success=True) assert result.step_name == "optimize" assert result.success is True assert result.error is None def test_failure_result(self): result = CompensationResult( step_name="optimize", success=False, error="rollback failed" ) assert result.success is False assert result.error == "rollback failed" class TestSagaOrchestrator: """SagaOrchestrator tests""" def test_record_completed(self): saga = SagaOrchestrator() saga.record_completed("step1", {"data": 1}, "compensate_1") saga.record_completed("step2", {"data": 2}) steps = saga.completed_steps assert len(steps) == 2 assert steps[0].step_name == "step1" assert steps[0].compensate_action == "compensate_1" assert steps[1].step_name == "step2" assert steps[1].compensate_action is None @pytest.mark.asyncio async def test_compensate_lifo_order(self): """Compensation should execute in LIFO (reverse) order""" execution_order = [] async def mock_execute_skill(skill_name: str, input_data): execution_order.append(skill_name) saga = SagaOrchestrator(execute_skill_func=mock_execute_skill) saga.record_completed("step1", {"data": 1}, "compensate_1") saga.record_completed("step2", {"data": 2}, "compensate_2") saga.record_completed("step3", {"data": 3}, "compensate_3") results = await saga.compensate() # LIFO order: step3, step2, step1 assert execution_order == ["compensate_3", "compensate_2", "compensate_1"] assert len(results) == 3 assert all(r.success for r in results) @pytest.mark.asyncio async def test_skip_steps_with_no_compensate_action(self): """Steps with no compensate_action should be skipped""" saga = SagaOrchestrator() saga.record_completed("read_only", {"data": 1}) # No compensation saga.record_completed("write_op", {"data": 2}, "rollback_write") results = await saga.compensate() assert len(results) == 2 # write_op is compensated first (LIFO), then read_only assert results[0].step_name == "write_op" assert results[0].success is True assert results[1].step_name == "read_only" assert results[1].success is True assert results[1].error == "no_compensation_needed" @pytest.mark.asyncio async def test_compensation_failure_doesnt_interrupt_others(self): """If one compensation fails, others should still execute""" execution_order = [] async def mock_execute_skill(skill_name: str, input_data): execution_order.append(skill_name) if skill_name == "compensate_2": raise RuntimeError("rollback failed") saga = SagaOrchestrator(execute_skill_func=mock_execute_skill) saga.record_completed("step1", {"data": 1}, "compensate_1") saga.record_completed("step2", {"data": 2}, "compensate_2") saga.record_completed("step3", {"data": 3}, "compensate_3") results = await saga.compensate() # All compensations should be attempted (LIFO: step3, step2, step1) assert execution_order == ["compensate_3", "compensate_2", "compensate_1"] assert len(results) == 3 # step3 succeeds assert results[0].success is True # step2 fails assert results[1].success is False assert results[1].error == "rollback failed" # step1 still succeeds assert results[2].success is True @pytest.mark.asyncio async def test_compensate_with_no_execute_skill_func(self): """Without execute_skill_func, compensation succeeds but does nothing""" saga = SagaOrchestrator() saga.record_completed("step1", {"data": 1}, "compensate_1") results = await saga.compensate() assert len(results) == 1 assert results[0].success is True def test_clear(self): saga = SagaOrchestrator() saga.record_completed("step1", {"data": 1}) saga.record_completed("step2", {"data": 2}) assert len(saga.completed_steps) == 2 saga.clear() assert len(saga.completed_steps) == 0 def test_completed_steps_returns_copy(self): saga = SagaOrchestrator() saga.record_completed("step1", {"data": 1}) steps = saga.completed_steps steps.clear() # Mutate the copy assert len(saga.completed_steps) == 1 # Original unchanged class TestPipelineIntegration: """Pipeline engine integration with retry and compensation""" @pytest.mark.asyncio async def test_step_failure_triggers_compensation(self): """When a step fails, Saga compensation should be triggered for completed steps""" engine = PipelineEngine() pipeline = Pipeline( name="test_compensation", version="1.0", description="Test compensation", stages=[ PipelineStage( name="step1", agent="agent_a", action="do_a", compensate="undo_a", ), PipelineStage( name="step2", agent="agent_b", action="do_b", depends_on=["step1"], ), ], ) # Dry-run mode (no dispatcher) — all steps succeed result = await engine.execute(pipeline) assert result.status == StageStatus.COMPLETED @pytest.mark.asyncio async def test_continue_on_failure(self): """Steps with continue_on_failure should not abort the pipeline""" engine = PipelineEngine() pipeline = Pipeline( name="test_continue", version="1.0", description="Test continue_on_failure", stages=[ PipelineStage( name="step1", agent="agent_a", action="do_a", continue_on_failure=True, ), PipelineStage( name="step2", agent="agent_b", action="do_b", depends_on=["step1"], ), ], ) # Dry-run mode — all steps succeed result = await engine.execute(pipeline) assert result.status == StageStatus.COMPLETED @pytest.mark.asyncio async def test_pipeline_with_retry_policy(self): """PipelineStage can have a retry_policy configured""" retry = StepRetryPolicy(max_attempts=5, base_delay=0.01, jitter=False) stage = PipelineStage( name="retry_step", agent="agent_a", action="do_a", retry_policy=retry, ) assert stage.retry_policy is not None assert stage.retry_policy.max_attempts == 5 @pytest.mark.asyncio async def test_pipeline_with_compensate(self): """PipelineStage can have a compensate action configured""" stage = PipelineStage( name="optimizable_step", agent="agent_a", action="do_a", compensate="undo_a", ) assert stage.compensate == "undo_a" @pytest.mark.asyncio async def test_pipeline_without_compensate(self): """PipelineStage without compensate defaults to None""" stage = PipelineStage( name="readonly_step", agent="agent_a", action="do_a", ) assert stage.compensate is None class TestGEOPipelineCompensations: """GEO Pipeline compensation definitions""" def test_compensation_definitions(self): """Verify GEO pipeline compensation definitions""" assert GEO_PIPELINE_COMPENSATIONS["detect"] is None assert GEO_PIPELINE_COMPENSATIONS["analyze_competitor"] is None assert GEO_PIPELINE_COMPENSATIONS["optimize"] == "revert_optimization" assert GEO_PIPELINE_COMPENSATIONS["schema"] is None assert GEO_PIPELINE_COMPENSATIONS["monitor"] is None def test_create_geo_pipeline_steps(self): """Verify GEO pipeline steps are created with compensation""" steps = create_geo_pipeline_steps() assert len(steps) == 5 step_names = [s.name for s in steps] assert step_names == [ "detect", "analyze_competitor", "optimize", "schema", "monitor", ] # Check compensation assignments step_map = {s.name: s for s in steps} assert step_map["detect"].compensate is None assert step_map["analyze_competitor"].compensate is None assert step_map["optimize"].compensate == "revert_optimization" assert step_map["schema"].compensate is None assert step_map["monitor"].compensate is None def test_geo_pipeline_steps_dependencies(self): """Verify GEO pipeline step dependencies form a valid chain""" steps = create_geo_pipeline_steps() step_map = {s.name: s for s in steps} assert step_map["detect"].depends_on == [] assert step_map["analyze_competitor"].depends_on == ["detect"] assert step_map["optimize"].depends_on == ["analyze_competitor"] assert step_map["schema"].depends_on == ["optimize"] assert step_map["monitor"].depends_on == ["schema"]