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.
408 lines
14 KiB
408 lines
14 KiB
"""
|
|
Unit tests for the Property Tracker tool.
|
|
|
|
Tests cover:
|
|
1. add_property schema — result contains required fields
|
|
2. Equity computed correctly — equity = current_value - mortgage_balance
|
|
3. Appreciation computed correctly — appreciation = current_value - purchase_price
|
|
4. list_properties empty — returns success with empty list and zero summary
|
|
5. list_properties with data — summary totals are mathematically correct
|
|
6. get_real_estate_equity — returns correct totals across multiple properties
|
|
7. Feature flag disabled — all tools return FEATURE_DISABLED
|
|
8. remove_property — removes the correct entry
|
|
9. remove_property not found — returns structured error, no crash
|
|
10. add_property validation — empty address returns structured error
|
|
11. add_property validation — zero purchase price returns structured error
|
|
12. No mortgage — equity equals full current value when mortgage_balance=0
|
|
13. current_value defaults to purchase_price when not supplied
|
|
"""
|
|
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _set_flag(value: str):
|
|
os.environ["ENABLE_REAL_ESTATE"] = value
|
|
|
|
|
|
def _clear_flag():
|
|
os.environ.pop("ENABLE_REAL_ESTATE", None)
|
|
|
|
|
|
_SAMPLE_ADDRESS = "123 Barton Hills Dr, Austin, TX 78704"
|
|
_SAMPLE_PURCHASE = 450_000.0
|
|
_SAMPLE_VALUE = 522_500.0
|
|
_SAMPLE_MORTGAGE = 380_000.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 1 — add_property schema
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_property_schema():
|
|
"""
|
|
GIVEN the feature is enabled
|
|
WHEN add_property is called with valid inputs
|
|
THEN the result has success=True, a tool_result_id, and a property dict
|
|
with all required fields.
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import add_property, property_store_clear
|
|
property_store_clear()
|
|
|
|
result = await add_property(
|
|
address=_SAMPLE_ADDRESS,
|
|
purchase_price=_SAMPLE_PURCHASE,
|
|
current_value=_SAMPLE_VALUE,
|
|
mortgage_balance=_SAMPLE_MORTGAGE,
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["tool_name"] == "property_tracker"
|
|
assert "tool_result_id" in result
|
|
|
|
prop = result["result"]["property"]
|
|
required_fields = {
|
|
"id", "address", "property_type", "purchase_price",
|
|
"current_value", "mortgage_balance", "equity", "equity_pct",
|
|
"appreciation", "appreciation_pct", "county_key", "added_at",
|
|
}
|
|
missing = required_fields - set(prop.keys())
|
|
assert not missing, f"Property missing fields: {missing}"
|
|
assert prop["address"] == _SAMPLE_ADDRESS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 2 — equity computed correctly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_equity_computed():
|
|
"""
|
|
GIVEN current_value=522500 and mortgage_balance=380000
|
|
WHEN add_property is called
|
|
THEN equity == 142500 and equity_pct ≈ 27.27%.
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import add_property, property_store_clear
|
|
property_store_clear()
|
|
|
|
result = await add_property(
|
|
address=_SAMPLE_ADDRESS,
|
|
purchase_price=_SAMPLE_PURCHASE,
|
|
current_value=_SAMPLE_VALUE,
|
|
mortgage_balance=_SAMPLE_MORTGAGE,
|
|
)
|
|
|
|
prop = result["result"]["property"]
|
|
assert prop["equity"] == pytest.approx(142_500.0), "equity must be current_value - mortgage"
|
|
assert prop["equity_pct"] == pytest.approx(27.27, abs=0.1), "equity_pct must be ~27.27%"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 3 — appreciation computed correctly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_appreciation_computed():
|
|
"""
|
|
GIVEN purchase_price=450000 and current_value=522500
|
|
WHEN add_property is called
|
|
THEN appreciation == 72500 and appreciation_pct ≈ 16.11%.
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import add_property, property_store_clear
|
|
property_store_clear()
|
|
|
|
result = await add_property(
|
|
address=_SAMPLE_ADDRESS,
|
|
purchase_price=_SAMPLE_PURCHASE,
|
|
current_value=_SAMPLE_VALUE,
|
|
mortgage_balance=_SAMPLE_MORTGAGE,
|
|
)
|
|
|
|
prop = result["result"]["property"]
|
|
assert prop["appreciation"] == pytest.approx(72_500.0)
|
|
assert prop["appreciation_pct"] == pytest.approx(16.11, abs=0.1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 4 — list_properties empty store
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_properties_empty():
|
|
"""
|
|
GIVEN no properties have been added
|
|
WHEN list_properties is called
|
|
THEN success=True, properties=[], all summary totals are zero.
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import list_properties, property_store_clear
|
|
property_store_clear()
|
|
|
|
result = await list_properties()
|
|
|
|
assert result["success"] is True
|
|
assert result["result"]["properties"] == []
|
|
summary = result["result"]["summary"]
|
|
assert summary["property_count"] == 0
|
|
assert summary["total_equity"] == 0
|
|
assert summary["total_current_value"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 5 — list_properties summary totals are correct
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_properties_totals():
|
|
"""
|
|
GIVEN two properties are added
|
|
WHEN list_properties is called
|
|
THEN summary totals are the correct arithmetic sum of both properties.
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import add_property, list_properties, property_store_clear
|
|
property_store_clear()
|
|
|
|
await add_property(
|
|
address="123 Main St, Austin, TX",
|
|
purchase_price=450_000,
|
|
current_value=522_500,
|
|
mortgage_balance=380_000,
|
|
)
|
|
await add_property(
|
|
address="456 Round Rock Ave, Round Rock, TX",
|
|
purchase_price=320_000,
|
|
current_value=403_500,
|
|
mortgage_balance=250_000,
|
|
county_key="williamson_county",
|
|
)
|
|
|
|
result = await list_properties()
|
|
assert result["success"] is True
|
|
|
|
summary = result["result"]["summary"]
|
|
assert summary["property_count"] == 2
|
|
assert summary["total_purchase_price"] == pytest.approx(770_000)
|
|
assert summary["total_current_value"] == pytest.approx(926_000)
|
|
assert summary["total_mortgage_balance"] == pytest.approx(630_000)
|
|
assert summary["total_equity"] == pytest.approx(296_000) # 926000 - 630000
|
|
assert summary["total_equity_pct"] == pytest.approx(31.96, abs=0.1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 6 — get_real_estate_equity returns correct totals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_real_estate_equity():
|
|
"""
|
|
GIVEN one property with equity 142500
|
|
WHEN get_real_estate_equity is called
|
|
THEN total_real_estate_equity == 142500.
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import add_property, get_real_estate_equity, property_store_clear
|
|
property_store_clear()
|
|
|
|
await add_property(
|
|
address=_SAMPLE_ADDRESS,
|
|
purchase_price=_SAMPLE_PURCHASE,
|
|
current_value=_SAMPLE_VALUE,
|
|
mortgage_balance=_SAMPLE_MORTGAGE,
|
|
)
|
|
|
|
result = await get_real_estate_equity()
|
|
assert result["success"] is True
|
|
assert result["result"]["total_real_estate_equity"] == pytest.approx(142_500.0)
|
|
assert result["result"]["total_real_estate_value"] == pytest.approx(522_500.0)
|
|
assert result["result"]["property_count"] == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 7 — feature flag disabled
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_feature_flag_disabled():
|
|
"""
|
|
GIVEN ENABLE_REAL_ESTATE=false
|
|
WHEN any property tracker tool is called
|
|
THEN all return success=False with PROPERTY_TRACKER_FEATURE_DISABLED.
|
|
"""
|
|
_set_flag("false")
|
|
from tools.property_tracker import (
|
|
add_property, list_properties, get_real_estate_equity,
|
|
remove_property, is_property_tracking_enabled, property_store_clear,
|
|
)
|
|
property_store_clear()
|
|
|
|
assert is_property_tracking_enabled() is False
|
|
|
|
for coro in [
|
|
add_property(_SAMPLE_ADDRESS, _SAMPLE_PURCHASE),
|
|
list_properties(),
|
|
get_real_estate_equity(),
|
|
remove_property("prop_001"),
|
|
]:
|
|
result = await coro
|
|
assert result["success"] is False
|
|
assert isinstance(result["error"], dict)
|
|
assert result["error"]["code"] == "PROPERTY_TRACKER_FEATURE_DISABLED"
|
|
|
|
_set_flag("true")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 8 — remove_property removes the correct entry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_property():
|
|
"""
|
|
GIVEN one property exists
|
|
WHEN remove_property is called with its ID
|
|
THEN success=True, and list_properties afterwards shows empty.
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import add_property, list_properties, remove_property, property_store_clear
|
|
property_store_clear()
|
|
|
|
add_result = await add_property(
|
|
address=_SAMPLE_ADDRESS,
|
|
purchase_price=_SAMPLE_PURCHASE,
|
|
current_value=_SAMPLE_VALUE,
|
|
mortgage_balance=_SAMPLE_MORTGAGE,
|
|
)
|
|
prop_id = add_result["result"]["property"]["id"]
|
|
|
|
remove_result = await remove_property(prop_id)
|
|
assert remove_result["success"] is True
|
|
assert remove_result["result"]["status"] == "removed"
|
|
|
|
list_result = await list_properties()
|
|
assert list_result["result"]["properties"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 9 — remove_property not found returns structured error
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_property_not_found():
|
|
"""
|
|
GIVEN the store is empty
|
|
WHEN remove_property is called with a non-existent ID
|
|
THEN success=False with code=PROPERTY_TRACKER_NOT_FOUND, no crash.
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import remove_property, property_store_clear
|
|
property_store_clear()
|
|
|
|
result = await remove_property("prop_999")
|
|
assert result["success"] is False
|
|
assert isinstance(result["error"], dict)
|
|
assert result["error"]["code"] == "PROPERTY_TRACKER_NOT_FOUND"
|
|
assert "prop_999" in result["error"]["message"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 10 — validation: empty address
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_property_empty_address():
|
|
"""
|
|
GIVEN an empty address string
|
|
WHEN add_property is called
|
|
THEN success=False with code=PROPERTY_TRACKER_INVALID_INPUT.
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import add_property, property_store_clear
|
|
property_store_clear()
|
|
|
|
result = await add_property(address=" ", purchase_price=450_000)
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == "PROPERTY_TRACKER_INVALID_INPUT"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 11 — validation: zero purchase price
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_property_zero_price():
|
|
"""
|
|
GIVEN a purchase_price of 0
|
|
WHEN add_property is called
|
|
THEN success=False with code=PROPERTY_TRACKER_INVALID_INPUT.
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import add_property, property_store_clear
|
|
property_store_clear()
|
|
|
|
result = await add_property(address=_SAMPLE_ADDRESS, purchase_price=0)
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == "PROPERTY_TRACKER_INVALID_INPUT"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 12 — no mortgage: equity equals full current value
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_mortgage_equity_full_value():
|
|
"""
|
|
GIVEN mortgage_balance defaults to 0
|
|
WHEN add_property is called
|
|
THEN equity == current_value (property is fully owned).
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import add_property, property_store_clear
|
|
property_store_clear()
|
|
|
|
result = await add_property(
|
|
address=_SAMPLE_ADDRESS,
|
|
purchase_price=_SAMPLE_PURCHASE,
|
|
current_value=_SAMPLE_VALUE,
|
|
)
|
|
prop = result["result"]["property"]
|
|
assert prop["equity"] == pytest.approx(_SAMPLE_VALUE)
|
|
assert prop["equity_pct"] == pytest.approx(100.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 13 — current_value defaults to purchase_price
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_current_value_defaults_to_purchase_price():
|
|
"""
|
|
GIVEN current_value is not supplied
|
|
WHEN add_property is called
|
|
THEN current_value equals purchase_price and appreciation == 0.
|
|
"""
|
|
_set_flag("true")
|
|
from tools.property_tracker import add_property, property_store_clear
|
|
property_store_clear()
|
|
|
|
result = await add_property(
|
|
address=_SAMPLE_ADDRESS,
|
|
purchase_price=_SAMPLE_PURCHASE,
|
|
)
|
|
prop = result["result"]["property"]
|
|
assert prop["current_value"] == pytest.approx(_SAMPLE_PURCHASE)
|
|
assert prop["appreciation"] == pytest.approx(0.0)
|
|
|