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.
This commit is contained in:
270
tests/api/test_status.py
Normal file
270
tests/api/test_status.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Tests for status, releases, staging, and manual trigger endpoints.
|
||||
Written FIRST (TDD RED phase).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from release_agent.api.status import router as status_router
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_test_app(
|
||||
*,
|
||||
versions: list[str] | None = None,
|
||||
staging_data: dict | None = None,
|
||||
) -> FastAPI:
|
||||
"""Return a FastAPI app with mocked state for status tests."""
|
||||
app = FastAPI()
|
||||
app.include_router(status_router)
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.operator_token.get_secret_value.return_value = ""
|
||||
mock_graphs = {
|
||||
"pr_completed": MagicMock(),
|
||||
"release": MagicMock(),
|
||||
}
|
||||
mock_clients = MagicMock()
|
||||
|
||||
mock_staging_store = MagicMock()
|
||||
mock_staging_store.list_versions = AsyncMock(return_value=versions or [])
|
||||
|
||||
# staging store returns StagingRelease-like or None
|
||||
if staging_data is not None:
|
||||
mock_staging_obj = MagicMock()
|
||||
mock_staging_obj.model_dump = MagicMock(return_value=staging_data)
|
||||
mock_staging_store.load = AsyncMock(return_value=mock_staging_obj)
|
||||
else:
|
||||
mock_staging_store.load = AsyncMock(return_value=None)
|
||||
|
||||
mock_pool = MagicMock()
|
||||
|
||||
app.state.settings = mock_settings
|
||||
app.state.graphs = mock_graphs
|
||||
app.state.tool_clients = mock_clients
|
||||
app.state.staging_store = mock_staging_store
|
||||
app.state.db_pool = mock_pool
|
||||
app.state.background_tasks = set()
|
||||
app.state.started_at = datetime.now(tz=timezone.utc)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetStatus:
|
||||
def test_returns_200(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/status")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_response_has_status_field(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/status")
|
||||
data = response.json()
|
||||
assert "status" in data
|
||||
assert data["status"] in ("ok", "degraded")
|
||||
|
||||
def test_response_has_version_field(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/status")
|
||||
data = response.json()
|
||||
assert "version" in data
|
||||
assert isinstance(data["version"], str)
|
||||
|
||||
def test_response_has_uptime_seconds(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/status")
|
||||
data = response.json()
|
||||
assert "uptime_seconds" in data
|
||||
assert data["uptime_seconds"] >= 0.0
|
||||
|
||||
def test_status_is_ok_when_healthy(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/status")
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /releases/{repo}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetReleaseVersions:
|
||||
def test_returns_200(self) -> None:
|
||||
app = _make_test_app(versions=["v1.0.0", "v1.1.0"])
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/releases/my-repo")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_response_has_repo_and_versions(self) -> None:
|
||||
app = _make_test_app(versions=["v1.0.0", "v1.1.0"])
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/releases/my-repo")
|
||||
data = response.json()
|
||||
assert data["repo"] == "my-repo"
|
||||
assert data["versions"] == ["v1.0.0", "v1.1.0"]
|
||||
|
||||
def test_empty_versions_list(self) -> None:
|
||||
app = _make_test_app(versions=[])
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/releases/unknown-repo")
|
||||
data = response.json()
|
||||
assert data["versions"] == []
|
||||
|
||||
def test_repo_name_in_path_used(self) -> None:
|
||||
mock_staging_store = MagicMock()
|
||||
mock_staging_store.list_versions = AsyncMock(return_value=[])
|
||||
app = _make_test_app()
|
||||
app.state.staging_store = mock_staging_store
|
||||
|
||||
with TestClient(app) as client:
|
||||
client.get("/releases/specific-repo")
|
||||
|
||||
mock_staging_store.list_versions.assert_called_once_with("specific-repo")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /staging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetStaging:
|
||||
def test_returns_200_with_staging(self) -> None:
|
||||
staging_data = {"version": "v1.0.0", "repo": "my-repo", "tickets": []}
|
||||
app = _make_test_app(staging_data=staging_data)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/staging?repo=my-repo")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_response_has_repo_and_staging(self) -> None:
|
||||
staging_data = {"version": "v1.0.0", "repo": "my-repo", "tickets": []}
|
||||
app = _make_test_app(staging_data=staging_data)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/staging?repo=my-repo")
|
||||
data = response.json()
|
||||
assert data["repo"] == "my-repo"
|
||||
assert data["staging"] is not None
|
||||
assert data["staging"]["version"] == "v1.0.0"
|
||||
|
||||
def test_returns_null_staging_when_not_found(self) -> None:
|
||||
app = _make_test_app(staging_data=None)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/staging?repo=no-staging-repo")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["staging"] is None
|
||||
|
||||
def test_missing_repo_query_returns_422(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/staging")
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /manual/pr/{pr_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestManualPrTrigger:
|
||||
def test_returns_202(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch(
|
||||
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
|
||||
):
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/manual/pr/42")
|
||||
assert response.status_code == 202
|
||||
|
||||
def test_response_has_thread_id(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch(
|
||||
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
|
||||
):
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/manual/pr/42")
|
||||
data = response.json()
|
||||
assert "thread_id" in data
|
||||
assert isinstance(data["thread_id"], str)
|
||||
|
||||
def test_response_has_message(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch(
|
||||
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
|
||||
):
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/manual/pr/42")
|
||||
assert "message" in response.json()
|
||||
|
||||
def test_schedules_background_task(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch(
|
||||
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
|
||||
) as mock_create:
|
||||
with TestClient(app) as client:
|
||||
client.post("/manual/pr/99")
|
||||
mock_create.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /manual/release
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestManualReleaseTrigger:
|
||||
def test_returns_202(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch(
|
||||
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
|
||||
):
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/manual/release",
|
||||
json={"repo": "my-repo"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
|
||||
def test_response_has_thread_id(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch(
|
||||
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
|
||||
):
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/manual/release",
|
||||
json={"repo": "my-repo"},
|
||||
)
|
||||
data = response.json()
|
||||
assert "thread_id" in data
|
||||
|
||||
def test_missing_repo_returns_422(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/manual/release",
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_schedules_background_task(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch(
|
||||
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
|
||||
) as mock_create:
|
||||
with TestClient(app) as client:
|
||||
client.post(
|
||||
"/manual/release",
|
||||
json={"repo": "my-repo"},
|
||||
)
|
||||
mock_create.assert_called_once()
|
||||
Reference in New Issue
Block a user