"""Tests for internal async helper functions. Tests _run_graph, _upsert_thread, _resume_graph, and exception handlers. Written FIRST then verified (TDD GREEN phase for internal helpers). """ import json from unittest.mock import AsyncMock, MagicMock, call, patch import pytest # --------------------------------------------------------------------------- # _upsert_thread tests # --------------------------------------------------------------------------- class TestUpsertThread: @pytest.mark.asyncio async def test_upsert_thread_executes_sql(self) -> None: from release_agent.api.webhooks import _upsert_thread mock_pool = MagicMock() mock_conn = AsyncMock() mock_cursor = AsyncMock() mock_cursor.execute = AsyncMock() mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) mock_cursor.__aexit__ = AsyncMock(return_value=False) mock_conn.cursor = MagicMock(return_value=mock_cursor) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) mock_pool.connection = MagicMock(return_value=mock_conn) await _upsert_thread( mock_pool, thread_id="t1", thread_status="running", state={"repo_name": "my-repo"}, ) mock_cursor.execute.assert_called_once() args = mock_cursor.execute.call_args[0] assert "agent_threads" in args[0] assert args[1][0] == "t1" assert args[1][4] == "running" # state is JSON-encoded state_json = json.loads(args[1][5]) assert state_json["repo_name"] == "my-repo" @pytest.mark.asyncio async def test_upsert_thread_completed_status(self) -> None: from release_agent.api.webhooks import _upsert_thread mock_pool = MagicMock() mock_conn = AsyncMock() mock_cursor = AsyncMock() mock_cursor.execute = AsyncMock() mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) mock_cursor.__aexit__ = AsyncMock(return_value=False) mock_conn.cursor = MagicMock(return_value=mock_cursor) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) mock_pool.connection = MagicMock(return_value=mock_conn) await _upsert_thread( mock_pool, thread_id="t2", thread_status="completed", state={}, ) args = mock_cursor.execute.call_args[0] assert args[1][4] == "completed" @pytest.mark.asyncio async def test_upsert_thread_failed_status(self) -> None: from release_agent.api.webhooks import _upsert_thread mock_pool = MagicMock() mock_conn = AsyncMock() mock_cursor = AsyncMock() mock_cursor.execute = AsyncMock() mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) mock_cursor.__aexit__ = AsyncMock(return_value=False) mock_conn.cursor = MagicMock(return_value=mock_cursor) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) mock_pool.connection = MagicMock(return_value=mock_conn) await _upsert_thread( mock_pool, thread_id="t3", thread_status="failed", state={"errors": ["something went wrong"]}, ) args = mock_cursor.execute.call_args[0] assert args[1][4] == "failed" state_json = json.loads(args[1][5]) assert state_json["errors"] == ["something went wrong"] # --------------------------------------------------------------------------- # _run_graph tests # --------------------------------------------------------------------------- class TestRunGraph: @pytest.mark.asyncio async def test_run_graph_success_upserts_completed(self) -> None: from release_agent.api.webhooks import _run_graph mock_graph = AsyncMock() mock_graph.ainvoke = AsyncMock(return_value={"messages": ["done"]}) mock_pool = MagicMock() mock_conn = AsyncMock() mock_cursor = AsyncMock() mock_cursor.execute = AsyncMock() mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) mock_cursor.__aexit__ = AsyncMock(return_value=False) mock_conn.cursor = MagicMock(return_value=mock_cursor) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) mock_pool.connection = MagicMock(return_value=mock_conn) await _run_graph( graph=mock_graph, initial_state={"repo_name": "test"}, thread_id="t1", tool_clients=MagicMock(), db_pool=mock_pool, ) # Should have been called with "running" then "completed" calls = mock_cursor.execute.call_args_list assert len(calls) == 2 # First call: "running", second call: "completed" assert calls[0][0][1][4] == "running" assert calls[1][0][1][4] == "completed" @pytest.mark.asyncio async def test_run_graph_failure_upserts_failed(self) -> None: from release_agent.api.webhooks import _run_graph mock_graph = AsyncMock() mock_graph.ainvoke = AsyncMock(side_effect=RuntimeError("graph crashed")) mock_pool = MagicMock() mock_conn = AsyncMock() mock_cursor = AsyncMock() mock_cursor.execute = AsyncMock() mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) mock_cursor.__aexit__ = AsyncMock(return_value=False) mock_conn.cursor = MagicMock(return_value=mock_cursor) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) mock_pool.connection = MagicMock(return_value=mock_conn) await _run_graph( graph=mock_graph, initial_state={"repo_name": "test"}, thread_id="t-fail", tool_clients=MagicMock(), db_pool=mock_pool, ) calls = mock_cursor.execute.call_args_list # First call: "running", second call: "failed" assert calls[0][0][1][4] == "running" assert calls[1][0][1][4] == "failed" # State should contain errors failed_state = json.loads(calls[1][0][1][5]) assert "errors" in failed_state @pytest.mark.asyncio async def test_run_graph_invokes_with_correct_config(self) -> None: from release_agent.api.webhooks import _run_graph mock_graph = AsyncMock() mock_graph.ainvoke = AsyncMock(return_value={}) mock_clients = MagicMock() mock_pool = MagicMock() mock_conn = AsyncMock() mock_cursor = AsyncMock() mock_cursor.execute = AsyncMock() mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) mock_cursor.__aexit__ = AsyncMock(return_value=False) mock_conn.cursor = MagicMock(return_value=mock_cursor) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) mock_pool.connection = MagicMock(return_value=mock_conn) await _run_graph( graph=mock_graph, initial_state={"repo_name": "test"}, thread_id="t-config", tool_clients=mock_clients, db_pool=mock_pool, ) call_args = mock_graph.ainvoke.call_args config = call_args[1]["config"] assert config["configurable"]["thread_id"] == "t-config" assert config["configurable"]["clients"] is mock_clients # --------------------------------------------------------------------------- # _resume_graph tests # --------------------------------------------------------------------------- class TestResumeGraphInternal: @pytest.mark.asyncio async def test_resume_graph_success(self) -> None: from release_agent.api.approvals import _resume_graph mock_graph = AsyncMock() mock_graph.ainvoke = AsyncMock(return_value={"result": "ok"}) mock_pool = MagicMock() mock_conn = AsyncMock() mock_cursor = AsyncMock() mock_cursor.execute = AsyncMock() mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) mock_cursor.__aexit__ = AsyncMock(return_value=False) mock_conn.cursor = MagicMock(return_value=mock_cursor) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) mock_pool.connection = MagicMock(return_value=mock_conn) result = await _resume_graph( graph=mock_graph, thread_id="t1", decision="merge", tool_clients=MagicMock(), db_pool=mock_pool, ) assert result == {"result": "ok"} mock_graph.ainvoke.assert_called_once() # Verify the decision was passed call_args = mock_graph.ainvoke.call_args assert call_args[0][0]["decision"] == "merge" @pytest.mark.asyncio async def test_resume_graph_failure_re_raises(self) -> None: from release_agent.api.approvals import _resume_graph mock_graph = AsyncMock() mock_graph.ainvoke = AsyncMock(side_effect=RuntimeError("resume failed")) mock_pool = MagicMock() mock_conn = AsyncMock() mock_cursor = AsyncMock() mock_cursor.execute = AsyncMock() mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) mock_cursor.__aexit__ = AsyncMock(return_value=False) mock_conn.cursor = MagicMock(return_value=mock_cursor) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) mock_pool.connection = MagicMock(return_value=mock_conn) with pytest.raises(RuntimeError, match="resume failed"): await _resume_graph( graph=mock_graph, thread_id="t1", decision="cancel", tool_clients=MagicMock(), db_pool=mock_pool, ) @pytest.mark.asyncio async def test_resume_graph_upserts_completed_on_success(self) -> None: from release_agent.api.approvals import _resume_graph mock_graph = AsyncMock() mock_graph.ainvoke = AsyncMock(return_value={"messages": ["done"]}) mock_pool = MagicMock() mock_conn = AsyncMock() mock_cursor = AsyncMock() mock_cursor.execute = AsyncMock() mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) mock_cursor.__aexit__ = AsyncMock(return_value=False) mock_conn.cursor = MagicMock(return_value=mock_cursor) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) mock_pool.connection = MagicMock(return_value=mock_conn) await _resume_graph( graph=mock_graph, thread_id="t-success", decision="approve", tool_clients=MagicMock(), db_pool=mock_pool, ) # The last execute call should be "completed" last_call = mock_cursor.execute.call_args_list[-1] assert last_call[0][1][4] == "completed" @pytest.mark.asyncio async def test_resume_graph_upserts_failed_on_exception(self) -> None: from release_agent.api.approvals import _resume_graph mock_graph = AsyncMock() mock_graph.ainvoke = AsyncMock(side_effect=ValueError("bad")) mock_pool = MagicMock() mock_conn = AsyncMock() mock_cursor = AsyncMock() mock_cursor.execute = AsyncMock() mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) mock_cursor.__aexit__ = AsyncMock(return_value=False) mock_conn.cursor = MagicMock(return_value=mock_cursor) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) mock_pool.connection = MagicMock(return_value=mock_conn) with pytest.raises(ValueError): await _resume_graph( graph=mock_graph, thread_id="t-fail", decision="skip", tool_clients=MagicMock(), db_pool=mock_pool, ) last_call = mock_cursor.execute.call_args_list[-1] assert last_call[0][1][4] == "failed" # --------------------------------------------------------------------------- # run_graph_in_background tests (main.py) # --------------------------------------------------------------------------- class TestRunGraphInBackground: @pytest.mark.asyncio async def test_success_with_db_pool(self) -> None: from release_agent.main import run_graph_in_background mock_graph = AsyncMock() mock_graph.ainvoke = AsyncMock(return_value={"done": True}) mock_pool = MagicMock() mock_conn = AsyncMock() mock_cursor = AsyncMock() mock_cursor.execute = AsyncMock() mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) mock_cursor.__aexit__ = AsyncMock(return_value=False) mock_conn.cursor = MagicMock(return_value=mock_cursor) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) mock_pool.connection = MagicMock(return_value=mock_conn) await run_graph_in_background( graph=mock_graph, initial_state={"repo_name": "test"}, thread_id="t-bg", db_pool=mock_pool, ) calls = mock_cursor.execute.call_args_list assert calls[0][0][1][4] == "running" assert calls[1][0][1][4] == "completed" @pytest.mark.asyncio async def test_failure_with_db_pool(self) -> None: from release_agent.main import run_graph_in_background mock_graph = AsyncMock() mock_graph.ainvoke = AsyncMock(side_effect=RuntimeError("bg failed")) mock_pool = MagicMock() mock_conn = AsyncMock() mock_cursor = AsyncMock() mock_cursor.execute = AsyncMock() mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) mock_cursor.__aexit__ = AsyncMock(return_value=False) mock_conn.cursor = MagicMock(return_value=mock_cursor) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) mock_pool.connection = MagicMock(return_value=mock_conn) await run_graph_in_background( graph=mock_graph, initial_state={}, thread_id="t-bg-fail", db_pool=mock_pool, ) last_call = mock_cursor.execute.call_args_list[-1] assert last_call[0][1][4] == "failed" @pytest.mark.asyncio async def test_success_without_db_pool(self) -> None: """run_graph_in_background works even without a db_pool.""" from release_agent.main import run_graph_in_background mock_graph = AsyncMock() mock_graph.ainvoke = AsyncMock(return_value={}) # Should not raise even with no db_pool await run_graph_in_background( graph=mock_graph, initial_state={}, thread_id="t-no-pool", db_pool=None, ) mock_graph.ainvoke.assert_called_once() # --------------------------------------------------------------------------- # Exception handler tests (main.py) # --------------------------------------------------------------------------- class TestExceptionHandlerFunctions: @pytest.mark.asyncio async def test_release_agent_error_handler_returns_500(self) -> None: from release_agent.main import _release_agent_error_handler from release_agent.exceptions import ServiceError request = MagicMock() exc = ServiceError(service="azdo", status_code=503, detail="unavailable") response = await _release_agent_error_handler(request, exc) assert response.status_code == 500 body = json.loads(response.body) assert body["error"] == "ServiceError" assert "unavailable" in body["detail"] @pytest.mark.asyncio async def test_generic_error_handler_returns_500(self) -> None: from release_agent.main import _generic_error_handler request = MagicMock() exc = ValueError("something generic") response = await _generic_error_handler(request, exc) assert response.status_code == 500 body = json.loads(response.body) assert body["error"] == "InternalServerError" assert "An unexpected error occurred" in body["detail"]