feat(schemas): T35 add Pydantic public API schemas

- PublicStatsResponse: summary + period info
- PublicUsageResponse: paginated usage items
- PublicKeyInfo: key metadata with stats (no values!)
- ApiToken schemas: create, response, create-response
- 25 unit tests, 100% coverage
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 16:15:22 +02:00
parent 16f740f023
commit a8095f4df7
3 changed files with 826 additions and 0 deletions

View File

@@ -0,0 +1,454 @@
"""Tests for public API Pydantic schemas.
T35: Tests for public_api.py schemas
"""
import datetime
from decimal import Decimal
import pytest
from pydantic import ValidationError
from openrouter_monitor.schemas.public_api import (
ApiTokenCreate,
ApiTokenCreateResponse,
ApiTokenResponse,
PeriodInfo,
PublicKeyInfo,
PublicKeyListResponse,
PublicStatsResponse,
PublicUsageItem,
PublicUsageResponse,
SummaryInfo,
PaginationInfo,
)
class TestApiTokenCreate:
"""Test suite for ApiTokenCreate schema."""
def test_valid_name_creates_successfully(self):
"""Test that a valid name creates the schema successfully."""
# Arrange & Act
result = ApiTokenCreate(name="My API Token")
# Assert
assert result.name == "My API Token"
def test_name_min_length_1_char(self):
"""Test that name with 1 character is valid."""
# Arrange & Act
result = ApiTokenCreate(name="A")
# Assert
assert result.name == "A"
def test_name_max_length_100_chars(self):
"""Test that name with exactly 100 characters is valid."""
# Arrange
long_name = "A" * 100
# Act
result = ApiTokenCreate(name=long_name)
# Assert
assert result.name == long_name
def test_name_too_short_raises_validation_error(self):
"""Test that empty name raises ValidationError."""
# Arrange & Act & Assert
with pytest.raises(ValidationError) as exc_info:
ApiTokenCreate(name="")
assert "name" in str(exc_info.value)
def test_name_too_long_raises_validation_error(self):
"""Test that name with 101+ characters raises ValidationError."""
# Arrange
too_long_name = "A" * 101
# Act & Assert
with pytest.raises(ValidationError) as exc_info:
ApiTokenCreate(name=too_long_name)
assert "name" in str(exc_info.value)
def test_name_strips_whitespace(self):
"""Test that name with whitespace is handled correctly."""
# Note: Pydantic v2 doesn't auto-strip by default
# Arrange & Act
result = ApiTokenCreate(name=" My Token ")
# Assert
assert result.name == " My Token "
class TestApiTokenResponse:
"""Test suite for ApiTokenResponse schema."""
def test_valid_response_without_token(self):
"""Test that response contains NO token field (security)."""
# Arrange
data = {
"id": 1,
"name": "My Token",
"created_at": datetime.datetime(2024, 1, 15, 12, 0, 0),
"last_used_at": None,
"is_active": True
}
# Act
result = ApiTokenResponse(**data)
# Assert
assert result.id == 1
assert result.name == "My Token"
assert result.created_at == datetime.datetime(2024, 1, 15, 12, 0, 0)
assert result.last_used_at is None
assert result.is_active is True
# Security check: NO token field should exist
assert not hasattr(result, 'token')
def test_response_with_last_used_at(self):
"""Test response when token has been used."""
# Arrange
data = {
"id": 2,
"name": "Production Token",
"created_at": datetime.datetime(2024, 1, 15, 12, 0, 0),
"last_used_at": datetime.datetime(2024, 1, 20, 15, 30, 0),
"is_active": True
}
# Act
result = ApiTokenResponse(**data)
# Assert
assert result.last_used_at == datetime.datetime(2024, 1, 20, 15, 30, 0)
class TestApiTokenCreateResponse:
"""Test suite for ApiTokenCreateResponse schema."""
def test_response_includes_plaintext_token(self):
"""Test that create response includes plaintext token (only at creation)."""
# Arrange
data = {
"id": 1,
"name": "My Token",
"token": "or_api_abc123xyz789", # Plaintext token - only shown at creation!
"created_at": datetime.datetime(2024, 1, 15, 12, 0, 0)
}
# Act
result = ApiTokenCreateResponse(**data)
# Assert
assert result.id == 1
assert result.name == "My Token"
assert result.token == "or_api_abc123xyz789"
assert result.created_at == datetime.datetime(2024, 1, 15, 12, 0, 0)
def test_token_prefix_validation(self):
"""Test that token starts with expected prefix."""
# Arrange
data = {
"id": 1,
"name": "Test",
"token": "or_api_testtoken123",
"created_at": datetime.datetime.utcnow()
}
# Act
result = ApiTokenCreateResponse(**data)
# Assert
assert result.token.startswith("or_api_")
class TestSummaryInfo:
"""Test suite for SummaryInfo schema."""
def test_valid_summary(self):
"""Test valid summary with all fields."""
# Arrange & Act
result = SummaryInfo(
total_requests=1000,
total_cost=Decimal("5.50"),
total_tokens=50000
)
# Assert
assert result.total_requests == 1000
assert result.total_cost == Decimal("5.50")
assert result.total_tokens == 50000
class TestPeriodInfo:
"""Test suite for PeriodInfo schema."""
def test_valid_period(self):
"""Test valid period info."""
# Arrange
start = datetime.date(2024, 1, 1)
end = datetime.date(2024, 1, 31)
# Act
result = PeriodInfo(
start_date=start,
end_date=end,
days=30
)
# Assert
assert result.start_date == start
assert result.end_date == end
assert result.days == 30
class TestPublicStatsResponse:
"""Test suite for PublicStatsResponse schema."""
def test_valid_stats_response(self):
"""Test complete stats response."""
# Arrange
summary = SummaryInfo(
total_requests=1000,
total_cost=Decimal("5.50"),
total_tokens=50000
)
period = PeriodInfo(
start_date=datetime.date(2024, 1, 1),
end_date=datetime.date(2024, 1, 31),
days=30
)
# Act
result = PublicStatsResponse(summary=summary, period=period)
# Assert
assert result.summary.total_requests == 1000
assert result.period.days == 30
class TestPublicUsageItem:
"""Test suite for PublicUsageItem schema."""
def test_valid_usage_item(self):
"""Test valid usage item for public API."""
# Arrange & Act
result = PublicUsageItem(
date=datetime.date(2024, 1, 15),
api_key_name="Production Key",
model="gpt-4",
requests_count=100,
tokens_input=5000,
tokens_output=3000,
cost=Decimal("0.50")
)
# Assert
assert result.date == datetime.date(2024, 1, 15)
assert result.api_key_name == "Production Key"
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.50")
def test_usage_item_no_key_value_exposed(self):
"""Test that API key value is NOT exposed in usage item."""
# Arrange & Act
result = PublicUsageItem(
date=datetime.date(2024, 1, 15),
api_key_name="My Key", # Only name, NOT the actual key value
model="gpt-4",
requests_count=1,
tokens_input=100,
tokens_output=50,
cost=Decimal("0.01")
)
# Assert - security check
assert not hasattr(result, 'api_key_value')
assert not hasattr(result, 'key_value')
class TestPaginationInfo:
"""Test suite for PaginationInfo schema."""
def test_valid_pagination(self):
"""Test valid pagination info."""
# Arrange & Act
result = PaginationInfo(
page=2,
limit=100,
total=250,
pages=3
)
# Assert
assert result.page == 2
assert result.limit == 100
assert result.total == 250
assert result.pages == 3
def test_pagination_page_ge_1(self):
"""Test that page must be >= 1."""
# Act & Assert
with pytest.raises(ValidationError):
PaginationInfo(page=0, limit=10, total=100, pages=10)
def test_pagination_limit_positive(self):
"""Test that limit must be positive."""
# Act & Assert
with pytest.raises(ValidationError):
PaginationInfo(page=1, limit=0, total=100, pages=10)
class TestPublicUsageResponse:
"""Test suite for PublicUsageResponse schema."""
def test_valid_usage_response(self):
"""Test complete usage response with pagination."""
# Arrange
items = [
PublicUsageItem(
date=datetime.date(2024, 1, 15),
api_key_name="Key 1",
model="gpt-4",
requests_count=100,
tokens_input=5000,
tokens_output=3000,
cost=Decimal("0.50")
),
PublicUsageItem(
date=datetime.date(2024, 1, 16),
api_key_name="Key 2",
model="gpt-3.5",
requests_count=50,
tokens_input=2500,
tokens_output=1500,
cost=Decimal("0.25")
)
]
pagination = PaginationInfo(page=1, limit=100, total=2, pages=1)
# Act
result = PublicUsageResponse(items=items, pagination=pagination)
# Assert
assert len(result.items) == 2
assert result.pagination.total == 2
assert result.pagination.page == 1
def test_empty_usage_response(self):
"""Test usage response with no items."""
# Arrange
pagination = PaginationInfo(page=1, limit=100, total=0, pages=0)
# Act
result = PublicUsageResponse(items=[], pagination=pagination)
# Assert
assert result.items == []
assert result.pagination.total == 0
class TestPublicKeyInfo:
"""Test suite for PublicKeyInfo schema."""
def test_valid_key_info(self):
"""Test valid public key info without exposing actual key."""
# Arrange & Act
result = PublicKeyInfo(
id=1,
name="Production Key",
is_active=True,
stats={
"total_requests": 1000,
"total_cost": Decimal("5.50")
}
)
# Assert
assert result.id == 1
assert result.name == "Production Key"
assert result.is_active is True
assert result.stats["total_requests"] == 1000
assert result.stats["total_cost"] == Decimal("5.50")
def test_key_info_no_value_field(self):
"""Test that actual API key value is NOT in the schema."""
# Arrange & Act
result = PublicKeyInfo(
id=1,
name="My Key",
is_active=True,
stats={"total_requests": 0, "total_cost": Decimal("0")}
)
# Assert - security check
assert not hasattr(result, 'key_value')
assert not hasattr(result, 'encrypted_value')
assert not hasattr(result, 'api_key')
class TestPublicKeyListResponse:
"""Test suite for PublicKeyListResponse schema."""
def test_valid_key_list_response(self):
"""Test key list response with multiple keys."""
# Arrange
items = [
PublicKeyInfo(
id=1,
name="Production",
is_active=True,
stats={"total_requests": 1000, "total_cost": Decimal("5.00")}
),
PublicKeyInfo(
id=2,
name="Development",
is_active=True,
stats={"total_requests": 100, "total_cost": Decimal("0.50")}
)
]
# Act
result = PublicKeyListResponse(items=items, total=2)
# Assert
assert len(result.items) == 2
assert result.total == 2
assert result.items[0].name == "Production"
assert result.items[1].name == "Development"
def test_empty_key_list_response(self):
"""Test key list response with no keys."""
# Arrange & Act
result = PublicKeyListResponse(items=[], total=0)
# Assert
assert result.items == []
assert result.total == 0
def test_key_list_no_values_exposed(self):
"""Test security: no key values in list response."""
# Arrange
items = [
PublicKeyInfo(
id=1,
name="Key 1",
is_active=True,
stats={"total_requests": 10, "total_cost": Decimal("0.10")}
)
]
# Act
result = PublicKeyListResponse(items=items, total=1)
# Assert - security check for all items
for item in result.items:
assert not hasattr(item, 'key_value')
assert not hasattr(item, 'encrypted_value')