Files
billo-release-agent/tests/api/test_internals.py
Yaojia Wang f5c2733cfb feat: initial commit — Billo Release Agent (LangGraph)
LangGraph-based release automation agent with:
- PR discovery (webhook + polling)
- AI code review via Claude Code CLI (subscription-based)
- Auto-create Jira tickets for PRs without ticket ID
- Jira ticket lifecycle management (code review -> staging -> done)
- CI/CD pipeline trigger, polling, and approval gates
- Slack interactive messages with approval buttons
- Per-repo semantic versioning
- PostgreSQL persistence (threads, staging, releases)
- FastAPI API (webhooks, approvals, status, manual triggers)
- Docker Compose deployment

1069 tests, 95%+ coverage.
2026-03-24 17:38:23 +01:00

447 lines
16 KiB
Python

"""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"]