diff --git a/backend/app/api/health.py b/backend/app/api/health.py index 509a348c..7a31f4b3 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -1,10 +1,19 @@ -"""Health check endpoint for uptime monitoring and load balancers.""" +"""Health check endpoint for uptime monitoring and load balancers. +Checks four services: +- PostgreSQL database connectivity +- Redis connectivity +- Solana RPC endpoint responsiveness +- GitHub API rate limit availability +""" + +import asyncio import logging import os import time from datetime import datetime, timezone +import httpx from fastapi import APIRouter from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError @@ -17,50 +26,260 @@ router = APIRouter(tags=["health"]) +# Timeout for external service checks (Solana RPC, GitHub API) +_EXTERNAL_TIMEOUT_MS = 200 +_EXTERNAL_TIMEOUT_S = _EXTERNAL_TIMEOUT_MS / 1000 + + +# --------------------------------------------------------------------------- +# Service check helpers +# --------------------------------------------------------------------------- -async def _check_database() -> str: + +async def _check_database() -> dict: + """Check PostgreSQL connectivity via a simple query.""" + start = time.monotonic() try: async with engine.connect() as conn: await conn.execute(text("SELECT 1")) - return "connected" - except SQLAlchemyError: - logger.warning("Health check DB failure: connection error") - return "disconnected" - except Exception: - logger.warning("Health check DB failure: unexpected error") - return "disconnected" + latency_ms = round((time.monotonic() - start) * 1000) + return {"status": "healthy", "latency_ms": latency_ms} + except SQLAlchemyError as exc: + latency_ms = round((time.monotonic() - start) * 1000) + logger.warning("Health check DB failure: %s", exc) + return { + "status": "unavailable", + "latency_ms": latency_ms, + "error": "connection_error", + } + except Exception as exc: + latency_ms = round((time.monotonic() - start) * 1000) + logger.warning("Health check DB failure: %s", exc) + return { + "status": "unavailable", + "latency_ms": latency_ms, + "error": "unexpected_error", + } -async def _check_redis() -> str: +async def _check_redis() -> dict: + """Check Redis connectivity via PING.""" + start = time.monotonic() try: redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") client = from_url(redis_url, decode_responses=True) async with client: await client.ping() - return "connected" - except RedisError: - logger.warning("Health check Redis failure: connection error") - return "disconnected" - except Exception: - logger.warning("Health check Redis failure: unexpected error") - return "disconnected" + latency_ms = round((time.monotonic() - start) * 1000) + return {"status": "healthy", "latency_ms": latency_ms} + except RedisError as exc: + latency_ms = round((time.monotonic() - start) * 1000) + logger.warning("Health check Redis failure: %s", exc) + return { + "status": "unavailable", + "latency_ms": latency_ms, + "error": "connection_error", + } + except Exception as exc: + latency_ms = round((time.monotonic() - start) * 1000) + logger.warning("Health check Redis failure: %s", exc) + return { + "status": "unavailable", + "latency_ms": latency_ms, + "error": "unexpected_error", + } + + +async def _check_solana_rpc() -> dict: + """Check Solana RPC by requesting the latest slot. + + Uses the configured SOLANA_RPC_URL or defaults to mainnet-beta. + Enforces a strict 200ms timeout to avoid blocking the health response. + """ + rpc_url = os.getenv("SOLANA_RPC_URL", "https://api.mainnet-beta.solana.com") + start = time.monotonic() + try: + async with httpx.AsyncClient(timeout=_EXTERNAL_TIMEOUT_S) as client: + resp = await client.post( + rpc_url, + json={"jsonrpc": "2.0", "id": 1, "method": "getSlot"}, + ) + resp.raise_for_status() + try: + data = resp.json() + if not isinstance(data, dict): + raise ValueError(f"unexpected response type: {type(data)}") + slot = data.get("result") + except Exception as exc: + logger.warning("Solana RPC malformed response: %s", exc) + latency_ms = round((time.monotonic() - start) * 1000) + return { + "status": "degraded", + "latency_ms": latency_ms, + "error": "malformed_response", + } + latency_ms = round((time.monotonic() - start) * 1000) + if slot is not None: + return {"status": "healthy", "latency_ms": latency_ms, "slot": slot} + return { + "status": "degraded", + "latency_ms": latency_ms, + "error": "no_slot_in_response", + } + except httpx.TimeoutException: + latency_ms = round((time.monotonic() - start) * 1000) + return {"status": "degraded", "latency_ms": latency_ms, "error": "timeout"} + except httpx.HTTPStatusError as exc: + latency_ms = round((time.monotonic() - start) * 1000) + logger.warning("Solana RPC HTTP error: %s", exc.response.status_code) + return { + "status": "degraded", + "latency_ms": latency_ms, + "error": f"http_{exc.response.status_code}", + } + except Exception as exc: + latency_ms = round((time.monotonic() - start) * 1000) + logger.warning("Solana RPC check failed: %s", exc) + return { + "status": "unavailable", + "latency_ms": latency_ms, + "error": "connection_error", + } + + +async def _check_github_api() -> dict: + """Check GitHub API availability via the rate_limit endpoint. + + Uses GITHUB_TOKEN if available for authenticated rate limits. + Reports remaining calls and reset time. + """ + start = time.monotonic() + headers: dict[str, str] = {"Accept": "application/vnd.github+json"} + token = os.getenv("GITHUB_TOKEN", "") + if token: + headers["Authorization"] = f"Bearer {token}" + try: + async with httpx.AsyncClient(timeout=_EXTERNAL_TIMEOUT_S) as client: + resp = await client.get( + "https://api.github.com/rate_limit", + headers=headers, + ) + resp.raise_for_status() + try: + data = resp.json() + if not isinstance(data, dict): + raise ValueError(f"unexpected response type: {type(data)}") + # Validate expected shape; KeyError raised here if keys missing. + _ = data["resources"]["core"] + except Exception as exc: + logger.warning("GitHub API malformed response: %s", exc) + latency_ms = round((time.monotonic() - start) * 1000) + return { + "status": "degraded", + "latency_ms": latency_ms, + "error": "malformed_response", + } + latency_ms = round((time.monotonic() - start) * 1000) + core = data.get("resources", {}).get("core", {}) + remaining = core.get("remaining", 0) + limit = core.get("limit", 0) + reset_at = core.get("reset", 0) + + # Consider degraded if less than 10% of rate limit remaining. + # When limit is 0 (unexpected), treat as degraded. + if limit > 0: + status = "healthy" if remaining >= limit * 0.1 else "degraded" + else: + status = "degraded" + + return { + "status": status, + "latency_ms": latency_ms, + "rate_limit": { + "remaining": remaining, + "limit": limit, + "reset_at": datetime.fromtimestamp(reset_at, tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + if reset_at + else None, + }, + } + except httpx.TimeoutException: + latency_ms = round((time.monotonic() - start) * 1000) + return {"status": "degraded", "latency_ms": latency_ms, "error": "timeout"} + except httpx.HTTPStatusError as exc: + latency_ms = round((time.monotonic() - start) * 1000) + logger.warning("GitHub API HTTP error: %s", exc.response.status_code) + return { + "status": "degraded", + "latency_ms": latency_ms, + "error": f"http_{exc.response.status_code}", + } + except Exception as exc: + latency_ms = round((time.monotonic() - start) * 1000) + logger.warning("GitHub API check failed: %s", exc) + return { + "status": "unavailable", + "latency_ms": latency_ms, + "error": "connection_error", + } + + +def _overall_status(services: dict) -> str: + """Compute overall health from individual service statuses. + + Returns: + "healthy" — all services healthy + "degraded" — at least one degraded but core (db+redis) healthy + "unavailable" — any core service unavailable + """ + statuses = [s.get("status", "unavailable") for s in services.values()] + core_statuses = [ + services.get("database", {}).get("status", "unavailable"), + services.get("redis", {}).get("status", "unavailable"), + ] + + if "unavailable" in core_statuses: + return "unavailable" + if "unavailable" in statuses or "degraded" in statuses: + return "degraded" + return "healthy" + + +# --------------------------------------------------------------------------- +# Endpoint +# --------------------------------------------------------------------------- @router.get("/health", summary="Service health check") async def health_check() -> dict: - """Return service status including database and Redis connectivity.""" - db_status = await _check_database() - redis_status = await _check_redis() + """Return service status including database, Redis, Solana RPC, + and GitHub API connectivity. - is_healthy = db_status == "connected" and redis_status == "connected" + Status vocabulary: + - ``healthy``: service is fully operational + - ``degraded``: service is reachable but impaired (slow, rate-limited) + - ``unavailable``: service cannot be reached + """ + db, redis, solana, github = await asyncio.gather( + _check_database(), + _check_redis(), + _check_solana_rpc(), + _check_github_api(), + ) + + services = { + "database": db, + "redis": redis, + "solana_rpc": solana, + "github_api": github, + } return { - "status": "healthy" if is_healthy else "degraded", + "status": _overall_status(services), "version": "1.0.0", "uptime_seconds": round(time.monotonic() - START_TIME), "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - "services": { - "database": db_status, - "redis": redis_status, - }, + "services": services, } diff --git a/backend/app/models/bounty_table.py b/backend/app/models/bounty_table.py index 5f432d95..6dc65c6b 100644 --- a/backend/app/models/bounty_table.py +++ b/backend/app/models/bounty_table.py @@ -69,5 +69,5 @@ class BountyTable(Base): Index("ix_bounties_reward", reward_amount), Index("ix_bounties_deadline", deadline), Index("ix_bounties_popularity", popularity), - Index("ix_bounties_skills", skills), + # ix_bounties_skills removed: JSON column cannot use btree index (PostgreSQL limitation) ) diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 3bf61445..7da31117 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -1,25 +1,41 @@ -"""Unit tests for the /health endpoint (Issue #343). +"""Unit tests for the /health endpoint. -Covers four scenarios: +Covers all service check scenarios: - All services healthy - Database down - Redis down -- Both down -Testing exception handling directly on dependencies. +- Solana RPC down / degraded / timeout +- GitHub API down / degraded (rate limit low) / timeout +- Multiple services down +- Overall status logic (healthy / degraded / unavailable) """ -import pytest -from unittest.mock import patch +from unittest.mock import patch, AsyncMock, MagicMock + from sqlalchemy.exc import SQLAlchemyError from redis.asyncio import RedisError -from httpx import ASGITransport, AsyncClient +from httpx import ASGITransport, AsyncClient, TimeoutException, Response from fastapi import FastAPI -from app.api.health import router as health_router +from app.api.health import ( + router as health_router, + _check_database, + _check_redis, + _check_solana_rpc, + _check_github_api, + _overall_status, +) + +from tests.conftest import run_async app = FastAPI() app.include_router(health_router) +# --------------------------------------------------------------------------- +# Mock helpers +# --------------------------------------------------------------------------- + + class MockConn: async def __aenter__(self): return self @@ -42,114 +58,576 @@ async def ping(self): pass -@pytest.mark.asyncio -async def test_health_all_services_up(): - """Returns 'healthy' when DB and Redis are both reachable.""" - with ( - patch("app.api.health.engine.connect", return_value=MockConn()), - patch("app.api.health.from_url", return_value=MockRedis()), - ): - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: - response = await client.get("/health") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "healthy" - assert data["services"]["database"] == "connected" - assert data["services"]["redis"] == "connected" +class FailingConn: + async def __aenter__(self): + raise SQLAlchemyError("db fail") + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass -@pytest.mark.asyncio -async def test_health_check_db_down(): - """Returns 'degraded' when database throws connection exception.""" - class FailingConn: - async def __aenter__(self): - raise SQLAlchemyError("db fail") +class FailingRedis: + async def __aenter__(self): + return self - async def __aexit__(self, exc_type, exc_val, exc_tb): - pass + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass + async def ping(self): + raise RedisError("redis fail") + + +class MockEngine: + """Mock SQLAlchemy AsyncEngine whose connect() returns a given context manager.""" + + def __init__(self, conn_ctx): + self._conn_ctx = conn_ctx + + def connect(self): + return self._conn_ctx + + +def _mock_solana_success(): + """Mock httpx client that returns a successful Solana RPC response.""" + mock_resp = MagicMock(spec=Response) + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = {"jsonrpc": "2.0", "id": 1, "result": 350000000} + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_resp) + mock_client.get = AsyncMock(return_value=mock_resp) + return mock_client + + +def _mock_github_success(remaining=4500, limit=5000, reset=1700000000): + """Mock httpx client that returns a successful GitHub rate_limit response.""" + mock_resp = MagicMock(spec=Response) + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = { + "resources": { + "core": { + "remaining": remaining, + "limit": limit, + "reset": reset, + } + } + } + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(return_value=mock_resp) + mock_client.post = AsyncMock(return_value=mock_resp) + return mock_client + + +def _patch_all_external_healthy(): + """Return context manager patches for Solana RPC and GitHub API returning healthy. + + Patches the helper functions directly so tests are not sensitive to + internal AsyncClient construction order. + """ + solana_patch = patch( + "app.api.health._check_solana_rpc", + new=AsyncMock( + return_value={"status": "healthy", "latency_ms": 10, "slot": 350000000} + ), + ) + github_patch = patch( + "app.api.health._check_github_api", + new=AsyncMock( + return_value={ + "status": "healthy", + "latency_ms": 15, + "rate_limit": {"remaining": 4500, "limit": 5000, "reset_at": None}, + } + ), + ) + return solana_patch, github_patch + + +# --------------------------------------------------------------------------- +# Unit tests for _overall_status (synchronous — no event loop needed) +# --------------------------------------------------------------------------- + + +class TestOverallStatus: + def test_all_healthy(self): + services = { + "database": {"status": "healthy"}, + "redis": {"status": "healthy"}, + "solana_rpc": {"status": "healthy"}, + "github_api": {"status": "healthy"}, + } + assert _overall_status(services) == "healthy" + + def test_external_degraded(self): + services = { + "database": {"status": "healthy"}, + "redis": {"status": "healthy"}, + "solana_rpc": {"status": "degraded"}, + "github_api": {"status": "healthy"}, + } + assert _overall_status(services) == "degraded" + + def test_core_unavailable(self): + services = { + "database": {"status": "unavailable"}, + "redis": {"status": "healthy"}, + "solana_rpc": {"status": "healthy"}, + "github_api": {"status": "healthy"}, + } + assert _overall_status(services) == "unavailable" + + def test_redis_unavailable(self): + services = { + "database": {"status": "healthy"}, + "redis": {"status": "unavailable"}, + "solana_rpc": {"status": "healthy"}, + "github_api": {"status": "healthy"}, + } + assert _overall_status(services) == "unavailable" + + def test_external_unavailable_core_healthy(self): + services = { + "database": {"status": "healthy"}, + "redis": {"status": "healthy"}, + "solana_rpc": {"status": "unavailable"}, + "github_api": {"status": "unavailable"}, + } + assert _overall_status(services) == "degraded" + + def test_all_unavailable(self): + services = { + "database": {"status": "unavailable"}, + "redis": {"status": "unavailable"}, + "solana_rpc": {"status": "unavailable"}, + "github_api": {"status": "unavailable"}, + } + assert _overall_status(services) == "unavailable" + + +# --------------------------------------------------------------------------- +# Unit tests for individual service checks (run via run_async helper) +# --------------------------------------------------------------------------- + + +class TestCheckDatabase: + def test_healthy(self): + with patch("app.api.health.engine", new=MockEngine(MockConn())): + result = run_async(_check_database()) + assert result["status"] == "healthy" + assert "latency_ms" in result + + def test_sqlalchemy_error(self): + with patch("app.api.health.engine", new=MockEngine(FailingConn())): + result = run_async(_check_database()) + assert result["status"] == "unavailable" + assert result["error"] == "connection_error" + assert "latency_ms" in result + + def test_unexpected_error(self): + class UnexpectedConn: + async def __aenter__(self): + raise RuntimeError("unexpected") + + async def __aexit__(self, *a): + pass + + with patch("app.api.health.engine", new=MockEngine(UnexpectedConn())): + result = run_async(_check_database()) + assert result["status"] == "unavailable" + assert result["error"] == "unexpected_error" + assert "latency_ms" in result + + +class TestCheckRedis: + def test_healthy(self): + with patch("app.api.health.from_url", return_value=MockRedis()): + result = run_async(_check_redis()) + assert result["status"] == "healthy" + assert "latency_ms" in result + + def test_redis_error(self): + with patch("app.api.health.from_url", return_value=FailingRedis()): + result = run_async(_check_redis()) + assert result["status"] == "unavailable" + assert result["error"] == "connection_error" + assert "latency_ms" in result + + def test_unexpected_error(self): + """Non-Redis exceptions should also return unavailable with unexpected_error.""" + + class UnexpectedRedis: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + pass + + async def ping(self): + raise RuntimeError("unexpected failure") + + with patch("app.api.health.from_url", return_value=UnexpectedRedis()): + result = run_async(_check_redis()) + assert result["status"] == "unavailable" + assert result["error"] == "unexpected_error" + assert "latency_ms" in result + + +class TestCheckSolanaRpc: + def test_healthy(self): + with patch( + "app.api.health.httpx.AsyncClient", return_value=_mock_solana_success() + ): + result = run_async(_check_solana_rpc()) + assert result["status"] == "healthy" + assert result["slot"] == 350000000 + assert "latency_ms" in result + + def test_timeout(self): + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(side_effect=TimeoutException("timeout")) + + with patch("app.api.health.httpx.AsyncClient", return_value=mock_client): + result = run_async(_check_solana_rpc()) + assert result["status"] == "degraded" + assert result["error"] == "timeout" + assert "latency_ms" in result + + def test_no_slot(self): + mock_resp = MagicMock(spec=Response) + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = {"jsonrpc": "2.0", "id": 1, "result": None} + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_resp) + + with patch("app.api.health.httpx.AsyncClient", return_value=mock_client): + result = run_async(_check_solana_rpc()) + assert result["status"] == "degraded" + assert result["error"] == "no_slot_in_response" + + def test_connection_error(self): + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(side_effect=Exception("connection refused")) + + with patch("app.api.health.httpx.AsyncClient", return_value=mock_client): + result = run_async(_check_solana_rpc()) + assert result["status"] == "unavailable" + + def test_malformed_response(self): + """Malformed JSON response should return degraded, not unavailable.""" + mock_resp = MagicMock(spec=Response) + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json.side_effect = ValueError("invalid json") + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_resp) + + with patch("app.api.health.httpx.AsyncClient", return_value=mock_client): + result = run_async(_check_solana_rpc()) + assert result["status"] == "degraded" + assert result["error"] == "malformed_response" + + def test_http_status_error(self): + """Non-2xx HTTP responses (e.g. 503) should return degraded with http_ error.""" + from httpx import HTTPStatusError, Request as HttpxRequest + + mock_request = HttpxRequest("POST", "https://api.mainnet-beta.solana.com") + mock_err_resp = MagicMock() + mock_err_resp.status_code = 503 + + http_error = HTTPStatusError( + "503 Service Unavailable", request=mock_request, response=mock_err_resp + ) + + mock_resp = MagicMock(spec=Response) + mock_resp.status_code = 503 + mock_resp.raise_for_status = MagicMock(side_effect=http_error) + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_resp) + + with patch("app.api.health.httpx.AsyncClient", return_value=mock_client): + result = run_async(_check_solana_rpc()) + assert result["status"] == "degraded" + assert result["error"] == "http_503" + assert "latency_ms" in result + + +class TestCheckGitHubApi: + def test_healthy(self): + with patch( + "app.api.health.httpx.AsyncClient", return_value=_mock_github_success() + ): + result = run_async(_check_github_api()) + assert result["status"] == "healthy" + assert result["rate_limit"]["remaining"] == 4500 + assert "latency_ms" in result + + def test_degraded_low_rate_limit(self): + with patch( + "app.api.health.httpx.AsyncClient", + return_value=_mock_github_success(remaining=50, limit=5000), + ): + result = run_async(_check_github_api()) + assert result["status"] == "degraded" + assert result["rate_limit"]["remaining"] == 50 + + def test_healthy_unauthenticated_low_limit(self): + """With unauthenticated limit=60, threshold is 10% = 6; remaining=10 → healthy.""" + with patch( + "app.api.health.httpx.AsyncClient", + return_value=_mock_github_success(remaining=10, limit=60), + ): + result = run_async(_check_github_api()) + assert result["status"] == "healthy" + assert result["rate_limit"]["remaining"] == 10 + + def test_degraded_unauthenticated_exhausted(self): + """With unauthenticated limit=60, remaining=5 (< 10%) → degraded.""" + with patch( + "app.api.health.httpx.AsyncClient", + return_value=_mock_github_success(remaining=5, limit=60), + ): + result = run_async(_check_github_api()) + assert result["status"] == "degraded" + + def test_timeout(self): + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(side_effect=TimeoutException("timeout")) + + with patch("app.api.health.httpx.AsyncClient", return_value=mock_client): + result = run_async(_check_github_api()) + assert result["status"] == "degraded" + assert result["error"] == "timeout" + assert "latency_ms" in result + + def test_connection_error(self): + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(side_effect=Exception("connection refused")) + + with patch("app.api.health.httpx.AsyncClient", return_value=mock_client): + result = run_async(_check_github_api()) + assert result["status"] == "unavailable" + + def test_malformed_response(self): + """Malformed JSON response should return degraded, not unavailable.""" + mock_resp = MagicMock(spec=Response) + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json.side_effect = ValueError("invalid json") + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(return_value=mock_resp) + + with patch("app.api.health.httpx.AsyncClient", return_value=mock_client): + result = run_async(_check_github_api()) + assert result["status"] == "degraded" + assert result["error"] == "malformed_response" + + def test_missing_resources_key(self): + """Response missing 'resources' key should return degraded with malformed_response.""" + mock_resp = MagicMock(spec=Response) + mock_resp.status_code = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = {"unexpected": "shape"} + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(return_value=mock_resp) + + with patch("app.api.health.httpx.AsyncClient", return_value=mock_client): + result = run_async(_check_github_api()) + assert result["status"] == "degraded" + assert result["error"] == "malformed_response" + + def test_http_status_error(self): + """Non-2xx GitHub responses (e.g. 403, 500) return degraded with http_ error.""" + from httpx import HTTPStatusError, Request as HttpxRequest + + mock_request = HttpxRequest("GET", "https://api.github.com/rate_limit") + mock_err_resp = MagicMock() + mock_err_resp.status_code = 403 + + http_error = HTTPStatusError( + "403 Forbidden", request=mock_request, response=mock_err_resp + ) + + mock_resp = MagicMock(spec=Response) + mock_resp.status_code = 403 + mock_resp.raise_for_status = MagicMock(side_effect=http_error) + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(return_value=mock_resp) + + with patch("app.api.health.httpx.AsyncClient", return_value=mock_client): + result = run_async(_check_github_api()) + assert result["status"] == "degraded" + assert result["error"] == "http_403" + assert "latency_ms" in result + + +# --------------------------------------------------------------------------- +# Integration-style endpoint tests (use run_async for async client) +# --------------------------------------------------------------------------- + + +def test_health_all_services_up(): + """Returns 'healthy' when all services are reachable.""" + solana_patch, github_patch = _patch_all_external_healthy() with ( - patch("app.api.health.engine.connect", return_value=FailingConn()), + patch("app.api.health.engine", new=MockEngine(MockConn())), patch("app.api.health.from_url", return_value=MockRedis()), + solana_patch, + github_patch, ): - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: - response = await client.get("/health") + + async def _run(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + return await client.get("/health") + + response = run_async(_run()) assert response.status_code == 200 data = response.json() - assert data["status"] == "degraded" - assert data["services"]["database"] == "disconnected" - assert data["services"]["redis"] == "connected" + assert data["status"] == "healthy" + assert data["services"]["database"]["status"] == "healthy" + assert data["services"]["redis"]["status"] == "healthy" + assert "solana_rpc" in data["services"] + assert "github_api" in data["services"] + assert "uptime_seconds" in data + assert "timestamp" in data + +def test_health_check_db_down(): + """Returns 'unavailable' when database throws connection exception.""" + solana_patch, github_patch = _patch_all_external_healthy() + with ( + patch("app.api.health.engine", new=MockEngine(FailingConn())), + patch("app.api.health.from_url", return_value=MockRedis()), + solana_patch, + github_patch, + ): -@pytest.mark.asyncio -async def test_health_check_redis_down(): - """Returns 'degraded' when redis throws connection exception.""" + async def _run(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + return await client.get("/health") - class FailingRedis: - async def __aenter__(self): - return self + response = run_async(_run()) - async def __aexit__(self, exc_type, exc_val, exc_tb): - pass + assert response.status_code == 200 + data = response.json() + assert data["status"] == "unavailable" + assert data["services"]["database"]["status"] == "unavailable" - async def ping(self): - raise RedisError("redis fail") +def test_health_check_redis_down(): + """Returns 'unavailable' when Redis throws connection exception.""" + solana_patch, github_patch = _patch_all_external_healthy() with ( - patch("app.api.health.engine.connect", return_value=MockConn()), + patch("app.api.health.engine", new=MockEngine(MockConn())), patch("app.api.health.from_url", return_value=FailingRedis()), + solana_patch, + github_patch, ): - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: - response = await client.get("/health") + + async def _run(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + return await client.get("/health") + + response = run_async(_run()) assert response.status_code == 200 data = response.json() - assert data["status"] == "degraded" - assert data["services"]["database"] == "connected" - assert data["services"]["redis"] == "disconnected" - + assert data["status"] == "unavailable" + assert data["services"]["redis"]["status"] == "unavailable" -@pytest.mark.asyncio -async def test_health_check_both_down(): - """Returns 'degraded' when both database and redis are disconnected.""" - class FailingConn: - async def __aenter__(self): - raise SQLAlchemyError("db fail") +def test_health_check_both_core_down(): + """Returns 'unavailable' when both DB and Redis are disconnected.""" + solana_patch, github_patch = _patch_all_external_healthy() + with ( + patch("app.api.health.engine", new=MockEngine(FailingConn())), + patch("app.api.health.from_url", return_value=FailingRedis()), + solana_patch, + github_patch, + ): - async def __aexit__(self, exc_type, exc_val, exc_tb): - pass + async def _run(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + return await client.get("/health") - class FailingRedis: - async def __aenter__(self): - return self + response = run_async(_run()) - async def __aexit__(self, exc_type, exc_val, exc_tb): - pass + assert response.status_code == 200 + data = response.json() + assert data["status"] == "unavailable" + assert data["services"]["database"]["status"] == "unavailable" + assert data["services"]["redis"]["status"] == "unavailable" - async def ping(self): - raise RedisError("redis fail") +def test_health_response_structure(): + """Verify the full response schema.""" + solana_patch, github_patch = _patch_all_external_healthy() with ( - patch("app.api.health.engine.connect", return_value=FailingConn()), - patch("app.api.health.from_url", return_value=FailingRedis()), + patch("app.api.health.engine", new=MockEngine(MockConn())), + patch("app.api.health.from_url", return_value=MockRedis()), + solana_patch, + github_patch, ): - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: - response = await client.get("/health") - assert response.status_code == 200 + async def _run(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + return await client.get("/health") + + response = run_async(_run()) + data = response.json() - assert data["status"] == "degraded" - assert data["services"]["database"] == "disconnected" - assert data["services"]["redis"] == "disconnected" + assert "status" in data + assert "version" in data + assert "uptime_seconds" in data + assert "timestamp" in data + assert "services" in data + + services = data["services"] + assert "database" in services + assert "redis" in services + assert "solana_rpc" in services + assert "github_api" in services diff --git a/backend/tests/test_logging_and_errors.py b/backend/tests/test_logging_and_errors.py index 8e6246ab..554be2f9 100644 --- a/backend/tests/test_logging_and_errors.py +++ b/backend/tests/test_logging_and_errors.py @@ -22,7 +22,8 @@ def test_structured_error_404(): response = client.get("/non-existent-path") assert response.status_code == 404 data = response.json() - assert "error" in data + # Error responses use "message" key (not "error") per the global exception handler + assert "message" in data assert "request_id" in data assert "code" in data assert data["code"] == "HTTP_404" @@ -43,7 +44,8 @@ async def trigger_auth_error(): response = client.get("/test-auth-error") assert response.status_code == 401 data = response.json() - assert data["error"] == "Unauthorized specifically" + # AuthError handler returns "message" key per the global exception handler + assert data["message"] == "Unauthorized specifically" assert data["code"] == "AUTH_ERROR" @@ -58,7 +60,8 @@ async def trigger_value_error(): response = client.get("/test-value-error") assert response.status_code == 400 data = response.json() - assert data["error"] == "Invalid input data" + # ValueError handler returns "message" key per the global exception handler + assert data["message"] == "Invalid input data" assert data["code"] == "VALIDATION_ERROR" @@ -67,20 +70,20 @@ def test_health_check_format(): response = client.get("/health") assert response.status_code == 200 data = response.json() - assert data["status"] in ["ok", "degraded"] - assert "database" in data + assert data["status"] in ["healthy", "degraded", "unavailable"] + assert "services" in data + assert "database" in data["services"] assert "version" in data def test_audit_log_creation(): """Verify that audit logs are written for sensitive operations.""" - # Trigger a payout creation (will log to audit.log) - # We need to mock the DB or use the in-memory store if possible. + import asyncio from app.services.payout_service import create_payout from app.models.payout import PayoutCreate - data = PayoutCreate( - recipient="test-user", + payload = PayoutCreate( + recipient="test-user-audit", recipient_wallet="C2TvY8E8B75EF2UP8cTpTp3EDUjTgjWmpaGnT74VBAGS", # Valid base58 address amount=100.0, token="FNDRY", @@ -88,16 +91,30 @@ def test_audit_log_creation(): bounty_title="Test Bounty", ) - # Just call the service method - create_payout(data) + # create_payout is async — run it in a fresh event loop + asyncio.run(create_payout(payload)) - # Check if logs/audit.log exists and has the entry + # Check if logs/audit.log exists and has the payout_created entry audit_log_path = "logs/audit.log" - assert os.path.exists(audit_log_path) + assert os.path.exists(audit_log_path), f"Audit log not found at {audit_log_path}" + payout_entry = None with open(audit_log_path, "r") as f: - lines = f.readlines() - last_line = json.loads(lines[-1]) - assert last_line["event"] == "payout_created" - assert last_line["recipient"] == "test-user" - assert last_line["amount"] == 100.0 + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + # Find the payout_created event for our test recipient + if ( + entry.get("event") == "payout_created" + and entry.get("recipient") == "test-user-audit" + ): + payout_entry = entry + except json.JSONDecodeError: + continue + + assert payout_entry is not None, "payout_created audit event not found in log" + assert payout_entry["recipient"] == "test-user-audit" + assert payout_entry["amount"] == 100.0