- 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
214 lines
7.6 KiB
Python
214 lines
7.6 KiB
Python
"""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 == ()
|