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:
Ryan Malloy 2025-09-09 08:56:01 -06:00
parent 8b4f9fbfff
commit 723123a6fe
9 changed files with 1298 additions and 1 deletions

View File

@ -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

View File

@ -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"

View File

@ -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
View 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
View 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)

View File

@ -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
View 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)

View File

@ -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
View 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()