From d35efae12e22e0889a9acc2923ca5b98ce0a1582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A8=B8=EB=8B=88=ED=8E=98=EB=8B=88?= Date: Fri, 20 Mar 2026 18:24:07 +0900 Subject: [PATCH] feat: add health check endpoints for Gitea, Discord, and queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new monitoring endpoints: - GET /health/gitea — Verifies Gitea API connectivity - GET /health/discord — Reports Discord bot connection status - GET /health/queue — Returns pending task count All endpoints return JSON with status field. The Gitea endpoint includes the API status code on success or error message on failure. Discord endpoint returns "not_configured", "connecting", or "ok" with bot username. Queue endpoint includes pending_tasks count. Tests use mock lifespan to avoid initializing task queue, message store, dispatcher, and discord handler during testing. --- agent/webapp.py | 36 ++++++++++ tests/test_health.py | 155 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 tests/test_health.py diff --git a/agent/webapp.py b/agent/webapp.py index 5f6e646..dd96ef1 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -134,6 +134,42 @@ async def health(): return {"status": "ok"} +@app.get("/health/gitea") +async def health_gitea(): + """Gitea 연결 상태를 확인한다.""" + try: + from agent.utils.gitea_client import get_gitea_client + client = get_gitea_client() + resp = await client._client.get("/settings/api") + return {"status": "ok", "gitea_status_code": resp.status_code} + except Exception as e: + return {"status": "error", "error": str(e)} + + +@app.get("/health/discord") +async def health_discord(request: Request): + """Discord 봇 연결 상태를 확인한다.""" + handler = getattr(request.app.state, "discord_handler", None) + if not handler: + return {"status": "not_configured"} + bot = handler.bot + if bot.is_ready(): + return {"status": "ok", "user": str(bot.user)} + return {"status": "connecting"} + + +@app.get("/health/queue") +async def health_queue(): + """작업 큐 상태를 반환한다.""" + from agent.task_queue import get_task_queue + task_queue = await get_task_queue() + pending = await task_queue.get_pending() + return { + "status": "ok", + "pending_tasks": len(pending), + } + + @app.post("/webhooks/gitea") @limiter.limit("10/minute") async def gitea_webhook(request: Request): diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..92d214e --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,155 @@ +"""Health check 엔드포인트 테스트.""" +import pytest +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx + + +@asynccontextmanager +async def mock_lifespan(app): + """테스트용 mock lifespan.""" + yield + + +@pytest.mark.asyncio +async def test_health_basic(): + """기본 health check가 200을 반환한다.""" + from agent.webapp import app + # Override lifespan for testing + app.router.lifespan_context = mock_lifespan + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + + +@pytest.mark.asyncio +async def test_health_queue(): + """큐 health check가 pending 카운트를 반환한다.""" + from agent.webapp import app + app.router.lifespan_context = mock_lifespan + + mock_queue = MagicMock() + mock_queue.get_pending = AsyncMock(return_value=[{"id": "1"}, {"id": "2"}]) + + with patch("agent.task_queue.get_task_queue", new_callable=AsyncMock, return_value=mock_queue): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health/queue") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["pending_tasks"] == 2 + + +@pytest.mark.asyncio +async def test_health_queue_empty(): + """큐가 비어있을 때 0을 반환한다.""" + from agent.webapp import app + app.router.lifespan_context = mock_lifespan + + mock_queue = MagicMock() + mock_queue.get_pending = AsyncMock(return_value=[]) + + with patch("agent.task_queue.get_task_queue", new_callable=AsyncMock, return_value=mock_queue): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health/queue") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["pending_tasks"] == 0 + + +@pytest.mark.asyncio +async def test_health_discord_not_configured(): + """Discord가 설정되지 않았을 때.""" + from agent.webapp import app + app.router.lifespan_context = mock_lifespan + # Ensure no discord_handler on app.state + if hasattr(app.state, "discord_handler"): + delattr(app.state, "discord_handler") + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health/discord") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "not_configured" + + +@pytest.mark.asyncio +async def test_health_discord_ready(): + """Discord 봇이 준비되었을 때.""" + from agent.webapp import app + app.router.lifespan_context = mock_lifespan + + mock_bot = MagicMock() + mock_bot.is_ready.return_value = True + mock_bot.user = "TestBot#1234" + + mock_handler = MagicMock() + mock_handler.bot = mock_bot + app.state.discord_handler = mock_handler + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health/discord") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["user"] == "TestBot#1234" + + +@pytest.mark.asyncio +async def test_health_discord_connecting(): + """Discord 봇이 연결 중일 때.""" + from agent.webapp import app + app.router.lifespan_context = mock_lifespan + + mock_bot = MagicMock() + mock_bot.is_ready.return_value = False + + mock_handler = MagicMock() + mock_handler.bot = mock_bot + app.state.discord_handler = mock_handler + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health/discord") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "connecting" + + +@pytest.mark.asyncio +async def test_health_gitea_ok(): + """Gitea API 연결이 성공할 때.""" + from agent.webapp import app + app.router.lifespan_context = mock_lifespan + + mock_response = MagicMock() + mock_response.status_code = 200 + + mock_client = MagicMock() + mock_client._client.get = AsyncMock(return_value=mock_response) + + with patch("agent.utils.gitea_client.get_gitea_client", return_value=mock_client): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health/gitea") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["gitea_status_code"] == 200 + + +@pytest.mark.asyncio +async def test_health_gitea_error(): + """Gitea API 연결이 실패할 때.""" + from agent.webapp import app + app.router.lifespan_context = mock_lifespan + + with patch("agent.utils.gitea_client.get_gitea_client", side_effect=Exception("Connection failed")): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health/gitea") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "error" + assert "Connection failed" in data["error"]