feat: complete phase 4 -- conversation replay API + analytics dashboard
- 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
This commit is contained in:
213
backend/tests/unit/analytics/test_queries.py
Normal file
213
backend/tests/unit/analytics/test_queries.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Unit tests for app.analytics.queries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _make_pool_with_fetchone(result: dict | None) -> MagicMock:
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(return_value=result)
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
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_pool_with_fetchall(result: list[dict]) -> MagicMock:
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchall = AsyncMock(return_value=result)
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
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
|
||||
|
||||
|
||||
class TestResolutionRate:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_float(self) -> None:
|
||||
from app.analytics.queries import resolution_rate
|
||||
|
||||
pool = _make_pool_with_fetchone({"rate": 0.85})
|
||||
result = await resolution_rate(pool, range_days=7)
|
||||
assert isinstance(result, float)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_state_returns_zero(self) -> None:
|
||||
from app.analytics.queries import resolution_rate
|
||||
|
||||
pool = _make_pool_with_fetchone(None)
|
||||
result = await resolution_rate(pool, range_days=7)
|
||||
assert result == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_correct_value(self) -> None:
|
||||
from app.analytics.queries import resolution_rate
|
||||
|
||||
pool = _make_pool_with_fetchone({"rate": 0.75})
|
||||
result = await resolution_rate(pool, range_days=7)
|
||||
assert result == 0.75
|
||||
|
||||
|
||||
class TestAgentUsageQuery:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_tuple(self) -> None:
|
||||
from app.analytics.queries import agent_usage
|
||||
|
||||
pool = _make_pool_with_fetchall([])
|
||||
result = await agent_usage(pool, range_days=7)
|
||||
assert isinstance(result, tuple)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_state_returns_empty_tuple(self) -> None:
|
||||
from app.analytics.queries import agent_usage
|
||||
|
||||
pool = _make_pool_with_fetchall([])
|
||||
result = await agent_usage(pool, range_days=7)
|
||||
assert result == ()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maps_rows_to_agent_usage_objects(self) -> None:
|
||||
from app.analytics.models import AgentUsage
|
||||
from app.analytics.queries import agent_usage
|
||||
|
||||
pool = _make_pool_with_fetchall([
|
||||
{"agent": "order_agent", "count": 10, "percentage": 66.7},
|
||||
{"agent": "discount_agent", "count": 5, "percentage": 33.3},
|
||||
])
|
||||
result = await agent_usage(pool, range_days=7)
|
||||
assert len(result) == 2
|
||||
assert isinstance(result[0], AgentUsage)
|
||||
assert result[0].agent == "order_agent"
|
||||
assert result[0].count == 10
|
||||
|
||||
|
||||
class TestEscalationRate:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_float(self) -> None:
|
||||
from app.analytics.queries import escalation_rate
|
||||
|
||||
pool = _make_pool_with_fetchone({"rate": 0.05})
|
||||
result = await escalation_rate(pool, range_days=7)
|
||||
assert isinstance(result, float)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_state_returns_zero(self) -> None:
|
||||
from app.analytics.queries import escalation_rate
|
||||
|
||||
pool = _make_pool_with_fetchone(None)
|
||||
result = await escalation_rate(pool, range_days=7)
|
||||
assert result == 0.0
|
||||
|
||||
|
||||
class TestCostPerConversation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_float(self) -> None:
|
||||
from app.analytics.queries import cost_per_conversation
|
||||
|
||||
pool = _make_pool_with_fetchone({"avg_cost": 0.03})
|
||||
result = await cost_per_conversation(pool, range_days=7)
|
||||
assert isinstance(result, float)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_state_returns_zero(self) -> None:
|
||||
from app.analytics.queries import cost_per_conversation
|
||||
|
||||
pool = _make_pool_with_fetchone(None)
|
||||
result = await cost_per_conversation(pool, range_days=7)
|
||||
assert result == 0.0
|
||||
|
||||
|
||||
class TestInterruptStatsQuery:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_interrupt_stats(self) -> None:
|
||||
from app.analytics.models import InterruptStats
|
||||
from app.analytics.queries import interrupt_stats
|
||||
|
||||
pool = _make_pool_with_fetchone(
|
||||
{"total": 10, "approved": 7, "rejected": 2, "expired": 1}
|
||||
)
|
||||
result = await interrupt_stats(pool, range_days=7)
|
||||
assert isinstance(result, InterruptStats)
|
||||
assert result.total == 10
|
||||
assert result.approved == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_state_returns_zeros(self) -> None:
|
||||
from app.analytics.models import InterruptStats
|
||||
from app.analytics.queries import interrupt_stats
|
||||
|
||||
pool = _make_pool_with_fetchone(None)
|
||||
result = await interrupt_stats(pool, range_days=7)
|
||||
assert isinstance(result, InterruptStats)
|
||||
assert result.total == 0
|
||||
assert result.approved == 0
|
||||
assert result.rejected == 0
|
||||
assert result.expired == 0
|
||||
|
||||
|
||||
class TestGetAnalytics:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_analytics_result(self) -> None:
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.analytics.models import AnalyticsResult, InterruptStats
|
||||
from app.analytics.queries import get_analytics
|
||||
|
||||
mock_pool = MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.analytics.queries.resolution_rate", return_value=0.85),
|
||||
patch("app.analytics.queries.escalation_rate", return_value=0.05),
|
||||
patch("app.analytics.queries.cost_per_conversation", return_value=0.03),
|
||||
patch("app.analytics.queries.agent_usage", return_value=()),
|
||||
patch(
|
||||
"app.analytics.queries.interrupt_stats",
|
||||
return_value=InterruptStats(),
|
||||
),
|
||||
patch("app.analytics.queries._total_conversations", return_value=100),
|
||||
patch("app.analytics.queries._avg_turns", return_value=4.2),
|
||||
):
|
||||
result = await get_analytics(mock_pool, range_days=7)
|
||||
|
||||
assert isinstance(result, AnalyticsResult)
|
||||
assert result.range == "7d"
|
||||
assert result.total_conversations == 100
|
||||
assert result.resolution_rate == 0.85
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_state_returns_zeros(self) -> None:
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.analytics.models import AnalyticsResult, InterruptStats
|
||||
from app.analytics.queries import get_analytics
|
||||
|
||||
mock_pool = MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.analytics.queries.resolution_rate", return_value=0.0),
|
||||
patch("app.analytics.queries.escalation_rate", return_value=0.0),
|
||||
patch("app.analytics.queries.cost_per_conversation", return_value=0.0),
|
||||
patch("app.analytics.queries.agent_usage", return_value=()),
|
||||
patch("app.analytics.queries.interrupt_stats", return_value=InterruptStats()),
|
||||
patch("app.analytics.queries._total_conversations", return_value=0),
|
||||
patch("app.analytics.queries._avg_turns", return_value=0.0),
|
||||
):
|
||||
result = await get_analytics(mock_pool, range_days=7)
|
||||
|
||||
assert isinstance(result, AnalyticsResult)
|
||||
assert result.total_conversations == 0
|
||||
assert result.resolution_rate == 0.0
|
||||
assert result.agent_usage == ()
|
||||
Reference in New Issue
Block a user