Files
billo-release-agent/tests/api/test_status.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

271 lines
9.5 KiB
Python

"""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()