"""Tests for graph/ci_nodes.py. Written FIRST (TDD RED phase). All external calls (azdo, slack, poll_until) are mocked. """ from unittest.mock import AsyncMock, MagicMock, patch import pytest from release_agent.graph.ci_nodes import notify_ci_result, poll_ci_build, trigger_ci_build from release_agent.models.build import BuildStatus from release_agent.models.pipeline import PipelineInfo from tests.graph.conftest import build_config, build_mock_clients # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_pipeline(pipeline_id: int = 10, name: str = "CI-build") -> dict: return {"id": pipeline_id, "name": name, "repo": "my-repo"} # --------------------------------------------------------------------------- # trigger_ci_build # --------------------------------------------------------------------------- class TestTriggerCiBuild: """Tests for trigger_ci_build node.""" async def test_triggers_pipeline_on_branch(self) -> None: clients = build_mock_clients() clients.azdo.list_build_pipelines.return_value = [ PipelineInfo(id=10, name="CI", repo="my-repo") ] clients.azdo.trigger_pipeline.return_value = {"id": 555, "state": "inProgress"} config = build_config(clients) state = {"repo_name": "my-repo", "version": "v1.0.0"} result = await trigger_ci_build(state, config) clients.azdo.trigger_pipeline.assert_called_once() assert "ci_build_id" in result assert result["ci_build_id"] == 555 async def test_returns_ci_build_id(self) -> None: clients = build_mock_clients() clients.azdo.list_build_pipelines.return_value = [ PipelineInfo(id=20, name="build-and-test", repo="my-repo") ] clients.azdo.trigger_pipeline.return_value = {"id": 999} config = build_config(clients) state = {"repo_name": "my-repo", "version": "v2.0.0"} result = await trigger_ci_build(state, config) assert result["ci_build_id"] == 999 async def test_appends_error_when_no_pipelines_found(self) -> None: clients = build_mock_clients() clients.azdo.list_build_pipelines.return_value = [] config = build_config(clients) state = {"repo_name": "my-repo", "version": "v1.0.0"} result = await trigger_ci_build(state, config) assert "errors" in result assert len(result["errors"]) >= 1 async def test_appends_error_on_trigger_failure(self) -> None: from release_agent.exceptions import ServiceError clients = build_mock_clients() clients.azdo.list_build_pipelines.return_value = [ PipelineInfo(id=10, name="CI", repo="my-repo") ] clients.azdo.trigger_pipeline.side_effect = ServiceError( service="azdo", status_code=500, detail="Internal error" ) config = build_config(clients) state = {"repo_name": "my-repo", "version": "v1.0.0"} result = await trigger_ci_build(state, config) assert "errors" in result async def test_uses_main_branch_when_no_version(self) -> None: clients = build_mock_clients() clients.azdo.list_build_pipelines.return_value = [ PipelineInfo(id=10, name="CI", repo="my-repo") ] clients.azdo.trigger_pipeline.return_value = {"id": 1} config = build_config(clients) state = {"repo_name": "my-repo"} result = await trigger_ci_build(state, config) call_kwargs = clients.azdo.trigger_pipeline.call_args[1] branch = call_kwargs.get("branch", "") assert "main" in branch or "refs/heads" in branch async def test_appends_message_on_success(self) -> None: clients = build_mock_clients() clients.azdo.list_build_pipelines.return_value = [ PipelineInfo(id=10, name="CI", repo="my-repo") ] clients.azdo.trigger_pipeline.return_value = {"id": 123} config = build_config(clients) state = {"repo_name": "my-repo", "version": "v1.0.0"} result = await trigger_ci_build(state, config) assert "messages" in result assert len(result["messages"]) >= 1 # --------------------------------------------------------------------------- # poll_ci_build # --------------------------------------------------------------------------- class TestPollCiBuild: """Tests for poll_ci_build node.""" async def test_returns_ci_build_status_and_result_on_completion(self) -> None: clients = build_mock_clients() completed_status = BuildStatus(status="completed", result="succeeded", build_url="https://build/1") config = build_config(clients) state = {"ci_build_id": 42, "repo_name": "my-repo"} with patch( "release_agent.graph.ci_nodes.poll_until", return_value=(completed_status, True), ): result = await poll_ci_build(state, config) assert result["ci_build_status"] == "completed" assert result["ci_build_result"] == "succeeded" async def test_returns_build_url(self) -> None: clients = build_mock_clients() completed_status = BuildStatus( status="completed", result="succeeded", build_url="https://dev.azure.com/build/42", ) config = build_config(clients) state = {"ci_build_id": 42, "repo_name": "my-repo"} with patch( "release_agent.graph.ci_nodes.poll_until", return_value=(completed_status, True), ): result = await poll_ci_build(state, config) assert result.get("ci_build_url") == "https://dev.azure.com/build/42" async def test_appends_error_on_timeout(self) -> None: clients = build_mock_clients() running_status = BuildStatus(status="inProgress", result=None, build_url=None) config = build_config(clients) state = {"ci_build_id": 42, "repo_name": "my-repo"} with patch( "release_agent.graph.ci_nodes.poll_until", return_value=(running_status, False), ): result = await poll_ci_build(state, config) assert "errors" in result async def test_appends_error_when_build_id_missing(self) -> None: clients = build_mock_clients() config = build_config(clients) state = {"repo_name": "my-repo"} # no ci_build_id result = await poll_ci_build(state, config) assert "errors" in result async def test_passes_correct_build_id_to_poll_fn(self) -> None: clients = build_mock_clients() clients.azdo.get_build_status.return_value = BuildStatus( status="completed", result="succeeded", build_url=None ) config = build_config(clients) state = {"ci_build_id": 77, "repo_name": "my-repo"} async def fake_poll_until(*, poll_fn, is_done, interval_seconds, max_wait_seconds, sleep_fn=None): result = await poll_fn() return result, True with patch("release_agent.graph.ci_nodes.poll_until", side_effect=fake_poll_until): await poll_ci_build(state, config) clients.azdo.get_build_status.assert_called_once_with(build_id=77) async def test_result_none_when_poll_returns_none(self) -> None: clients = build_mock_clients() config = build_config(clients) state = {"ci_build_id": 42, "repo_name": "my-repo"} with patch( "release_agent.graph.ci_nodes.poll_until", return_value=(None, False), ): result = await poll_ci_build(state, config) assert "errors" in result # --------------------------------------------------------------------------- # notify_ci_result # --------------------------------------------------------------------------- class TestNotifyCiResult: """Tests for notify_ci_result node.""" async def test_sends_notification_on_success(self) -> None: clients = build_mock_clients() clients.slack.send_notification.return_value = True config = build_config(clients) state = { "repo_name": "my-repo", "ci_build_status": "completed", "ci_build_result": "succeeded", "ci_build_url": "https://build/99", } result = await notify_ci_result(state, config) clients.slack.send_notification.assert_called_once() assert "messages" in result async def test_sends_notification_on_failure(self) -> None: clients = build_mock_clients() clients.slack.send_notification.return_value = True config = build_config(clients) state = { "repo_name": "my-repo", "ci_build_status": "completed", "ci_build_result": "failed", "ci_build_url": None, } result = await notify_ci_result(state, config) clients.slack.send_notification.assert_called_once() async def test_handles_slack_error_gracefully(self) -> None: from release_agent.exceptions import ServiceError clients = build_mock_clients() clients.slack.send_notification.side_effect = ServiceError( service="slack", status_code=500, detail="Slack error" ) config = build_config(clients) state = { "repo_name": "my-repo", "ci_build_result": "succeeded", "ci_build_url": None, } result = await notify_ci_result(state, config) # Should not re-raise; should append error assert "errors" in result async def test_includes_repo_name_in_message(self) -> None: clients = build_mock_clients() clients.slack.send_notification.return_value = True config = build_config(clients) state = { "repo_name": "super-service", "ci_build_result": "succeeded", "ci_build_url": None, } await notify_ci_result(state, config) call_kwargs = clients.slack.send_notification.call_args[1] text_or_blocks = str(call_kwargs) assert "super-service" in text_or_blocks async def test_returns_empty_dict_when_state_has_no_data(self) -> None: clients = build_mock_clients() clients.slack.send_notification.return_value = True config = build_config(clients) state = {} result = await notify_ci_result(state, config) # Should not crash; may return messages or empty dict assert isinstance(result, dict)