"""Тесты OSRM road-distance в POST /api/v1/parcels/{cad}/analyze (#39 A2). Покрывает: 1. flag ON + mocked OSRM → distance_m в score_breakdown заменён дорожным расстоянием, POI-вклад (score) отражает road-distance, а не straight-line. 2. flag ON + OSRM бросает OsrmLocalUnavailableError → fallback на straight-line, analyze возвращает 200 (НЕ 500), score = straight-line baseline. 3. flag OFF (default) → get_road_distances_m НЕ вызывается ни разу (байт-в-байт как сегодня). Mock-стратегия как в test_analyze_inline_weights.py: DB через dependency_overrides, тяжёлые service-вызовы через patch, OSRM-клиент через patch на app.api.v1.parcels.get_road_distances_m. """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock, patch import pytest from fastapi.testclient import TestClient from app.core.config import settings from app.main import app from app.services.site_finder.osrm_client_local import OsrmLocalUnavailableError _CAD = "66:41:0204016:10" _WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))" _GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}' def _make_mapping(data: dict[str, Any]) -> MagicMock: m = MagicMock() m.__getitem__ = lambda self, k: data[k] m.get = lambda k, default=None: data.get(k, default) return m def _make_db_for_analyze(poi_rows: list[Any]) -> MagicMock: db = MagicMock() geom_row = _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}) wkt_row = _make_mapping({"wkt": _WKT}) district_row = _make_mapping( { "district_name": "Октябрьский", "ekb_reference_median": 120000, "quarter_median_12m": None, "quarter_deals_count": 0, "dist_to_center": 1500.0, } ) centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605}) def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: sql = " ".join(str(args[0]).split()) if args else "" first_val: Any = None all_val: list[Any] = [] if "AS geom_geojson" in sql: first_val = geom_row elif "AS wkt" in sql: first_val = wkt_row elif "AS ekb_reference_median" in sql and "district_name" in sql: first_val = district_row elif "AS lon" in sql and "AS lat" in sql: # И centroid_row для блока 6, и OSRM-origin SELECT (тот же shape). first_val = centroid_row elif "category" in sql and "ST_Distance" in sql and "p.geom" in sql: all_val = poi_rows r = MagicMock() r.mappings.return_value.first.return_value = first_val r.mappings.return_value.all.return_value = all_val r.scalar.return_value = 0 return r db.execute.side_effect = _execute_side_effect ctx = MagicMock() ctx.__enter__ = MagicMock(return_value=ctx) ctx.__exit__ = MagicMock(return_value=False) db.begin_nested.return_value = ctx return db def _override_db(db: MagicMock): def _get_db_override(): yield db return _get_db_override _PATCHES = [ patch("app.api.v1.parcels.get_air_quality_cached", return_value=None), patch("app.api.v1.parcels.get_weather_cached", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( "app.api.v1.parcels.get_quarter_dump_data", return_value={ "nspd_zoning": None, "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False}, }, ), patch("app.api.v1.parcels.compute_velocity", return_value=None), patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), patch( "app.api.v1.parcels.build_ird_analyze_block", return_value={ "ird_overlaps": [], "ird_by_kind": {}, "opportunity_overlaps": [], "planning_projects": [], "functional_zone": None, "krt": [], "zone_regulation": None, }, ), ] def _start_patches() -> None: for p in _PATCHES: p.start() def _stop_patches() -> None: for p in _PATCHES: p.stop() def _poi_row(category: str, name: str, lon: float, lat: float, distance_m: float) -> MagicMock: """POI-строка как RowMapping: parcels.py делает dict(r), поэтому нужен .keys()+items.""" data = { "category": category, "name": name, "lat": lat, "lon": lon, "distance_m": distance_m, "last_osm_edit_date": None, } m = MagicMock() m.__getitem__ = lambda self, k: data[k] m.keys = lambda: data.keys() m.__iter__ = lambda self: iter(data) return m def _distance_in_breakdown(body: dict[str, Any], name: str) -> float: """Найти distance_m конкретного POI по имени в score_breakdown.""" for _cat, items in body["score_breakdown"].items(): for it in items: if it["name"] == name: return float(it["distance_m"]) raise AssertionError(f"POI {name!r} не найден в score_breakdown: {body['score_breakdown']}") def test_flag_on_osrm_replaces_distance(monkeypatch) -> None: """flag ON + mocked OSRM → distance_m заменён дорожным, score отражает road-distance.""" from app.core.db import get_db # straight-line 200м; OSRM вернёт 700м (дорога длиннее) → меньший decay → меньший score. poi_rows = [_poi_row("school", "Школа №1", 60.61, 56.84, 200.0)] db = _make_db_for_analyze(poi_rows) app.dependency_overrides[get_db] = _override_db(db) monkeypatch.setattr(settings, "use_osrm_distances", True) _start_patches() try: with patch("app.api.v1.parcels.get_road_distances_m", return_value=[700.0]) as mock_osrm: client = TestClient(app) resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") assert resp.status_code == 200, resp.text body = resp.json() # OSRM вызван ровно один раз (один POI-fetch). assert mock_osrm.call_count >= 1 # distance_m в breakdown — дорожное (700), не straight-line (200). assert _distance_in_breakdown(body, "Школа №1") == pytest.approx(700.0, abs=1.0) finally: app.dependency_overrides.clear() _stop_patches() def test_flag_on_osrm_unavailable_falls_back(monkeypatch) -> None: """flag ON + OSRM бросает → straight-line fallback, analyze=200 (НЕ 500).""" from app.core.db import get_db poi_rows = [_poi_row("school", "Школа №1", 60.61, 56.84, 200.0)] db = _make_db_for_analyze(poi_rows) app.dependency_overrides[get_db] = _override_db(db) monkeypatch.setattr(settings, "use_osrm_distances", True) _start_patches() try: with patch( "app.api.v1.parcels.get_road_distances_m", side_effect=OsrmLocalUnavailableError("osrm down"), ): client = TestClient(app) resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") # КРИТИЧНО: /analyze НЕ падает в 500 когда OSRM лежит. assert resp.status_code == 200, resp.text body = resp.json() # distance_m остаётся straight-line baseline (200), OSRM проигнорирован. assert _distance_in_breakdown(body, "Школа №1") == pytest.approx(200.0, abs=1.0) finally: app.dependency_overrides.clear() _stop_patches() def test_flag_off_never_calls_osrm(monkeypatch) -> None: """flag OFF (default) → get_road_distances_m НЕ вызывается, distance = straight-line.""" from app.core.db import get_db poi_rows = [_poi_row("school", "Школа №1", 60.61, 56.84, 200.0)] db = _make_db_for_analyze(poi_rows) app.dependency_overrides[get_db] = _override_db(db) # Явно OFF (default, но фиксируем на случай env-override в окружении). monkeypatch.setattr(settings, "use_osrm_distances", False) _start_patches() try: with patch("app.api.v1.parcels.get_road_distances_m") as mock_osrm: client = TestClient(app) resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") assert resp.status_code == 200, resp.text body = resp.json() # OSRM НЕ дёрнут ни разу — байт-в-байт как сегодня. assert mock_osrm.call_count == 0 # distance_m = straight-line baseline. assert _distance_in_breakdown(body, "Школа №1") == pytest.approx(200.0, abs=1.0) finally: app.dependency_overrides.clear() _stop_patches()