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