Files

235 lines
9.7 KiB
Python
Raw Permalink Normal View History

"""Monitoring tests — AC1-6: Health checks, Prometheus metrics, structured logging."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from app.core.monitoring import (
check_database,
check_redis,
check_storage,
check_worker,
generate_metrics,
record_arq_job,
record_error,
record_request,
)
@pytest.mark.asyncio
class TestHealthChecks:
"""AC1-2: Health endpoint with DB, Redis, storage, worker checks."""
async def test_health_returns_200_with_checks_structure(self, client: AsyncClient):
"""AC1: GET /api/v1/health → 200 + JSON with status, checks.database, checks.redis,
checks.storage, checks.worker."""
resp = await client.get("/api/v1/health")
assert resp.status_code == 200
data = resp.json()
assert "status" in data
assert "checks" in data
assert "database" in data["checks"]
assert "redis" in data["checks"]
assert "storage" in data["checks"]
assert "worker" in data["checks"]
# Each check should have a status field
for check_name in ["database", "redis", "storage", "worker"]:
assert "status" in data["checks"][check_name]
async def test_health_db_down_returns_degraded(self, client: AsyncClient):
"""AC2: GET /api/v1/health with DB down → 200 + status=degraded,
checks.database.status=down."""
with patch("app.routes.health.get_health_status") as mock_health:
mock_health.return_value = {
"status": "degraded",
"version": "1.0.0",
"checks": {
"database": {"status": "down", "error": "Connection refused"},
"redis": {"status": "up"},
"storage": {"status": "up"},
"worker": {"status": "up"},
},
}
resp = await client.get("/api/v1/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "degraded"
assert data["checks"]["database"]["status"] == "down"
async def test_health_no_auth_required(self, client: AsyncClient):
"""Health endpoint should work without authentication."""
resp = await client.get("/api/v1/health")
assert resp.status_code == 200
async def test_check_database_returns_dict(self, client: AsyncClient):
"""check_database returns a dict with status field."""
result = await check_database()
assert isinstance(result, dict)
assert "status" in result
async def test_check_redis_returns_dict(self, client: AsyncClient):
"""check_redis returns a dict with status field."""
result = await check_redis()
assert isinstance(result, dict)
assert "status" in result
async def test_check_storage_returns_dict(self, client: AsyncClient):
"""check_storage returns a dict with status field."""
result = await check_storage()
assert isinstance(result, dict)
assert "status" in result
async def test_check_worker_returns_dict(self, client: AsyncClient):
"""check_worker returns a dict with status field."""
result = await check_worker()
assert isinstance(result, dict)
assert "status" in result
@pytest.mark.asyncio
class TestMetricsEndpoint:
"""AC3-4: Prometheus metrics endpoint (admin-only)."""
async def test_metrics_admin_returns_200_text_plain(self, client: AsyncClient, db_session):
"""AC3: GET /api/v1/metrics → 200 + text/plain Prometheus format (admin only)."""
from tests.conftest import login_client, seed_tenant_and_users
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get("/api/v1/metrics")
assert resp.status_code == 200
assert "text/plain" in resp.headers.get("content-type", "")
async def test_metrics_non_admin_returns_403(self, client: AsyncClient, db_session):
"""AC3: GET /api/v1/metrics → 403 for non-admin users."""
from tests.conftest import login_client, seed_tenant_and_users
await seed_tenant_and_users(db_session)
await login_client(client, "viewer@tenanta.com")
resp = await client.get("/api/v1/metrics")
assert resp.status_code == 403
async def test_metrics_unauthenticated_returns_401(self, client: AsyncClient):
"""Metrics endpoint requires authentication — 401 without auth."""
resp = await client.get("/api/v1/metrics")
assert resp.status_code == 401
async def test_metrics_include_required_metric_names(self, client: AsyncClient, db_session):
"""AC4: Prometheus metrics include leocrm_http_requests_total, leocrm_db_pool_connections,
leocrm_arq_jobs_total."""
from tests.conftest import login_client, seed_tenant_and_users
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
# Make a request first to generate some metrics
await client.get("/api/v1/health")
resp = await client.get("/api/v1/metrics")
assert resp.status_code == 200
text = resp.text
assert "leocrm_http_requests_total" in text
assert "leocrm_db_pool_connections" in text
assert "leocrm_arq_jobs_total" in text
def test_generate_metrics_returns_bytes(self):
"""generate_metrics() returns bytes in Prometheus format."""
data = generate_metrics()
assert isinstance(data, bytes)
text = data.decode("utf-8")
assert "leocrm_" in text
class TestStructuredLogging:
"""AC5-6: Structured JSON logging and error logging."""
def test_record_request_increments_counter(self):
"""AC5: record_request increments http_requests_total counter and logs JSON entry."""
with patch("app.core.monitoring.logger") as mock_logger:
record_request(
method="GET",
path="/api/v1/health",
status_code=200,
duration_ms=12.5,
tenant_id="test-tenant-id",
)
mock_logger.info.assert_called_once()
call_args = mock_logger.info.call_args
assert call_args[1]["method"] == "GET"
assert call_args[1]["path"] == "/api/v1/health"
assert call_args[1]["status"] == 200
assert call_args[1]["duration_ms"] == 12.5
assert call_args[1]["tenant_id"] == "test-tenant-id"
def test_record_request_log_has_required_fields(self):
"""AC5: Structured JSON log entry has: timestamp, level, event, method, path, status,
duration_ms, tenant_id."""
with patch("app.core.monitoring.logger") as mock_logger:
record_request(
method="POST",
path="/api/v1/contacts",
status_code=201,
duration_ms=45.3,
tenant_id="tenant-123",
)
call_args = mock_logger.info.call_args
logged_data = call_args[1]
# event is passed as positional arg by structlog
assert call_args[0][0] == "api_request"
# Check all required fields are present in kwargs
logged_data = call_args[1]
assert "method" in logged_data
assert "path" in logged_data
assert "status" in logged_data
assert "duration_ms" in logged_data
assert "tenant_id" in logged_data
def test_record_error_logs_stacktrace_and_context(self):
"""AC6: Error log includes stacktrace and request context."""
with patch("app.core.monitoring.logger") as mock_logger:
tb = "Traceback (most recent call last):\n File 'test.py', line 1\n raise ValueError('test')\n"
record_error(
event="unhandled_exception",
method="GET",
path="/api/v1/contacts/123",
status_code=500,
error="ValueError: test error",
traceback_str=tb,
tenant_id="tenant-456",
)
mock_logger.error.assert_called_once()
call_args = mock_logger.error.call_args
logged_data = call_args[1]
# event is passed as keyword arg in structlog
assert call_args[1]["event"] == "unhandled_exception"
assert logged_data["error"] == "ValueError: test error"
assert logged_data["traceback"] == tb
assert logged_data["method"] == "GET"
assert logged_data["path"] == "/api/v1/contacts/123"
assert logged_data["status"] == 500
assert logged_data["tenant_id"] == "tenant-456"
def test_record_error_without_traceback(self):
"""Error logging works even without traceback."""
with patch("app.core.monitoring.logger") as mock_logger:
record_error(
event="db_error",
method="GET",
path="/api/v1/health",
status_code=503,
error="Connection refused",
)
mock_logger.error.assert_called_once()
call_args = mock_logger.error.call_args
assert call_args[1]["error"] == "Connection refused"
assert call_args[1]["traceback"] is None
def test_record_arq_job_increments_counter(self):
"""record_arq_job increments the arq_jobs_total counter."""
with patch("app.core.monitoring.arq_jobs_total") as mock_counter:
record_arq_job("export_contacts", "completed")
mock_counter.labels.assert_called_once_with(
function="export_contacts", status="completed"
)