- 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
454 lines
13 KiB
Python
454 lines
13 KiB
Python
"""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') |