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