Spaces:
Running
Running
File size: 11,788 Bytes
eb7e023 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | """
THAMAN API β Unit Tests
========================
Run with: pytest tests/ -v
"""
import pytest
from fastapi.testclient import TestClient
from api.main import app
# ββ Fixtures ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
BROOKLYN_A1 = {
"latitude": 40.6892,
"longitude": -73.9442,
"gross_square_feet": 1800,
"building_age": 55,
"bldgclass": "A1",
"borough": 3,
"numfloors": 2,
"residential_units": 1,
}
MANHATTAN_D4 = {
"latitude": 40.7589,
"longitude": -73.9851,
"gross_square_feet": 950,
"building_age": 40,
"bldgclass": "D4",
"borough": 1,
"numfloors": 12,
"residential_units": 1,
}
# ββ Info endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_root_serves_ui(client):
"""GET / should serve the map UI directly (200)."""
response = client.get("/", follow_redirects=False)
assert response.status_code == 200
def test_api_info(client):
"""GET /api returns version and endpoint list."""
response = client.get("/api")
assert response.status_code == 200
data = response.json()
assert data["version"] == "2.2.0"
assert "endpoints" in data
def test_health_ok(client):
"""GET /health returns model_loaded and spatial_loaded flags."""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert "model_loaded" in data
assert "spatial_loaded" in data
assert "status" in data
def test_bldgclasses_returns_list(client):
"""GET /bldgclasses returns a non-empty list of codes."""
response = client.get("/bldgclasses")
assert response.status_code == 200
data = response.json()
assert "bldgclasses" in data
assert len(data["bldgclasses"]) > 10
assert "A1" in data["bldgclasses"]
# ββ Prediction endpoint βββββββββββββββββββββββββββββββββββββββββββββββ
def test_predict_brooklyn_a1(client):
"""Brooklyn A1 should return a realistic price ($300Kβ$3M)."""
response = client.post("/predict", json=BROOKLYN_A1)
assert response.status_code == 200
data = response.json()
assert "predicted_price" in data
assert "confidence_low" in data
assert "confidence_high" in data
assert "top_drivers" in data
assert 300_000 < data["predicted_price"] < 3_000_000
def test_predict_confidence_range(client):
"""Confidence low < predicted < high."""
response = client.post("/predict", json=BROOKLYN_A1)
assert response.status_code == 200
data = response.json()
assert data["confidence_low"] < data["predicted_price"] < data["confidence_high"]
def test_predict_manhattan_d4(client):
"""Manhattan D4 elevator building should be > Brooklyn A1."""
r_bk = client.post("/predict", json=BROOKLYN_A1).json()
r_mn = client.post("/predict", json=MANHATTAN_D4).json()
assert r_mn["predicted_price"] > r_bk["predicted_price"]
def test_predict_shap_drivers(client):
"""top_drivers should contain feature, impact, direction."""
response = client.post("/predict", json=BROOKLYN_A1)
data = response.json()
assert len(data["top_drivers"]) > 0
for d in data["top_drivers"]:
assert "feature" in d
assert "impact" in d
assert "direction" in d
assert d["direction"] in ("positive", "negative")
def test_predict_spatial_features(client):
"""spatial_features should include subway and income keys."""
response = client.post("/predict", json=BROOKLYN_A1)
sf = response.json()["spatial_features"]
assert "dist_subway_m" in sf
assert "median_income_nta" in sf
assert sf["dist_subway_m"] > 0
def test_predict_invalid_out_of_nyc(client):
"""Coordinates outside NYC bounding box should return 422."""
payload = {**BROOKLYN_A1, "latitude": 51.5074, "longitude": -0.1278} # London
response = client.post("/predict", json=payload)
assert response.status_code == 422
def test_predict_missing_required_field(client):
"""Missing required field should return 422."""
payload = {k: v for k, v in BROOKLYN_A1.items() if k != "bldgclass"}
response = client.post("/predict", json=payload)
assert response.status_code == 422
def test_predict_negative_sqft(client):
"""Negative gross_square_feet should return 422."""
payload = {**BROOKLYN_A1, "gross_square_feet": -100}
response = client.post("/predict", json=payload)
assert response.status_code == 422
# ββ Batch endpoint ββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_batch_two_properties(client):
"""Batch endpoint returns results for each property."""
response = client.post("/batch", json=[BROOKLYN_A1, MANHATTAN_D4])
assert response.status_code == 200
data = response.json()
assert data["count"] == 2
assert all("predicted_price" in r for r in data["results"])
def test_batch_too_many(client):
"""Batch size > 50 should return 400."""
payload = [BROOKLYN_A1] * 51
response = client.post("/batch", json=payload)
assert response.status_code == 400
# ββ Nearby endpoint βββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_nearby_returns_sales(client):
"""GET /nearby should return at least 1 sale near Brooklyn."""
response = client.get("/nearby?lat=40.6892&lon=-73.9442")
assert response.status_code == 200
data = response.json()
assert "nearby" in data
assert data["count"] > 0
def test_nearby_fields(client):
"""Each nearby sale should have required fields."""
response = client.get("/nearby?lat=40.6892&lon=-73.9442&limit=3")
data = response.json()
for sale in data["nearby"]:
assert "sale_price" in sale
assert "distance_m" in sale
assert "bldgclass" in sale
assert sale["sale_price"] > 0
assert sale["distance_m"] >= 0
def test_nearby_invalid_coords(client):
"""Invalid coordinates should return 422."""
response = client.get("/nearby?lat=999&lon=999")
assert response.status_code == 422
# ββ AVM QC block ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_predict_returns_avm_qc_block(client):
"""/predict response must include an avm_qc block."""
data = client.post("/predict", json=BROOKLYN_A1).json()
assert "avm_qc" in data, "avm_qc block missing from /predict response"
assert data["avm_qc"] is not None
def test_avm_qc_has_required_fields(client):
"""avm_qc must contain all 2026 AVM standard fields."""
qc = client.post("/predict", json=BROOKLYN_A1).json()["avm_qc"]
for field in ["confidence_score", "confidence_grade", "segment_medape_pct",
"comparables_found", "comparables_radius_m", "sparse_market", "qc_flags"]:
assert field in qc, f"Missing avm_qc field: {field}"
def test_avm_qc_confidence_score_range(client):
"""confidence_score must be an integer in [0, 100]."""
qc = client.post("/predict", json=BROOKLYN_A1).json()["avm_qc"]
assert isinstance(qc["confidence_score"], int)
assert 0 <= qc["confidence_score"] <= 100
def test_avm_qc_comparable_count_non_negative(client):
"""comparables_found must be β₯ 0 and radius must be 800."""
qc = client.post("/predict", json=BROOKLYN_A1).json()["avm_qc"]
assert qc["comparables_found"] >= 0
assert qc["comparables_radius_m"] == 800
def test_luxury_flag_for_high_price(client):
"""LUXURY_SEGMENT flag should appear when predicted price exceeds $3M."""
data = client.post("/predict", json=MANHATTAN_D4).json()
qc = data["avm_qc"]
# If the model predicts above $3M, the flag must be present
if data["predicted_price"] > 3_000_000:
assert "LUXURY_SEGMENT" in qc["qc_flags"]
# qc_flags is always a list (may be empty)
assert isinstance(qc["qc_flags"], list)
# ββ Riyadh batch ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
RIYADH_APT = {
"latitude": 24.75, "longitude": 46.68,
"property_type": "Ψ΄ΩΨ©", "area_sqm": 150.0,
}
RIYADH_VILLA = {
"latitude": 24.80, "longitude": 46.72,
"property_type": "ΩΩΩΨ§", "area_sqm": 400.0,
}
def test_riyadh_batch_two_properties(client):
"""/batch/riyadh returns SAR estimates for each property."""
response = client.post("/batch/riyadh", json=[RIYADH_APT, RIYADH_VILLA])
assert response.status_code == 200
data = response.json()
assert data["count"] == 2
for r in data["results"]:
assert "predicted_total_sar" in r
assert "predicted_price_sqm" in r
assert r["predicted_total_sar"] > 0
def test_riyadh_batch_confidence_intervals(client):
"""Confidence bounds must bracket the point estimate."""
data = client.post("/batch/riyadh", json=[RIYADH_APT]).json()
r = data["results"][0]
assert r["confidence_low_sar"] < r["predicted_total_sar"]
assert r["confidence_high_sar"] > r["predicted_total_sar"]
assert r["confidence_low_sqm"] < r["predicted_price_sqm"]
assert r["confidence_high_sqm"] > r["predicted_price_sqm"]
def test_riyadh_batch_district_returned(client):
"""district_ar should be a non-empty Arabic string."""
data = client.post("/batch/riyadh", json=[RIYADH_APT]).json()
r = data["results"][0]
assert r.get("district_ar"), "district_ar should be non-empty"
def test_riyadh_batch_too_many(client):
"""/batch/riyadh rejects > 50 properties."""
response = client.post("/batch/riyadh", json=[RIYADH_APT] * 51)
assert response.status_code == 400
def test_riyadh_batch_area_in_response(client):
"""area_sqm in response must match the input."""
data = client.post("/batch/riyadh", json=[RIYADH_APT]).json()
assert data["results"][0]["area_sqm"] == RIYADH_APT["area_sqm"]
# ββ SEO / utility endpoints βββββββββββββββββββββββββββββββββββββββββββ
def test_robots_txt(client):
"""GET /robots.txt returns plain text with sitemap reference."""
r = client.get("/robots.txt")
assert r.status_code == 200
assert "User-agent" in r.text
assert "sitemap.xml" in r.text
assert r.headers["content-type"].startswith("text/plain")
def test_sitemap_xml(client):
"""GET /sitemap.xml returns valid XML with key URLs."""
r = client.get("/sitemap.xml")
assert r.status_code == 200
assert "<urlset" in r.text
assert "/ui" in r.text
assert "/ui/batch.html" in r.text
def test_404_json_for_api_client(client):
"""Unknown route returns 404 JSON for non-browser clients."""
r = client.get("/nonexistent-route-xyz")
assert r.status_code == 404
assert r.json().get("detail") is not None
def test_404_html_for_browser(client):
"""Unknown route returns 404 HTML when Accept: text/html."""
r = client.get("/nonexistent-route-xyz", headers={"accept": "text/html"})
assert r.status_code == 404
assert "THAMAN" in r.text or "404" in r.text
|