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_API_KEY=your_rentcast_api_key_here
|
||||||
RENTCAST_BASE_URL=https://api.rentcast.io/v1
|
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
|
# Rate Limiting
|
||||||
DAILY_API_LIMIT=100
|
DAILY_API_LIMIT=100
|
||||||
MONTHLY_API_LIMIT=1000
|
MONTHLY_API_LIMIT=1000
|
||||||
|
15
Makefile
15
Makefile
@ -72,6 +72,21 @@ test: ## Run tests in container
|
|||||||
test-local: ## Run tests locally with uv
|
test-local: ## Run tests locally with uv
|
||||||
uv run pytest
|
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
|
coverage: ## Run tests with coverage report
|
||||||
docker compose exec mcrentcast-server uv run pytest --cov=src --cov-report=html:reports/coverage_html
|
docker compose exec mcrentcast-server uv run pytest --cov=src --cov-report=html:reports/coverage_html
|
||||||
@echo "Coverage report: reports/coverage_html/index.html"
|
@echo "Coverage report: reports/coverage_html/index.html"
|
||||||
|
@ -1,4 +1,33 @@
|
|||||||
services:
|
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:
|
mcrentcast-server:
|
||||||
build:
|
build:
|
||||||
context: .
|
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_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")
|
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
|
# Rate Limiting
|
||||||
daily_api_limit: int = Field(default=100, description="Daily API request limit")
|
daily_api_limit: int = Field(default=100, description="Daily API request limit")
|
||||||
monthly_api_limit: int = Field(default=1000, description="Monthly 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):
|
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
|
||||||
self.api_key = api_key or settings.rentcast_api_key
|
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:
|
if not self.api_key:
|
||||||
raise ValueError("Rentcast API key is required")
|
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