mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
345 lines
13 KiB
345 lines
13 KiB
"""
|
|
Tests for wealth_bridge.py, property_tracker.py (full CRUD), and teleport_api.py.
|
|
|
|
Tests:
|
|
1. test_down_payment_austin_portfolio_94k
|
|
portfolio_value = 94000 → can_afford_full is True for at least one market
|
|
2. test_down_payment_small_portfolio
|
|
portfolio_value = 20000 → can_afford_safe is False for all markets
|
|
3. test_job_offer_seattle_not_real_raise
|
|
$120k Austin → $180k Seattle = NOT a real raise
|
|
4. test_job_offer_sf_genuine_raise
|
|
$80k Austin → $250k SF = IS a real raise
|
|
5. test_job_offer_global_city_london
|
|
Any two-city comparison returns expected fields
|
|
6. test_property_crud_full_cycle
|
|
CREATE → READ → UPDATE → DELETE
|
|
7. test_net_worth_combines_portfolio_and_property
|
|
equity + portfolio = expected total
|
|
8. test_teleport_fallback_works_when_api_unavailable
|
|
get_city_housing_data("seattle") returns usable data
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures / helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _set_flag(value: str):
|
|
os.environ["ENABLE_REAL_ESTATE"] = value
|
|
|
|
|
|
def _clear_flag():
|
|
os.environ.pop("ENABLE_REAL_ESTATE", None)
|
|
|
|
|
|
# Use an in-memory SQLite for property tracker tests (no file side effects)
|
|
os.environ["PROPERTIES_DB_PATH"] = ":memory:"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 1 — down payment power with $94k portfolio
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_down_payment_austin_portfolio_94k():
|
|
"""
|
|
GIVEN portfolio_value = 94000 (sufficient for Caldwell County at $237,491)
|
|
WHEN calculate_down_payment_power is called with no target_cities
|
|
THEN at least one market shows can_afford_full = True
|
|
and portfolio_value is preserved in the result
|
|
"""
|
|
from tools.wealth_bridge import calculate_down_payment_power
|
|
|
|
result = calculate_down_payment_power(94_000)
|
|
|
|
assert result["portfolio_value"] == 94_000
|
|
assert "markets" in result
|
|
assert len(result["markets"]) > 0
|
|
|
|
# Caldwell County median = $237,491 → 20% down = $47,498 → $94k covers it
|
|
affordable_full = [m for m in result["markets"] if m["can_afford_full"]]
|
|
assert len(affordable_full) > 0, (
|
|
f"Expected at least one market with can_afford_full=True at $94k. "
|
|
f"Markets: {[(m['area'], m['required_down_20pct']) for m in result['markets']]}"
|
|
)
|
|
|
|
# Verify key output fields present
|
|
first_market = result["markets"][0]
|
|
assert "median_price" in first_market
|
|
assert "required_down_20pct" in first_market
|
|
assert "monthly_payment_estimate" in first_market
|
|
assert "rent_vs_buy_verdict" in first_market
|
|
|
|
# Down payment scenarios
|
|
assert result["down_payment_scenarios"]["full"] == 94_000
|
|
assert result["down_payment_scenarios"]["conservative"] == pytest.approx(75_200)
|
|
assert result["down_payment_scenarios"]["safe"] == pytest.approx(56_400)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 2 — small portfolio ($20k) cannot afford safe 20% down anywhere
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_down_payment_small_portfolio():
|
|
"""
|
|
GIVEN portfolio_value = 20000 (insufficient for 20% down on any Austin market)
|
|
WHEN calculate_down_payment_power is called
|
|
THEN can_afford_safe is False for all markets
|
|
"""
|
|
from tools.wealth_bridge import calculate_down_payment_power
|
|
|
|
result = calculate_down_payment_power(20_000)
|
|
|
|
assert result["portfolio_value"] == 20_000
|
|
|
|
# safe = 20000 * 0.60 = 12000; cheapest market Caldwell = $237,491 → need $47,498
|
|
# So all markets should have can_afford_safe = False
|
|
safe_affordable = [m for m in result["markets"] if m["can_afford_safe"]]
|
|
assert len(safe_affordable) == 0, (
|
|
f"Expected no safe-affordable markets at $20k. "
|
|
f"Got: {[(m['area'], m['required_down_20pct']) for m in safe_affordable]}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 3 — Seattle $180k offer vs Austin $120k is NOT a real raise
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_job_offer_seattle_not_real_raise():
|
|
"""
|
|
GIVEN current: $120k in Austin (COL index 95.4)
|
|
offer: $180k in Seattle (COL index 150.2)
|
|
WHEN calculate_job_offer_affordability is called
|
|
THEN is_real_raise = False
|
|
real_raise_amount < 0
|
|
breakeven_salary_needed > 180000
|
|
|
|
Math: adjusted = 180000 * (95.4 / 150.2) ≈ $114,407
|
|
real_raise = 114,407 - 120,000 = -$5,593
|
|
"""
|
|
from tools.wealth_bridge import calculate_job_offer_affordability
|
|
|
|
result = await calculate_job_offer_affordability(
|
|
offer_salary=180_000,
|
|
offer_city="Seattle",
|
|
current_salary=120_000,
|
|
current_city="Austin",
|
|
)
|
|
|
|
assert result["is_real_raise"] is False, (
|
|
f"Expected Seattle $180k to NOT be a real raise vs Austin $120k. "
|
|
f"Got: adjusted={result['adjusted_offer_in_current_city_terms']}"
|
|
)
|
|
assert result["real_raise_amount"] < 0, "Real raise should be negative"
|
|
assert result["breakeven_salary_needed"] > 180_000, (
|
|
"Breakeven in Seattle should exceed the offer salary"
|
|
)
|
|
assert "verdict" in result
|
|
assert len(result["verdict"]) > 20
|
|
assert "offer_city" in result and result["offer_city"] != ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 4 — San Francisco $250k vs Austin $80k IS a genuine raise
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_job_offer_sf_genuine_raise():
|
|
"""
|
|
GIVEN current: $80k in Austin (COL index 95.4)
|
|
offer: $250k in San Francisco (COL index 178.1)
|
|
WHEN calculate_job_offer_affordability is called
|
|
THEN is_real_raise = True
|
|
adjusted_offer_in_current_city_terms > 80000
|
|
|
|
Math: adjusted = 250000 * (95.4 / 178.1) ≈ $133,969
|
|
real_raise = 133,969 - 80,000 = +$53,969
|
|
"""
|
|
from tools.wealth_bridge import calculate_job_offer_affordability
|
|
|
|
result = await calculate_job_offer_affordability(
|
|
offer_salary=250_000,
|
|
offer_city="San Francisco",
|
|
current_salary=80_000,
|
|
current_city="Austin",
|
|
)
|
|
|
|
assert result["is_real_raise"] is True, (
|
|
f"Expected SF $250k to be a real raise vs Austin $80k. "
|
|
f"Got: adjusted={result.get('adjusted_offer_in_current_city_terms')}"
|
|
)
|
|
assert result["adjusted_offer_in_current_city_terms"] > 80_000
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 5 — Global city (London) comparison returns expected fields
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_job_offer_global_city_london():
|
|
"""
|
|
GIVEN any salary offer in London vs Austin
|
|
WHEN calculate_job_offer_affordability is called
|
|
THEN result has all required fields and offer_city is non-empty
|
|
"""
|
|
from tools.wealth_bridge import calculate_job_offer_affordability
|
|
|
|
result = await calculate_job_offer_affordability(
|
|
offer_salary=150_000,
|
|
offer_city="London",
|
|
current_salary=120_000,
|
|
current_city="Austin",
|
|
)
|
|
|
|
required_fields = {
|
|
"current_salary", "current_city", "current_col_index",
|
|
"offer_salary", "offer_city", "offer_col_index",
|
|
"adjusted_offer_in_current_city_terms",
|
|
"real_raise_amount", "is_real_raise", "breakeven_salary_needed",
|
|
"verdict", "tax_note", "housing_comparison",
|
|
}
|
|
missing = required_fields - set(result.keys())
|
|
assert not missing, f"Missing fields in job offer result: {missing}"
|
|
assert result["offer_city"] != "", "offer_city must be non-empty"
|
|
assert isinstance(result["is_real_raise"], bool)
|
|
assert result["current_col_index"] > 0
|
|
assert result["offer_col_index"] > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 6 — Property CRUD full cycle (CREATE → READ → UPDATE → DELETE)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_property_crud_full_cycle():
|
|
"""
|
|
GIVEN ENABLE_REAL_ESTATE=true
|
|
WHEN full CRUD cycle is executed
|
|
THEN each operation produces correct results:
|
|
CREATE: equity = current_value - mortgage_balance = $130,000
|
|
READ: property appears in list
|
|
UPDATE: equity recalculates to $140,000 after value bump
|
|
DELETE: property no longer appears in list
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import (
|
|
add_property, get_properties, update_property,
|
|
remove_property, property_store_clear,
|
|
)
|
|
property_store_clear()
|
|
|
|
# CREATE
|
|
prop_result = await add_property(
|
|
address="123 Test St Austin TX",
|
|
purchase_price=400_000,
|
|
current_value=450_000,
|
|
mortgage_balance=320_000,
|
|
)
|
|
assert prop_result["success"] is True, f"add_property failed: {prop_result}"
|
|
prop = prop_result["result"]["property"]
|
|
assert prop["equity"] == pytest.approx(130_000), (
|
|
f"Expected equity=130000, got {prop['equity']}"
|
|
)
|
|
assert "id" in prop
|
|
prop_id = prop["id"]
|
|
|
|
# READ
|
|
list_result = await get_properties()
|
|
assert list_result["success"] is True
|
|
ids = [p["id"] for p in list_result["result"]["properties"]]
|
|
assert prop_id in ids, f"Added property ID {prop_id} not found in list: {ids}"
|
|
|
|
# UPDATE
|
|
updated = await update_property(prop_id, current_value=460_000)
|
|
assert updated["success"] is True, f"update_property failed: {updated}"
|
|
updated_prop = updated["result"]["property"]
|
|
assert updated_prop["equity"] == pytest.approx(140_000), (
|
|
f"Expected equity=140000 after update, got {updated_prop['equity']}"
|
|
)
|
|
|
|
# DELETE (soft)
|
|
removed = await remove_property(prop_id)
|
|
assert removed["success"] is True, f"remove_property failed: {removed}"
|
|
assert removed["result"]["status"] == "removed"
|
|
|
|
# Verify gone from active list
|
|
props_after = await get_properties()
|
|
ids_after = [p["id"] for p in props_after["result"]["properties"]]
|
|
assert prop_id not in ids_after, (
|
|
f"Property {prop_id} should be removed but still appears in: {ids_after}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 7 — Net worth combines portfolio value and real estate equity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_net_worth_combines_portfolio_and_property():
|
|
"""
|
|
GIVEN one property: current_value=$400k, mortgage=$250k → equity=$150k
|
|
WHEN get_total_net_worth(portfolio_value=94000) is called
|
|
THEN real_estate_equity == 150000
|
|
total_net_worth == 244000
|
|
investment_portfolio == 94000
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import (
|
|
add_property, get_total_net_worth, property_store_clear,
|
|
)
|
|
property_store_clear()
|
|
|
|
await add_property(
|
|
address="456 Equity St Austin TX",
|
|
purchase_price=300_000,
|
|
current_value=400_000,
|
|
mortgage_balance=250_000,
|
|
)
|
|
|
|
result = await get_total_net_worth(portfolio_value=94_000)
|
|
assert result["success"] is True, f"get_total_net_worth failed: {result}"
|
|
|
|
data = result["result"]
|
|
assert data["real_estate_equity"] == pytest.approx(150_000), (
|
|
f"Expected real_estate_equity=150000, got {data['real_estate_equity']}"
|
|
)
|
|
assert data["total_net_worth"] == pytest.approx(244_000), (
|
|
f"Expected total_net_worth=244000, got {data['total_net_worth']}"
|
|
)
|
|
assert data["investment_portfolio"] == pytest.approx(94_000)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 8 — Teleport fallback works when API is unavailable (or any city name)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_teleport_fallback_works_when_api_unavailable():
|
|
"""
|
|
GIVEN "seattle" is requested from teleport_api
|
|
WHEN get_city_housing_data("seattle") is called
|
|
THEN result contains MedianRentMonthly and city is non-empty
|
|
(live API or fallback — either is acceptable)
|
|
"""
|
|
from tools.teleport_api import get_city_housing_data
|
|
|
|
result = await get_city_housing_data("seattle")
|
|
|
|
assert isinstance(result, dict), "Result must be a dict"
|
|
assert result.get("city", "") != "", "city field must be non-empty"
|
|
|
|
has_rent = "MedianRentMonthly" in result
|
|
has_price = "median_price" in result or "ListPrice" in result
|
|
assert has_rent or has_price, (
|
|
f"Result must have MedianRentMonthly or median_price. Got keys: {list(result.keys())}"
|
|
)
|
|
|