test(suite): re-align drifted tests with current code (CI-rehab 2/3)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 1m8s
Deploy / build-backend (push) Successful in 36s
Deploy / deploy (push) Successful in 1m13s

Suite was uncollectable for a while → code evolved, mocks didn't (42 failed /
5 errors → 1687 passed / 0 failed / 0 errors). TEST-ONLY, no app code changed,
no real bugs found (code-reviewed  — no test weakened to fake-green).

- best_layouts: sum_deals→deals_window rename; velocity-scaling direction for
  new SF-01 divisors (last_month=1.0/last_year=12.0)
- quarter_dump_lookup: 7→10-tuple mock rows (risks/opportunity/red_lines cols);
  fix early-exit call-counts for the cad_zouit fallback (#232)
- cadastre_bulk: add xmin/ymin/xmax/ymax to harvest-quarter db mock
- nspd_client: add required QuarterDump.opportunity
- TopLayoutRow: add required is_oversold
- analyze_{market_price,recent_permits,inline_weights}: SQL-signature dispatch
  instead of fragile positional db.execute indices (SF-B5 query reorder; also
  fixes a double-POST geom-starvation false-negative)
- custom_pois: move centroid >15km so center_bonus=0 isolates custom-POI delta
- layout/report PDF: runtime WeasyPrint probe (skip macOS dev, RUN on CI)
- mv_layout SQL: normalize +psycopg DSN + connectivity-probe skipif
- admin token tests: skip (X-Admin-Token gate removed #437/#426; protection is
  Caddy basic_auth + RBAC, covered by test_rbac)

Refs #944.
This commit is contained in:
Light1YT 2026-06-03 19:05:43 +05:00
parent 0247f13fb6
commit e9585bcd3b
14 changed files with 377 additions and 252 deletions

View file

@ -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",

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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="<p>x</p>").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"):

View file

@ -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,
}
)
)
)
)
)

View file

@ -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)

View file

@ -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(

View file

@ -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="<p>x</p>").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(

View file

@ -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",

View file

@ -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"] == []