Add comprehensive Pydantic schemas for statistics management: - UsageStatsCreate: input validation for creating usage stats - UsageStatsResponse: orm_mode response schema - StatsSummary: aggregated statistics with totals and averages - StatsByModel: per-model breakdown with percentages - StatsByDate: daily usage aggregation - DashboardResponse: complete dashboard data structure All schemas use Decimal for cost precision and proper validation. Test: 16 unit tests, 100% coverage on stats.py
325 lines
9.7 KiB
Python
325 lines
9.7 KiB
Python
"""Tests for statistics Pydantic schemas.
|
|
|
|
T30: Tests for stats schemas - RED phase (test fails before implementation)
|
|
"""
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from openrouter_monitor.schemas.stats import (
|
|
DashboardResponse,
|
|
StatsByDate,
|
|
StatsByModel,
|
|
StatsSummary,
|
|
UsageStatsCreate,
|
|
UsageStatsResponse,
|
|
)
|
|
|
|
|
|
class TestUsageStatsCreate:
|
|
"""Tests for UsageStatsCreate schema."""
|
|
|
|
def test_create_with_valid_data(self):
|
|
"""Test creating UsageStatsCreate with valid data."""
|
|
data = {
|
|
"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"),
|
|
}
|
|
|
|
result = UsageStatsCreate(**data)
|
|
|
|
assert result.api_key_id == 1
|
|
assert result.date == date(2024, 1, 15)
|
|
assert result.model == "gpt-4"
|
|
assert result.requests_count == 100
|
|
assert result.tokens_input == 5000
|
|
assert result.tokens_output == 3000
|
|
assert result.cost == Decimal("0.123456")
|
|
|
|
def test_create_with_minimal_data(self):
|
|
"""Test creating UsageStatsCreate with minimal required data."""
|
|
data = {
|
|
"api_key_id": 1,
|
|
"date": date(2024, 1, 15),
|
|
"model": "gpt-3.5-turbo",
|
|
}
|
|
|
|
result = UsageStatsCreate(**data)
|
|
|
|
assert result.api_key_id == 1
|
|
assert result.date == date(2024, 1, 15)
|
|
assert result.model == "gpt-3.5-turbo"
|
|
assert result.requests_count == 0 # default
|
|
assert result.tokens_input == 0 # default
|
|
assert result.tokens_output == 0 # default
|
|
assert result.cost == Decimal("0") # default
|
|
|
|
def test_create_with_string_date(self):
|
|
"""Test creating UsageStatsCreate with date as string."""
|
|
data = {
|
|
"api_key_id": 1,
|
|
"date": "2024-01-15",
|
|
"model": "claude-3",
|
|
}
|
|
|
|
result = UsageStatsCreate(**data)
|
|
|
|
assert result.date == date(2024, 1, 15)
|
|
|
|
def test_create_missing_required_fields(self):
|
|
"""Test that missing required fields raise ValidationError."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
UsageStatsCreate()
|
|
|
|
errors = exc_info.value.errors()
|
|
# Pydantic v2 uses 'loc' (location) instead of 'field'
|
|
assert any("api_key_id" in e["loc"] for e in errors)
|
|
assert any("date" in e["loc"] for e in errors)
|
|
assert any("model" in e["loc"] for e in errors)
|
|
|
|
def test_create_empty_model_raises_error(self):
|
|
"""Test that empty model raises ValidationError."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
UsageStatsCreate(
|
|
api_key_id=1,
|
|
date=date(2024, 1, 15),
|
|
model="",
|
|
)
|
|
|
|
assert "model" in str(exc_info.value)
|
|
|
|
|
|
class TestUsageStatsResponse:
|
|
"""Tests for UsageStatsResponse schema with orm_mode."""
|
|
|
|
def test_response_with_all_fields(self):
|
|
"""Test UsageStatsResponse with all fields."""
|
|
data = {
|
|
"id": 1,
|
|
"api_key_id": 2,
|
|
"date": date(2024, 1, 15),
|
|
"model": "gpt-4",
|
|
"requests_count": 100,
|
|
"tokens_input": 5000,
|
|
"tokens_output": 3000,
|
|
"cost": Decimal("0.123456"),
|
|
"created_at": datetime(2024, 1, 15, 12, 0, 0),
|
|
}
|
|
|
|
result = UsageStatsResponse(**data)
|
|
|
|
assert result.id == 1
|
|
assert result.api_key_id == 2
|
|
assert result.model == "gpt-4"
|
|
assert result.cost == Decimal("0.123456")
|
|
|
|
def test_response_from_attributes(self):
|
|
"""Test UsageStatsResponse with from_attributes=True (orm_mode)."""
|
|
# Simulate SQLAlchemy model object
|
|
class MockUsageStats:
|
|
id = 1
|
|
api_key_id = 2
|
|
date = date(2024, 1, 15)
|
|
model = "gpt-4"
|
|
requests_count = 100
|
|
tokens_input = 5000
|
|
tokens_output = 3000
|
|
cost = Decimal("0.123456")
|
|
created_at = datetime(2024, 1, 15, 12, 0, 0)
|
|
|
|
result = UsageStatsResponse.model_validate(MockUsageStats())
|
|
|
|
assert result.id == 1
|
|
assert result.model == "gpt-4"
|
|
|
|
|
|
class TestStatsSummary:
|
|
"""Tests for StatsSummary schema."""
|
|
|
|
def test_summary_with_all_fields(self):
|
|
"""Test StatsSummary with all aggregation fields."""
|
|
data = {
|
|
"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,
|
|
}
|
|
|
|
result = StatsSummary(**data)
|
|
|
|
assert result.total_requests == 1000
|
|
assert result.total_cost == Decimal("5.678901")
|
|
assert result.total_tokens_input == 50000
|
|
assert result.total_tokens_output == 30000
|
|
assert result.avg_cost_per_request == Decimal("0.005679")
|
|
assert result.period_days == 30
|
|
|
|
def test_summary_defaults(self):
|
|
"""Test StatsSummary default values."""
|
|
data = {
|
|
"total_requests": 100,
|
|
"total_cost": Decimal("1.00"),
|
|
}
|
|
|
|
result = StatsSummary(**data)
|
|
|
|
assert result.total_tokens_input == 0
|
|
assert result.total_tokens_output == 0
|
|
assert result.avg_cost_per_request == Decimal("0")
|
|
assert result.period_days == 0
|
|
|
|
|
|
class TestStatsByModel:
|
|
"""Tests for StatsByModel schema."""
|
|
|
|
def test_stats_by_model_with_all_fields(self):
|
|
"""Test StatsByModel with all fields."""
|
|
data = {
|
|
"model": "gpt-4",
|
|
"requests_count": 500,
|
|
"cost": Decimal("3.456789"),
|
|
"percentage_requests": 50.0,
|
|
"percentage_cost": 60.5,
|
|
}
|
|
|
|
result = StatsByModel(**data)
|
|
|
|
assert result.model == "gpt-4"
|
|
assert result.requests_count == 500
|
|
assert result.cost == Decimal("3.456789")
|
|
assert result.percentage_requests == 50.0
|
|
assert result.percentage_cost == 60.5
|
|
|
|
def test_stats_by_model_defaults(self):
|
|
"""Test StatsByModel default values for percentages."""
|
|
data = {
|
|
"model": "gpt-3.5-turbo",
|
|
"requests_count": 200,
|
|
"cost": Decimal("0.50"),
|
|
}
|
|
|
|
result = StatsByModel(**data)
|
|
|
|
assert result.percentage_requests == 0.0
|
|
assert result.percentage_cost == 0.0
|
|
|
|
|
|
class TestStatsByDate:
|
|
"""Tests for StatsByDate schema."""
|
|
|
|
def test_stats_by_date_with_all_fields(self):
|
|
"""Test StatsByDate with all fields."""
|
|
data = {
|
|
"date": date(2024, 1, 15),
|
|
"requests_count": 100,
|
|
"cost": Decimal("0.567890"),
|
|
}
|
|
|
|
result = StatsByDate(**data)
|
|
|
|
assert result.date == date(2024, 1, 15)
|
|
assert result.requests_count == 100
|
|
assert result.cost == Decimal("0.567890")
|
|
|
|
def test_stats_by_date_with_string_date(self):
|
|
"""Test StatsByDate with date as string."""
|
|
data = {
|
|
"date": "2024-12-25",
|
|
"requests_count": 50,
|
|
"cost": Decimal("0.25"),
|
|
}
|
|
|
|
result = StatsByDate(**data)
|
|
|
|
assert result.date == date(2024, 12, 25)
|
|
|
|
|
|
class TestDashboardResponse:
|
|
"""Tests for DashboardResponse schema."""
|
|
|
|
def test_dashboard_response_complete(self):
|
|
"""Test DashboardResponse with complete data."""
|
|
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=500,
|
|
cost=Decimal("3.456789"),
|
|
percentage_requests=50.0,
|
|
percentage_cost=60.5,
|
|
),
|
|
StatsByModel(
|
|
model="gpt-3.5-turbo",
|
|
requests_count=500,
|
|
cost=Decimal("2.222112"),
|
|
percentage_requests=50.0,
|
|
percentage_cost=39.5,
|
|
),
|
|
]
|
|
|
|
by_date = [
|
|
StatsByDate(date=date(2024, 1, 1), requests_count=50, cost=Decimal("0.25")),
|
|
StatsByDate(date=date(2024, 1, 2), requests_count=75, cost=Decimal("0.375")),
|
|
]
|
|
|
|
top_models = ["gpt-4", "gpt-3.5-turbo"]
|
|
|
|
result = DashboardResponse(
|
|
summary=summary,
|
|
by_model=by_model,
|
|
by_date=by_date,
|
|
top_models=top_models,
|
|
)
|
|
|
|
assert result.summary.total_requests == 1000
|
|
assert len(result.by_model) == 2
|
|
assert len(result.by_date) == 2
|
|
assert result.top_models == ["gpt-4", "gpt-3.5-turbo"]
|
|
|
|
def test_dashboard_response_empty_lists(self):
|
|
"""Test DashboardResponse with empty lists."""
|
|
summary = StatsSummary(
|
|
total_requests=0,
|
|
total_cost=Decimal("0"),
|
|
)
|
|
|
|
result = DashboardResponse(
|
|
summary=summary,
|
|
by_model=[],
|
|
by_date=[],
|
|
top_models=[],
|
|
)
|
|
|
|
assert result.by_model == []
|
|
assert result.by_date == []
|
|
assert result.top_models == []
|
|
|
|
def test_dashboard_response_missing_top_models(self):
|
|
"""Test DashboardResponse without top_models (optional)."""
|
|
summary = StatsSummary(total_requests=100, total_cost=Decimal("1.00"))
|
|
|
|
result = DashboardResponse(
|
|
summary=summary,
|
|
by_model=[],
|
|
by_date=[],
|
|
)
|
|
|
|
assert result.top_models == []
|