- Replay models: StepType enum, ReplayStep, ReplayPage frozen dataclasses
- Checkpoint transformer: PostgresSaver JSONB -> structured timeline steps
- Replay API: GET /api/conversations (paginated), GET /api/replay/{thread_id}
- Analytics models: AgentUsage, InterruptStats, AnalyticsResult
- Analytics event recorder: Protocol + PostgresAnalyticsRecorder + NoOp
- Analytics queries: resolution_rate, agent_usage, escalation_rate, cost, interrupts
- Analytics API: GET /api/analytics?range=Xd with envelope response
- DB migration: analytics_events table + conversations column additions
- 74 new tests, 399 total passing, 92.87% coverage
150 lines
4.6 KiB
Python
150 lines
4.6 KiB
Python
"""Unit tests for app.analytics.api."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
def _build_app() -> FastAPI:
|
|
from app.analytics.api import router
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
return app
|
|
|
|
|
|
def _make_mock_pool() -> MagicMock:
|
|
mock_conn = AsyncMock()
|
|
mock_ctx = AsyncMock()
|
|
mock_ctx.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_ctx.__aexit__ = AsyncMock(return_value=None)
|
|
mock_pool = MagicMock()
|
|
mock_pool.connection.return_value = mock_ctx
|
|
return mock_pool
|
|
|
|
|
|
def _make_analytics_result() -> object:
|
|
from app.analytics.models import AgentUsage, AnalyticsResult, InterruptStats
|
|
|
|
return AnalyticsResult(
|
|
range="7d",
|
|
total_conversations=50,
|
|
resolution_rate=0.8,
|
|
escalation_rate=0.1,
|
|
avg_turns_per_conversation=3.5,
|
|
avg_cost_per_conversation_usd=0.02,
|
|
agent_usage=(AgentUsage(agent="order_agent", count=30, percentage=60.0),),
|
|
interrupt_stats=InterruptStats(total=5, approved=4, rejected=1, expired=0),
|
|
)
|
|
|
|
|
|
def _get_analytics(app: FastAPI, path: str = "/api/analytics", **patch_kwargs: object) -> object:
|
|
"""Helper: patch get_analytics, make request, return (response, mock)."""
|
|
analytics_result = _make_analytics_result()
|
|
with (
|
|
patch("app.analytics.api.get_analytics", return_value=analytics_result) as mock_ga,
|
|
TestClient(app) as client,
|
|
):
|
|
resp = client.get(path)
|
|
return resp, mock_ga
|
|
|
|
|
|
class TestAnalyticsEndpoint:
|
|
def test_returns_200_with_default_range(self) -> None:
|
|
app = _build_app()
|
|
app.state.pool = _make_mock_pool()
|
|
resp, _ = _get_analytics(app)
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["success"] is True
|
|
assert body["error"] is None
|
|
assert body["data"]["range"] == "7d"
|
|
|
|
def test_returns_correct_analytics_structure(self) -> None:
|
|
app = _build_app()
|
|
app.state.pool = _make_mock_pool()
|
|
resp, _ = _get_analytics(app)
|
|
|
|
data = resp.json()["data"]
|
|
assert "total_conversations" in data
|
|
assert "resolution_rate" in data
|
|
assert "escalation_rate" in data
|
|
assert "avg_turns_per_conversation" in data
|
|
assert "avg_cost_per_conversation_usd" in data
|
|
assert "agent_usage" in data
|
|
assert "interrupt_stats" in data
|
|
|
|
def test_custom_range_7d(self) -> None:
|
|
app = _build_app()
|
|
app.state.pool = _make_mock_pool()
|
|
resp, mock_ga = _get_analytics(app, "/api/analytics?range=7d")
|
|
|
|
assert resp.status_code == 200
|
|
mock_ga.assert_called_once()
|
|
call_kwargs = mock_ga.call_args
|
|
assert call_kwargs[1]["range_days"] == 7 or call_kwargs[0][1] == 7
|
|
|
|
def test_custom_range_30d(self) -> None:
|
|
app = _build_app()
|
|
app.state.pool = _make_mock_pool()
|
|
resp, mock_ga = _get_analytics(app, "/api/analytics?range=30d")
|
|
|
|
assert resp.status_code == 200
|
|
call_kwargs = mock_ga.call_args
|
|
assert call_kwargs[1].get("range_days") == 30 or (
|
|
len(call_kwargs[0]) > 1 and call_kwargs[0][1] == 30
|
|
)
|
|
|
|
def test_invalid_range_format_returns_400(self) -> None:
|
|
app = _build_app()
|
|
app.state.pool = _make_mock_pool()
|
|
|
|
with TestClient(app) as client:
|
|
resp = client.get("/api/analytics?range=invalid")
|
|
|
|
assert resp.status_code == 400
|
|
|
|
def test_range_without_d_suffix_returns_400(self) -> None:
|
|
app = _build_app()
|
|
app.state.pool = _make_mock_pool()
|
|
|
|
with TestClient(app) as client:
|
|
resp = client.get("/api/analytics?range=7")
|
|
|
|
assert resp.status_code == 400
|
|
|
|
def test_agent_usage_in_response(self) -> None:
|
|
app = _build_app()
|
|
app.state.pool = _make_mock_pool()
|
|
resp, _ = _get_analytics(app)
|
|
|
|
data = resp.json()["data"]
|
|
assert len(data["agent_usage"]) == 1
|
|
assert data["agent_usage"][0]["agent"] == "order_agent"
|
|
|
|
def test_interrupt_stats_in_response(self) -> None:
|
|
app = _build_app()
|
|
app.state.pool = _make_mock_pool()
|
|
resp, _ = _get_analytics(app)
|
|
|
|
data = resp.json()["data"]
|
|
assert data["interrupt_stats"]["total"] == 5
|
|
assert data["interrupt_stats"]["approved"] == 4
|
|
|
|
def test_envelope_format(self) -> None:
|
|
app = _build_app()
|
|
app.state.pool = _make_mock_pool()
|
|
resp, _ = _get_analytics(app)
|
|
|
|
body = resp.json()
|
|
assert "success" in body
|
|
assert "data" in body
|
|
assert "error" in body
|