"""Tests for graph/release.py node functions. Written FIRST (TDD RED phase). Each node is an async function (state, config) -> dict. Tests call nodes directly — no graph compilation required. """ from datetime import date from unittest.mock import AsyncMock, patch import pytest from release_agent.graph.dependencies import JsonFileStagingStore from release_agent.graph.release import ( approve_stage, archive_release, check_release_approvals, create_release_pr, interrupt_confirm_approve, interrupt_confirm_merge_release, interrupt_confirm_release, interrupt_confirm_trigger, list_pipelines, load_staging, merge_release_pr, move_tickets_to_done, send_slack_notification, trigger_pipelines, build_release_graph, ) from release_agent.models.pipeline import PipelineInfo, ReleasePipelineStage from release_agent.models.release import StagingRelease from release_agent.models.ticket import TicketEntry from tests.graph.conftest import build_config, build_mock_clients # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_ticket(ticket_id: str = "ALLPOST-1") -> TicketEntry: return TicketEntry( id=ticket_id, summary="Fix something", pr_id="42", pr_url="https://dev.azure.com/org/proj/_git/repo/pullrequest/42", pr_title="Fix: something", branch=f"feature/{ticket_id}-fix", merged_at=date(2025, 1, 15), ) def _make_staging( *, repo: str = "my-repo", version: str = "v1.0.0", tickets: list | None = None, ) -> StagingRelease: t = tickets if tickets is not None else [_make_ticket()] return StagingRelease( version=version, repo=repo, started_at=date(2025, 1, 1), tickets=t, ) def _staging_dict(staging: StagingRelease) -> dict: return staging.model_dump(mode="json") # --------------------------------------------------------------------------- # load_staging # --------------------------------------------------------------------------- class TestLoadStaging: async def test_loads_staging_from_store(self, tmp_path) -> None: staging_store = JsonFileStagingStore(directory=tmp_path) staging = _make_staging() await staging_store.save(staging) clients = build_mock_clients() config = build_config(clients, staging_store=staging_store) state = {"repo_name": "my-repo"} result = await load_staging(state, config) assert "staging" in result assert result["staging"]["version"] == "v1.0.0" async def test_returns_none_when_no_staging(self, tmp_path) -> None: staging_store = JsonFileStagingStore(directory=tmp_path) clients = build_mock_clients() config = build_config(clients, staging_store=staging_store) state = {"repo_name": "nonexistent"} result = await load_staging(state, config) assert result.get("staging") is None async def test_staging_includes_tickets(self, tmp_path) -> None: staging_store = JsonFileStagingStore(directory=tmp_path) staging = _make_staging(tickets=[_make_ticket("BILL-10"), _make_ticket("BILL-11")]) await staging_store.save(staging) clients = build_mock_clients() config = build_config(clients, staging_store=staging_store) state = {"repo_name": "my-repo"} result = await load_staging(state, config) assert len(result["staging"]["tickets"]) == 2 # --------------------------------------------------------------------------- # interrupt_confirm_release # --------------------------------------------------------------------------- class TestInterruptConfirmRelease: async def test_calls_interrupt_with_staging_summary(self) -> None: config = build_config() staging = _make_staging() state = { "repo_name": "my-repo", "staging": _staging_dict(staging), } with patch("release_agent.graph.release.interrupt") as mock_interrupt: mock_interrupt.return_value = "confirm" await interrupt_confirm_release(state, config) mock_interrupt.assert_called_once() call_arg = mock_interrupt.call_args[0][0] assert isinstance(call_arg, str) async def test_interrupt_contains_version_and_repo(self) -> None: config = build_config() staging = _make_staging(version="v2.5.0", repo="backend") state = { "repo_name": "backend", "staging": _staging_dict(staging), } with patch("release_agent.graph.release.interrupt") as mock_interrupt: mock_interrupt.return_value = "confirm" await interrupt_confirm_release(state, config) call_arg = mock_interrupt.call_args[0][0] assert "v2.5.0" in call_arg or "backend" in call_arg # --------------------------------------------------------------------------- # create_release_pr # --------------------------------------------------------------------------- class TestCreateReleasePr: async def test_calls_azdo_create_pr(self) -> None: clients = build_mock_clients() clients.azdo.create_pr = AsyncMock(return_value={ "pullRequestId": 99, "lastMergeSourceCommit": {"commitId": "deadbeef"}, }) config = build_config(clients) staging = _make_staging(version="v1.2.0") state = { "repo_name": "my-repo", "version": "v1.2.0", "staging": _staging_dict(staging), } result = await create_release_pr(state, config) clients.azdo.create_pr.assert_called_once() call_kwargs = clients.azdo.create_pr.call_args.kwargs assert call_kwargs["repo"] == "my-repo" async def test_sets_release_pr_id(self) -> None: clients = build_mock_clients() clients.azdo.create_pr = AsyncMock(return_value={ "pullRequestId": 77, "lastMergeSourceCommit": {"commitId": "cafe1234"}, }) config = build_config(clients) staging = _make_staging(version="v1.0.3") state = { "repo_name": "my-repo", "version": "v1.0.3", "staging": _staging_dict(staging), } result = await create_release_pr(state, config) assert result["release_pr_id"] == "77" async def test_sets_release_pr_commit(self) -> None: clients = build_mock_clients() clients.azdo.create_pr = AsyncMock(return_value={ "pullRequestId": 77, "lastMergeSourceCommit": {"commitId": "cafe1234"}, }) config = build_config(clients) staging = _make_staging() state = { "repo_name": "my-repo", "version": "v1.0.0", "staging": _staging_dict(staging), } result = await create_release_pr(state, config) assert result["release_pr_commit"] == "cafe1234" async def test_re_raises_on_service_error(self) -> None: from release_agent.exceptions import ServiceError clients = build_mock_clients() clients.azdo.create_pr = AsyncMock(side_effect=ServiceError( service="azdo", status_code=422, detail="Invalid branch" )) config = build_config(clients) staging = _make_staging() state = { "repo_name": "my-repo", "version": "v1.0.0", "staging": _staging_dict(staging), } with pytest.raises(ServiceError): await create_release_pr(state, config) # --------------------------------------------------------------------------- # interrupt_confirm_merge_release # --------------------------------------------------------------------------- class TestInterruptConfirmMergeRelease: async def test_calls_interrupt_with_pr_info(self) -> None: config = build_config() state = { "release_pr_id": "99", "version": "v1.0.0", "repo_name": "my-repo", } with patch("release_agent.graph.release.interrupt") as mock_interrupt: mock_interrupt.return_value = "confirm" await interrupt_confirm_merge_release(state, config) mock_interrupt.assert_called_once() call_arg = mock_interrupt.call_args[0][0] assert isinstance(call_arg, str) assert len(call_arg) > 0 # --------------------------------------------------------------------------- # merge_release_pr # --------------------------------------------------------------------------- class TestMergeReleasePr: async def test_calls_azdo_merge_pr(self) -> None: clients = build_mock_clients() clients.azdo.merge_pr = AsyncMock(return_value=True) config = build_config(clients) state = { "release_pr_id": "99", "release_pr_commit": "abc123", } await merge_release_pr(state, config) clients.azdo.merge_pr.assert_called_once_with( pr_id=99, last_merge_source_commit="abc123" ) async def test_re_raises_on_service_error(self) -> None: from release_agent.exceptions import ServiceError clients = build_mock_clients() clients.azdo.merge_pr = AsyncMock(side_effect=ServiceError( service="azdo", status_code=409, detail="Conflict" )) config = build_config(clients) state = {"release_pr_id": "99", "release_pr_commit": "abc"} with pytest.raises(ServiceError): await merge_release_pr(state, config) # --------------------------------------------------------------------------- # move_tickets_to_done # --------------------------------------------------------------------------- class TestMoveTicketsToDone: async def test_transitions_all_tickets(self) -> None: clients = build_mock_clients() clients.jira.transition_issue = AsyncMock(return_value=True) config = build_config(clients) staging = _make_staging(tickets=[_make_ticket("BILL-1"), _make_ticket("BILL-2")]) state = {"staging": _staging_dict(staging)} await move_tickets_to_done(state, config) assert clients.jira.transition_issue.call_count == 2 async def test_calls_transition_with_done_name(self) -> None: clients = build_mock_clients() clients.jira.transition_issue = AsyncMock(return_value=True) config = build_config(clients) staging = _make_staging(tickets=[_make_ticket("BILL-1")]) state = {"staging": _staging_dict(staging)} await move_tickets_to_done(state, config) call_args = clients.jira.transition_issue.call_args_list[0] ticket_id, transition = call_args[0] assert ticket_id == "BILL-1" assert "done" in transition.lower() or "released" in transition.lower() async def test_appends_error_on_jira_failure(self) -> None: from release_agent.exceptions import ServiceError clients = build_mock_clients() clients.jira.transition_issue = AsyncMock(side_effect=ServiceError( service="jira", status_code=500, detail="Error" )) config = build_config(clients) staging = _make_staging(tickets=[_make_ticket()]) state = {"staging": _staging_dict(staging)} result = await move_tickets_to_done(state, config) assert "errors" in result async def test_empty_tickets_no_calls(self) -> None: clients = build_mock_clients() clients.jira.transition_issue = AsyncMock() config = build_config(clients) staging = _make_staging(tickets=[]) state = {"staging": _staging_dict(staging)} await move_tickets_to_done(state, config) clients.jira.transition_issue.assert_not_called() # --------------------------------------------------------------------------- # send_slack_notification # --------------------------------------------------------------------------- class TestSendSlackNotification: async def test_calls_slack_send_release_notification(self) -> None: clients = build_mock_clients() clients.slack.send_release_notification = AsyncMock(return_value=True) config = build_config(clients) staging = _make_staging() state = { "repo_name": "my-repo", "version": "v1.0.0", "staging": _staging_dict(staging), } result = await send_slack_notification(state, config) clients.slack.send_release_notification.assert_called_once() async def test_appends_error_on_slack_failure(self) -> None: from release_agent.exceptions import ServiceError clients = build_mock_clients() clients.slack.send_release_notification = AsyncMock(side_effect=ServiceError( service="slack", status_code=500, detail="Webhook error" )) config = build_config(clients) staging = _make_staging() state = { "repo_name": "my-repo", "version": "v1.0.0", "staging": _staging_dict(staging), } result = await send_slack_notification(state, config) assert "errors" in result # --------------------------------------------------------------------------- # archive_release # --------------------------------------------------------------------------- class TestArchiveRelease: async def test_archives_staging_to_store(self, tmp_path) -> None: staging_store = JsonFileStagingStore(directory=tmp_path) staging = _make_staging() await staging_store.save(staging) clients = build_mock_clients() config = build_config(clients, staging_store=staging_store) state = { "repo_name": "my-repo", "staging": _staging_dict(staging), } await archive_release(state, config) # Staging should be gone now assert await staging_store.load("my-repo") is None async def test_archive_file_created_in_store(self, tmp_path) -> None: staging_store = JsonFileStagingStore(directory=tmp_path) staging = _make_staging(version="v3.0.0") await staging_store.save(staging) clients = build_mock_clients() config = build_config(clients, staging_store=staging_store) state = { "repo_name": "my-repo", "staging": _staging_dict(staging), } await archive_release(state, config) versions = await staging_store.list_versions("my-repo") assert "v3.0.0" in versions # --------------------------------------------------------------------------- # list_pipelines # --------------------------------------------------------------------------- class TestListPipelines: async def test_fetches_pipelines_from_azdo(self) -> None: clients = build_mock_clients() pipelines = [PipelineInfo(id=1, name="build", repo="my-repo")] clients.azdo.list_build_pipelines = AsyncMock(return_value=pipelines) config = build_config(clients) state = {"repo_name": "my-repo"} result = await list_pipelines(state, config) clients.azdo.list_build_pipelines.assert_called_once_with(repo="my-repo") assert "pipelines" in result assert len(result["pipelines"]) == 1 async def test_stores_pipelines_as_list_of_dicts(self) -> None: clients = build_mock_clients() pipelines = [ PipelineInfo(id=1, name="build", repo="my-repo"), PipelineInfo(id=2, name="deploy", repo="my-repo"), ] clients.azdo.list_build_pipelines = AsyncMock(return_value=pipelines) config = build_config(clients) state = {"repo_name": "my-repo"} result = await list_pipelines(state, config) assert len(result["pipelines"]) == 2 assert result["pipelines"][0]["id"] == 1 async def test_empty_pipelines_stored_as_empty_list(self) -> None: clients = build_mock_clients() clients.azdo.list_build_pipelines = AsyncMock(return_value=[]) config = build_config(clients) state = {"repo_name": "my-repo"} result = await list_pipelines(state, config) assert result["pipelines"] == [] async def test_appends_error_on_service_failure(self) -> None: from release_agent.exceptions import ServiceError clients = build_mock_clients() clients.azdo.list_build_pipelines = AsyncMock(side_effect=ServiceError( service="azdo", status_code=500, detail="Error" )) config = build_config(clients) state = {"repo_name": "my-repo"} result = await list_pipelines(state, config) assert "errors" in result # --------------------------------------------------------------------------- # interrupt_confirm_trigger # --------------------------------------------------------------------------- class TestInterruptConfirmTrigger: async def test_calls_interrupt_with_pipelines_summary(self) -> None: config = build_config() state = { "repo_name": "my-repo", "version": "v1.0.0", "pipelines": [{"id": 1, "name": "build", "repo": "my-repo"}], } with patch("release_agent.graph.release.interrupt") as mock_interrupt: mock_interrupt.return_value = "confirm" await interrupt_confirm_trigger(state, config) mock_interrupt.assert_called_once() call_arg = mock_interrupt.call_args[0][0] assert isinstance(call_arg, str) # --------------------------------------------------------------------------- # trigger_pipelines # --------------------------------------------------------------------------- class TestTriggerPipelines: async def test_triggers_each_pipeline(self) -> None: clients = build_mock_clients() clients.azdo.trigger_pipeline = AsyncMock(return_value={"id": 1001}) config = build_config(clients) state = { "repo_name": "my-repo", "version": "v1.0.0", "pipelines": [ {"id": 1, "name": "build", "repo": "my-repo"}, {"id": 2, "name": "deploy", "repo": "my-repo"}, ], } result = await trigger_pipelines(state, config) assert clients.azdo.trigger_pipeline.call_count == 2 assert "triggered_builds" in result assert len(result["triggered_builds"]) == 2 async def test_no_pipelines_no_calls(self) -> None: clients = build_mock_clients() clients.azdo.trigger_pipeline = AsyncMock() config = build_config(clients) state = { "repo_name": "my-repo", "version": "v1.0.0", "pipelines": [], } result = await trigger_pipelines(state, config) clients.azdo.trigger_pipeline.assert_not_called() assert result["triggered_builds"] == [] async def test_appends_error_on_trigger_failure(self) -> None: from release_agent.exceptions import ServiceError clients = build_mock_clients() clients.azdo.trigger_pipeline = AsyncMock(side_effect=ServiceError( service="azdo", status_code=500, detail="Error" )) config = build_config(clients) state = { "repo_name": "my-repo", "version": "v1.0.0", "pipelines": [{"id": 1, "name": "build", "repo": "my-repo"}], } result = await trigger_pipelines(state, config) assert "errors" in result # --------------------------------------------------------------------------- # check_release_approvals # --------------------------------------------------------------------------- class TestCheckReleaseApprovals: async def test_fetches_pending_approvals_from_builds(self) -> None: clients = build_mock_clients() clients.azdo.get_build_status = AsyncMock(return_value="completed") config = build_config(clients) state = { "triggered_builds": [{"id": 1001}], } result = await check_release_approvals(state, config) assert "pending_approvals" in result async def test_empty_builds_means_no_approvals(self) -> None: clients = build_mock_clients() config = build_config(clients) state = {"triggered_builds": []} result = await check_release_approvals(state, config) assert result["pending_approvals"] == [] async def test_appends_error_on_failure(self) -> None: from release_agent.exceptions import ServiceError clients = build_mock_clients() clients.azdo.get_build_status = AsyncMock(side_effect=ServiceError( service="azdo", status_code=500, detail="Error" )) config = build_config(clients) state = {"triggered_builds": [{"id": 1001}]} result = await check_release_approvals(state, config) assert "errors" in result # --------------------------------------------------------------------------- # interrupt_confirm_approve # --------------------------------------------------------------------------- class TestInterruptConfirmApprove: async def test_calls_interrupt_with_approvals_summary(self) -> None: config = build_config() state = { "pending_approvals": [{"approval_id": "aaa", "stage_name": "Production"}], "version": "v1.0.0", } with patch("release_agent.graph.release.interrupt") as mock_interrupt: mock_interrupt.return_value = "confirm" await interrupt_confirm_approve(state, config) mock_interrupt.assert_called_once() # --------------------------------------------------------------------------- # approve_stage # --------------------------------------------------------------------------- class TestApproveStage: async def test_approves_each_pending_approval(self) -> None: clients = build_mock_clients() clients.azdo.approve_release = AsyncMock(return_value={"status": "approved"}) config = build_config(clients) state = { "pending_approvals": [ {"approval_id": "aaa"}, {"approval_id": "bbb"}, ], } result = await approve_stage(state, config) assert clients.azdo.approve_release.call_count == 2 async def test_no_approvals_no_calls(self) -> None: clients = build_mock_clients() clients.azdo.approve_release = AsyncMock() config = build_config(clients) state = {"pending_approvals": []} await approve_stage(state, config) clients.azdo.approve_release.assert_not_called() async def test_appends_error_on_failure(self) -> None: from release_agent.exceptions import ServiceError clients = build_mock_clients() clients.azdo.approve_release = AsyncMock(side_effect=ServiceError( service="azdo", status_code=500, detail="Error" )) config = build_config(clients) state = {"pending_approvals": [{"approval_id": "aaa"}]} result = await approve_stage(state, config) assert "errors" in result # --------------------------------------------------------------------------- # build_release_graph # --------------------------------------------------------------------------- class TestBuildReleaseGraph: def test_returns_compiled_graph(self) -> None: graph = build_release_graph() assert graph is not None def test_graph_includes_trigger_ci_build_main_node(self) -> None: graph = build_release_graph() graph_nodes = graph.get_graph().nodes assert "trigger_ci_build_main" in graph_nodes def test_graph_includes_poll_ci_build_main_node(self) -> None: graph = build_release_graph() graph_nodes = graph.get_graph().nodes assert "poll_ci_build_main" in graph_nodes def test_graph_includes_wait_for_cd_release_node(self) -> None: graph = build_release_graph() graph_nodes = graph.get_graph().nodes assert "wait_for_cd_release" in graph_nodes def test_graph_includes_poll_release_approvals_node(self) -> None: graph = build_release_graph() graph_nodes = graph.get_graph().nodes assert "poll_release_approvals" in graph_nodes def test_graph_includes_interrupt_sandbox_approval_node(self) -> None: graph = build_release_graph() graph_nodes = graph.get_graph().nodes assert "interrupt_sandbox_approval" in graph_nodes def test_graph_includes_interrupt_prod_approval_node(self) -> None: graph = build_release_graph() graph_nodes = graph.get_graph().nodes assert "interrupt_prod_approval" in graph_nodes def test_graph_includes_execute_sandbox_approval_node(self) -> None: graph = build_release_graph() graph_nodes = graph.get_graph().nodes assert "execute_sandbox_approval" in graph_nodes def test_graph_includes_execute_prod_approval_node(self) -> None: graph = build_release_graph() graph_nodes = graph.get_graph().nodes assert "execute_prod_approval" in graph_nodes def test_graph_includes_notify_ci_failure_node(self) -> None: graph = build_release_graph() graph_nodes = graph.get_graph().nodes assert "notify_ci_failure" in graph_nodes # --------------------------------------------------------------------------- # New release graph node: wait_for_cd_release # --------------------------------------------------------------------------- class TestWaitForCdRelease: """Tests for wait_for_cd_release node.""" async def test_sets_release_id_when_found(self) -> None: from release_agent.graph.release import wait_for_cd_release clients = build_mock_clients() clients.azdo.get_latest_release.return_value = {"id": 100, "name": "Release-100"} config = build_config(clients) state = {"release_definition_id": 5, "repo_name": "my-repo"} result = await wait_for_cd_release(state, config) assert "release_id" in result assert result["release_id"] == 100 async def test_appends_error_when_no_release(self) -> None: from release_agent.graph.release import wait_for_cd_release clients = build_mock_clients() clients.azdo.get_latest_release.return_value = {} config = build_config(clients) state = {"release_definition_id": 5, "repo_name": "my-repo"} result = await wait_for_cd_release(state, config) assert "errors" in result async def test_works_without_release_definition_id(self) -> None: from release_agent.graph.release import wait_for_cd_release clients = build_mock_clients() config = build_config(clients) state = {"repo_name": "my-repo"} result = await wait_for_cd_release(state, config) assert isinstance(result, dict) # --------------------------------------------------------------------------- # New release graph node: poll_release_approvals # --------------------------------------------------------------------------- class TestPollReleaseApprovals: """Tests for poll_release_approvals node.""" async def test_sets_pending_approvals_from_azdo(self) -> None: from release_agent.graph.release import poll_release_approvals from release_agent.models.build import ApprovalRecord clients = build_mock_clients() clients.azdo.get_release_approvals.return_value = [ ApprovalRecord(approval_id="a1", stage_name="Sandbox", status="pending", release_id=10), ] config = build_config(clients) state = {"release_id": 10} result = await poll_release_approvals(state, config) assert "pending_approvals" in result assert len(result["pending_approvals"]) == 1 async def test_returns_empty_list_when_no_approvals(self) -> None: from release_agent.graph.release import poll_release_approvals clients = build_mock_clients() clients.azdo.get_release_approvals.return_value = [] config = build_config(clients) state = {"release_id": 10} result = await poll_release_approvals(state, config) assert result.get("pending_approvals") == [] async def test_appends_error_on_failure(self) -> None: from release_agent.exceptions import ServiceError from release_agent.graph.release import poll_release_approvals clients = build_mock_clients() clients.azdo.get_release_approvals.side_effect = ServiceError( service="azdo", status_code=500, detail="error" ) config = build_config(clients) state = {"release_id": 10} result = await poll_release_approvals(state, config) assert "errors" in result # --------------------------------------------------------------------------- # New release graph node: interrupt_sandbox_approval # --------------------------------------------------------------------------- class TestInterruptSandboxApproval: async def test_calls_interrupt(self) -> None: from release_agent.graph.release import interrupt_sandbox_approval config = build_config() state = { "pending_approvals": [{"approval_id": "x", "stage_name": "Sandbox"}], "version": "v1.0.0", } with patch("release_agent.graph.release.interrupt") as mock_interrupt: mock_interrupt.return_value = "confirm" await interrupt_sandbox_approval(state, config) mock_interrupt.assert_called_once() async def test_sets_current_stage_to_sandbox_pending(self) -> None: from release_agent.graph.release import interrupt_sandbox_approval config = build_config() state = { "pending_approvals": [{"approval_id": "x", "stage_name": "Sandbox"}], } with patch("release_agent.graph.release.interrupt", return_value="yes"): result = await interrupt_sandbox_approval(state, config) assert result.get("current_stage") == "sandbox_pending" # --------------------------------------------------------------------------- # New release graph node: interrupt_prod_approval # --------------------------------------------------------------------------- class TestInterruptProdApproval: async def test_calls_interrupt(self) -> None: from release_agent.graph.release import interrupt_prod_approval config = build_config() state = { "pending_approvals": [{"approval_id": "y", "stage_name": "Production"}], "version": "v1.0.0", } with patch("release_agent.graph.release.interrupt") as mock_interrupt: mock_interrupt.return_value = "confirm" await interrupt_prod_approval(state, config) mock_interrupt.assert_called_once() async def test_sets_current_stage_to_prod_pending(self) -> None: from release_agent.graph.release import interrupt_prod_approval config = build_config() state = { "pending_approvals": [{"approval_id": "y", "stage_name": "Production"}], } with patch("release_agent.graph.release.interrupt", return_value="yes"): result = await interrupt_prod_approval(state, config) assert result.get("current_stage") == "prod_pending" # --------------------------------------------------------------------------- # New release graph node: execute_sandbox_approval # --------------------------------------------------------------------------- class TestExecuteSandboxApproval: async def test_approves_sandbox_approvals(self) -> None: from release_agent.graph.release import execute_sandbox_approval clients = build_mock_clients() clients.azdo.approve_release.return_value = {"status": "approved"} config = build_config(clients) state = { "pending_approvals": [{"approval_id": "sb1", "stage_name": "Sandbox"}], } result = await execute_sandbox_approval(state, config) clients.azdo.approve_release.assert_called() async def test_returns_empty_dict_on_success(self) -> None: from release_agent.graph.release import execute_sandbox_approval clients = build_mock_clients() clients.azdo.approve_release.return_value = {"status": "approved"} config = build_config(clients) state = {"pending_approvals": [{"approval_id": "sb1"}]} result = await execute_sandbox_approval(state, config) assert "errors" not in result or result["errors"] == [] # --------------------------------------------------------------------------- # New release graph node: execute_prod_approval # --------------------------------------------------------------------------- class TestExecuteProdApproval: async def test_approves_prod_approvals(self) -> None: from release_agent.graph.release import execute_prod_approval clients = build_mock_clients() clients.azdo.approve_release.return_value = {"status": "approved"} config = build_config(clients) state = { "pending_approvals": [{"approval_id": "pd1", "stage_name": "Production"}], } result = await execute_prod_approval(state, config) clients.azdo.approve_release.assert_called() # --------------------------------------------------------------------------- # New release graph node: notify_ci_failure # --------------------------------------------------------------------------- class TestNotifyCiFailure: async def test_sends_slack_notification(self) -> None: from release_agent.graph.release import notify_ci_failure clients = build_mock_clients() clients.slack.send_notification.return_value = True config = build_config(clients) state = { "repo_name": "my-repo", "ci_build_result": "failed", "ci_build_url": "https://build/1", } result = await notify_ci_failure(state, config) clients.slack.send_notification.assert_called_once() async def test_appends_message_on_success(self) -> None: from release_agent.graph.release import notify_ci_failure clients = build_mock_clients() clients.slack.send_notification.return_value = True config = build_config(clients) state = {"repo_name": "my-repo", "ci_build_result": "failed"} result = await notify_ci_failure(state, config) assert "messages" in result or isinstance(result, dict)