"""Integration tests for the /api/v1/analytics endpoint. Tests the full API layer (routing, parameter validation, serialization, error handling) with a mocked database pool. """ from __future__ import annotations from dataclasses import asdict from unittest.mock import AsyncMock, MagicMock, patch import pytest from httpx import ASGITransport, AsyncClient from app.analytics.models import AnalyticsResult, InterruptStats pytestmark = pytest.mark.integration _SAMPLE_RESULT = AnalyticsResult( range="7d", total_conversations=42, resolution_rate=0.85, escalation_rate=0.05, avg_turns_per_conversation=3.2, avg_cost_per_conversation_usd=0.012, agent_usage=(), interrupt_stats=InterruptStats(total=10, approved=7, rejected=2, expired=1), ) def _build_app(): """Build a minimal FastAPI app with the analytics router and mocked deps.""" from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from app.analytics.api import router as analytics_router from app.api_utils import envelope test_app = FastAPI() test_app.include_router(analytics_router) @test_app.exception_handler(Exception) async def _catch_all(request, exc): return JSONResponse( status_code=500, content=envelope(None, success=False, error="Internal server error"), ) from fastapi import HTTPException @test_app.exception_handler(HTTPException) async def _http_exc(request, exc): return JSONResponse( status_code=exc.status_code, content=envelope(None, success=False, error=exc.detail), ) @test_app.exception_handler(RequestValidationError) async def _validation_exc(request, exc): return JSONResponse( status_code=422, content=envelope(None, success=False, error=str(exc)), ) # No admin_api_key set -> auth is skipped test_app.state.settings = MagicMock(admin_api_key="") test_app.state.pool = MagicMock() return test_app class TestAnalyticsValidRange: """Test analytics endpoint with valid range parameters.""" async def test_valid_range_7d_returns_envelope(self) -> None: """GET /api/v1/analytics?range=7d returns success envelope with data.""" test_app = _build_app() with patch( "app.analytics.api.get_analytics", new_callable=AsyncMock, return_value=_SAMPLE_RESULT, ): async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test" ) as client: resp = await client.get("/api/v1/analytics", params={"range": "7d"}) assert resp.status_code == 200 body = resp.json() assert body["success"] is True assert body["error"] is None assert body["data"]["total_conversations"] == 42 assert body["data"]["resolution_rate"] == 0.85 async def test_default_range_returns_success(self) -> None: """GET /api/v1/analytics with no range param defaults to 7d.""" test_app = _build_app() with patch( "app.analytics.api.get_analytics", new_callable=AsyncMock, return_value=_SAMPLE_RESULT, ) as mock_get: async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test" ) as client: resp = await client.get("/api/v1/analytics") assert resp.status_code == 200 # Verify default range of 7 days was passed mock_get.assert_called_once() call_args = mock_get.call_args assert call_args[1].get("range_days", call_args[0][1] if len(call_args[0]) > 1 else None) in (7, None) or call_args[0][1] == 7 async def test_large_range_365d_works(self) -> None: """GET /api/v1/analytics?range=365d is accepted (max boundary).""" test_app = _build_app() result = AnalyticsResult( range="365d", total_conversations=1000, resolution_rate=0.9, escalation_rate=0.02, avg_turns_per_conversation=4.0, avg_cost_per_conversation_usd=0.01, agent_usage=(), interrupt_stats=InterruptStats(), ) with patch( "app.analytics.api.get_analytics", new_callable=AsyncMock, return_value=result, ): async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test" ) as client: resp = await client.get("/api/v1/analytics", params={"range": "365d"}) assert resp.status_code == 200 assert resp.json()["success"] is True class TestAnalyticsInvalidRange: """Test analytics endpoint with invalid range parameters.""" async def test_invalid_range_format_returns_400(self) -> None: """GET /api/v1/analytics?range=abc returns 400 error envelope.""" test_app = _build_app() async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test" ) as client: resp = await client.get("/api/v1/analytics", params={"range": "abc"}) assert resp.status_code == 400 body = resp.json() assert body["success"] is False assert body["data"] is None assert "Invalid range format" in body["error"] async def test_zero_day_range_returns_400(self) -> None: """GET /api/v1/analytics?range=0d returns 400 because 0 is below minimum.""" test_app = _build_app() async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test" ) as client: resp = await client.get("/api/v1/analytics", params={"range": "0d"}) assert resp.status_code == 400 body = resp.json() assert body["success"] is False assert "between 1 and 365" in body["error"] async def test_range_exceeding_max_returns_400(self) -> None: """GET /api/v1/analytics?range=999d returns 400 because it exceeds 365.""" test_app = _build_app() async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test" ) as client: resp = await client.get("/api/v1/analytics", params={"range": "999d"}) assert resp.status_code == 400 body = resp.json() assert body["success"] is False assert "between 1 and 365" in body["error"]