diff --git a/.env.example b/.env.example index 4f39f4f..b0eb9d1 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Makefile b/Makefile index 37c096a..3103223 100644 --- a/Makefile +++ b/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" diff --git a/docker-compose.yml b/docker-compose.yml index 6ab6207..e879ab4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: . diff --git a/docs/mock-api.md b/docs/mock-api.md new file mode 100644 index 0000000..c6adfd2 --- /dev/null +++ b/docs/mock-api.md @@ -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 \ No newline at end of file diff --git a/scripts/test_mock_api.py b/scripts/test_mock_api.py new file mode 100755 index 0000000..6f6897f --- /dev/null +++ b/scripts/test_mock_api.py @@ -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) \ No newline at end of file diff --git a/src/mcrentcast/config.py b/src/mcrentcast/config.py index af8423f..99043f4 100644 --- a/src/mcrentcast/config.py +++ b/src/mcrentcast/config.py @@ -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") diff --git a/src/mcrentcast/mock_api.py b/src/mcrentcast/mock_api.py new file mode 100644 index 0000000..ac7ed83 --- /dev/null +++ b/src/mcrentcast/mock_api.py @@ -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) \ No newline at end of file diff --git a/src/mcrentcast/rentcast_client.py b/src/mcrentcast/rentcast_client.py index 535df39..43d37c1 100644 --- a/src/mcrentcast/rentcast_client.py +++ b/src/mcrentcast/rentcast_client.py @@ -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") diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..1c92504 --- /dev/null +++ b/tests/test_integration.py @@ -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() \ No newline at end of file