"""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')