- 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
184 lines
6.4 KiB
Python
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"]
|