"""Tests for graph/routing.py. Written FIRST (TDD RED phase). All routing functions are pure — they take a state dict and return a string. Every branch is tested, including missing state fields (defaults to falsy). """ import pytest from release_agent.graph.routing import ( has_pending_approvals, has_pipelines, has_ticket, is_pr_already_merged, is_review_approved, route_after_fetch, route_approval_stage, route_ci_result, should_continue_to_release, ) # --------------------------------------------------------------------------- # is_pr_already_merged # --------------------------------------------------------------------------- class TestIsPrAlreadyMerged: def test_returns_merged_when_true(self) -> None: state = {"pr_already_merged": True} assert is_pr_already_merged(state) == "merged" def test_returns_active_when_false(self) -> None: state = {"pr_already_merged": False} assert is_pr_already_merged(state) == "active" def test_returns_active_when_field_missing(self) -> None: state = {} assert is_pr_already_merged(state) == "active" def test_returns_active_when_none(self) -> None: state = {"pr_already_merged": None} assert is_pr_already_merged(state) == "active" # --------------------------------------------------------------------------- # is_review_approved # --------------------------------------------------------------------------- class TestIsReviewApproved: def test_returns_approve_when_true(self) -> None: state = {"review_approved": True} assert is_review_approved(state) == "approve" def test_returns_request_changes_when_false(self) -> None: state = {"review_approved": False} assert is_review_approved(state) == "request_changes" def test_returns_request_changes_when_field_missing(self) -> None: state = {} assert is_review_approved(state) == "request_changes" def test_returns_request_changes_when_none(self) -> None: state = {"review_approved": None} assert is_review_approved(state) == "request_changes" # --------------------------------------------------------------------------- # has_ticket # --------------------------------------------------------------------------- class TestHasTicket: def test_returns_yes_when_true(self) -> None: state = {"has_ticket": True} assert has_ticket(state) == "yes" def test_returns_no_when_false(self) -> None: state = {"has_ticket": False} assert has_ticket(state) == "no" def test_returns_no_when_field_missing(self) -> None: state = {} assert has_ticket(state) == "no" def test_returns_no_when_none(self) -> None: state = {"has_ticket": None} assert has_ticket(state) == "no" # --------------------------------------------------------------------------- # should_continue_to_release # --------------------------------------------------------------------------- class TestShouldContinueToRelease: def test_returns_yes_when_true(self) -> None: state = {"continue_to_release": True} assert should_continue_to_release(state) == "yes" def test_returns_no_when_false(self) -> None: state = {"continue_to_release": False} assert should_continue_to_release(state) == "no" def test_returns_no_when_field_missing(self) -> None: state = {} assert should_continue_to_release(state) == "no" def test_returns_no_when_none(self) -> None: state = {"continue_to_release": None} assert should_continue_to_release(state) == "no" # --------------------------------------------------------------------------- # has_pipelines # --------------------------------------------------------------------------- class TestHasPipelines: def test_returns_yes_when_non_empty_list(self) -> None: state = {"pipelines": [{"id": 1}]} assert has_pipelines(state) == "yes" def test_returns_no_when_empty_list(self) -> None: state = {"pipelines": []} assert has_pipelines(state) == "no" def test_returns_no_when_field_missing(self) -> None: state = {} assert has_pipelines(state) == "no" def test_returns_no_when_none(self) -> None: state = {"pipelines": None} assert has_pipelines(state) == "no" def test_returns_yes_with_multiple_pipelines(self) -> None: state = {"pipelines": [{"id": 1}, {"id": 2}]} assert has_pipelines(state) == "yes" # --------------------------------------------------------------------------- # has_pending_approvals # --------------------------------------------------------------------------- class TestHasPendingApprovals: def test_returns_yes_when_non_empty_list(self) -> None: state = {"pending_approvals": [{"approval_id": "abc"}]} assert has_pending_approvals(state) == "yes" def test_returns_no_when_empty_list(self) -> None: state = {"pending_approvals": []} assert has_pending_approvals(state) == "no" def test_returns_no_when_field_missing(self) -> None: state = {} assert has_pending_approvals(state) == "no" def test_returns_no_when_none(self) -> None: state = {"pending_approvals": None} assert has_pending_approvals(state) == "no" def test_returns_yes_with_multiple_approvals(self) -> None: state = {"pending_approvals": [{"approval_id": "a"}, {"approval_id": "b"}]} assert has_pending_approvals(state) == "yes" # --------------------------------------------------------------------------- # route_ci_result # --------------------------------------------------------------------------- class TestRouteCiResult: """Tests for route_ci_result routing function.""" def test_returns_ci_passed_when_succeeded(self) -> None: state = {"ci_build_result": "succeeded"} assert route_ci_result(state) == "ci_passed" def test_returns_ci_failed_when_failed(self) -> None: state = {"ci_build_result": "failed"} assert route_ci_result(state) == "ci_failed" def test_returns_ci_failed_when_canceled(self) -> None: state = {"ci_build_result": "canceled"} assert route_ci_result(state) == "ci_failed" def test_returns_ci_failed_when_partially_succeeded(self) -> None: state = {"ci_build_result": "partiallySucceeded"} assert route_ci_result(state) == "ci_failed" def test_returns_ci_failed_when_field_missing(self) -> None: state = {} assert route_ci_result(state) == "ci_failed" def test_returns_ci_failed_when_none(self) -> None: state = {"ci_build_result": None} assert route_ci_result(state) == "ci_failed" def test_returns_ci_failed_when_empty_string(self) -> None: state = {"ci_build_result": ""} assert route_ci_result(state) == "ci_failed" def test_case_sensitive_succeeded(self) -> None: # AzDo returns "succeeded" (lowercase) state = {"ci_build_result": "succeeded"} assert route_ci_result(state) == "ci_passed" # --------------------------------------------------------------------------- # route_approval_stage # --------------------------------------------------------------------------- class TestRouteApprovalStage: """Tests for route_approval_stage routing function.""" def test_returns_all_deployed_when_no_pending_approvals(self) -> None: state = {"pending_approvals": []} assert route_approval_stage(state) == "all_deployed" def test_returns_all_deployed_when_field_missing(self) -> None: state = {} assert route_approval_stage(state) == "all_deployed" def test_returns_all_deployed_when_none(self) -> None: state = {"pending_approvals": None} assert route_approval_stage(state) == "all_deployed" def test_returns_sandbox_pending_when_sandbox_approval_exists(self) -> None: state = { "current_stage": "sandbox_pending", "pending_approvals": [{"approval_id": "x", "stage_name": "Sandbox"}], } assert route_approval_stage(state) == "sandbox_pending" def test_returns_prod_pending_when_prod_approval_exists(self) -> None: state = { "current_stage": "prod_pending", "pending_approvals": [{"approval_id": "y", "stage_name": "Production"}], } assert route_approval_stage(state) == "prod_pending" def test_uses_current_stage_field_when_present(self) -> None: state = { "current_stage": "sandbox_pending", "pending_approvals": [{"approval_id": "z"}], } assert route_approval_stage(state) == "sandbox_pending" def test_returns_all_deployed_when_no_current_stage_and_has_approvals(self) -> None: # When current_stage is missing but approvals exist, stage is unknown # so we treat as sandbox by default (first stage) state = { "pending_approvals": [{"approval_id": "a"}], } # Must return either sandbox_pending or prod_pending (not all_deployed) result = route_approval_stage(state) assert result in ("sandbox_pending", "prod_pending") def test_sandbox_pending_from_current_stage(self) -> None: state = {"current_stage": "sandbox_pending", "pending_approvals": [{"approval_id": "x"}]} assert route_approval_stage(state) == "sandbox_pending" def test_prod_pending_from_current_stage(self) -> None: state = {"current_stage": "prod_pending", "pending_approvals": [{"approval_id": "x"}]} assert route_approval_stage(state) == "prod_pending" # --------------------------------------------------------------------------- # route_after_fetch # --------------------------------------------------------------------------- class TestRouteAfterFetch: """Tests for route_after_fetch — 3-way routing replacing is_pr_already_merged.""" def test_returns_merged_when_pr_already_merged(self) -> None: state = {"pr_already_merged": True} assert route_after_fetch(state) == "merged" def test_returns_active_with_ticket_when_active_and_has_ticket(self) -> None: state = {"pr_already_merged": False, "has_ticket": True} assert route_after_fetch(state) == "active_with_ticket" def test_returns_active_no_ticket_when_active_and_no_ticket(self) -> None: state = {"pr_already_merged": False, "has_ticket": False} assert route_after_fetch(state) == "active_no_ticket" def test_returns_active_no_ticket_when_has_ticket_missing(self) -> None: state = {"pr_already_merged": False} assert route_after_fetch(state) == "active_no_ticket" def test_returns_active_no_ticket_when_has_ticket_none(self) -> None: state = {"pr_already_merged": False, "has_ticket": None} assert route_after_fetch(state) == "active_no_ticket" def test_returns_active_no_ticket_when_all_fields_missing(self) -> None: state = {} assert route_after_fetch(state) == "active_no_ticket" def test_merged_takes_precedence_over_has_ticket(self) -> None: # Even if has_ticket is True, merged PR should route to "merged" state = {"pr_already_merged": True, "has_ticket": True} assert route_after_fetch(state) == "merged" def test_returns_active_with_ticket_ignores_merged_false(self) -> None: state = {"pr_already_merged": False, "has_ticket": True} result = route_after_fetch(state) assert result != "merged" assert result == "active_with_ticket"