Backend (516 tests, 94% coverage): - Add azure_openai endpoint/deployment validation tests (config.py -> 100%) - Add _total_conversations and _avg_turns direct tests (queries.py -> 100%) - Add transformer edge cases: list content, string checkpoint, invalid JSON, malformed message graceful skip (transformer.py -> 93%) - Add safety combined status_code+error_message interaction tests - Fix ambiguous 200/422 assertion to strict 422 - Add E2E pagination shape assertions (total, page, per_page, row count) - Fix ReplayPool mock to respect LIMIT/OFFSET params Frontend (23 tests, vitest + happy-dom + @testing-library/react): - Add vitest infrastructure with happy-dom environment - Add api.ts tests: success, HTTP error, success=false, URL encoding - Add DashboardPage tests: loading, data, error, empty states - Add ReplayListPage tests: loading, empty, data, error, status badge classes - Add ReplayPage tests: loading, steps, empty, error states
250 lines
8.7 KiB
Python
250 lines
8.7 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 TestTotalConversations:
|
|
@pytest.mark.asyncio
|
|
async def test_returns_count(self) -> None:
|
|
from app.analytics.queries import _total_conversations
|
|
|
|
pool = _make_pool_with_fetchone({"total": 42})
|
|
result = await _total_conversations(pool, range_days=7)
|
|
assert result == 42
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_zero_state_returns_zero(self) -> None:
|
|
from app.analytics.queries import _total_conversations
|
|
|
|
pool = _make_pool_with_fetchone(None)
|
|
result = await _total_conversations(pool, range_days=7)
|
|
assert result == 0
|
|
|
|
|
|
class TestAvgTurns:
|
|
@pytest.mark.asyncio
|
|
async def test_returns_float(self) -> None:
|
|
from app.analytics.queries import _avg_turns
|
|
|
|
pool = _make_pool_with_fetchone({"avg_turns": 3.5})
|
|
result = await _avg_turns(pool, range_days=7)
|
|
assert result == 3.5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_zero_state_returns_zero(self) -> None:
|
|
from app.analytics.queries import _avg_turns
|
|
|
|
pool = _make_pool_with_fetchone(None)
|
|
result = await _avg_turns(pool, range_days=7)
|
|
assert result == 0.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 == ()
|