- Complete FastAPI proxy server with all Rentcast API endpoints - Intelligent caching with SQLite backend and Redis support - Rate limiting and usage tracking per API key - CLI administration tools for key and cache management - Comprehensive models with SQLAlchemy for persistence - Health checks and metrics endpoints - Production-ready configuration management - Extensive test coverage with pytest and fixtures - Rich CLI interface with click and rich libraries - Soft delete caching strategy for analytics - TTL-based cache expiration with endpoint-specific durations - CORS, compression, and security middleware - Structured logging with JSON format - Cost tracking and estimation for API usage - Background task support architecture - Docker deployment ready - Comprehensive documentation and setup instructions
238 lines
7.5 KiB
Python
238 lines
7.5 KiB
Python
"""
|
|
Tests for RentCache models.
|
|
"""
|
|
import pytest
|
|
import pytest_asyncio
|
|
from datetime import datetime, timezone, timedelta
|
|
import hashlib
|
|
import json
|
|
|
|
from rentcache.models import (
|
|
CacheEntry, APIKey, RateLimit, UsageStats,
|
|
CreateAPIKeyRequest, UpdateAPIKeyRequest
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def api_key(test_session):
|
|
"""Create a test API key."""
|
|
key_hash = hashlib.sha256("test_key_123".encode()).hexdigest()
|
|
|
|
api_key = APIKey(
|
|
key_name="test_key",
|
|
key_hash=key_hash,
|
|
daily_limit=1000,
|
|
monthly_limit=10000
|
|
)
|
|
|
|
test_session.add(api_key)
|
|
await test_session.commit()
|
|
await test_session.refresh(api_key)
|
|
|
|
return api_key
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def cache_entry(test_session):
|
|
"""Create a test cache entry."""
|
|
cache_entry = CacheEntry(
|
|
cache_key="test_cache_key",
|
|
endpoint="properties",
|
|
method="GET",
|
|
params_hash="abc123",
|
|
params_json='{"test": "params"}',
|
|
response_data='{"test": "response"}',
|
|
status_code=200,
|
|
headers_json='{"Content-Type": "application/json"}',
|
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
|
ttl_seconds=3600
|
|
)
|
|
|
|
test_session.add(cache_entry)
|
|
await test_session.commit()
|
|
await test_session.refresh(cache_entry)
|
|
|
|
return cache_entry
|
|
|
|
|
|
class TestCacheEntry:
|
|
"""Tests for CacheEntry model."""
|
|
|
|
def test_cache_key_generation(self):
|
|
"""Test cache key generation."""
|
|
endpoint = "properties"
|
|
method = "GET"
|
|
params = {"address": "123 Main St", "city": "Austin"}
|
|
|
|
key1 = CacheEntry.generate_cache_key(endpoint, method, params)
|
|
key2 = CacheEntry.generate_cache_key(endpoint, method, params)
|
|
|
|
# Should be deterministic
|
|
assert key1 == key2
|
|
|
|
# Should be different with different params
|
|
different_params = {"address": "456 Oak Ave", "city": "Austin"}
|
|
key3 = CacheEntry.generate_cache_key(endpoint, method, different_params)
|
|
assert key1 != key3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_entry_expiration(self, cache_entry):
|
|
"""Test cache entry expiration logic."""
|
|
# Should not be expired initially
|
|
assert not cache_entry.is_expired()
|
|
|
|
# Make it expired
|
|
cache_entry.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
|
assert cache_entry.is_expired()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_entry_hit_increment(self, cache_entry):
|
|
"""Test hit counter increment."""
|
|
initial_hits = cache_entry.hit_count
|
|
initial_accessed = cache_entry.last_accessed
|
|
|
|
cache_entry.increment_hit()
|
|
|
|
assert cache_entry.hit_count == initial_hits + 1
|
|
assert cache_entry.last_accessed != initial_accessed
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_entry_data_parsing(self, cache_entry):
|
|
"""Test response data parsing."""
|
|
response_data = cache_entry.get_response_data()
|
|
assert response_data == {"test": "response"}
|
|
|
|
params = cache_entry.get_params()
|
|
assert params == {"test": "params"}
|
|
|
|
|
|
class TestAPIKey:
|
|
"""Tests for APIKey model."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_key_usage_limits(self, api_key):
|
|
"""Test API key usage limit checking."""
|
|
# Should be able to make requests initially
|
|
assert api_key.can_make_request()
|
|
|
|
# Exceed daily limit
|
|
api_key.daily_usage = api_key.daily_limit
|
|
assert not api_key.can_make_request()
|
|
|
|
# Reset daily usage, exceed monthly limit
|
|
api_key.daily_usage = 0
|
|
api_key.monthly_usage = api_key.monthly_limit
|
|
assert not api_key.can_make_request()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_key_expiration(self, api_key):
|
|
"""Test API key expiration."""
|
|
# Set expiration in the past
|
|
api_key.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
|
assert not api_key.can_make_request()
|
|
|
|
# Set expiration in the future
|
|
api_key.expires_at = datetime.now(timezone.utc) + timedelta(days=1)
|
|
assert api_key.can_make_request()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_key_inactive(self, api_key):
|
|
"""Test inactive API key."""
|
|
api_key.is_active = False
|
|
assert not api_key.can_make_request()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_key_usage_increment(self, api_key):
|
|
"""Test usage increment."""
|
|
initial_daily = api_key.daily_usage
|
|
initial_monthly = api_key.monthly_usage
|
|
|
|
api_key.increment_usage()
|
|
|
|
assert api_key.daily_usage == initial_daily + 1
|
|
assert api_key.monthly_usage == initial_monthly + 1
|
|
|
|
|
|
class TestRateLimit:
|
|
"""Tests for RateLimit model."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_checking(self, test_session, api_key):
|
|
"""Test rate limit checking."""
|
|
rate_limit = RateLimit(
|
|
api_key_id=api_key.id,
|
|
endpoint="properties",
|
|
requests_per_minute=60,
|
|
requests_per_hour=3600
|
|
)
|
|
|
|
test_session.add(rate_limit)
|
|
await test_session.commit()
|
|
|
|
# Should allow requests initially
|
|
assert rate_limit.can_make_request()
|
|
|
|
# Exceed minute limit
|
|
rate_limit.minute_requests = rate_limit.requests_per_minute
|
|
assert not rate_limit.can_make_request()
|
|
|
|
# Reset minute, exceed hour limit
|
|
rate_limit.minute_requests = 0
|
|
rate_limit.hour_requests = rate_limit.requests_per_hour
|
|
assert not rate_limit.can_make_request()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_backoff(self, test_session, api_key):
|
|
"""Test exponential backoff."""
|
|
rate_limit = RateLimit(
|
|
api_key_id=api_key.id,
|
|
endpoint="properties",
|
|
backoff_until=datetime.now(timezone.utc) + timedelta(minutes=5)
|
|
)
|
|
|
|
test_session.add(rate_limit)
|
|
await test_session.commit()
|
|
|
|
# Should be blocked due to backoff
|
|
assert not rate_limit.can_make_request()
|
|
|
|
# Remove backoff
|
|
rate_limit.backoff_until = None
|
|
assert rate_limit.can_make_request()
|
|
|
|
|
|
class TestPydanticModels:
|
|
"""Tests for Pydantic request/response models."""
|
|
|
|
def test_create_api_key_request_validation(self):
|
|
"""Test CreateAPIKeyRequest validation."""
|
|
# Valid request
|
|
valid_data = {
|
|
"key_name": "test_key",
|
|
"rentcast_api_key": "valid_key_123",
|
|
"daily_limit": 1000,
|
|
"monthly_limit": 10000
|
|
}
|
|
request = CreateAPIKeyRequest(**valid_data)
|
|
assert request.key_name == "test_key"
|
|
|
|
# Invalid key name with special characters
|
|
with pytest.raises(ValueError):
|
|
CreateAPIKeyRequest(
|
|
key_name="test@key",
|
|
rentcast_api_key="valid_key_123",
|
|
daily_limit=1000,
|
|
monthly_limit=10000
|
|
)
|
|
|
|
def test_update_api_key_request(self):
|
|
"""Test UpdateAPIKeyRequest validation."""
|
|
# Valid update with some fields
|
|
update_data = {
|
|
"daily_limit": 2000,
|
|
"is_active": False
|
|
}
|
|
request = UpdateAPIKeyRequest(**update_data)
|
|
assert request.daily_limit == 2000
|
|
assert request.is_active is False
|
|
assert request.monthly_limit is None # Not provided |