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.
271 lines
9.5 KiB
Python
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()
|