"""Tests for GET /api/v1/trade-in/location-index (replaces test_location_coef_endpoint.py). Mirrors the IDOR + mocked-DB conventions of test_estimate_idor.py / the deleted test_location_coef_endpoint.py (no live Postgres needed). Covers: - IDOR guard (owner / other pilot 404 / admin / unauthenticated 401) - honest degradation: no lat/lon, point outside EKB coverage, insufficient comparable sample, empty osm_poi_ekb_local mirror (independent of the index status) - happy path: status="ok", location_index_pct computed, nearby_poi surfaced - radius_m query-param clamping """ from __future__ import annotations import os import sys from types import SimpleNamespace from unittest.mock import MagicMock # psycopg v3 driver required; stub DATABASE_URL before any app import. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") # WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import # (trade_in.py imports generate_trade_in_pdf at module load). _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) sys.modules.setdefault("weasyprint.CSS", _wp_mock) sys.modules.setdefault("weasyprint.HTML", _wp_mock) import pytest # noqa: E402 from fastapi import FastAPI # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 _ESTIMATE_ID = "22222222-2222-2222-2222-222222222222" _LAT_IN_EKB = 56.838 _LON_IN_EKB = 60.605 @pytest.fixture(autouse=True) def _restore_get_role(): """Restore app.core.auth.get_role after each test (mirror test_estimate_idor).""" from app.core import auth as auth_mod original = auth_mod.get_role yield auth_mod.get_role = original @pytest.fixture() def trade_in_app() -> FastAPI: """Minimal FastAPI app mounting only the trade-in router.""" from app.api.v1 import trade_in as trade_in_module application = FastAPI() application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") return application def _client_with(app: FastAPI, db_mock: MagicMock, role: str | None) -> TestClient: """Override get_db with *db_mock*; patch get_role to return *role* (or raise KeyError).""" from app.core.db import get_db def _override_db(): yield db_mock app.dependency_overrides[get_db] = _override_db auth_mod = sys.modules["app.core.auth"] if role is None: def _raise_keyerror(_u: str): raise KeyError(_u) auth_mod.get_role = _raise_keyerror # type: ignore[assignment] else: auth_mod.get_role = lambda _u: role # type: ignore[assignment] return TestClient(app) def _fetchone_result(row: object) -> MagicMock: r = MagicMock() r.fetchone.return_value = row return r def _scalar_result(value: object) -> MagicMock: r = MagicMock() r.scalar.return_value = value return r def _mapping_all_result(rows: list[dict]) -> MagicMock: r = MagicMock() r.mappings.return_value.all.return_value = rows return r def _mapping_one_result(row: dict | None) -> MagicMock: r = MagicMock() r.mappings.return_value.first.return_value = row return r def _db_with(*results: MagicMock) -> MagicMock: db = MagicMock() db.execute.side_effect = list(results) return db # ── IDOR guard ──────────────────────────────────────────────────────────────── def test_location_index_other_pilot_gets_404(trade_in_app: FastAPI) -> None: """Non-owner pilot must NOT read someone else's location-index → 404.""" db = _db_with(_fetchone_result(SimpleNamespace(created_by="victim"))) client = _client_with(trade_in_app, db, role="pilot") resp = client.get( "/api/v1/trade-in/location-index", params={"estimate_id": _ESTIMATE_ID}, headers={"X-Authenticated-User": "attacker"}, ) assert resp.status_code == 404 def test_location_index_requires_authenticated_user(trade_in_app: FastAPI) -> None: """No X-Authenticated-User header → 401 (guard query already ran, header check fails).""" db = _db_with(_fetchone_result(SimpleNamespace(created_by="kopylov"))) client = _client_with(trade_in_app, db, role="pilot") resp = client.get( "/api/v1/trade-in/location-index", params={"estimate_id": _ESTIMATE_ID}, ) assert resp.status_code == 401 def test_location_index_unknown_estimate_returns_404(trade_in_app: FastAPI) -> None: db = _db_with(_fetchone_result(None)) client = _client_with(trade_in_app, db, role="pilot") resp = client.get( "/api/v1/trade-in/location-index", params={"estimate_id": _ESTIMATE_ID}, headers={"X-Authenticated-User": "kopylov"}, ) assert resp.status_code == 404 def test_location_index_admin_can_read_any(trade_in_app: FastAPI) -> None: """Admin reads any estimate's location-index regardless of owner → 200.""" db = _db_with( _fetchone_result(SimpleNamespace(created_by="someone_else")), # guard _fetchone_result(SimpleNamespace(lat=None, lon=None)), # target — no lat/lon ) client = _client_with(trade_in_app, db, role="admin") resp = client.get( "/api/v1/trade-in/location-index", params={"estimate_id": _ESTIMATE_ID}, headers={"X-Authenticated-User": "admin"}, ) assert resp.status_code == 200 # ── Honest degradation ──────────────────────────────────────────────────────── def test_location_index_no_lat_lon_returns_out_of_coverage(trade_in_app: FastAPI) -> None: """Estimate without lat/lon → out_of_coverage, no compute_location_index DB call at all.""" db = _db_with( _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard _fetchone_result(SimpleNamespace(lat=None, lon=None)), # target ) client = _client_with(trade_in_app, db, role="pilot") resp = client.get( "/api/v1/trade-in/location-index", params={"estimate_id": _ESTIMATE_ID}, headers={"X-Authenticated-User": "kopylov"}, ) assert resp.status_code == 200 body = resp.json() assert body["status"] == "out_of_coverage" assert body["location_index_pct"] is None assert body["nearby_poi"] == [] assert body["poi_status"] == "unavailable" # Short-circuits before compute_location_index: only guard + target queries ran. assert db.execute.call_count == 2 def test_location_index_point_outside_ekb_bbox_returns_out_of_coverage( trade_in_app: FastAPI, ) -> None: """Estimate has lat/lon, but outside the EKB coverage bbox (e.g. Nizhny Tagil).""" db = _db_with( _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard _fetchone_result(SimpleNamespace(lat=57.910, lon=59.970)), # target — Nizhny Tagil ) client = _client_with(trade_in_app, db, role="pilot") resp = client.get( "/api/v1/trade-in/location-index", params={"estimate_id": _ESTIMATE_ID}, headers={"X-Authenticated-User": "kopylov"}, ) assert resp.status_code == 200 body = resp.json() assert body["status"] == "out_of_coverage" assert body["location_index_pct"] is None # compute_location_index short-circuits — no further DB calls beyond guard + target. assert db.execute.call_count == 2 def test_location_index_insufficient_sample_returns_no_fabricated_number( trade_in_app: FastAPI, ) -> None: """Comparable sample stays below MIN_SAMPLE_SIZE even at max radius → insufficient_data, never a noisy number computed from a handful of listings.""" db = _db_with( _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard _fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target _scalar_result(0), # osm_poi_ekb_local count _mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide _mapping_one_result({"median_ppm2": 200_000.0, "n": 5}), # 800m _mapping_one_result({"median_ppm2": 195_000.0, "n": 12}), # 1500m _mapping_one_result({"median_ppm2": 190_000.0, "n": 15}), # 2500m — still short ) client = _client_with(trade_in_app, db, role="pilot") resp = client.get( "/api/v1/trade-in/location-index", params={"estimate_id": _ESTIMATE_ID}, headers={"X-Authenticated-User": "kopylov"}, ) assert resp.status_code == 200 body = resp.json() assert body["status"] == "insufficient_data" assert body["location_index_pct"] is None assert body["local_median_price_per_m2"] is None assert body["sample_size"] == 15 assert body["city_median_price_per_m2"] == 150_000 def test_location_index_poi_mirror_empty_does_not_block_index(trade_in_app: FastAPI) -> None: """poi_status="unavailable" is independent of status="ok" — an un-refreshed POI mirror must not prevent a perfectly computable price-based index.""" db = _db_with( _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard _fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target _scalar_result(0), # osm_poi_ekb_local count == 0 _mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide _mapping_one_result({"median_ppm2": 172_500.0, "n": 40}), # 800m — sufficient ) client = _client_with(trade_in_app, db, role="pilot") resp = client.get( "/api/v1/trade-in/location-index", params={"estimate_id": _ESTIMATE_ID}, headers={"X-Authenticated-User": "kopylov"}, ) assert resp.status_code == 200 body = resp.json() assert body["status"] == "ok" assert body["location_index_pct"] == 15.0 assert body["poi_status"] == "unavailable" assert body["nearby_poi"] == [] # ── Happy path ──────────────────────────────────────────────────────────────── def test_location_index_happy_path(trade_in_app: FastAPI) -> None: """Comparable sample found → index computed, nearby POI surfaced as qualitative info.""" poi_rows = [ {"name": "Школа №1", "category": "school", "distance_m": 300.0}, {"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0}, ] db = _db_with( _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard _fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target _scalar_result(1000), # osm_poi_ekb_local count _mapping_all_result(poi_rows), # nearest POI _mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide _mapping_one_result({"median_ppm2": 165_000.0, "n": 25}), # 800m — sufficient ) client = _client_with(trade_in_app, db, role="pilot") resp = client.get( "/api/v1/trade-in/location-index", params={"estimate_id": _ESTIMATE_ID}, headers={"X-Authenticated-User": "kopylov"}, ) assert resp.status_code == 200 body = resp.json() assert body["status"] == "ok" assert body["poi_status"] == "ok" assert body["location_index_pct"] == 10.0 assert body["local_median_price_per_m2"] == 165_000 assert body["city_median_price_per_m2"] == 150_000 assert body["sample_size"] == 25 assert body["radius_m"] == 800 assert len(body["nearby_poi"]) == 2 poi_types = {f["poi_type"] for f in body["nearby_poi"]} assert poi_types == {"school", "metro_stop"} # New contract must NOT resurrect the misleading price-multiplier fields. assert "coef" not in body assert "base_price_rub" not in body assert "result_price_rub" not in body def test_location_index_radius_m_clamped_to_bounds(trade_in_app: FastAPI) -> None: """Explicit radius_m outside [500, 3000] is clamped before hitting the DB query.""" db = _db_with( _fetchone_result(SimpleNamespace(created_by="kopylov")), # guard _fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target _scalar_result(0), # osm_poi_ekb_local count _mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide _mapping_one_result({"median_ppm2": 160_000.0, "n": 50}), # explicit radius query ) client = _client_with(trade_in_app, db, role="pilot") resp = client.get( "/api/v1/trade-in/location-index", params={"estimate_id": _ESTIMATE_ID, "radius_m": 50}, headers={"X-Authenticated-User": "kopylov"}, ) assert resp.status_code == 200 body = resp.json() assert body["radius_m"] == 500 # 5th execute() call (index 4) is the explicit-radius local-median query. nearest_call = db.execute.call_args_list[4] params = ( nearest_call.args[1] if len(nearest_call.args) > 1 else nearest_call.kwargs.get("params") ) assert params["radius_m"] == 500