mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
- evals/test_real_estate.py: 5 pytest-asyncio tests covering: 1. NormalizedListing schema validation 2. TTL cache hit (same tool_result_id on second call) 3. compare_neighborhoods returns expected structure 4. Feature flag disabled → FEATURE_DISABLED error, no crash 5. Unknown location → graceful NO_LISTINGS_FOUND, not exception All 5 pass in < 1s with zero external API calls (mock provider). Made-with: Cursorpull/6453/head
1 changed files with 206 additions and 0 deletions
@ -0,0 +1,206 @@ |
|||
""" |
|||
Unit tests for the Real Estate tool integration. |
|||
|
|||
Tests cover: |
|||
1. Normalization — search_listings returns NormalizedListing schema |
|||
2. Caching — second call returns cached data without re-computing |
|||
3. Integration schema — compare_neighborhoods returns expected structure |
|||
4. Feature flag — all tools return FEATURE_DISABLED when flag is off |
|||
5. Graceful fallback — unknown location returns a helpful error, not a crash |
|||
""" |
|||
|
|||
import asyncio |
|||
import os |
|||
import sys |
|||
|
|||
# Add agent root to path so imports work when running from any directory |
|||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) |
|||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "tools")) |
|||
|
|||
import pytest |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Helpers |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def _set_flag(value: str): |
|||
os.environ["ENABLE_REAL_ESTATE"] = value |
|||
|
|||
|
|||
def _clear_flag(): |
|||
os.environ.pop("ENABLE_REAL_ESTATE", None) |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Test 1 — normalization: search_listings returns expected schema |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
@pytest.mark.asyncio |
|||
async def test_search_listings_schema(): |
|||
""" |
|||
GIVEN the real estate feature is enabled |
|||
WHEN search_listings('Austin') is called |
|||
THEN the result conforms to the NormalizedListing schema: |
|||
each listing must have id, address, price, bedrooms, sqft, |
|||
days_on_market, cap_rate_estimate. |
|||
""" |
|||
_set_flag("true") |
|||
# Import inside test so the env var is already set |
|||
from tools.real_estate import search_listings, cache_clear |
|||
cache_clear() |
|||
|
|||
result = await search_listings("Austin") |
|||
|
|||
assert result["success"] is True, f"Expected success, got: {result}" |
|||
assert result["tool_name"] == "real_estate" |
|||
assert "tool_result_id" in result |
|||
|
|||
listings = result["result"]["listings"] |
|||
assert len(listings) >= 1, "Expected at least 1 listing" |
|||
|
|||
required_fields = { |
|||
"id", "address", "city", "state", "price", "bedrooms", |
|||
"bathrooms", "sqft", "days_on_market", "cap_rate_estimate", |
|||
} |
|||
for listing in listings: |
|||
missing = required_fields - set(listing.keys()) |
|||
assert not missing, f"Listing missing fields: {missing}" |
|||
assert isinstance(listing["price"], (int, float)), "price must be numeric" |
|||
assert listing["price"] > 0, "price must be positive" |
|||
assert isinstance(listing["cap_rate_estimate"], (int, float)) |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Test 2 — caching: repeated call returns cached result |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
@pytest.mark.asyncio |
|||
async def test_neighborhood_snapshot_caching(): |
|||
""" |
|||
GIVEN the real estate feature is enabled |
|||
WHEN get_neighborhood_snapshot('Austin') is called twice |
|||
THEN the second call returns the same tool_result_id (from cache) |
|||
and does not mutate data. |
|||
""" |
|||
_set_flag("true") |
|||
from tools.real_estate import get_neighborhood_snapshot, cache_clear |
|||
cache_clear() |
|||
|
|||
first = await get_neighborhood_snapshot("Austin") |
|||
second = await get_neighborhood_snapshot("Austin") |
|||
|
|||
assert first["success"] is True |
|||
assert second["success"] is True |
|||
|
|||
# Both calls must return same tool_result_id (cache hit) |
|||
assert first["tool_result_id"] == second["tool_result_id"], ( |
|||
"Expected same tool_result_id on cache hit" |
|||
) |
|||
|
|||
# Data must be identical |
|||
assert first["result"]["median_price"] == second["result"]["median_price"] |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Test 3 — integration schema: compare_neighborhoods returns correct structure |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
@pytest.mark.asyncio |
|||
async def test_compare_neighborhoods_schema(): |
|||
""" |
|||
GIVEN the real estate feature is enabled |
|||
WHEN compare_neighborhoods('Austin', 'Denver') is called |
|||
THEN the result contains both locations, all metric keys, and summaries. |
|||
""" |
|||
_set_flag("true") |
|||
from tools.real_estate import compare_neighborhoods, cache_clear |
|||
cache_clear() |
|||
|
|||
result = await compare_neighborhoods("Austin", "Denver") |
|||
|
|||
assert result["success"] is True, f"Expected success, got: {result}" |
|||
comp = result["result"] |
|||
|
|||
assert "location_a" in comp |
|||
assert "location_b" in comp |
|||
assert "metrics" in comp |
|||
assert "summaries" in comp |
|||
|
|||
required_metrics = { |
|||
"median_price", "price_per_sqft", "gross_rental_yield_pct", |
|||
"days_on_market", "walk_score", |
|||
} |
|||
for metric in required_metrics: |
|||
assert metric in comp["metrics"], f"Missing metric: {metric}" |
|||
assert "a" in comp["metrics"][metric] |
|||
assert "b" in comp["metrics"][metric] |
|||
|
|||
# Both summaries must be non-empty strings |
|||
for loc, summary in comp["summaries"].items(): |
|||
assert isinstance(summary, str) and len(summary) > 20, ( |
|||
f"Summary for {loc} is too short or missing" |
|||
) |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Test 4 — feature flag: all tools return FEATURE_DISABLED when flag is off |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
@pytest.mark.asyncio |
|||
async def test_feature_flag_disabled(): |
|||
""" |
|||
GIVEN ENABLE_REAL_ESTATE is not set (or set to false) |
|||
WHEN any real estate tool is called |
|||
THEN it returns success=False with error=FEATURE_DISABLED (no crash). |
|||
""" |
|||
_set_flag("false") |
|||
from tools.real_estate import ( |
|||
search_listings, |
|||
get_neighborhood_snapshot, |
|||
compare_neighborhoods, |
|||
is_real_estate_enabled, |
|||
cache_clear, |
|||
) |
|||
cache_clear() |
|||
|
|||
assert is_real_estate_enabled() is False |
|||
|
|||
for coro in [ |
|||
search_listings("Austin"), |
|||
get_neighborhood_snapshot("Austin"), |
|||
compare_neighborhoods("Austin", "Denver"), |
|||
]: |
|||
result = await coro |
|||
assert result["success"] is False |
|||
assert result["error"] == "FEATURE_DISABLED", ( |
|||
f"Expected FEATURE_DISABLED, got: {result}" |
|||
) |
|||
|
|||
# Restore for other tests |
|||
_set_flag("true") |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Test 5 — graceful fallback: unknown location returns helpful error, no crash |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
@pytest.mark.asyncio |
|||
async def test_unknown_location_graceful_error(): |
|||
""" |
|||
GIVEN the real estate feature is enabled |
|||
WHEN search_listings is called with an unsupported location |
|||
THEN it returns success=False with error=NO_LISTINGS_FOUND and a helpful |
|||
message listing supported cities (no exception raised). |
|||
""" |
|||
_set_flag("true") |
|||
from tools.real_estate import search_listings, cache_clear |
|||
cache_clear() |
|||
|
|||
result = await search_listings("Atlantis") |
|||
|
|||
assert result["success"] is False |
|||
assert result["error"] == "NO_LISTINGS_FOUND" |
|||
assert "Atlantis" in result["message"] |
|||
# Message must name at least one supported city so user knows what to try |
|||
assert any(city in result["message"].lower() for city in ["austin", "denver", "seattle"]) |
|||
Loading…
Reference in new issue