Add comprehensive mock Rentcast API for testing
- Complete mock API server with all Rentcast endpoints - Static test API keys with different tiers and limits - Rate limiting simulation for testing - Docker service configuration for mock API - Integration tests using mock API - Configuration support for switching between real/mock APIs - Test script to verify mock API functionality - Comprehensive documentation for mock API usage Test API Keys: - test_key_free_tier: 50 daily limit - test_key_basic: 100 daily limit - test_key_pro: 1000 daily limit - test_key_enterprise: 10000 daily limit - test_key_rate_limited: 1 daily limit (for testing) - test_key_invalid: For testing auth errors
This commit is contained in:
parent
8b4f9fbfff
commit
723123a6fe
@ -9,6 +9,10 @@ MODE=development # development or production
|
||||
RENTCAST_API_KEY=your_rentcast_api_key_here
|
||||
RENTCAST_BASE_URL=https://api.rentcast.io/v1
|
||||
|
||||
# Mock API Settings (for testing)
|
||||
USE_MOCK_API=false
|
||||
MOCK_API_URL=http://mock-rentcast-api:8001/v1
|
||||
|
||||
# Rate Limiting
|
||||
DAILY_API_LIMIT=100
|
||||
MONTHLY_API_LIMIT=1000
|
||||
|
15
Makefile
15
Makefile
@ -72,6 +72,21 @@ test: ## Run tests in container
|
||||
test-local: ## Run tests locally with uv
|
||||
uv run pytest
|
||||
|
||||
test-mock: setup ## Run with mock API for testing
|
||||
@echo "Starting services with mock API..."
|
||||
USE_MOCK_API=true docker compose --profile mock up -d
|
||||
@echo "Mock API started at: https://mock-api.$(DOMAIN)"
|
||||
@echo "Available test API keys:"
|
||||
@echo " - test_key_free_tier (50 daily limit)"
|
||||
@echo " - test_key_basic (100 daily limit)"
|
||||
@echo " - test_key_pro (1000 daily limit)"
|
||||
@echo " - test_key_rate_limited (1 daily limit for testing)"
|
||||
|
||||
mock-api: ## Start only the mock API service
|
||||
docker compose --profile mock up -d mock-rentcast-api
|
||||
@echo "Mock API running at http://localhost:8001"
|
||||
@echo "View test keys at: http://localhost:8001/test-keys"
|
||||
|
||||
coverage: ## Run tests with coverage report
|
||||
docker compose exec mcrentcast-server uv run pytest --cov=src --cov-report=html:reports/coverage_html
|
||||
@echo "Coverage report: reports/coverage_html/index.html"
|
||||
|
@ -1,4 +1,33 @@
|
||||
services:
|
||||
# Mock Rentcast API for testing (only in development mode)
|
||||
mock-rentcast-api:
|
||||
build:
|
||||
context: .
|
||||
target: ${MODE:-development}
|
||||
command: ["uv", "run", "uvicorn", "mcrentcast.mock_api:mock_app", "--host", "0.0.0.0", "--port", "8001", "--reload"]
|
||||
volumes:
|
||||
- ./src:/app/src:ro
|
||||
- ./.env:/app/.env:ro
|
||||
environment:
|
||||
- MODE=${MODE:-development}
|
||||
expose:
|
||||
- "8001"
|
||||
labels:
|
||||
caddy: mock-api.${DOMAIN}
|
||||
caddy.reverse_proxy: "{{upstreams}}"
|
||||
networks:
|
||||
- caddy
|
||||
- internal
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- mock
|
||||
- test
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
mcrentcast-server:
|
||||
build:
|
||||
context: .
|
||||
|
244
docs/mock-api.md
Normal file
244
docs/mock-api.md
Normal file
@ -0,0 +1,244 @@
|
||||
# Mock Rentcast API Documentation
|
||||
|
||||
The mcrentcast project includes a complete mock implementation of the Rentcast API for testing and development purposes. This allows you to:
|
||||
|
||||
- Test without consuming real API credits
|
||||
- Develop offline
|
||||
- Test rate limiting and error scenarios
|
||||
- Run integration tests reliably
|
||||
|
||||
## Available Test API Keys
|
||||
|
||||
The mock API provides several test keys with different rate limits:
|
||||
|
||||
| API Key | Tier | Daily Limit | Monthly Limit | Use Case |
|
||||
|---------|------|-------------|---------------|----------|
|
||||
| `test_key_free_tier` | Free | 50 | 50 | Testing free tier limits |
|
||||
| `test_key_basic` | Basic | 100 | 1,000 | Standard testing |
|
||||
| `test_key_pro` | Pro | 1,000 | 10,000 | High-volume testing |
|
||||
| `test_key_enterprise` | Enterprise | 10,000 | 100,000 | Unlimited testing |
|
||||
| `test_key_rate_limited` | Test | 1 | 1 | Testing rate limit errors |
|
||||
| `test_key_invalid` | Invalid | 0 | 0 | Testing auth errors |
|
||||
|
||||
## Starting the Mock API
|
||||
|
||||
### Option 1: Full Stack with Mock API
|
||||
```bash
|
||||
make test-mock
|
||||
```
|
||||
This starts all services with the mock API enabled.
|
||||
|
||||
### Option 2: Mock API Only
|
||||
```bash
|
||||
make mock-api
|
||||
```
|
||||
This starts only the mock API server on port 8001.
|
||||
|
||||
### Option 3: Docker Compose
|
||||
```bash
|
||||
USE_MOCK_API=true docker compose --profile mock up -d
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
To use the mock API, set these environment variables in your `.env` file:
|
||||
|
||||
```env
|
||||
USE_MOCK_API=true
|
||||
MOCK_API_URL=http://mock-rentcast-api:8001/v1
|
||||
RENTCAST_API_KEY=test_key_basic
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
The mock API implements all Rentcast endpoints:
|
||||
|
||||
### Property Records
|
||||
- `GET /v1/property-records` - Search properties
|
||||
- `GET /v1/property-records/random` - Get random properties
|
||||
- `GET /v1/property-record/{id}` - Get specific property
|
||||
|
||||
### Valuations
|
||||
- `GET /v1/value-estimate` - Property value estimate
|
||||
- `GET /v1/rent-estimate-long-term` - Rental price estimate
|
||||
|
||||
### Listings
|
||||
- `GET /v1/sale-listings` - Properties for sale
|
||||
- `GET /v1/sale-listing/{id}` - Specific sale listing
|
||||
- `GET /v1/rental-listings-long-term` - Rental properties
|
||||
- `GET /v1/rental-listing-long-term/{id}` - Specific rental
|
||||
|
||||
### Market Data
|
||||
- `GET /v1/market-statistics` - Market statistics
|
||||
|
||||
### Utility Endpoints
|
||||
- `GET /health` - Health check
|
||||
- `GET /test-keys` - List available test keys
|
||||
|
||||
## Testing the Mock API
|
||||
|
||||
### Manual Testing
|
||||
```bash
|
||||
# Start the mock API
|
||||
make mock-api
|
||||
|
||||
# Run the test script
|
||||
python scripts/test_mock_api.py
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
```bash
|
||||
# Run integration tests with mock API
|
||||
USE_MOCK_API=true uv run pytest tests/test_integration.py -v
|
||||
```
|
||||
|
||||
### Using curl
|
||||
```bash
|
||||
# Get test keys
|
||||
curl http://localhost:8001/test-keys
|
||||
|
||||
# Search properties
|
||||
curl -H "X-Api-Key: test_key_basic" \
|
||||
"http://localhost:8001/v1/property-records?city=Austin&state=TX&limit=5"
|
||||
|
||||
# Get value estimate
|
||||
curl -H "X-Api-Key: test_key_basic" \
|
||||
"http://localhost:8001/v1/value-estimate?address=123+Main+St"
|
||||
```
|
||||
|
||||
## Mock Data Characteristics
|
||||
|
||||
The mock API generates realistic but randomized data:
|
||||
|
||||
### Property Records
|
||||
- Random addresses from predefined street names
|
||||
- Property types: Single Family, Condo, Townhouse, Multi Family
|
||||
- Bedrooms: 1-5
|
||||
- Bathrooms: 1.0-4.0 (in 0.5 increments)
|
||||
- Square footage: 800-4000
|
||||
- Year built: 1950-2023
|
||||
- Prices: $150,000-$1,500,000
|
||||
|
||||
### Value Estimates
|
||||
- Base price: $200,000-$1,000,000
|
||||
- Price range: ±10% of base price
|
||||
- Confidence levels: High, Medium, Low
|
||||
- Includes 3 comparable properties
|
||||
|
||||
### Rent Estimates
|
||||
- Base rent: $1,500-$5,000/month
|
||||
- Rent range: ±10% of base rent
|
||||
- Adjusts based on bedrooms and square footage
|
||||
- Includes 3 comparable rentals
|
||||
|
||||
### Market Statistics
|
||||
- Median sale price: $300,000-$800,000
|
||||
- Median rent: $1,500-$3,500
|
||||
- Average days on market: 15-60
|
||||
- Inventory count: 100-1,000 properties
|
||||
- Price appreciation: -5% to +15%
|
||||
|
||||
## Rate Limiting Simulation
|
||||
|
||||
The mock API simulates Rentcast's rate limiting:
|
||||
|
||||
1. Each API key has daily and monthly limits
|
||||
2. Requests are tracked per key
|
||||
3. Returns 429 status when limits exceeded
|
||||
4. Daily counters reset after 24 hours
|
||||
|
||||
### Testing Rate Limits
|
||||
```python
|
||||
# Use the rate-limited test key
|
||||
client = RentcastClient(api_key="test_key_rate_limited")
|
||||
|
||||
# First request succeeds
|
||||
response1 = await client.get_property_records(limit=1) # ✅
|
||||
|
||||
# Second request fails with rate limit error
|
||||
response2 = await client.get_property_records(limit=1) # ❌ 429 Error
|
||||
```
|
||||
|
||||
## Error Simulation
|
||||
|
||||
The mock API simulates various error conditions:
|
||||
|
||||
### Authentication Errors (401)
|
||||
- Use an invalid API key
|
||||
- Omit the X-Api-Key header
|
||||
|
||||
### Forbidden Access (403)
|
||||
- Use `test_key_invalid` (suspended account)
|
||||
|
||||
### Rate Limiting (429)
|
||||
- Exceed daily/monthly limits
|
||||
- Use `test_key_rate_limited` for immediate limiting
|
||||
|
||||
### Bad Requests (400)
|
||||
- Omit required parameters
|
||||
- Use invalid parameter values
|
||||
|
||||
## Advantages of Mock API
|
||||
|
||||
1. **Cost-Free Testing**: No API credits consumed
|
||||
2. **Predictable Data**: Consistent test results
|
||||
3. **Offline Development**: No internet required
|
||||
4. **Error Testing**: Simulate edge cases easily
|
||||
5. **CI/CD Integration**: Reliable automated testing
|
||||
6. **Performance Testing**: No rate limits for load testing
|
||||
|
||||
## Switching Between Real and Mock APIs
|
||||
|
||||
### Development (Mock API)
|
||||
```bash
|
||||
USE_MOCK_API=true make dev
|
||||
```
|
||||
|
||||
### Production (Real API)
|
||||
```bash
|
||||
USE_MOCK_API=false RENTCAST_API_KEY=your_real_key make prod
|
||||
```
|
||||
|
||||
### Toggle in Code
|
||||
```python
|
||||
from src.mcrentcast.config import settings
|
||||
|
||||
# Check current mode
|
||||
if settings.use_mock_api:
|
||||
print("Using mock API")
|
||||
else:
|
||||
print("Using real Rentcast API")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Mock API not responding
|
||||
```bash
|
||||
# Check if service is running
|
||||
docker compose ps mock-rentcast-api
|
||||
|
||||
# View logs
|
||||
docker compose logs mock-rentcast-api
|
||||
|
||||
# Restart service
|
||||
docker compose restart mock-rentcast-api
|
||||
```
|
||||
|
||||
### Rate limiting not working
|
||||
- Ensure you're using the correct test key
|
||||
- Check that daily counters haven't been reset
|
||||
- Verify the API usage tracking in logs
|
||||
|
||||
### Data inconsistency
|
||||
- Mock data is randomly generated
|
||||
- Use fixed seeds for deterministic testing
|
||||
- Cache responses for consistent results
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use appropriate test keys** for different scenarios
|
||||
2. **Test error conditions** with special keys
|
||||
3. **Verify caching** works with mock responses
|
||||
4. **Run integration tests** before deploying
|
||||
5. **Document test scenarios** using mock data
|
||||
6. **Monitor mock API logs** for debugging
|
170
scripts/test_mock_api.py
Executable file
170
scripts/test_mock_api.py
Executable file
@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script to verify mock API is working correctly."""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
async def test_mock_api(base_url: str = "http://localhost:8001"):
|
||||
"""Test the mock Rentcast API endpoints."""
|
||||
|
||||
print("🧪 Testing Mock Rentcast API")
|
||||
print("=" * 50)
|
||||
|
||||
# Test API keys
|
||||
test_keys = {
|
||||
"valid": "test_key_basic",
|
||||
"rate_limited": "test_key_rate_limited",
|
||||
"invalid": "invalid_key_123"
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Test 1: Health check
|
||||
print("\n1. Testing health check...")
|
||||
response = await client.get(f"{base_url}/health")
|
||||
assert response.status_code == 200
|
||||
print(" ✅ Health check passed")
|
||||
|
||||
# Test 2: Get test keys
|
||||
print("\n2. Getting available test keys...")
|
||||
response = await client.get(f"{base_url}/test-keys")
|
||||
assert response.status_code == 200
|
||||
keys_data = response.json()
|
||||
print(f" ✅ Found {len(keys_data['test_keys'])} test keys:")
|
||||
for key_info in keys_data['test_keys']:
|
||||
print(f" - {key_info['key']}: {key_info['description']}")
|
||||
|
||||
# Test 3: Valid API request
|
||||
print("\n3. Testing valid API request...")
|
||||
headers = {"X-Api-Key": test_keys["valid"]}
|
||||
response = await client.get(
|
||||
f"{base_url}/v1/property-records",
|
||||
headers=headers,
|
||||
params={"city": "Austin", "state": "TX", "limit": 3}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
print(f" ✅ Retrieved {len(data['properties'])} properties")
|
||||
|
||||
# Test 4: Value estimate
|
||||
print("\n4. Testing value estimate...")
|
||||
response = await client.get(
|
||||
f"{base_url}/v1/value-estimate",
|
||||
headers=headers,
|
||||
params={"address": "123 Test St, Austin, TX"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
estimate = response.json()
|
||||
print(f" ✅ Value estimate: ${estimate['price']:,}")
|
||||
|
||||
# Test 5: Rent estimate
|
||||
print("\n5. Testing rent estimate...")
|
||||
response = await client.get(
|
||||
f"{base_url}/v1/rent-estimate-long-term",
|
||||
headers=headers,
|
||||
params={
|
||||
"address": "456 Test Ave, Dallas, TX",
|
||||
"bedrooms": 3,
|
||||
"bathrooms": 2.0
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
estimate = response.json()
|
||||
print(f" ✅ Rent estimate: ${estimate['rent']:,}/month")
|
||||
|
||||
# Test 6: Market statistics
|
||||
print("\n6. Testing market statistics...")
|
||||
response = await client.get(
|
||||
f"{base_url}/v1/market-statistics",
|
||||
headers=headers,
|
||||
params={"city": "Phoenix", "state": "AZ"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
stats = response.json()
|
||||
print(f" ✅ Median sale price: ${stats['medianSalePrice']:,}")
|
||||
print(f" ✅ Median rent: ${stats['medianRent']:,}")
|
||||
|
||||
# Test 7: Invalid API key
|
||||
print("\n7. Testing invalid API key...")
|
||||
headers = {"X-Api-Key": test_keys["invalid"]}
|
||||
response = await client.get(
|
||||
f"{base_url}/v1/property-records",
|
||||
headers=headers,
|
||||
params={"limit": 1}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
print(" ✅ Invalid key correctly rejected")
|
||||
|
||||
# Test 8: Rate limiting
|
||||
print("\n8. Testing rate limiting...")
|
||||
headers = {"X-Api-Key": test_keys["rate_limited"]}
|
||||
|
||||
# First request should succeed
|
||||
response = await client.get(
|
||||
f"{base_url}/v1/property-records",
|
||||
headers=headers,
|
||||
params={"limit": 1}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
print(" ✅ First request succeeded")
|
||||
|
||||
# Second request should be rate limited
|
||||
response = await client.get(
|
||||
f"{base_url}/v1/property-records",
|
||||
headers=headers,
|
||||
params={"limit": 1}
|
||||
)
|
||||
assert response.status_code == 429
|
||||
print(" ✅ Rate limiting working correctly")
|
||||
|
||||
# Test 9: Specific property by ID
|
||||
print("\n9. Testing get property by ID...")
|
||||
headers = {"X-Api-Key": test_keys["valid"]}
|
||||
response = await client.get(
|
||||
f"{base_url}/v1/property-record/prop_000123",
|
||||
headers=headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
property_data = response.json()
|
||||
assert property_data["property"]["id"] == "prop_000123"
|
||||
print(f" ✅ Retrieved property: {property_data['property']['address']}")
|
||||
|
||||
# Test 10: Random properties
|
||||
print("\n10. Testing random properties...")
|
||||
response = await client.get(
|
||||
f"{base_url}/v1/property-records/random",
|
||||
headers=headers,
|
||||
params={"limit": 5}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
cities = {prop["city"] for prop in data["properties"]}
|
||||
print(f" ✅ Retrieved {len(data['properties'])} random properties")
|
||||
print(f" ✅ Cities: {', '.join(cities)}")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("✅ All tests passed successfully!")
|
||||
print("\n📝 Mock API is ready for use with these test keys:")
|
||||
print(" - test_key_free_tier (50 daily limit)")
|
||||
print(" - test_key_basic (100 daily limit)")
|
||||
print(" - test_key_pro (1000 daily limit)")
|
||||
print(" - test_key_enterprise (10000 daily limit)")
|
||||
print(" - test_key_rate_limited (1 daily limit - for testing)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(test_mock_api())
|
||||
except AssertionError as e:
|
||||
print(f"\n❌ Test failed: {e}")
|
||||
exit(1)
|
||||
except httpx.ConnectError:
|
||||
print("\n❌ Could not connect to mock API server")
|
||||
print(" Please ensure the mock API is running:")
|
||||
print(" make mock-api")
|
||||
exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n❌ Unexpected error: {e}")
|
||||
exit(1)
|
@ -19,6 +19,10 @@ class Settings(BaseSettings):
|
||||
rentcast_api_key: Optional[str] = Field(default=None, description="Rentcast API key")
|
||||
rentcast_base_url: str = Field(default="https://api.rentcast.io/v1", description="Rentcast API base URL")
|
||||
|
||||
# Mock API Settings
|
||||
use_mock_api: bool = Field(default=False, description="Use mock API for testing")
|
||||
mock_api_url: str = Field(default="http://mock-rentcast-api:8001/v1", description="Mock API URL")
|
||||
|
||||
# Rate Limiting
|
||||
daily_api_limit: int = Field(default=100, description="Daily API request limit")
|
||||
monthly_api_limit: int = Field(default=1000, description="Monthly API request limit")
|
||||
|
486
src/mcrentcast/mock_api.py
Normal file
486
src/mcrentcast/mock_api.py
Normal file
@ -0,0 +1,486 @@
|
||||
"""Mock Rentcast API server for testing."""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Header, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Create FastAPI app for mock server
|
||||
mock_app = FastAPI(title="Mock Rentcast API", version="1.0.0")
|
||||
|
||||
# Static test API keys
|
||||
TEST_API_KEYS = {
|
||||
"test_key_free_tier": {"tier": "free", "daily_limit": 50, "monthly_limit": 50},
|
||||
"test_key_basic": {"tier": "basic", "daily_limit": 100, "monthly_limit": 1000},
|
||||
"test_key_pro": {"tier": "pro", "daily_limit": 1000, "monthly_limit": 10000},
|
||||
"test_key_enterprise": {"tier": "enterprise", "daily_limit": 10000, "monthly_limit": 100000},
|
||||
"test_key_rate_limited": {"tier": "test", "daily_limit": 1, "monthly_limit": 1},
|
||||
"test_key_invalid": {"tier": "invalid", "daily_limit": 0, "monthly_limit": 0},
|
||||
}
|
||||
|
||||
# Track API usage for rate limiting simulation
|
||||
api_usage: Dict[str, Dict[str, int]] = {}
|
||||
|
||||
# Test data generators
|
||||
def generate_property_record(index: int = 0, city: str = "Austin", state: str = "TX") -> Dict[str, Any]:
|
||||
"""Generate a mock property record."""
|
||||
streets = ["Main St", "Oak Ave", "Park Blvd", "Elm Dr", "First St", "Second Ave", "Lake Rd", "Hill Dr"]
|
||||
property_types = ["Single Family", "Condo", "Townhouse", "Multi Family"]
|
||||
|
||||
return {
|
||||
"id": f"prop_{index:06d}",
|
||||
"address": f"{100 + index} {random.choice(streets)}",
|
||||
"city": city,
|
||||
"state": state,
|
||||
"zipCode": f"{78700 + (index % 100):05d}",
|
||||
"county": f"{city} County",
|
||||
"propertyType": random.choice(property_types),
|
||||
"bedrooms": random.randint(1, 5),
|
||||
"bathrooms": random.choice([1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0]),
|
||||
"squareFootage": random.randint(800, 4000),
|
||||
"lotSize": round(random.uniform(0.1, 2.0), 2),
|
||||
"yearBuilt": random.randint(1950, 2023),
|
||||
"lastSaleDate": f"{random.randint(2010, 2023)}-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}",
|
||||
"lastSalePrice": random.randint(150000, 1500000),
|
||||
"taxAssessments": [
|
||||
{
|
||||
"year": 2023,
|
||||
"land": random.randint(50000, 200000),
|
||||
"improvements": random.randint(100000, 800000),
|
||||
"total": random.randint(150000, 1000000)
|
||||
}
|
||||
],
|
||||
"owner": {
|
||||
"name": f"Owner {index}",
|
||||
"mailingAddress": f"{200 + index} Business Park",
|
||||
"mailingCity": city,
|
||||
"mailingState": state,
|
||||
"mailingZipCode": f"{78700 + (index % 100):05d}"
|
||||
},
|
||||
"features": {
|
||||
"cooling": "Central Air",
|
||||
"heating": "Forced Air",
|
||||
"parking": f"{random.randint(1, 3)} Car Garage",
|
||||
"pool": random.choice([True, False]),
|
||||
"fireplace": random.choice([True, False])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def generate_value_estimate(address: str) -> Dict[str, Any]:
|
||||
"""Generate a mock value estimate."""
|
||||
base_price = random.randint(200000, 1000000)
|
||||
return {
|
||||
"address": address,
|
||||
"price": base_price,
|
||||
"priceRangeLow": int(base_price * 0.9),
|
||||
"priceRangeHigh": int(base_price * 1.1),
|
||||
"confidence": random.choice(["High", "Medium", "Low"]),
|
||||
"lastSaleDate": f"{random.randint(2015, 2023)}-{random.randint(1, 12):02d}-15",
|
||||
"lastSalePrice": int(base_price * random.uniform(0.8, 0.95)),
|
||||
"comparables": [
|
||||
{
|
||||
"address": f"{100 + i} Nearby St",
|
||||
"price": base_price + random.randint(-50000, 50000),
|
||||
"distance": round(random.uniform(0.1, 1.0), 2)
|
||||
}
|
||||
for i in range(3)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def generate_rent_estimate(address: str) -> Dict[str, Any]:
|
||||
"""Generate a mock rent estimate."""
|
||||
base_rent = random.randint(1500, 5000)
|
||||
return {
|
||||
"address": address,
|
||||
"rent": base_rent,
|
||||
"rentRangeLow": int(base_rent * 0.9),
|
||||
"rentRangeHigh": int(base_rent * 1.1),
|
||||
"confidence": random.choice(["High", "Medium", "Low"]),
|
||||
"comparables": [
|
||||
{
|
||||
"address": f"{200 + i} Rental Ave",
|
||||
"rent": base_rent + random.randint(-300, 300),
|
||||
"distance": round(random.uniform(0.1, 1.0), 2)
|
||||
}
|
||||
for i in range(3)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def generate_sale_listing(index: int = 0, city: str = "Austin", state: str = "TX") -> Dict[str, Any]:
|
||||
"""Generate a mock sale listing."""
|
||||
return {
|
||||
"id": f"sale_{index:06d}",
|
||||
"address": f"{300 + index} Market St",
|
||||
"city": city,
|
||||
"state": state,
|
||||
"zipCode": f"{78700 + (index % 100):05d}",
|
||||
"price": random.randint(250000, 1500000),
|
||||
"bedrooms": random.randint(2, 5),
|
||||
"bathrooms": random.choice([2.0, 2.5, 3.0, 3.5, 4.0]),
|
||||
"squareFootage": random.randint(1200, 4000),
|
||||
"propertyType": random.choice(["Single Family", "Condo", "Townhouse"]),
|
||||
"listingDate": f"2024-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}",
|
||||
"daysOnMarket": random.randint(1, 120),
|
||||
"photos": [f"https://example.com/photo{i}.jpg" for i in range(5)],
|
||||
"description": f"Beautiful {random.choice(['modern', 'updated', 'spacious'])} home in {city}"
|
||||
}
|
||||
|
||||
|
||||
def generate_rental_listing(index: int = 0, city: str = "Austin", state: str = "TX") -> Dict[str, Any]:
|
||||
"""Generate a mock rental listing."""
|
||||
return {
|
||||
"id": f"rental_{index:06d}",
|
||||
"address": f"{400 + index} Rental Rd",
|
||||
"city": city,
|
||||
"state": state,
|
||||
"zipCode": f"{78700 + (index % 100):05d}",
|
||||
"rent": random.randint(1200, 4000),
|
||||
"bedrooms": random.randint(1, 4),
|
||||
"bathrooms": random.choice([1.0, 1.5, 2.0, 2.5, 3.0]),
|
||||
"squareFootage": random.randint(700, 2500),
|
||||
"propertyType": random.choice(["Apartment", "Single Family", "Condo", "Townhouse"]),
|
||||
"availableDate": f"2024-{random.randint(1, 12):02d}-01",
|
||||
"pets": random.choice(["Cats OK", "Dogs OK", "No Pets", "Cats and Dogs OK"]),
|
||||
"photos": [f"https://example.com/rental{i}.jpg" for i in range(3)],
|
||||
"description": f"Charming {random.choice(['cozy', 'spacious', 'modern'])} rental in {city}"
|
||||
}
|
||||
|
||||
|
||||
def generate_market_statistics(city: Optional[str] = None, state: Optional[str] = None,
|
||||
zipCode: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Generate mock market statistics."""
|
||||
return {
|
||||
"city": city or "Austin",
|
||||
"state": state or "TX",
|
||||
"zipCode": zipCode,
|
||||
"medianSalePrice": random.randint(300000, 800000),
|
||||
"medianRent": random.randint(1500, 3500),
|
||||
"averageDaysOnMarket": random.randint(15, 60),
|
||||
"inventoryCount": random.randint(100, 1000),
|
||||
"pricePerSquareFoot": round(random.uniform(150, 400), 2),
|
||||
"rentPerSquareFoot": round(random.uniform(1.0, 3.0), 2),
|
||||
"appreciation": round(random.uniform(-5.0, 15.0), 2)
|
||||
}
|
||||
|
||||
|
||||
def check_api_key(api_key: str) -> Dict[str, Any]:
|
||||
"""Check if API key is valid and not rate limited."""
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=401, detail="API key required")
|
||||
|
||||
if api_key not in TEST_API_KEYS:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
key_info = TEST_API_KEYS[api_key]
|
||||
|
||||
# Check if this is the invalid test key
|
||||
if key_info["tier"] == "invalid":
|
||||
raise HTTPException(status_code=403, detail="API key has been suspended")
|
||||
|
||||
# Track usage
|
||||
if api_key not in api_usage:
|
||||
api_usage[api_key] = {"daily": 0, "monthly": 0, "last_reset": datetime.now()}
|
||||
|
||||
usage = api_usage[api_key]
|
||||
|
||||
# Reset daily counter if needed
|
||||
if datetime.now() - usage["last_reset"] > timedelta(days=1):
|
||||
usage["daily"] = 0
|
||||
usage["last_reset"] = datetime.now()
|
||||
|
||||
# Check rate limits
|
||||
if usage["daily"] >= key_info["daily_limit"]:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Daily rate limit exceeded ({key_info['daily_limit']} requests)"
|
||||
)
|
||||
|
||||
if usage["monthly"] >= key_info["monthly_limit"]:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Monthly rate limit exceeded ({key_info['monthly_limit']} requests)"
|
||||
)
|
||||
|
||||
# Increment usage
|
||||
usage["daily"] += 1
|
||||
usage["monthly"] += 1
|
||||
|
||||
return key_info
|
||||
|
||||
|
||||
# API Endpoints
|
||||
|
||||
@mock_app.get("/v1/property-records")
|
||||
async def get_property_records(
|
||||
x_api_key: str = Header(None, alias="X-Api-Key"),
|
||||
address: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
zipCode: Optional[str] = None,
|
||||
limit: int = Query(10, le=500),
|
||||
offset: int = Query(0, ge=0)
|
||||
):
|
||||
"""Get property records."""
|
||||
check_api_key(x_api_key)
|
||||
|
||||
# Generate mock properties based on search criteria
|
||||
properties = []
|
||||
for i in range(offset, min(offset + limit, 500)):
|
||||
prop = generate_property_record(i, city or "Austin", state or "TX")
|
||||
|
||||
# Filter by search criteria
|
||||
if address and address.lower() not in prop["address"].lower():
|
||||
continue
|
||||
if city and city.lower() != prop["city"].lower():
|
||||
continue
|
||||
if state and state.upper() != prop["state"].upper():
|
||||
continue
|
||||
if zipCode and zipCode != prop["zipCode"]:
|
||||
continue
|
||||
|
||||
properties.append(prop)
|
||||
|
||||
if len(properties) >= limit:
|
||||
break
|
||||
|
||||
return {"properties": properties, "total": len(properties)}
|
||||
|
||||
|
||||
@mock_app.get("/v1/property-records/random")
|
||||
async def get_random_property_records(
|
||||
x_api_key: str = Header(None, alias="X-Api-Key"),
|
||||
limit: int = Query(10, le=500)
|
||||
):
|
||||
"""Get random property records."""
|
||||
check_api_key(x_api_key)
|
||||
|
||||
properties = []
|
||||
for _ in range(limit):
|
||||
index = random.randint(0, 10000)
|
||||
city = random.choice(["Austin", "Dallas", "Houston", "San Antonio", "Phoenix", "Denver"])
|
||||
state = random.choice(["TX", "AZ", "CO", "CA", "FL"])
|
||||
properties.append(generate_property_record(index, city, state))
|
||||
|
||||
return {"properties": properties}
|
||||
|
||||
|
||||
@mock_app.get("/v1/property-record/{property_id}")
|
||||
async def get_property_record(
|
||||
property_id: str,
|
||||
x_api_key: str = Header(None, alias="X-Api-Key")
|
||||
):
|
||||
"""Get specific property record."""
|
||||
check_api_key(x_api_key)
|
||||
|
||||
# Extract index from property_id or use random
|
||||
try:
|
||||
index = int(property_id.split("_")[1])
|
||||
except:
|
||||
index = random.randint(0, 10000)
|
||||
|
||||
property_data = generate_property_record(index)
|
||||
property_data["id"] = property_id
|
||||
|
||||
return {"property": property_data}
|
||||
|
||||
|
||||
@mock_app.get("/v1/value-estimate")
|
||||
async def get_value_estimate(
|
||||
address: str,
|
||||
x_api_key: str = Header(None, alias="X-Api-Key")
|
||||
):
|
||||
"""Get property value estimate."""
|
||||
check_api_key(x_api_key)
|
||||
|
||||
if not address:
|
||||
raise HTTPException(status_code=400, detail="Address is required")
|
||||
|
||||
return generate_value_estimate(address)
|
||||
|
||||
|
||||
@mock_app.get("/v1/rent-estimate-long-term")
|
||||
async def get_rent_estimate(
|
||||
address: str,
|
||||
x_api_key: str = Header(None, alias="X-Api-Key"),
|
||||
propertyType: Optional[str] = None,
|
||||
bedrooms: Optional[int] = None,
|
||||
bathrooms: Optional[float] = None,
|
||||
squareFootage: Optional[int] = None
|
||||
):
|
||||
"""Get rent estimate."""
|
||||
check_api_key(x_api_key)
|
||||
|
||||
if not address:
|
||||
raise HTTPException(status_code=400, detail="Address is required")
|
||||
|
||||
estimate = generate_rent_estimate(address)
|
||||
|
||||
# Adjust estimate based on provided details
|
||||
if bedrooms:
|
||||
estimate["rent"] += (bedrooms - 2) * 200
|
||||
if squareFootage:
|
||||
estimate["rent"] = int(estimate["rent"] * (squareFootage / 1500))
|
||||
|
||||
return estimate
|
||||
|
||||
|
||||
@mock_app.get("/v1/sale-listings")
|
||||
async def get_sale_listings(
|
||||
x_api_key: str = Header(None, alias="X-Api-Key"),
|
||||
address: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
zipCode: Optional[str] = None,
|
||||
limit: int = Query(10, le=500),
|
||||
offset: int = Query(0, ge=0)
|
||||
):
|
||||
"""Get sale listings."""
|
||||
check_api_key(x_api_key)
|
||||
|
||||
listings = []
|
||||
for i in range(offset, min(offset + limit, 500)):
|
||||
listing = generate_sale_listing(i, city or "Austin", state or "TX")
|
||||
|
||||
# Filter by search criteria
|
||||
if city and city.lower() != listing["city"].lower():
|
||||
continue
|
||||
if state and state.upper() != listing["state"].upper():
|
||||
continue
|
||||
if zipCode and zipCode != listing["zipCode"]:
|
||||
continue
|
||||
|
||||
listings.append(listing)
|
||||
|
||||
if len(listings) >= limit:
|
||||
break
|
||||
|
||||
return {"listings": listings, "total": len(listings)}
|
||||
|
||||
|
||||
@mock_app.get("/v1/sale-listing/{listing_id}")
|
||||
async def get_sale_listing(
|
||||
listing_id: str,
|
||||
x_api_key: str = Header(None, alias="X-Api-Key")
|
||||
):
|
||||
"""Get specific sale listing."""
|
||||
check_api_key(x_api_key)
|
||||
|
||||
try:
|
||||
index = int(listing_id.split("_")[1])
|
||||
except:
|
||||
index = random.randint(0, 10000)
|
||||
|
||||
listing = generate_sale_listing(index)
|
||||
listing["id"] = listing_id
|
||||
|
||||
return {"listing": listing}
|
||||
|
||||
|
||||
@mock_app.get("/v1/rental-listings-long-term")
|
||||
async def get_rental_listings(
|
||||
x_api_key: str = Header(None, alias="X-Api-Key"),
|
||||
address: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
zipCode: Optional[str] = None,
|
||||
limit: int = Query(10, le=500),
|
||||
offset: int = Query(0, ge=0)
|
||||
):
|
||||
"""Get rental listings."""
|
||||
check_api_key(x_api_key)
|
||||
|
||||
listings = []
|
||||
for i in range(offset, min(offset + limit, 500)):
|
||||
listing = generate_rental_listing(i, city or "Austin", state or "TX")
|
||||
|
||||
# Filter by search criteria
|
||||
if city and city.lower() != listing["city"].lower():
|
||||
continue
|
||||
if state and state.upper() != listing["state"].upper():
|
||||
continue
|
||||
if zipCode and zipCode != listing["zipCode"]:
|
||||
continue
|
||||
|
||||
listings.append(listing)
|
||||
|
||||
if len(listings) >= limit:
|
||||
break
|
||||
|
||||
return {"listings": listings, "total": len(listings)}
|
||||
|
||||
|
||||
@mock_app.get("/v1/rental-listing-long-term/{listing_id}")
|
||||
async def get_rental_listing(
|
||||
listing_id: str,
|
||||
x_api_key: str = Header(None, alias="X-Api-Key")
|
||||
):
|
||||
"""Get specific rental listing."""
|
||||
check_api_key(x_api_key)
|
||||
|
||||
try:
|
||||
index = int(listing_id.split("_")[1])
|
||||
except:
|
||||
index = random.randint(0, 10000)
|
||||
|
||||
listing = generate_rental_listing(index)
|
||||
listing["id"] = listing_id
|
||||
|
||||
return {"listing": listing}
|
||||
|
||||
|
||||
@mock_app.get("/v1/market-statistics")
|
||||
async def get_market_statistics(
|
||||
x_api_key: str = Header(None, alias="X-Api-Key"),
|
||||
city: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
zipCode: Optional[str] = None
|
||||
):
|
||||
"""Get market statistics."""
|
||||
check_api_key(x_api_key)
|
||||
|
||||
if not any([city, state, zipCode]):
|
||||
raise HTTPException(status_code=400, detail="At least one location parameter required")
|
||||
|
||||
return generate_market_statistics(city, state, zipCode)
|
||||
|
||||
|
||||
@mock_app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "mock-rentcast-api",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
@mock_app.get("/test-keys")
|
||||
async def get_test_keys():
|
||||
"""Get list of available test API keys."""
|
||||
return {
|
||||
"test_keys": [
|
||||
{
|
||||
"key": key,
|
||||
"tier": info["tier"],
|
||||
"daily_limit": info["daily_limit"],
|
||||
"monthly_limit": info["monthly_limit"],
|
||||
"description": f"Test key for {info['tier']} tier"
|
||||
}
|
||||
for key, info in TEST_API_KEYS.items()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(mock_app, host="0.0.0.0", port=8001)
|
@ -45,7 +45,15 @@ class RentcastClient:
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
|
||||
self.api_key = api_key or settings.rentcast_api_key
|
||||
self.base_url = base_url or settings.rentcast_base_url
|
||||
|
||||
# Use mock API if configured
|
||||
if settings.use_mock_api:
|
||||
self.base_url = settings.mock_api_url
|
||||
# Use a test key if no key provided and in mock mode
|
||||
if not self.api_key:
|
||||
self.api_key = "test_key_basic"
|
||||
else:
|
||||
self.base_url = base_url or settings.rentcast_base_url
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("Rentcast API key is required")
|
||||
|
337
tests/test_integration.py
Normal file
337
tests/test_integration.py
Normal file
@ -0,0 +1,337 @@
|
||||
"""Integration tests using mock Rentcast API."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
# Set mock API mode for tests
|
||||
os.environ["USE_MOCK_API"] = "true"
|
||||
os.environ["MOCK_API_URL"] = "http://localhost:8001/v1"
|
||||
os.environ["RENTCAST_API_KEY"] = "test_key_basic"
|
||||
|
||||
from src.mcrentcast.config import settings
|
||||
from src.mcrentcast.rentcast_client import RentcastClient, RateLimitExceeded, RentcastAPIError
|
||||
from src.mcrentcast.database import DatabaseManager
|
||||
from src.mcrentcast.mock_api import mock_app, TEST_API_KEYS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_api_server():
|
||||
"""Start mock API server for testing."""
|
||||
import uvicorn
|
||||
from threading import Thread
|
||||
|
||||
# Run server in a thread
|
||||
server = uvicorn.Server(
|
||||
uvicorn.Config(mock_app, host="127.0.0.1", port=8001, log_level="error")
|
||||
)
|
||||
thread = Thread(target=server.run)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Wait for server to start
|
||||
await asyncio.sleep(1)
|
||||
|
||||
yield
|
||||
|
||||
# Server will stop when thread ends
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Create Rentcast client for testing."""
|
||||
settings.use_mock_api = True
|
||||
settings.mock_api_url = "http://localhost:8001/v1"
|
||||
client = RentcastClient(api_key="test_key_basic")
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_manager():
|
||||
"""Create database manager for testing."""
|
||||
# Use in-memory SQLite for tests
|
||||
db = DatabaseManager("sqlite:///:memory:")
|
||||
db.create_tables()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_property_search(mock_api_server, client, db_manager):
|
||||
"""Test searching for properties."""
|
||||
# Search properties
|
||||
properties, is_cached, cache_age = await client.get_property_records(
|
||||
city="Austin", state="TX", limit=5
|
||||
)
|
||||
|
||||
assert len(properties) == 5
|
||||
assert not is_cached # First request
|
||||
assert cache_age is None
|
||||
|
||||
# Check properties have expected fields
|
||||
for prop in properties:
|
||||
assert prop.city == "Austin"
|
||||
assert prop.state == "TX"
|
||||
assert prop.address is not None
|
||||
assert prop.bedrooms is not None
|
||||
assert prop.bathrooms is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_caching_behavior(mock_api_server, client, db_manager):
|
||||
"""Test that responses are cached properly."""
|
||||
# Patch db_manager in client module
|
||||
with patch("src.mcrentcast.rentcast_client.db_manager", db_manager):
|
||||
# First request - should not be cached
|
||||
properties1, is_cached1, cache_age1 = await client.get_property_records(
|
||||
city="Dallas", state="TX", limit=3
|
||||
)
|
||||
|
||||
assert not is_cached1
|
||||
assert cache_age1 is None
|
||||
assert len(properties1) == 3
|
||||
|
||||
# Second identical request - should be cached
|
||||
properties2, is_cached2, cache_age2 = await client.get_property_records(
|
||||
city="Dallas", state="TX", limit=3
|
||||
)
|
||||
|
||||
assert is_cached2
|
||||
assert cache_age2 is not None
|
||||
assert cache_age2 >= 0
|
||||
assert properties2 == properties1 # Same data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_value_estimate(mock_api_server, client):
|
||||
"""Test getting property value estimate."""
|
||||
estimate, is_cached, cache_age = await client.get_value_estimate(
|
||||
address="123 Main St, Austin, TX"
|
||||
)
|
||||
|
||||
assert estimate is not None
|
||||
assert estimate.address == "123 Main St, Austin, TX"
|
||||
assert estimate.price is not None
|
||||
assert estimate.priceRangeLow is not None
|
||||
assert estimate.priceRangeHigh is not None
|
||||
assert estimate.confidence in ["High", "Medium", "Low"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rent_estimate(mock_api_server, client):
|
||||
"""Test getting rent estimate."""
|
||||
estimate, is_cached, cache_age = await client.get_rent_estimate(
|
||||
address="456 Oak Ave, Dallas, TX",
|
||||
bedrooms=3,
|
||||
bathrooms=2.0,
|
||||
squareFootage=1800
|
||||
)
|
||||
|
||||
assert estimate is not None
|
||||
assert estimate.address == "456 Oak Ave, Dallas, TX"
|
||||
assert estimate.rent is not None
|
||||
assert estimate.rentRangeLow is not None
|
||||
assert estimate.rentRangeHigh is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sale_listings(mock_api_server, client):
|
||||
"""Test searching sale listings."""
|
||||
listings, is_cached, cache_age = await client.get_sale_listings(
|
||||
city="Houston", state="TX", limit=10
|
||||
)
|
||||
|
||||
assert len(listings) <= 10
|
||||
for listing in listings:
|
||||
assert listing.city == "Houston"
|
||||
assert listing.state == "TX"
|
||||
assert listing.price is not None
|
||||
assert listing.bedrooms is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rental_listings(mock_api_server, client):
|
||||
"""Test searching rental listings."""
|
||||
listings, is_cached, cache_age = await client.get_rental_listings(
|
||||
city="San Antonio", state="TX", limit=8
|
||||
)
|
||||
|
||||
assert len(listings) <= 8
|
||||
for listing in listings:
|
||||
assert listing.city == "San Antonio"
|
||||
assert listing.state == "TX"
|
||||
assert listing.rent is not None
|
||||
assert listing.bedrooms is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_market_statistics(mock_api_server, client):
|
||||
"""Test getting market statistics."""
|
||||
stats, is_cached, cache_age = await client.get_market_statistics(
|
||||
city="Phoenix", state="AZ"
|
||||
)
|
||||
|
||||
assert stats is not None
|
||||
assert stats.city == "Phoenix"
|
||||
assert stats.state == "AZ"
|
||||
assert stats.medianSalePrice is not None
|
||||
assert stats.medianRent is not None
|
||||
assert stats.inventoryCount is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limiting(mock_api_server):
|
||||
"""Test rate limiting with limited API key."""
|
||||
# Use rate limited key
|
||||
limited_client = RentcastClient(api_key="test_key_rate_limited")
|
||||
|
||||
try:
|
||||
# First request should succeed
|
||||
properties1, _, _ = await limited_client.get_property_records(limit=1)
|
||||
assert len(properties1) >= 0
|
||||
|
||||
# Second request should fail due to rate limit
|
||||
with pytest.raises(RentcastAPIError) as exc_info:
|
||||
await limited_client.get_property_records(limit=1)
|
||||
|
||||
assert "rate limit" in str(exc_info.value).lower()
|
||||
|
||||
finally:
|
||||
await limited_client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_api_key(mock_api_server):
|
||||
"""Test using invalid API key."""
|
||||
# Use invalid key
|
||||
invalid_client = RentcastClient(api_key="invalid_key_123")
|
||||
|
||||
try:
|
||||
with pytest.raises(RentcastAPIError) as exc_info:
|
||||
await invalid_client.get_property_records(limit=1)
|
||||
|
||||
assert "Invalid API key" in str(exc_info.value) or "401" in str(exc_info.value)
|
||||
|
||||
finally:
|
||||
await invalid_client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_specific_property_by_id(mock_api_server, client):
|
||||
"""Test getting specific property by ID."""
|
||||
property_record, is_cached, cache_age = await client.get_property_record(
|
||||
property_id="prop_000123"
|
||||
)
|
||||
|
||||
assert property_record is not None
|
||||
assert property_record.id == "prop_000123"
|
||||
assert property_record.address is not None
|
||||
assert property_record.city is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_random_properties(mock_api_server, client):
|
||||
"""Test getting random property records."""
|
||||
properties, is_cached, cache_age = await client.get_random_property_records(
|
||||
limit=5
|
||||
)
|
||||
|
||||
assert len(properties) == 5
|
||||
# Check that properties have varied cities (randomized)
|
||||
cities = {prop.city for prop in properties}
|
||||
assert len(cities) >= 1 # At least some variety
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_expiration(mock_api_server, db_manager):
|
||||
"""Test cache expiration and cleanup."""
|
||||
with patch("src.mcrentcast.rentcast_client.db_manager", db_manager):
|
||||
client = RentcastClient(api_key="test_key_basic")
|
||||
|
||||
try:
|
||||
# Make a request to cache it
|
||||
await client.get_property_records(city="Denver", state="CO", limit=2)
|
||||
|
||||
# Check cache stats
|
||||
stats = await db_manager.get_cache_stats()
|
||||
assert stats.total_entries == 1
|
||||
|
||||
# Force expire cache
|
||||
cache_key = client._create_cache_key(
|
||||
"property-records",
|
||||
{"city": "Denver", "state": "CO", "limit": 2}
|
||||
)
|
||||
expired = await db_manager.expire_cache_entry(cache_key)
|
||||
assert expired
|
||||
|
||||
# Check cache stats again
|
||||
stats = await db_manager.get_cache_stats()
|
||||
assert stats.total_entries == 0
|
||||
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cost_estimation(mock_api_server, client):
|
||||
"""Test API cost estimation."""
|
||||
# Test various endpoints
|
||||
assert client._estimate_cost("property-records") == Decimal("0.10")
|
||||
assert client._estimate_cost("value-estimate") == Decimal("0.15")
|
||||
assert client._estimate_cost("rent-estimate-long-term") == Decimal("0.15")
|
||||
assert client._estimate_cost("sale-listings") == Decimal("0.08")
|
||||
assert client._estimate_cost("market-statistics") == Decimal("0.20")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagination(mock_api_server, client):
|
||||
"""Test pagination with offset."""
|
||||
# Get first page
|
||||
page1, _, _ = await client.get_property_records(
|
||||
city="Austin", state="TX", limit=5, offset=0
|
||||
)
|
||||
|
||||
# Get second page
|
||||
page2, _, _ = await client.get_property_records(
|
||||
city="Austin", state="TX", limit=5, offset=5
|
||||
)
|
||||
|
||||
# Pages should have different properties
|
||||
page1_addresses = {prop.address for prop in page1}
|
||||
page2_addresses = {prop.address for prop in page2}
|
||||
|
||||
# No overlap between pages
|
||||
assert len(page1_addresses.intersection(page2_addresses)) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_usage_tracking(mock_api_server, db_manager):
|
||||
"""Test API usage tracking."""
|
||||
with patch("src.mcrentcast.rentcast_client.db_manager", db_manager):
|
||||
client = RentcastClient(api_key="test_key_basic")
|
||||
|
||||
try:
|
||||
# Make several API calls
|
||||
await client.get_property_records(city="Austin", limit=2)
|
||||
await client.get_value_estimate("123 Test St")
|
||||
await client.get_rent_estimate("456 Test Ave")
|
||||
|
||||
# Check usage stats
|
||||
stats = await db_manager.get_usage_stats(days=1)
|
||||
|
||||
assert stats["total_requests"] == 3
|
||||
assert stats["cache_hits"] == 0
|
||||
assert stats["cache_misses"] == 3
|
||||
assert stats["total_cost"] > 0
|
||||
|
||||
# Make cached request
|
||||
await client.get_property_records(city="Austin", limit=2)
|
||||
|
||||
stats = await db_manager.get_usage_stats(days=1)
|
||||
assert stats["total_requests"] == 4
|
||||
assert stats["cache_hits"] == 1
|
||||
|
||||
finally:
|
||||
await client.close()
|
Loading…
x
Reference in New Issue
Block a user