Files
smart-support/backend/tests/unit/analytics/test_queries.py
Yaojia Wang 33db5aeb10 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
2026-03-31 13:35:45 +02:00

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 == ()