- Add app/health.py with HealthChecker and MetricsCollector classes - Implement composite /health endpoint checking DB and HTTP client - Add /metrics endpoint with Prometheus exposition format - Add webhook request metrics (counters, histograms, gauges) - Create app/http_client.py for shared AsyncClient management - Update app/main.py lifespan to init/close HTTP client - Add comprehensive tests in tests/test_health.py This enables proper observability with health checks and metrics for monitoring system status and webhook processing performance.
181 lines
7.1 KiB
Python
181 lines
7.1 KiB
Python
import pytest
|
|
from unittest.mock import patch, AsyncMock, Mock
|
|
from app.health import HealthChecker, MetricsCollector
|
|
|
|
|
|
class TestHealthChecker:
|
|
@pytest.mark.asyncio
|
|
async def test_database_check_success(self):
|
|
"""Test successful database health check."""
|
|
checker = HealthChecker()
|
|
|
|
# Mock successful database operation (SessionLocal returns sync session)
|
|
with patch('app.health.SessionLocal') as mock_session:
|
|
mock_db = Mock()
|
|
mock_session.return_value = mock_db
|
|
|
|
result = await checker.check_database()
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["cached"] is False
|
|
assert "last_check" in result
|
|
mock_db.execute.assert_called_with("SELECT 1")
|
|
mock_db.close.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_database_check_failure(self):
|
|
"""Test failed database health check."""
|
|
checker = HealthChecker()
|
|
|
|
with patch('app.health.SessionLocal') as mock_session:
|
|
mock_db = Mock()
|
|
mock_session.return_value = mock_db
|
|
mock_db.execute.side_effect = Exception("Connection failed")
|
|
|
|
result = await checker.check_database()
|
|
|
|
assert result["status"] == "error"
|
|
assert "Connection failed" in result["error"]
|
|
assert result["cached"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_http_client_check_success(self):
|
|
"""Test successful HTTP client health check."""
|
|
checker = HealthChecker()
|
|
|
|
with patch('app.health.get_http_client') as mock_get_client:
|
|
mock_client = AsyncMock()
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = await checker.check_http_client()
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["cached"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_http_client_check_with_fallback(self):
|
|
"""Test HTTP client check when primary client is None (fallback to httpx)."""
|
|
checker = HealthChecker()
|
|
|
|
with patch('app.health.get_http_client') as mock_get_client, \
|
|
patch('httpx.AsyncClient') as mock_httpx_client:
|
|
|
|
mock_get_client.return_value = None
|
|
mock_client = AsyncMock()
|
|
mock_httpx_client.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_httpx_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
result = await checker.check_http_client()
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["cached"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_overall_health_check_success(self):
|
|
"""Test comprehensive health check when all components are healthy."""
|
|
checker = HealthChecker()
|
|
|
|
with patch.object(checker, 'check_database', new_callable=AsyncMock) as mock_db_check, \
|
|
patch.object(checker, 'check_http_client', new_callable=AsyncMock) as mock_http_check:
|
|
|
|
mock_db_check.return_value = {"status": "ok"}
|
|
mock_http_check.return_value = {"status": "ok"}
|
|
|
|
result = await checker.check_overall()
|
|
|
|
assert result["status"] == "ok"
|
|
assert "timestamp" in result
|
|
assert "checks" in result
|
|
assert result["checks"]["database"]["status"] == "ok"
|
|
assert result["checks"]["http_client"]["status"] == "ok"
|
|
assert result["version"] == "2.2.0"
|
|
assert result["service"] == "webhook-relay"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_overall_health_check_partial_failure(self):
|
|
"""Test comprehensive health check when some components fail."""
|
|
checker = HealthChecker()
|
|
|
|
with patch.object(checker, 'check_database', new_callable=AsyncMock) as mock_db_check, \
|
|
patch.object(checker, 'check_http_client', new_callable=AsyncMock) as mock_http_check:
|
|
|
|
mock_db_check.return_value = {"status": "ok"}
|
|
mock_http_check.return_value = {"status": "error", "error": "Connection timeout"}
|
|
|
|
result = await checker.check_overall()
|
|
|
|
assert result["status"] == "error"
|
|
assert result["checks"]["database"]["status"] == "ok"
|
|
assert result["checks"]["http_client"]["status"] == "error"
|
|
|
|
|
|
class TestMetricsCollector:
|
|
def test_increment_counter(self):
|
|
"""Test counter metric increment."""
|
|
collector = MetricsCollector()
|
|
|
|
collector.increment_counter("test_counter")
|
|
collector.increment_counter("test_counter", 2.0)
|
|
|
|
prometheus_output = collector.get_prometheus_format()
|
|
assert "test_counter 3.0" in prometheus_output
|
|
assert "# TYPE test_counter counter" in prometheus_output
|
|
|
|
def test_increment_counter_with_labels(self):
|
|
"""Test counter metric with labels."""
|
|
collector = MetricsCollector()
|
|
|
|
collector.increment_counter("requests_total", labels={"method": "POST", "status": "200"})
|
|
|
|
prometheus_output = collector.get_prometheus_format()
|
|
assert 'requests_total{method=POST,status=200} 1.0' in prometheus_output
|
|
|
|
def test_set_gauge(self):
|
|
"""Test gauge metric setting."""
|
|
collector = MetricsCollector()
|
|
|
|
collector.set_gauge("memory_usage", 85.5, labels={"unit": "percent"})
|
|
|
|
prometheus_output = collector.get_prometheus_format()
|
|
assert 'memory_usage{unit=percent} 85.5' in prometheus_output
|
|
assert "# TYPE memory_usage gauge" in prometheus_output
|
|
|
|
def test_observe_histogram(self):
|
|
"""Test histogram metric observation."""
|
|
collector = MetricsCollector()
|
|
|
|
collector.observe_histogram("response_time", 0.125, labels={"endpoint": "/health"})
|
|
|
|
prometheus_output = collector.get_prometheus_format()
|
|
assert 'response_time{endpoint=/health} 0.125' in prometheus_output
|
|
assert "# TYPE response_time histogram" in prometheus_output
|
|
|
|
def test_uptime_metric(self):
|
|
"""Test that uptime metric is automatically included."""
|
|
collector = MetricsCollector()
|
|
|
|
prometheus_output = collector.get_prometheus_format()
|
|
assert "# TYPE uptime_seconds gauge" in prometheus_output
|
|
assert "uptime_seconds " in prometheus_output
|
|
|
|
def test_prometheus_format_structure(self):
|
|
"""Test overall Prometheus format structure."""
|
|
collector = MetricsCollector()
|
|
|
|
collector.increment_counter("webhook_requests_total", labels={"status": "success"})
|
|
collector.observe_histogram("webhook_processing_duration_seconds", 0.5)
|
|
|
|
output = collector.get_prometheus_format()
|
|
|
|
# Should contain HELP comments for known metrics
|
|
assert "# HELP webhook_requests_total Total number of webhook requests processed" in output
|
|
assert "# HELP webhook_processing_duration_seconds Time spent processing webhooks" in output
|
|
|
|
# Should have proper TYPE declarations
|
|
assert "# TYPE webhook_requests_total counter" in output
|
|
assert "# TYPE webhook_processing_duration_seconds histogram" in output
|
|
|
|
# Should have metric values
|
|
assert 'webhook_requests_total{status=success} 1.0' in output
|
|
assert 'webhook_processing_duration_seconds 0.5' in output
|