diff --git a/backend/tests/api/v1/test_admin_cadastre.py b/backend/tests/api/v1/test_admin_cadastre.py index 72116c70..3df39895 100644 --- a/backend/tests/api/v1/test_admin_cadastre.py +++ b/backend/tests/api/v1/test_admin_cadastre.py @@ -296,8 +296,17 @@ def test_cancel_job_not_found_returns_404() -> None: app.dependency_overrides.clear() +@pytest.mark.skip( + reason=( + "X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — " + "Caddy basic_auth + RBAC middleware достаточны). Admin endpoint'ы больше не " + "несут verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode " + "RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без " + "реверта security-решения #437. Тест проверял удалённое поведение." + ) +) def test_create_job_no_token_returns_401() -> None: - """Без X-Admin-Token → 401/503.""" + """Без X-Admin-Token → 401/503 (устарело: токен-гейт удалён в #437).""" client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs", diff --git a/backend/tests/api/v1/test_admin_ekburg_permits.py b/backend/tests/api/v1/test_admin_ekburg_permits.py index a03c10be..7c75f376 100644 --- a/backend/tests/api/v1/test_admin_ekburg_permits.py +++ b/backend/tests/api/v1/test_admin_ekburg_permits.py @@ -75,8 +75,17 @@ def test_trigger_invalid_year_returns_422(bad_year: int) -> None: assert response.status_code == 422, f"year={bad_year} должен возвращать 422" +@pytest.mark.skip( + reason=( + "X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — " + "Caddy basic_auth + RBAC middleware достаточны). trigger_ekburg_permits больше не " + "несёт verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode " + "RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без " + "реверта security-решения #437. Тест проверял удалённое поведение." + ) +) def test_trigger_no_token_returns_401_or_503() -> None: - """Без X-Admin-Token → 401 или 503.""" + """Без X-Admin-Token → 401 или 503 (устарело: токен-гейт удалён в #437).""" client = TestClient(app) response = client.post(ENDPOINT, json={}) assert response.status_code in (401, 503), response.text diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index 14b18bb5..05805746 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -47,26 +47,14 @@ def _make_db_for_analyze( ) -> MagicMock: """Сконструировать mock DB Session для analyze_parcel. - Порядок db.execute calls в analyze_parcel: - 0. UNION ALL geom + source → .mappings().first() - 1. WKT query → .mappings().first() - 2. District → .mappings().first() - 3. POI rows → .mappings().all() - 4. Competitor rows → .mappings().all() - 5. Pipeline rows → .mappings().all() - 6. Centroid lat/lon → .mappings().first() - 7. Noise rows → .mappings().all() - 8. Hydrology → .mappings().all() - 9. Utilities → .mappings().all() - 10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2 - 11. Market trend → .mappings().first() - 12. Zoning (begin_nested) → .mappings().first() - 13. Success recommendation (begin_nested) → .mappings().all() - 14. Market price (begin_nested) → .mappings().first() - 15. Recent permits (begin_nested) → .mappings().all() ← #105 Phase 5 - 16. _geotech_risk (industrial count) → .scalar() - 17. _neighbors_summary (neighbor_rows) → .mappings().all() - 18. _neighbors_summary (overlap_row) → .mappings().first() + Mock диспетчеризует строки по СИГНАТУРЕ SQL, а не по позиционному индексу. + Это критично: weight-validation (422) в обработчике срабатывает ПОСЛЕ резолва + геометрии (parcels.py:~1213 geom → ~1382 validation), а несколько тестов шлют + ДВА POST подряд на один mock — монотонный счётчик «съедал» бы geom-row на втором + запросе → handler уходил в inline NSPD fetch (202 «fetching») вместо 422. + Сигнатурный матчинг возвращает geom/wkt/district/centroid независимо от числа + предыдущих вызовов. Прочие запросы обёрнуты в try/except SAVEPOINT → дефолтный + пустой mock их не валит. begin_nested() — возвращаем context manager чтобы поддержать `with` statement. """ @@ -93,44 +81,28 @@ def _make_db_for_analyze( _poi_rows = poi_rows or [] - # Счётчик вызовов execute — разводим first() / all() / scalar() по очерёдности - call_idx = [0] - # Ответы в порядке вызовов: - responses: list[Any] = [ - ("first", geom_row), # 0: geom UNION ALL - ("first", wkt_row), # 1: WKT - ("first", district_row), # 2: district - ("all", _poi_rows), # 3: POI rows - ("all", []), # 4: competitor rows - ("all", []), # 5: pipeline rows - ("first", centroid_row), # 6: centroid - ("all", []), # 7: noise rows - ("all", []), # 8: hydrology rows - ("all", []), # 9: utilities rows - ("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2 - ("first", None), # 11: market trend - ("first", None), # 12: zoning (inside begin_nested) - ("all", []), # 13: success recommendation (inside begin_nested) - ("scalar", 0), # 14: geotech_risk industrial count - ("all", []), # 15: neighbors - ("first", None), # 16: overlap - ] - def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: - idx = call_idx[0] - call_idx[0] += 1 - if idx >= len(responses): - # Безопасный fallback для непредусмотренных вызовов - r = MagicMock() - r.mappings.return_value.first.return_value = None - r.mappings.return_value.all.return_value = [] - r.scalar.return_value = 0 - return r - kind, data = responses[idx] + 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 median_price_per_m2" in sql and "district_name" in sql: + first_val = district_row + elif "AS lon" in sql and "AS lat" in sql: + first_val = centroid_row + # POI rows — категория/имя/координаты в радиусе. + 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 = data - r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] - r.scalar.return_value = data if kind == "scalar" else 0 + 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 @@ -298,9 +270,9 @@ def test_inline_weights_rejects_nan() -> None: content=raw_body, headers={"Content-Type": "application/json"}, ) - assert ( - resp.status_code == 422 - ), f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}" + assert resp.status_code == 422, ( + f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}" + ) finally: app.dependency_overrides.clear() _stop_patches() diff --git a/backend/tests/api/v1/test_analyze_market_price.py b/backend/tests/api/v1/test_analyze_market_price.py index 4fbe9081..41fa62b1 100644 --- a/backend/tests/api/v1/test_analyze_market_price.py +++ b/backend/tests/api/v1/test_analyze_market_price.py @@ -8,26 +8,15 @@ Стратегия mock: аналогична test_analyze_inline_weights.py — DB mock через dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch. -Порядок db.execute calls в analyze_parcel (v3.7 + #33 + #29 G2): - 0. UNION ALL geom + source → .mappings().first() - 1. WKT query → .mappings().first() - 2. District → .mappings().first() - 3. POI rows → .mappings().all() - 4. Competitor rows → .mappings().all() - 5. Pipeline rows → .mappings().all() - 6. Centroid lat/lon → .mappings().first() - 7. Noise rows → .mappings().all() - 8. Hydrology → .mappings().all() - 9. Utilities → .mappings().all() - 10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2 - 11. Market trend → .mappings().first() - 12. Zoning (begin_nested) → .mappings().first() - 13. Success recommendation (begin_nested) → .mappings().all() - 14. Market price (begin_nested) → .mappings().first() ← #33 - 15. Recent permits (begin_nested) → .mappings().all() ← #105 Phase 5 - 16. _geotech_risk (industrial count) → .scalar() - 17. _neighbors_summary (neighbor_rows) → .mappings().all() - 18. _neighbors_summary (overlap_row) → .mappings().first() +Порядок db.execute в analyze_parcel меняется при добавлении блоков (SF-B5 добавил +red_lines / district_price / risk_zones между market-trend и market-price), поэтому +жёсткая нумерация быстро устаревает. Mock диспетчеризует строки по СИГНАТУРЕ SQL +(а не по позиционному индексу) — устойчиво к реордерингу: + • geom UNION ALL / WKT / district / centroid — по содержимому SELECT + • market-price (#33) — по уникальной сигнатуре "median_6m, median_12m, median_24m" +Прочие запросы (POI/competitors/noise/red_lines/risk/permits/neighbors) обёрнуты в +try/except SAVEPOINT в коде → дефолтный пустой mock (first=None, all=[], scalar=0) +не валит обработчик. """ from __future__ import annotations @@ -93,43 +82,30 @@ def _make_db_for_analyze( mp_mock = _make_mapping(market_price_row) if market_price_row is not None else None - call_idx = [0] - responses: list[Any] = [ - ("first", geom_row), # 0: geom UNION ALL - ("first", wkt_row), # 1: WKT - ("first", district_row), # 2: district - ("all", []), # 3: POI rows - ("all", []), # 4: competitor rows - ("all", []), # 5: pipeline rows - ("first", centroid_row), # 6: centroid - ("all", []), # 7: noise rows - ("all", []), # 8: hydrology rows - ("all", []), # 9: utilities rows - ("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2 - ("first", None), # 11: market trend - ("first", None), # 12: zoning (begin_nested) - ("all", []), # 13: success recommendation (begin_nested) - ("first", mp_mock), # 14: market price (begin_nested) ← #33 - ("all", []), # 15: recent permits (begin_nested) - ("scalar", 0), # 16: geotech_risk - ("all", []), # 17: neighbors - ("first", None), # 18: overlap - ] - def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: - idx = call_idx[0] - call_idx[0] += 1 - if idx >= len(responses): - r = MagicMock() - r.mappings.return_value.first.return_value = None - r.mappings.return_value.all.return_value = [] - r.scalar.return_value = 0 - return r - kind, data = responses[idx] + # Нормализуем SQL для сигнатурного матчинга (collapse whitespace). + sql = " ".join(str(args[0]).split()) if args else "" + + first_val: Any = None + all_val: list[Any] = [] + + # Структурные запросы (нужны чтобы обработчик дошёл до market-price без 404/202). + if "AS geom_geojson" in sql: + first_val = geom_row + elif "AS wkt" in sql: + first_val = wkt_row + elif "AS median_price_per_m2" in sql and "district_name" in sql: + first_val = district_row + elif "AS lon" in sql and "AS lat" in sql: + first_val = centroid_row + # market-price (#33) — уникальная сигнатура трёх скользящих медиан. + elif "median_6m" in sql and "median_12m" in sql and "median_24m" in sql: + first_val = mp_mock + r = MagicMock() - r.mappings.return_value.first.return_value = data - r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] - r.scalar.return_value = data if kind == "scalar" else 0 + 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 diff --git a/backend/tests/api/v1/test_analyze_recent_permits.py b/backend/tests/api/v1/test_analyze_recent_permits.py index e2db9849..b935d9a2 100644 --- a/backend/tests/api/v1/test_analyze_recent_permits.py +++ b/backend/tests/api/v1/test_analyze_recent_permits.py @@ -5,26 +5,14 @@ 2. analyze с quarter без permits → recent_permits=[], summary={rns_count=0, ...} 3. analyze 404 на invalid cad (no regression) -Порядок db.execute calls в analyze_parcel (после #105 Phase 5 + #29 G2): - 0. UNION ALL geom + source → .mappings().first() - 1. WKT query → .mappings().first() - 2. District → .mappings().first() - 3. POI rows → .mappings().all() - 4. Competitor rows → .mappings().all() - 5. Pipeline rows → .mappings().all() - 6. Centroid lat/lon → .mappings().first() - 7. Noise rows → .mappings().all() - 8. Hydrology → .mappings().all() - 9. Utilities → .mappings().all() - 10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2 - 11. Market trend → .mappings().first() - 12. Zoning (begin_nested) → .mappings().first() - 13. Success recommendation (begin_nested) → .mappings().all() - 14. Market price (begin_nested) → .mappings().first() - 15. Recent permits (begin_nested) → .mappings().all() ← #105 - 16. _geotech_risk (industrial count) → .scalar() - 17. _neighbors_summary (neighbor_rows) → .mappings().all() - 18. _neighbors_summary (overlap_row) → .mappings().first() +Порядок db.execute в analyze_parcel меняется при добавлении блоков (SF-B5 вклинил +red_lines / district_price / risk_zones), поэтому mock диспетчеризует строки по +СИГНАТУРЕ SQL, а не по позиционному индексу — устойчиво к реордерингу: + • geom UNION ALL / WKT / district / centroid — по содержимому SELECT + • recent permits (#105) — по уникальной сигнатуре "permit_type, permit_number ... + developer_inn" +Прочие запросы обёрнуты в try/except SAVEPOINT в коде → дефолтный пустой mock +(first=None, all=[], scalar=0) их не валит. """ from __future__ import annotations @@ -84,43 +72,29 @@ def _make_db_for_analyze( raw_permits = [_make_mapping(p) for p in (permits_rows or [])] - call_idx = [0] - responses: list[Any] = [ - ("first", geom_row), # 0: geom UNION ALL - ("first", wkt_row), # 1: WKT - ("first", district_row), # 2: district - ("all", []), # 3: POI rows - ("all", []), # 4: competitor rows - ("all", []), # 5: pipeline rows - ("first", centroid_row), # 6: centroid - ("all", []), # 7: noise rows - ("all", []), # 8: hydrology rows - ("all", []), # 9: utilities rows - ("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2 - ("first", None), # 11: market trend - ("first", None), # 12: zoning (begin_nested) - ("all", []), # 13: success recommendation (begin_nested) - ("first", None), # 14: market price (begin_nested) - ("all", raw_permits), # 15: recent permits (begin_nested) ← #105 - ("scalar", 0), # 16: geotech_risk - ("all", []), # 17: neighbors - ("first", None), # 18: overlap - ] - def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: - idx = call_idx[0] - call_idx[0] += 1 - if idx >= len(responses): - r = MagicMock() - r.mappings.return_value.first.return_value = None - r.mappings.return_value.all.return_value = [] - r.scalar.return_value = 0 - return r - kind, data = responses[idx] + sql = " ".join(str(args[0]).split()) if args else "" + + first_val: Any = None + all_val: list[Any] = [] + + # Структурные запросы (нужны чтобы обработчик дошёл до permits без 404/202). + if "AS geom_geojson" in sql: + first_val = geom_row + elif "AS wkt" in sql: + first_val = wkt_row + elif "AS median_price_per_m2" in sql and "district_name" in sql: + first_val = district_row + elif "AS lon" in sql and "AS lat" in sql: + first_val = centroid_row + # recent permits (#105) — уникальная сигнатура permit_type+permit_number+developer_inn. + elif "permit_type" in sql and "permit_number" in sql and "developer_inn" in sql: + all_val = raw_permits + r = MagicMock() - r.mappings.return_value.first.return_value = data - r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] - r.scalar.return_value = data if kind == "scalar" else 0 + 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 diff --git a/backend/tests/api/v1/test_custom_pois.py b/backend/tests/api/v1/test_custom_pois.py index a579ff47..bfd6e4c7 100644 --- a/backend/tests/api/v1/test_custom_pois.py +++ b/backend/tests/api/v1/test_custom_pois.py @@ -263,7 +263,10 @@ def _make_db_for_analyze() -> MagicMock: "dist_to_center": 1500.0, } ) - centroid_row = _make_mapping_analyze({"lat": 56.84, "lon": 60.605}) + # Центроид УМЫШЛЕННО далеко (>15км) от центра ЕКБ (56.838011, 60.597474) — + # тогда center_bonus == 0.0 (parcels.py:~1594 порог <15км) и итоговый score + # изолирует ТОЛЬКО вклад custom-POI. ~29км на север (Δlat≈+0.26°). + centroid_row = _make_mapping_analyze({"lat": 57.10, "lon": 60.597}) call_idx = [0] responses: list[Any] = [ @@ -389,9 +392,9 @@ def test_analyze_custom_poi_increases_score() -> None: # contribution = 2.0 * 0.5 = 1.0 assert items[0]["contribution"] == pytest.approx(1.0, abs=0.01) - # score должен учитывать contribution custom POI - # (базовый score без POI = center_bonus из 1500м → 1.5; + custom 1.0 = 2.5) - assert body["score"] == pytest.approx(2.5, abs=0.1) + # Центроид >15км от центра ЕКБ → center_bonus = 0.0, поэтому итоговый score + # == вклад custom-POI (1.0). Так тест изолирует custom-contribution. + assert body["score"] == pytest.approx(1.0, abs=0.1) finally: app.dependency_overrides.clear() _stop_analyze_patches() @@ -450,8 +453,9 @@ def test_analyze_custom_poi_negative_weight_decreases_score() -> None: items = body["custom_poi_score_items"] assert len(items) == 1 assert items[0]["contribution"] == pytest.approx(-1.5, abs=0.01) - # center_bonus = 1.5 (1500м), custom = -1.5 → итого ≈ 0 - assert body["score"] == pytest.approx(0.0, abs=0.1) + # Центроид >15км от центра ЕКБ → center_bonus = 0.0, поэтому итоговый score + # == вклад custom-POI (-1.5). score_final НЕ клампится снизу (parcels.py:2410). + assert body["score"] == pytest.approx(-1.5, abs=0.1) finally: app.dependency_overrides.clear() _stop_analyze_patches() diff --git a/backend/tests/api/v1/test_parcel_best_layouts.py b/backend/tests/api/v1/test_parcel_best_layouts.py index 70066681..a649d32a 100644 --- a/backend/tests/api/v1/test_parcel_best_layouts.py +++ b/backend/tests/api/v1/test_parcel_best_layouts.py @@ -264,10 +264,15 @@ def test_min_velocity_per_month_filters_low_rows() -> None: def test_time_window_velocity_scaling() -> None: - """last_month vs last_year дают разный velocity_per_month для одних deals.""" - # sum_deals=24 → last_month: 24/24=1.0, last_year: 24/2=12.0 + """last_month vs last_year дают разный velocity_per_month для одних deals. + + Fix SF-01: velocity_per_month = deals_window / months_in_window. Делители + из _TIME_WINDOW_PARAMS: last_month=1.0, last_year=12.0. При одинаковом + deals_window короткое окно даёт ВЫШЕ месячную velocity (делитель меньше). + """ + # deals_window=24 → last_month: 24/1=24.0, last_year: 24/12=2.0 id_rows = [_obj_id_row(1)] - vel_rows_fixed = [_vel_row("2", sum_deals=24.0, obj_ids=[1])] + vel_rows_fixed = [_vel_row("2", deals_window=24.0, obj_ids=[1])] from app.core.db import get_db @@ -291,10 +296,10 @@ def test_time_window_velocity_scaling() -> None: finally: app.dependency_overrides.clear() - # last_year velocity должна быть выше (делитель меньше: 2 vs 24) - assert v_year > v_month - assert v_month == pytest.approx(1.0) - assert v_year == pytest.approx(12.0) + # last_month velocity выше (делитель меньше: 1 vs 12) при одинаковом deals_window + assert v_month > v_year + assert v_month == pytest.approx(24.0) + assert v_year == pytest.approx(2.0) def test_obj_class_filter_passes_through() -> None: @@ -341,8 +346,8 @@ def test_target_total_flats_fills_abs_units() -> None: """target_total_flats=100 → abs_units заполнен в mix, sum примерно = 100.""" id_rows = [_obj_id_row(1), _obj_id_row(2)] vel_rows = [ - _vel_row("1", sum_deals=60.0, obj_ids=[1]), - _vel_row("2", sum_deals=40.0, obj_ids=[2]), + _vel_row("1", deals_window=60.0, obj_ids=[1]), + _vel_row("2", deals_window=40.0, obj_ids=[2]), ] db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows) from app.core.db import get_db @@ -365,8 +370,8 @@ def test_target_total_flats_fills_abs_units() -> None: def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None: """raw sold_pct > 100 → returned sold_pct_of_supply=100.0, is_oversold=True.""" id_rows = [_obj_id_row(1)] - # sum_deals=199, supply=100 → raw = 199% (несопоставимые окна) - vel_rows = [_vel_row("2", sum_deals=199.0, obj_ids=[1])] + # deals_window=199, supply=100 → raw = 199% (несопоставимые окна) + vel_rows = [_vel_row("2", deals_window=199.0, obj_ids=[1])] supply_rows = [_supply_row("2", "40-60", 100)] db = _make_db( coord=_coord_row(), @@ -394,8 +399,8 @@ def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None: def test_sold_pct_below_100_is_not_oversold() -> None: """raw sold_pct <= 100 → sold_pct_of_supply возвращается as-is, is_oversold=False.""" id_rows = [_obj_id_row(1)] - # sum_deals=50, supply=100 → raw = 50% - vel_rows = [_vel_row("1", sum_deals=50.0, obj_ids=[1])] + # deals_window=50, supply=100 → raw = 50% + vel_rows = [_vel_row("1", deals_window=50.0, obj_ids=[1])] supply_rows = [_supply_row("1", "40-60", 100)] db = _make_db( coord=_coord_row(), @@ -424,7 +429,7 @@ def test_filter_competitor_obj_ids_applied() -> None: """filter_competitor_obj_ids=[1] оставляет только obj_id=1.""" id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)] # После фильтрации остаётся только obj_id=1, velocity запрос получит [1] - vel_rows = [_vel_row("2", sum_deals=24.0, obj_ids=[1])] + vel_rows = [_vel_row("2", deals_window=24.0, obj_ids=[1])] db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows) from app.core.db import get_db diff --git a/backend/tests/services/exporters/test_report_pdf.py b/backend/tests/services/exporters/test_report_pdf.py index 2c170e44..22618b0b 100644 --- a/backend/tests/services/exporters/test_report_pdf.py +++ b/backend/tests/services/exporters/test_report_pdf.py @@ -20,6 +20,8 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: import datetime as dt +import pytest + from app.services.exporters.report_pdf import ( _ADVISORY_MARKER, _TITLE_FUTURE_MARKET, @@ -44,6 +46,31 @@ from app.services.forecasting.report import ( SiteFinderReport, ) + +def _weasyprint_native_libs_available() -> tuple[bool, str]: + """Probe whether WeasyPrint can render (native GTK/Pango/GObject libs present). + + WeasyPrint is lazy-imported inside export_report_pdf, so the missing-native-lib + OSError fires only at write_pdf() time (libpango/libgobject absent on macOS dev, + present in the Docker/CI image). We probe by rendering a trivial document; failure + ⇒ skip the PDF-rendering tests on dev, while the HTML-only tests still run and the + PDF path still runs on CI. NB: the HTML-content tests (_build_html only) are NOT + skipped — they never touch WeasyPrint. + """ + try: + from weasyprint import HTML + + HTML(string="
x
").write_pdf() + except (OSError, ImportError) as e: + return False, str(e) + return True, "" + + +_WP_OK, _WP_ERR = _weasyprint_native_libs_available() +_skip_if_no_weasyprint = pytest.mark.skipif( + not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}" +) + # Шесть ожидаемых заголовков блоков (по одному на содержательную секцию §13). _EXPECTED_TITLES: tuple[str, ...] = ( _TITLE_SUMMARY, @@ -133,6 +160,7 @@ def _full_report() -> SiteFinderReport: # ── Полный отчёт: PDF-байты (магия %PDF), нетривиальная длина ─────────────────── +@_skip_if_no_weasyprint class TestFullReportExport: def test_returns_pdf_magic_bytes(self) -> None: payload = export_report_pdf(_full_report()) @@ -195,6 +223,7 @@ class TestRenderedHtmlContent: class TestGracefulPartialReport: + @_skip_if_no_weasyprint def test_empty_report_still_valid_pdf(self) -> None: # Полностью дефолтный (пустой) отчёт — валидный PDF, не падает. payload = export_report_pdf(SiteFinderReport()) @@ -208,6 +237,7 @@ class TestGracefulPartialReport: # ADVISORY-маркер остаётся даже у пустого отчёта. assert "ADVISORY" in html_str + @_skip_if_no_weasyprint def test_partial_report_some_sections(self) -> None: # Заполнены только meta + exec_summary — остальные пусты, PDF валиден. report = SiteFinderReport( @@ -220,6 +250,7 @@ class TestGracefulPartialReport: assert "Тонкий анализ" in html_str assert "66:41:0000000:2" in html_str + @_skip_if_no_weasyprint def test_garbage_input_does_not_crash(self) -> None: # Мусор (None / не-отчёт) → пустой, но валидный PDF (нормализация в {}). for junk in (None, 123, "not a report"): diff --git a/backend/tests/services/test_cadastre_bulk.py b/backend/tests/services/test_cadastre_bulk.py index 952a8891..7659aaf4 100644 --- a/backend/tests/services/test_cadastre_bulk.py +++ b/backend/tests/services/test_cadastre_bulk.py @@ -391,7 +391,7 @@ def test_upsert_parcel_sql_uses_st_multi_for_multipolygon_schema() -> None: db = MagicMock() executed_sqls: list[str] = [] - db.execute = lambda stmt, params=None: (executed_sqls.append(str(stmt)) or MagicMock()) + db.execute = lambda stmt, params=None: executed_sqls.append(str(stmt)) or MagicMock() upsert_parcel(db, _make_parcel_feature(), source="search") @@ -423,13 +423,23 @@ async def test_harvest_quarter_does_not_early_exit_on_shared_phase_done() -> Non ) db = MagicMock() - # Симулируем shared phase_state с phase=done от ДРУГОГО quarter + # Симулируем shared phase_state с phase=done от ДРУГОГО quarter. + # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → + # grid-walk + territorial_zones фазы корректно пропускаются (тест про + # snapshot/idempotency, не про geometry). Тот же dict возвращается на ВСЕ + # db.execute().mappings().first() в этом тесте. db.execute = MagicMock( return_value=MagicMock( mappings=MagicMock( return_value=MagicMock( first=MagicMock( - return_value={"phase_state": {"phase": "done", "quarter": "66:41:9999999"}} + return_value={ + "phase_state": {"phase": "done", "quarter": "66:41:9999999"}, + "xmin": None, + "ymin": None, + "xmax": None, + "ymax": None, + } ) ) ) @@ -479,10 +489,24 @@ async def test_harvest_quarter_calls_upsert_features() -> None: db = MagicMock() # phase_state = None → начинаем с нуля + # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → + # grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / + # per-cat-probe, не про geometry). Тот же dict на ВСЕ + # db.execute().mappings().first() вызовы. db.execute = MagicMock( return_value=MagicMock( mappings=MagicMock( - return_value=MagicMock(first=MagicMock(return_value={"phase_state": None})) + return_value=MagicMock( + first=MagicMock( + return_value={ + "phase_state": None, + "xmin": None, + "ymin": None, + "xmax": None, + "ymax": None, + } + ) + ) ) ) ) @@ -669,10 +693,24 @@ async def test_harvest_quarter_calls_per_cat_probe_for_zouit_when_meta_nonzero() ) db = MagicMock() + # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → + # grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / + # per-cat-probe, не про geometry). Тот же dict на ВСЕ + # db.execute().mappings().first() вызовы. db.execute = MagicMock( return_value=MagicMock( mappings=MagicMock( - return_value=MagicMock(first=MagicMock(return_value={"phase_state": None})) + return_value=MagicMock( + first=MagicMock( + return_value={ + "phase_state": None, + "xmin": None, + "ymin": None, + "xmax": None, + "ymax": None, + } + ) + ) ) ) ) @@ -730,10 +768,24 @@ async def test_harvest_quarter_skips_per_cat_probe_when_meta_zero() -> None: ) db = MagicMock() + # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → + # grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / + # per-cat-probe, не про geometry). Тот же dict на ВСЕ + # db.execute().mappings().first() вызовы. db.execute = MagicMock( return_value=MagicMock( mappings=MagicMock( - return_value=MagicMock(first=MagicMock(return_value={"phase_state": None})) + return_value=MagicMock( + first=MagicMock( + return_value={ + "phase_state": None, + "xmin": None, + "ymin": None, + "xmax": None, + "ymax": None, + } + ) + ) ) ) ) @@ -786,10 +838,24 @@ async def test_harvest_quarter_per_cat_probe_enk_called_when_meta_nonzero() -> N ) db = MagicMock() + # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → + # grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / + # per-cat-probe, не про geometry). Тот же dict на ВСЕ + # db.execute().mappings().first() вызовы. db.execute = MagicMock( return_value=MagicMock( mappings=MagicMock( - return_value=MagicMock(first=MagicMock(return_value={"phase_state": None})) + return_value=MagicMock( + first=MagicMock( + return_value={ + "phase_state": None, + "xmin": None, + "ymin": None, + "xmax": None, + "ymax": None, + } + ) + ) ) ) ) @@ -1091,10 +1157,24 @@ async def test_harvest_quarter_geom_heal_failure_does_not_propagate() -> None: ) db = MagicMock() + # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → + # grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / + # per-cat-probe, не про geometry). Тот же dict на ВСЕ + # db.execute().mappings().first() вызовы. db.execute = MagicMock( return_value=MagicMock( mappings=MagicMock( - return_value=MagicMock(first=MagicMock(return_value={"phase_state": None})) + return_value=MagicMock( + first=MagicMock( + return_value={ + "phase_state": None, + "xmin": None, + "ymin": None, + "xmax": None, + "ymax": None, + } + ) + ) ) ) ) diff --git a/backend/tests/sql/test_mv_layout_velocity_weighted_avg.py b/backend/tests/sql/test_mv_layout_velocity_weighted_avg.py index 4dd7a2b0..47d97da7 100644 --- a/backend/tests/sql/test_mv_layout_velocity_weighted_avg.py +++ b/backend/tests/sql/test_mv_layout_velocity_weighted_avg.py @@ -9,23 +9,53 @@ Uses psycopg v3 (never psycopg2) with a temporary table containing known data. All bind parameters use CAST(:x AS type) — never :x::type. """ +import os from decimal import Decimal import psycopg import pytest +# --------------------------------------------------------------------------- +# DSN + skip guard +# +# This is an INTEGRATION test — it needs a real PostgreSQL (it CREATE TEMP TABLE +# and runs the weighted-AVG SQL). On CI (Docker) Postgres is reachable; on a dev +# laptop without the SSH tunnel it is not. We: +# 1. Prefer TEST_DATABASE_URL (mirror tests/integration/conftest.py), then +# DATABASE_URL — but normalize the SQLAlchemy dialect suffix `+psycopg`, +# which psycopg.connect() cannot parse (other test modules set DATABASE_URL +# to `postgresql+psycopg://...` via os.environ.setdefault → ProgrammingError). +# 2. Probe connectivity at import time; if the DB is unreachable, SKIP the whole +# module cleanly (never ERROR). On CI the probe succeeds → tests run, so the +# #295 weighted-AVG regression stays guarded. +# --------------------------------------------------------------------------- -# --------------------------------------------------------------------------- -# Fixture: connect to the test / local DB via env-var DATABASE_URL, -# fall back to the tunnel URL used in local dev. -# --------------------------------------------------------------------------- def _get_dsn() -> str: - import os - return os.environ.get( + raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( "DATABASE_URL", "postgresql://gendesign@localhost:15432/gendesign", ) + # SQLAlchemy dialect form `postgresql+psycopg://` → plain libpq DSN for psycopg. + return raw.replace("+psycopg", "") + + +def _db_reachable() -> tuple[bool, str]: + try: + with psycopg.connect(_get_dsn(), connect_timeout=3): + return True, "" + except Exception as e: # OperationalError / ProgrammingError / etc. + return False, str(e) + + +_DB_OK, _DB_ERR = _db_reachable() +pytestmark = pytest.mark.skipif( + not _DB_OK, + reason=( + "Нет доступной plain-postgres БД (TEST_DATABASE_URL/DATABASE_URL) — " + f"интеграционный тест #295 пропущен off-CI: {_DB_ERR}" + ), +) @pytest.fixture() @@ -79,6 +109,7 @@ ORDER BY room_bucket; # Test cases # --------------------------------------------------------------------------- + class TestWeightedAvgFormula: """Unit-level verification of the count-weighted formula using a temp table.""" @@ -98,8 +129,8 @@ class TestWeightedAvgFormula: cur.execute(_CREATE_TEMP) rows = [ ("studio", 10, 25.0, 180.0), - ("studio", 0, 0.0, 0.0), - ("studio", 0, 0.0, 0.0), + ("studio", 0, 0.0, 0.0), + ("studio", 0, 0.0, 0.0), ] cur.executemany(_INSERT_ROW, rows) cur.execute(_WEIGHTED_QUERY) @@ -131,9 +162,7 @@ class TestWeightedAvgFormula: assert weighted_area != naive_area assert weighted_price != naive_price - def test_weighted_differs_from_naive_sparse_project( - self, conn: psycopg.Connection - ) -> None: + def test_weighted_differs_from_naive_sparse_project(self, conn: psycopg.Connection) -> None: """ Scenario inspired by real data: 3-room flat, project with 5 deals spread across 5 active months out of 15 total months. @@ -150,7 +179,7 @@ class TestWeightedAvgFormula: result = cur.fetchone() assert result is not None - room, total_deals, naive_area, weighted_area, naive_price, weighted_price = result + room, total_deals, naive_area, weighted_area, _naive_price, weighted_price = result assert room == "3" assert total_deals == 5 @@ -164,13 +193,9 @@ class TestWeightedAvgFormula: # Correction factor > 2x ratio = float(weighted_area) / float(naive_area) - assert ratio > 2.5, ( - f"Expected correction factor > 2.5x for sparse project, got {ratio:.2f}" - ) + assert ratio > 2.5, f"Expected correction factor > 2.5x for sparse project, got {ratio:.2f}" - def test_no_zero_months_weighted_equals_naive( - self, conn: psycopg.Connection - ) -> None: + def test_no_zero_months_weighted_equals_naive(self, conn: psycopg.Connection) -> None: """ When every month has deals, weighted and naive AVG should be equal (within numeric(10,2) rounding) only if counts are uniform. @@ -199,9 +224,7 @@ class TestWeightedAvgFormula: ) assert weighted_price == naive_price - def test_nullif_prevents_division_by_zero( - self, conn: psycopg.Connection - ) -> None: + def test_nullif_prevents_division_by_zero(self, conn: psycopg.Connection) -> None: """ When all months have 0 deals, NULLIF(SUM(cnt), 0) → NULL instead of divide-by-zero. The buggy AVG() also returns 0 (not NULL) for all-zero rows, which is arguably @@ -218,7 +241,7 @@ class TestWeightedAvgFormula: result = cur.fetchone() assert result is not None - _, total_deals, naive_area, weighted_area, naive_price, weighted_price = result + _, total_deals, naive_area, weighted_area, _naive_price, weighted_price = result assert total_deals == 0 # Weighted formula returns NULL for all-zero-deal case (correct — no real data) @@ -231,9 +254,7 @@ class TestWeightedAvgFormula: # Naive AVG returns 0.00 — a misleading non-NULL value assert naive_area == Decimal("0.00") - def test_hand_computed_weighted_average( - self, conn: psycopg.Connection - ) -> None: + def test_hand_computed_weighted_average(self, conn: psycopg.Connection) -> None: """ End-to-end hand-computed check with multiple room buckets and mixed deal counts to verify the formula is exactly correct. @@ -247,11 +268,11 @@ class TestWeightedAvgFormula: with conn.cursor() as cur: cur.execute(_CREATE_TEMP) rows = [ - ("studio", 8, 30.0, 160.0), + ("studio", 8, 30.0, 160.0), ("studio", 12, 28.0, 170.0), - ("studio", 3, 27.0, 155.0), - ("1", 15, 38.0, 175.0), - ("1", 5, 40.0, 180.0), + ("studio", 3, 27.0, 155.0), + ("1", 15, 38.0, 175.0), + ("1", 5, 40.0, 180.0), ] cur.executemany(_INSERT_ROW, rows) cur.execute(_WEIGHTED_QUERY) diff --git a/backend/tests/test_admin_weight_profiles.py b/backend/tests/test_admin_weight_profiles.py index c3bad23a..da53c58d 100644 --- a/backend/tests/test_admin_weight_profiles.py +++ b/backend/tests/test_admin_weight_profiles.py @@ -404,8 +404,18 @@ def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> N # ── Auth ─────────────────────────────────────────────────────────────────────── +_TOKEN_REMOVED_REASON = ( + "App-level X-Admin-Token header удалён 2026-05-23 (см. docstring " + "app/api/v1/admin_weight_profiles.py: Caddy basic_auth PR #426 + RBAC достаточны, " + "двойная auth избыточна). Endpoint больше не несёт verify_admin_token dependency. " + "В test-mode RBAC bypass'ится (CI-rehab 1/3) → 401 здесь недостижим без реверта " + "security-решения. Тест проверял удалённое поведение." +) + + +@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON) def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None: - """Запрос без X-Admin-Token → 401 (или 503 если токен не задан в settings).""" + """Запрос без X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23).""" monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) client = TestClient(app) r = client.get( @@ -416,8 +426,9 @@ def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None: assert r.status_code == 401 +@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON) def test_unauthorized_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None: - """Неверный X-Admin-Token → 401.""" + """Неверный X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23).""" monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) client = TestClient(app) r = client.get( diff --git a/backend/tests/test_layout_tz_pdf.py b/backend/tests/test_layout_tz_pdf.py index 1fbe2dd2..b65833f5 100644 --- a/backend/tests/test_layout_tz_pdf.py +++ b/backend/tests/test_layout_tz_pdf.py @@ -9,19 +9,40 @@ import datetime as dt import pytest -# Attempt to import the module under test; skip entire module if native libs missing. -try: - from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf -except (OSError, ImportError) as _e: # GTK libs missing on Windows, or weasyprint not installed - pytest.skip(f"WeasyPrint deps missing: {_e}", allow_module_level=True) -from app.schemas.parcel import ( +def _weasyprint_native_libs_available() -> tuple[bool, str]: + """Probe whether WeasyPrint can actually render (native GTK/Pango/GObject libs). + + WeasyPrint is lazy-imported inside render_layout_tz_pdf, so a plain module + import no longer surfaces the missing-native-lib OSError. The native libs + (libgobject/libpango) are present in the Docker/CI image (Linux) but absent + on macOS/Windows dev machines — the OSError fires only at write_pdf() time. + We probe by rendering a trivial document; failure ⇒ skip on dev, but the + suite still runs the PDF path on CI where the libs exist. + """ + try: + from weasyprint import HTML + + HTML(string="x
").write_pdf() + except (OSError, ImportError) as e: + return False, str(e) + return True, "" + + +_WP_OK, _WP_ERR = _weasyprint_native_libs_available() +if not _WP_OK: + pytest.skip(f"WeasyPrint native libs missing: {_WP_ERR}", allow_module_level=True) + +# Импорты app-модулей ПОСЛЕ module-level skip guard выше — иначе на macOS без +# native libs импорт схемы не нужен (модуль skip'ается). E402 здесь намеренный. +from app.schemas.parcel import ( # noqa: E402 BestLayoutsResponse, LayoutDataQuality, LayoutTzMixRow, LayoutTzRecommendation, TopLayoutRow, ) +from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf # noqa: E402 def _sample_response() -> BestLayoutsResponse: @@ -40,6 +61,7 @@ def _sample_response() -> BestLayoutsResponse: avg_area_m2=38.5, supply_units_in_radius=312, sold_pct_of_supply=21.5, + is_oversold=False, ), TopLayoutRow( rank=2, @@ -54,6 +76,7 @@ def _sample_response() -> BestLayoutsResponse: avg_area_m2=22.0, supply_units_in_radius=100, sold_pct_of_supply=40.0, + is_oversold=False, ), ], recommendation_for_tz=LayoutTzRecommendation( diff --git a/backend/tests/test_nspd_client.py b/backend/tests/test_nspd_client.py index 9fa82d81..efc9f796 100644 --- a/backend/tests/test_nspd_client.py +++ b/backend/tests/test_nspd_client.py @@ -412,6 +412,7 @@ def test_quarter_dump_total_features() -> None: "flooding": [_make_feat("fl1"), _make_feat("fl2"), _make_feat("fl3")], # 3 "landslide": [_make_feat("ls1")], # 1 }, + opportunity={}, # 0 layers_fetched=("search", "parcels", "buildings"), bbox_3857=(6700000.0, 7700000.0, 6800000.0, 7800000.0), fetched_at_utc="2026-05-12T00:00:00+00:00", @@ -432,6 +433,7 @@ def test_quarter_dump_frozen() -> None: engineering_structures=[], zouit={}, risks={}, + opportunity={}, layers_fetched=("search",), bbox_3857=None, fetched_at_utc="2026-05-12T00:00:00+00:00", diff --git a/backend/tests/test_quarter_dump_lookup.py b/backend/tests/test_quarter_dump_lookup.py index d5c09d23..35b75d84 100644 --- a/backend/tests/test_quarter_dump_lookup.py +++ b/backend/tests/test_quarter_dump_lookup.py @@ -92,8 +92,9 @@ def test_get_quarter_dump_stale() -> None: """Дамп старше 180 дней → stale=True, harvest triggered, available=False.""" old_date = datetime.now(UTC) - timedelta(days=200) # row: (quarter_cad, fetched_at_utc, total_features, harvest_error, - # territorial_zones_count, zouit_count, engineering_count) - db = _make_db_mock(row=("66:41:0204016", old_date, 50, None, 5, 2, 1)) + # territorial_zones_count, zouit_count, engineering_count, + # risks_count, opportunity_count, red_lines_count) + db = _make_db_mock(row=("66:41:0204016", old_date, 50, None, 5, 2, 1, 0, 0, 0)) with patch( "app.services.site_finder.quarter_dump_lookup._trigger_harvest", @@ -111,7 +112,7 @@ def test_get_quarter_dump_stale() -> None: def test_get_quarter_dump_with_harvest_error() -> None: """Дамп с harvest_error → harvest_triggered=True, available=False.""" fresh_date = datetime.now(UTC) - timedelta(days=10) - db = _make_db_mock(row=("66:41:0204016", fresh_date, 0, "WAF 429", 0, 0, 0)) + db = _make_db_mock(row=("66:41:0204016", fresh_date, 0, "WAF 429", 0, 0, 0, 0, 0, 0)) with patch( "app.services.site_finder.quarter_dump_lookup._trigger_harvest", @@ -130,7 +131,7 @@ def test_get_quarter_dump_with_harvest_error() -> None: def test_get_quarter_dump_no_parcel_geometry() -> None: """parcel_wkt=None → metadata only, нет spatial queries.""" fresh_date = datetime.now(UTC) - timedelta(days=5) - db = _make_db_mock(row=("66:41:0204016", fresh_date, 120, None, 10, 3, 2)) + db = _make_db_mock(row=("66:41:0204016", fresh_date, 120, None, 10, 3, 2, 0, 0, 0)) result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt=None) @@ -162,9 +163,10 @@ def _make_db_mock_with_spatial( Хрупко при реорганизации порядка запросов в production-коде. При изменении порядка в get_quarter_dump_data — обновить индексы call_count здесь. - dump_row должен быть 7-элементным tuple: + dump_row должен быть 10-элементным tuple: (quarter_cad, fetched_at_utc, total_features, harvest_error, - territorial_zones_count, zouit_count, engineering_count). + territorial_zones_count, zouit_count, engineering_count, + risks_count, opportunity_count, red_lines_count). """ db = MagicMock() call_count = 0 @@ -196,7 +198,7 @@ def _make_db_mock_with_spatial( def test_get_quarter_dump_fresh_zoning() -> None: """Свежий дамп с territorial_zone → nspd_zoning populated.""" fresh_date = datetime.now(UTC) - timedelta(days=30) - dump_row = ("66:41:0204016", fresh_date, 100, None, 5, 0, 0) + dump_row = ("66:41:0204016", fresh_date, 100, None, 5, 0, 0, 0, 0, 0) # zone_props JSONB в виде dict (SQLAlchemy вернёт dict) zone_props: dict[str, Any] = { "reg_numb_border": "Ж-3", @@ -225,7 +227,7 @@ def test_get_quarter_dump_fresh_zoning() -> None: def test_get_quarter_dump_fresh_zoning_no_match() -> None: """Свежий дамп, centroid вне всех territorial_zones → nspd_zoning=None.""" fresh_date = datetime.now(UTC) - timedelta(days=30) - dump_row = ("66:41:0204016", fresh_date, 100, None, 3, 0, 0) + dump_row = ("66:41:0204016", fresh_date, 100, None, 3, 0, 0, 0, 0, 0) db = _make_db_mock_with_spatial( dump_row=dump_row, @@ -247,7 +249,7 @@ def test_get_quarter_dump_fresh_zouit_overlaps() -> None: fresh_date = datetime.now(UTC) - timedelta(days=10) # territorial_zones_count > 0 чтобы все 3 spatial запроса вызывались # (иначе call_count в mock смещается и zouit попадает не в тот слот) - dump_row = ("66:41:0204016", fresh_date, 200, None, 1, 5, 0) + dump_row = ("66:41:0204016", fresh_date, 200, None, 1, 5, 0, 0, 0, 0) props1: dict[str, Any] = {"name": "ОКН Дом купца", "subcategory": "okn"} props2: dict[str, Any] = {"name": "ОЗ газопровода", "subcategory": "engineering"} @@ -280,7 +282,7 @@ def test_get_quarter_dump_fresh_zouit_overlaps() -> None: def test_get_quarter_dump_includes_nspd_fields() -> None: """Базовая проверка что все 4 nspd-ключа присутствуют в возвращаемом dict.""" fresh_date = datetime.now(UTC) - timedelta(days=1) - dump_row = ("66:41:0204016", fresh_date, 55, None, 2, 1, 0) + dump_row = ("66:41:0204016", fresh_date, 55, None, 2, 1, 0, 0, 0, 0) db = _make_db_mock_with_spatial( dump_row=dump_row, @@ -345,7 +347,7 @@ def test_early_exit_all_counts_zero_no_spatial_queries() -> None: """Если все denormalized counts == 0 — spatial queries не вызываются.""" fresh_date = datetime.now(UTC) - timedelta(days=5) # territorial_zones_count=0, zouit_count=0, engineering_count=0 - dump_row = ("66:41:0204016", fresh_date, 50, None, 0, 0, 0) + dump_row = ("66:41:0204016", fresh_date, 50, None, 0, 0, 0, 0, 0, 0) db = _make_db_mock_with_spatial(dump_row=dump_row) @@ -353,8 +355,11 @@ def test_early_exit_all_counts_zero_no_spatial_queries() -> None: db, "66:41:0204016", parcel_wkt="POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))" ) - # Только 1 execute-вызов — чтение dump row; spatial helpers пропущены - assert db.execute.call_count == 1 + # 2 execute-вызова: чтение dump row (0) + cad_zouit fallback (#232) — при + # zouit_count==0 _get_zouit_overlaps не early-exit'ит, а делает fallback на + # cad_zouit. Остальные spatial helpers (zoning/engineering/risk/opportunity/ + # red_lines) пропущены по своим нулевым счётчикам. + assert db.execute.call_count == 2 assert result["nspd_zoning"] is None assert result["nspd_zouit_overlaps"] == [] assert result["nspd_engineering_nearby"] == [] @@ -365,7 +370,7 @@ def test_early_exit_partial_counts() -> None: """territorial_zones_count > 0, zouit_count == 0 → zoning query вызывается, zouit и engineering — нет.""" fresh_date = datetime.now(UTC) - timedelta(days=5) - dump_row = ("66:41:0204016", fresh_date, 30, None, 3, 0, 0) + dump_row = ("66:41:0204016", fresh_date, 30, None, 3, 0, 0, 0, 0, 0) zone_props: dict[str, Any] = {"reg_numb_border": "Ж-1", "type_zone": "Зона жилая"} zoning_row_mock = MagicMock() zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None @@ -381,8 +386,11 @@ def test_early_exit_partial_counts() -> None: db, "66:41:0204016", parcel_wkt="POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))" ) - # dump lookup (0) + zoning (1) = 2 вызова; zouit и engineering — пропущены - assert db.execute.call_count == 2 + # dump lookup (0) + zoning (1) + cad_zouit fallback (2) = 3 вызова; + # engineering/risk/opportunity/red_lines — пропущены по нулевым счётчикам. + # zouit_count==0 → _get_zouit_overlaps делает fallback на cad_zouit (#232), + # который возвращает [] (mock fetchall пуст). + assert db.execute.call_count == 3 assert result["nspd_zoning"] is not None assert result["nspd_zoning"]["zone_code"] == "Ж-1" assert result["nspd_zouit_overlaps"] == []