Files
enclava/backend/tests/integration/api/test_analytics_endpoints.py
2025-08-25 17:13:15 +02:00

715 lines
28 KiB
Python

#!/usr/bin/env python3
"""
Analytics API Endpoints Tests - Phase 2 API Coverage
Priority: app/api/v1/analytics.py
Tests comprehensive analytics API functionality:
- Usage metrics retrieval
- Cost analysis and trends
- System health monitoring
- Endpoint statistics
- Admin vs user access control
- Error handling and validation
"""
import pytest
import json
from datetime import datetime, timedelta
from unittest.mock import Mock, patch, AsyncMock, MagicMock
from httpx import AsyncClient
from fastapi import status
from app.main import app
from app.models.user import User
from app.models.usage_tracking import UsageTracking
class TestAnalyticsEndpoints:
"""Comprehensive test suite for Analytics API endpoints"""
@pytest.fixture
async def client(self):
"""Create test HTTP client"""
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
@pytest.fixture
def auth_headers(self):
"""Authentication headers for test user"""
return {"Authorization": "Bearer test_access_token"}
@pytest.fixture
def admin_headers(self):
"""Authentication headers for admin user"""
return {"Authorization": "Bearer admin_access_token"}
@pytest.fixture
def mock_user(self):
"""Mock regular user"""
return {
'id': 1,
'username': 'testuser',
'email': 'test@example.com',
'is_active': True,
'role': 'user',
'is_superuser': False
}
@pytest.fixture
def mock_admin_user(self):
"""Mock admin user"""
return {
'id': 2,
'username': 'admin',
'email': 'admin@example.com',
'is_active': True,
'role': 'admin',
'is_superuser': True
}
@pytest.fixture
def sample_metrics(self):
"""Sample usage metrics data"""
return {
'total_requests': 150,
'total_cost_cents': 2500, # $25.00
'avg_response_time': 250.5,
'error_rate': 0.02, # 2%
'budget_usage_percentage': 15.5,
'tokens_used': 50000,
'unique_users': 5,
'top_models': ['gpt-3.5-turbo', 'gpt-4'],
'period_start': '2024-01-01T00:00:00Z',
'period_end': '2024-01-01T23:59:59Z'
}
@pytest.fixture
def sample_health_data(self):
"""Sample system health data"""
return {
'status': 'healthy',
'score': 95,
'database_status': 'connected',
'qdrant_status': 'connected',
'redis_status': 'connected',
'llm_service_status': 'operational',
'uptime_seconds': 86400,
'memory_usage_percent': 45.2,
'cpu_usage_percent': 12.8
}
# === USAGE METRICS TESTS ===
@pytest.mark.asyncio
async def test_get_usage_metrics_success(self, client, auth_headers, mock_user, sample_metrics):
"""Test successful usage metrics retrieval"""
from app.main import app
from app.core.security import get_current_user
from app.db.database import get_db
mock_analytics_service = Mock()
mock_analytics_service.get_usage_metrics = AsyncMock(return_value=Mock(**sample_metrics))
# Override app dependencies
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[get_db] = lambda: AsyncMock()
try:
with patch('app.api.v1.analytics.get_analytics_service') as mock_get_analytics:
mock_get_analytics.return_value = mock_analytics_service
response = await client.get(
"/api/v1/analytics/metrics?hours=24",
headers=auth_headers
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["success"] is True
assert "data" in data
assert data["period_hours"] == 24
# Verify service was called with correct parameters
mock_analytics_service.get_usage_metrics.assert_called_once_with(
hours=24,
user_id=mock_user['id']
)
finally:
# Clean up dependency overrides
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_get_usage_metrics_custom_period(self, client, auth_headers, mock_user):
"""Test usage metrics with custom time period"""
mock_analytics_service = Mock()
mock_analytics_service.get_usage_metrics = AsyncMock(return_value=Mock())
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_user
with patch('app.api.v1.analytics.get_db') as mock_get_db:
mock_session = AsyncMock()
mock_get_db.return_value = mock_session
with patch('app.api.v1.analytics.get_analytics_service') as mock_get_analytics:
mock_get_analytics.return_value = mock_analytics_service
response = await client.get(
"/api/v1/analytics/metrics?hours=168", # 7 days
headers=auth_headers
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["period_hours"] == 168
@pytest.mark.asyncio
async def test_get_usage_metrics_invalid_hours(self, client, auth_headers, mock_user):
"""Test usage metrics with invalid hours parameter"""
test_cases = [
{"hours": 0, "description": "zero hours"},
{"hours": -5, "description": "negative hours"},
{"hours": 200, "description": "too many hours (>168)"}
]
for case in test_cases:
response = await client.get(
f"/api/v1/analytics/metrics?hours={case['hours']}",
headers=auth_headers
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_get_usage_metrics_unauthorized(self, client):
"""Test usage metrics without authentication"""
response = await client.get("/api/v1/analytics/metrics")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# === SYSTEM METRICS TESTS (ADMIN ONLY) ===
@pytest.mark.asyncio
async def test_get_system_metrics_admin_success(self, client, admin_headers, mock_admin_user, sample_metrics):
"""Test successful system metrics retrieval by admin"""
mock_analytics_service = Mock()
mock_analytics_service.get_usage_metrics = AsyncMock(return_value=Mock(**sample_metrics))
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_admin_user
with patch('app.api.v1.analytics.get_db') as mock_get_db:
mock_session = AsyncMock()
mock_get_db.return_value = mock_session
with patch('app.api.v1.analytics.get_analytics_service') as mock_get_analytics:
mock_get_analytics.return_value = mock_analytics_service
response = await client.get(
"/api/v1/analytics/metrics/system?hours=48",
headers=admin_headers
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["success"] is True
assert "data" in data
assert data["period_hours"] == 48
# Verify service was called without user_id (system-wide)
mock_analytics_service.get_usage_metrics.assert_called_once_with(hours=48)
@pytest.mark.asyncio
async def test_get_system_metrics_non_admin_denied(self, client, auth_headers, mock_user):
"""Test system metrics access denied for non-admin users"""
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_user
response = await client.get(
"/api/v1/analytics/metrics/system",
headers=auth_headers
)
assert response.status_code == status.HTTP_403_FORBIDDEN
data = response.json()
assert "admin access required" in data["detail"].lower()
# === SYSTEM HEALTH TESTS ===
@pytest.mark.asyncio
async def test_get_system_health_success(self, client, auth_headers, mock_user, sample_health_data):
"""Test successful system health retrieval"""
mock_analytics_service = Mock()
mock_analytics_service.get_system_health = AsyncMock(return_value=Mock(**sample_health_data))
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_user
with patch('app.api.v1.analytics.get_db') as mock_get_db:
mock_session = AsyncMock()
mock_get_db.return_value = mock_session
with patch('app.api.v1.analytics.get_analytics_service') as mock_get_analytics:
mock_get_analytics.return_value = mock_analytics_service
response = await client.get(
"/api/v1/analytics/health",
headers=auth_headers
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["success"] is True
assert "data" in data
# Verify service was called
mock_analytics_service.get_system_health.assert_called_once()
@pytest.mark.asyncio
async def test_get_system_health_service_error(self, client, auth_headers, mock_user):
"""Test system health with service error"""
mock_analytics_service = Mock()
mock_analytics_service.get_system_health = AsyncMock(
side_effect=Exception("Service connection failed")
)
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_user
with patch('app.api.v1.analytics.get_db') as mock_get_db:
mock_session = AsyncMock()
mock_get_db.return_value = mock_session
with patch('app.api.v1.analytics.get_analytics_service') as mock_get_analytics:
mock_get_analytics.return_value = mock_analytics_service
response = await client.get(
"/api/v1/analytics/health",
headers=auth_headers
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
data = response.json()
assert "connection failed" in data["detail"].lower()
# === COST ANALYSIS TESTS ===
@pytest.mark.asyncio
async def test_get_cost_analysis_success(self, client, auth_headers, mock_user):
"""Test successful cost analysis retrieval"""
cost_analysis_data = {
'total_cost_cents': 5000, # $50.00
'daily_costs': [
{'date': '2024-01-01', 'cost_cents': 1000},
{'date': '2024-01-02', 'cost_cents': 1500},
{'date': '2024-01-03', 'cost_cents': 2500}
],
'cost_by_model': {
'gpt-3.5-turbo': 2000,
'gpt-4': 3000
},
'projected_monthly_cost': 15000, # $150.00
'period_days': 30
}
mock_analytics_service = Mock()
mock_analytics_service.get_cost_analysis = AsyncMock(return_value=Mock(**cost_analysis_data))
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_user
with patch('app.api.v1.analytics.get_db') as mock_get_db:
mock_session = AsyncMock()
mock_get_db.return_value = mock_session
with patch('app.api.v1.analytics.get_analytics_service') as mock_get_analytics:
mock_get_analytics.return_value = mock_analytics_service
response = await client.get(
"/api/v1/analytics/costs?days=30",
headers=auth_headers
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["success"] is True
assert "data" in data
assert data["period_days"] == 30
# Verify service was called with correct parameters
mock_analytics_service.get_cost_analysis.assert_called_once_with(
days=30,
user_id=mock_user['id']
)
@pytest.mark.asyncio
async def test_get_system_cost_analysis_admin(self, client, admin_headers, mock_admin_user):
"""Test system-wide cost analysis by admin"""
mock_analytics_service = Mock()
mock_analytics_service.get_cost_analysis = AsyncMock(return_value=Mock())
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_admin_user
with patch('app.api.v1.analytics.get_db') as mock_get_db:
mock_session = AsyncMock()
mock_get_db.return_value = mock_session
with patch('app.api.v1.analytics.get_analytics_service') as mock_get_analytics:
mock_get_analytics.return_value = mock_analytics_service
response = await client.get(
"/api/v1/analytics/costs/system?days=7",
headers=admin_headers
)
assert response.status_code == status.HTTP_200_OK
# Verify service was called without user_id (system-wide)
mock_analytics_service.get_cost_analysis.assert_called_once_with(days=7)
# === ENDPOINT STATISTICS TESTS ===
@pytest.mark.asyncio
async def test_get_endpoint_stats_success(self, client, auth_headers, mock_user):
"""Test successful endpoint statistics retrieval"""
endpoint_stats = {
'/api/v1/llm/chat/completions': 150,
'/api/v1/rag/search': 75,
'/api/v1/budgets': 25
}
status_codes = {
200: 220,
400: 20,
401: 5,
500: 5
}
model_stats = {
'gpt-3.5-turbo': 100,
'gpt-4': 50
}
mock_analytics_service = Mock()
mock_analytics_service.endpoint_stats = endpoint_stats
mock_analytics_service.status_codes = status_codes
mock_analytics_service.model_stats = model_stats
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_user
with patch('app.api.v1.analytics.get_db') as mock_get_db:
mock_session = AsyncMock()
mock_get_db.return_value = mock_session
with patch('app.api.v1.analytics.get_analytics_service') as mock_get_analytics:
mock_get_analytics.return_value = mock_analytics_service
response = await client.get(
"/api/v1/analytics/endpoints",
headers=auth_headers
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["success"] is True
assert "data" in data
assert "endpoint_stats" in data["data"]
assert "status_codes" in data["data"]
assert "model_stats" in data["data"]
# === USAGE TRENDS TESTS ===
@pytest.mark.asyncio
async def test_get_usage_trends_success(self, client, auth_headers, mock_user):
"""Test successful usage trends retrieval"""
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_user
with patch('app.api.v1.analytics.get_db') as mock_get_db:
mock_session = AsyncMock()
mock_get_db.return_value = mock_session
# Mock database query results
mock_usage_data = [
(datetime(2024, 1, 1).date(), 50, 5000, 500), # date, requests, tokens, cost_cents
(datetime(2024, 1, 2).date(), 75, 7500, 750),
(datetime(2024, 1, 3).date(), 60, 6000, 600)
]
mock_session.query.return_value.filter.return_value.group_by.return_value.order_by.return_value.all.return_value = mock_usage_data
response = await client.get(
"/api/v1/analytics/usage-trends?days=7",
headers=auth_headers
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["success"] is True
assert "data" in data
assert "trends" in data["data"]
assert data["data"]["period_days"] == 7
assert len(data["data"]["trends"]) == 3
# Verify trend data structure
first_trend = data["data"]["trends"][0]
assert "date" in first_trend
assert "requests" in first_trend
assert "tokens" in first_trend
assert "cost_cents" in first_trend
assert "cost_dollars" in first_trend
# === ANALYTICS OVERVIEW TESTS ===
@pytest.mark.asyncio
async def test_get_analytics_overview_success(self, client, auth_headers, mock_user):
"""Test successful analytics overview retrieval"""
mock_metrics = Mock(
total_requests=100,
total_cost_cents=2000,
avg_response_time=150.5,
error_rate=0.01,
budget_usage_percentage=20.5
)
mock_health = Mock(
status='healthy',
score=98
)
mock_analytics_service = Mock()
mock_analytics_service.get_usage_metrics = AsyncMock(return_value=mock_metrics)
mock_analytics_service.get_system_health = AsyncMock(return_value=mock_health)
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_user
with patch('app.api.v1.analytics.get_db') as mock_get_db:
mock_session = AsyncMock()
mock_get_db.return_value = mock_session
with patch('app.api.v1.analytics.get_analytics_service') as mock_get_analytics:
mock_get_analytics.return_value = mock_analytics_service
response = await client.get(
"/api/v1/analytics/overview",
headers=auth_headers
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["success"] is True
assert "data" in data
overview = data["data"]
assert overview["total_requests"] == 100
assert overview["total_cost_dollars"] == 20.0 # 2000 cents = $20
assert overview["avg_response_time"] == 150.5
assert overview["error_rate"] == 0.01
assert overview["budget_usage_percentage"] == 20.5
assert overview["system_health"] == "healthy"
assert overview["health_score"] == 98
# === MODULE ANALYTICS TESTS ===
@pytest.mark.asyncio
async def test_get_module_analytics_success(self, client, auth_headers, mock_user):
"""Test successful module analytics retrieval"""
mock_modules = {
'chatbot': Mock(initialized=True),
'rag': Mock(initialized=True),
'cache': Mock(initialized=False)
}
# Mock module with get_stats method
mock_chatbot_stats = {'requests': 150, 'conversations': 25}
mock_modules['chatbot'].get_stats = Mock(return_value=mock_chatbot_stats)
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_user
with patch('app.api.v1.analytics.get_db') as mock_get_db:
mock_session = AsyncMock()
mock_get_db.return_value = mock_session
with patch('app.api.v1.analytics.module_manager') as mock_module_manager:
mock_module_manager.modules = mock_modules
response = await client.get(
"/api/v1/analytics/modules",
headers=auth_headers
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["success"] is True
assert "data" in data
assert "modules" in data["data"]
assert data["data"]["total_modules"] == 3
# Find chatbot module in results
chatbot_module = None
for module in data["data"]["modules"]:
if module["name"] == "chatbot":
chatbot_module = module
break
assert chatbot_module is not None
assert chatbot_module["initialized"] is True
assert chatbot_module["requests"] == 150
@pytest.mark.asyncio
async def test_get_module_analytics_with_errors(self, client, auth_headers, mock_user):
"""Test module analytics with some modules having errors"""
mock_modules = {
'working_module': Mock(initialized=True),
'broken_module': Mock(initialized=True)
}
# Mock working module
mock_modules['working_module'].get_stats = Mock(return_value={'status': 'ok'})
# Mock broken module that throws error
mock_modules['broken_module'].get_stats = Mock(side_effect=Exception("Module error"))
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_user
with patch('app.api.v1.analytics.get_db') as mock_get_db:
mock_session = AsyncMock()
mock_get_db.return_value = mock_session
with patch('app.api.v1.analytics.module_manager') as mock_module_manager:
mock_module_manager.modules = mock_modules
response = await client.get(
"/api/v1/analytics/modules",
headers=auth_headers
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["success"] is True
# Find broken module in results
broken_module = None
for module in data["data"]["modules"]:
if module["name"] == "broken_module":
broken_module = module
break
assert broken_module is not None
assert "error" in broken_module
assert "Module error" in broken_module["error"]
# === ERROR HANDLING AND EDGE CASES ===
@pytest.mark.asyncio
async def test_analytics_service_unavailable(self, client, auth_headers, mock_user):
"""Test handling of analytics service unavailability"""
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_user
with patch('app.api.v1.analytics.get_analytics_service') as mock_get_analytics:
mock_get_analytics.side_effect = Exception("Analytics service unavailable")
response = await client.get(
"/api/v1/analytics/metrics",
headers=auth_headers
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
data = response.json()
assert "unavailable" in data["detail"].lower()
@pytest.mark.asyncio
async def test_database_connection_error(self, client, auth_headers, mock_user):
"""Test handling of database connection errors in trends"""
with patch('app.api.v1.analytics.get_current_user') as mock_get_user:
mock_get_user.return_value = mock_user
with patch('app.api.v1.analytics.get_db') as mock_get_db:
mock_session = Mock()
mock_get_db.return_value = mock_session
# Mock database connection error
mock_session.query.side_effect = Exception("Database connection failed")
response = await client.get(
"/api/v1/analytics/usage-trends",
headers=auth_headers
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
data = response.json()
assert "connection failed" in data["detail"].lower()
@pytest.mark.asyncio
async def test_cost_analysis_invalid_period(self, client, auth_headers, mock_user):
"""Test cost analysis with invalid period"""
invalid_periods = [0, -5, 400] # 0, negative, > 365
for days in invalid_periods:
response = await client.get(
f"/api/v1/analytics/costs?days={days}",
headers=auth_headers
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
"""
COVERAGE ANALYSIS FOR ANALYTICS API ENDPOINTS:
✅ Usage Metrics (4+ tests):
- Successful metrics retrieval for user
- Custom time period handling
- Invalid parameters validation
- Unauthorized access handling
✅ System Metrics - Admin Only (2+ tests):
- Admin access to system-wide metrics
- Non-admin access denial
✅ System Health (2+ tests):
- Successful health status retrieval
- Service error handling
✅ Cost Analysis (2+ tests):
- User cost analysis retrieval
- System-wide cost analysis (admin)
✅ Endpoint Statistics (1+ test):
- Endpoint usage statistics retrieval
✅ Usage Trends (1+ test):
- Daily usage trends from database
✅ Analytics Overview (1+ test):
- Combined metrics and health overview
✅ Module Analytics (2+ tests):
- Module statistics with working modules
- Module error handling
✅ Error Handling (3+ tests):
- Analytics service unavailability
- Database connection errors
- Invalid parameter handling
ESTIMATED COVERAGE IMPROVEMENT:
- Test Count: 18+ comprehensive API tests
- Business Impact: Medium-High (monitoring and insights)
- Implementation: Complete analytics API flow validation
- Phase 2 Completion: All major API endpoints now tested
"""