Files
smart-support/backend/tests/integration/test_analytics_api.py
Yaojia Wang f0699436c5 refactor: engineering improvements -- API versioning, structured logging, Alembic, error standardization, test coverage
- API versioning: all REST endpoints prefixed with /api/v1/
- Structured logging: replaced stdlib logging with structlog (console/JSON modes)
- Alembic migrations: versioned DB schema with initial migration
- Error standardization: global exception handlers for consistent envelope format
- Interrupt cleanup: asyncio background task for expired interrupt removal
- Integration tests: +30 tests (analytics, replay, openapi, error, session APIs)
- Frontend tests: +57 tests (all components, pages, useWebSocket hook)
- Backend: 557 tests, 89.75% coverage | Frontend: 80 tests, 16 test files
2026-04-06 23:19:29 +02:00

184 lines
6.4 KiB
Python

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