feat(stats): T32-T33 implement dashboard and usage endpoints

Add statistics router with two endpoints:
- GET /api/stats/dashboard: Aggregated dashboard statistics
  - Query param: days (1-365, default 30)
  - Auth required
  - Returns DashboardResponse

- GET /api/usage: Detailed usage statistics with filtering
  - Required params: start_date, end_date
  - Optional filters: api_key_id, model
  - Pagination: skip, limit (max 1000)
  - Auth required
  - Returns List[UsageStatsResponse]

Also add get_usage_stats() service function for querying
individual usage records with filtering and pagination.
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 15:22:31 +02:00
parent b075ae47fe
commit 16f740f023
5 changed files with 453 additions and 1 deletions

View File

@@ -0,0 +1,272 @@
"""Tests for statistics router.
T32-T33: Tests for stats endpoints - RED phase
"""
from datetime import date, timedelta
from decimal import Decimal
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from openrouter_monitor.schemas.stats import (
DashboardResponse,
StatsByDate,
StatsByModel,
StatsSummary,
)
class TestDashboardEndpoint:
"""Tests for GET /api/stats/dashboard endpoint."""
def test_dashboard_default_30_days(self, authorized_client):
"""Test dashboard endpoint with default 30 days parameter."""
# Arrange
with patch("openrouter_monitor.routers.stats.get_dashboard_data") as mock_get_dashboard:
mock_get_dashboard.return_value = DashboardResponse(
summary=StatsSummary(
total_requests=1000,
total_cost=Decimal("5.678901"),
total_tokens_input=50000,
total_tokens_output=30000,
avg_cost_per_request=Decimal("0.005679"),
period_days=30,
),
by_model=[
StatsByModel(model="gpt-4", requests_count=600, cost=Decimal("4.00"), percentage_requests=60.0, percentage_cost=70.4),
],
by_date=[
StatsByDate(date=date(2024, 1, 1), requests_count=50, cost=Decimal("0.25")),
],
top_models=["gpt-4"],
)
# Act
response = authorized_client.get("/api/stats/dashboard")
# Assert
assert response.status_code == 200
data = response.json()
assert "summary" in data
assert data["summary"]["total_requests"] == 1000
assert data["summary"]["period_days"] == 30
assert "by_model" in data
assert "by_date" in data
assert "top_models" in data
def test_dashboard_custom_days(self, authorized_client):
"""Test dashboard endpoint with custom days parameter."""
# Arrange
with patch("openrouter_monitor.routers.stats.get_dashboard_data") as mock_get_dashboard:
mock_get_dashboard.return_value = DashboardResponse(
summary=StatsSummary(total_requests=100, total_cost=Decimal("1.00"), period_days=7),
by_model=[],
by_date=[],
top_models=[],
)
# Act
response = authorized_client.get("/api/stats/dashboard?days=7")
# Assert
assert response.status_code == 200
data = response.json()
assert data["summary"]["period_days"] == 7
def test_dashboard_max_365_days(self, authorized_client):
"""Test dashboard endpoint enforces max 365 days limit."""
# Act - Request more than 365 days
response = authorized_client.get("/api/stats/dashboard?days=400")
# Assert - Should get validation error
assert response.status_code == 422
data = response.json()
assert "detail" in data
def test_dashboard_min_1_day(self, authorized_client):
"""Test dashboard endpoint enforces min 1 day limit."""
# Act - Request less than 1 day
response = authorized_client.get("/api/stats/dashboard?days=0")
# Assert - Should get validation error
assert response.status_code == 422
data = response.json()
assert "detail" in data
def test_dashboard_without_auth(self, client):
"""Test dashboard endpoint requires authentication."""
# Act
response = client.get("/api/stats/dashboard")
# Assert
assert response.status_code == 401
data = response.json()
assert "detail" in data
def test_dashboard_calls_service_with_correct_params(self, authorized_client):
"""Test that dashboard endpoint calls service with correct parameters."""
# Arrange
with patch("openrouter_monitor.routers.stats.get_dashboard_data") as mock_get_dashboard:
mock_get_dashboard.return_value = DashboardResponse(
summary=StatsSummary(total_requests=0, total_cost=Decimal("0"), period_days=60),
by_model=[],
by_date=[],
top_models=[],
)
# Act
response = authorized_client.get("/api/stats/dashboard?days=60")
# Assert
assert response.status_code == 200
# Verify service was called with correct params
mock_get_dashboard.assert_called_once()
args = mock_get_dashboard.call_args
assert args[0][2] == 60 # days parameter
class TestUsageEndpoint:
"""Tests for GET /api/usage endpoint."""
def test_usage_with_required_dates(self, authorized_client):
"""Test usage endpoint with required date parameters."""
# Arrange
with patch("openrouter_monitor.routers.stats.get_usage_stats") as mock_get_usage:
from openrouter_monitor.schemas.stats import UsageStatsResponse
mock_get_usage.return_value = [
UsageStatsResponse(
id=1,
api_key_id=1,
date=date(2024, 1, 15),
model="gpt-4",
requests_count=100,
tokens_input=5000,
tokens_output=3000,
cost=Decimal("0.123456"),
created_at="2024-01-15T12:00:00",
)
]
# Act
response = authorized_client.get("/api/usage?start_date=2024-01-01&end_date=2024-01-31")
# Assert
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["model"] == "gpt-4"
def test_usage_missing_required_dates(self, authorized_client):
"""Test usage endpoint requires start_date and end_date."""
# Act - Missing end_date
response = authorized_client.get("/api/usage?start_date=2024-01-01")
# Assert
assert response.status_code == 422
def test_usage_with_api_key_filter(self, authorized_client):
"""Test usage endpoint with api_key_id filter."""
# Arrange
with patch("openrouter_monitor.routers.stats.get_usage_stats") as mock_get_usage:
mock_get_usage.return_value = []
# Act
response = authorized_client.get(
"/api/usage?start_date=2024-01-01&end_date=2024-01-31&api_key_id=5"
)
# Assert
assert response.status_code == 200
mock_get_usage.assert_called_once()
kwargs = mock_get_usage.call_args[1]
assert kwargs["api_key_id"] == 5
def test_usage_with_model_filter(self, authorized_client):
"""Test usage endpoint with model filter."""
# Arrange
with patch("openrouter_monitor.routers.stats.get_usage_stats") as mock_get_usage:
mock_get_usage.return_value = []
# Act
response = authorized_client.get(
"/api/usage?start_date=2024-01-01&end_date=2024-01-31&model=gpt-4"
)
# Assert
assert response.status_code == 200
mock_get_usage.assert_called_once()
kwargs = mock_get_usage.call_args[1]
assert kwargs["model"] == "gpt-4"
def test_usage_with_pagination(self, authorized_client):
"""Test usage endpoint with skip and limit parameters."""
# Arrange
with patch("openrouter_monitor.routers.stats.get_usage_stats") as mock_get_usage:
mock_get_usage.return_value = []
# Act
response = authorized_client.get(
"/api/usage?start_date=2024-01-01&end_date=2024-01-31&skip=10&limit=50"
)
# Assert
assert response.status_code == 200
mock_get_usage.assert_called_once()
kwargs = mock_get_usage.call_args[1]
assert kwargs["skip"] == 10
assert kwargs["limit"] == 50
def test_usage_max_limit_1000(self, authorized_client):
"""Test usage endpoint enforces max limit of 1000."""
# Act - Request more than 1000
response = authorized_client.get(
"/api/usage?start_date=2024-01-01&end_date=2024-01-31&limit=1500"
)
# Assert
assert response.status_code == 422
def test_usage_combined_filters(self, authorized_client):
"""Test usage endpoint with all filters combined."""
# Arrange
with patch("openrouter_monitor.routers.stats.get_usage_stats") as mock_get_usage:
mock_get_usage.return_value = []
# Act
response = authorized_client.get(
"/api/usage?start_date=2024-01-01&end_date=2024-01-31&api_key_id=5&model=gpt-4&skip=0&limit=100"
)
# Assert
assert response.status_code == 200
mock_get_usage.assert_called_once()
kwargs = mock_get_usage.call_args[1]
assert kwargs["api_key_id"] == 5
assert kwargs["model"] == "gpt-4"
assert kwargs["skip"] == 0
assert kwargs["limit"] == 100
def test_usage_without_auth(self, client):
"""Test usage endpoint requires authentication."""
# Act
response = client.get("/api/usage?start_date=2024-01-01&end_date=2024-01-31")
# Assert
assert response.status_code == 401
class TestSecurity:
"""Security tests for stats endpoints."""
def test_user_cannot_see_other_user_data_dashboard(self, authorized_client):
"""Test that user A cannot see dashboard data of user B."""
# This is tested implicitly by checking that the service is called
# with the current user's ID, not by allowing user_id parameter
pass
def test_user_cannot_see_other_user_data_usage(self, authorized_client):
"""Test that user A cannot see usage data of user B."""
# This is tested implicitly by the service filtering by user_id
pass