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() 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: def test_create_job_no_token_returns_401() -> None:
"""Без X-Admin-Token → 401/503.""" """Без X-Admin-Token → 401/503 (устарело: токен-гейт удалён в #437)."""
client = TestClient(app) client = TestClient(app)
response = client.post( response = client.post(
"/api/v1/admin/cadastre/jobs", "/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" 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: def test_trigger_no_token_returns_401_or_503() -> None:
"""Без X-Admin-Token → 401 или 503.""" """Без X-Admin-Token → 401 или 503 (устарело: токен-гейт удалён в #437)."""
client = TestClient(app) client = TestClient(app)
response = client.post(ENDPOINT, json={}) response = client.post(ENDPOINT, json={})
assert response.status_code in (401, 503), response.text assert response.status_code in (401, 503), response.text

View file

@ -47,26 +47,14 @@ def _make_db_for_analyze(
) -> MagicMock: ) -> MagicMock:
"""Сконструировать mock DB Session для analyze_parcel. """Сконструировать mock DB Session для analyze_parcel.
Порядок db.execute calls в analyze_parcel: Mock диспетчеризует строки по СИГНАТУРЕ SQL, а не по позиционному индексу.
0. UNION ALL geom + source .mappings().first() Это критично: weight-validation (422) в обработчике срабатывает ПОСЛЕ резолва
1. WKT query .mappings().first() геометрии (parcels.py:~1213 geom ~1382 validation), а несколько тестов шлют
2. District .mappings().first() ДВА POST подряд на один mock монотонный счётчик «съедал» бы geom-row на втором
3. POI rows .mappings().all() запросе handler уходил в inline NSPD fetch (202 «fetching») вместо 422.
4. Competitor rows .mappings().all() Сигнатурный матчинг возвращает geom/wkt/district/centroid независимо от числа
5. Pipeline rows .mappings().all() предыдущих вызовов. Прочие запросы обёрнуты в try/except SAVEPOINT дефолтный
6. Centroid lat/lon .mappings().first() пустой mock их не валит.
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()
begin_nested() возвращаем context manager чтобы поддержать `with` statement. begin_nested() возвращаем context manager чтобы поддержать `with` statement.
""" """
@ -93,44 +81,28 @@ def _make_db_for_analyze(
_poi_rows = poi_rows or [] _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: def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
idx = call_idx[0] sql = " ".join(str(args[0]).split()) if args else ""
call_idx[0] += 1
if idx >= len(responses): first_val: Any = None
# Безопасный fallback для непредусмотренных вызовов all_val: list[Any] = []
r = MagicMock()
r.mappings.return_value.first.return_value = None if "AS geom_geojson" in sql:
r.mappings.return_value.all.return_value = [] first_val = geom_row
r.scalar.return_value = 0 elif "AS wkt" in sql:
return r first_val = wkt_row
kind, data = responses[idx] 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 = MagicMock()
r.mappings.return_value.first.return_value = data r.mappings.return_value.first.return_value = first_val
r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] r.mappings.return_value.all.return_value = all_val
r.scalar.return_value = data if kind == "scalar" else 0 r.scalar.return_value = 0
return r return r
db.execute.side_effect = _execute_side_effect db.execute.side_effect = _execute_side_effect
@ -298,9 +270,9 @@ def test_inline_weights_rejects_nan() -> None:
content=raw_body, content=raw_body,
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
assert ( assert resp.status_code == 422, (
resp.status_code == 422 f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}"
), f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}" )
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
_stop_patches() _stop_patches()

View file

@ -8,26 +8,15 @@
Стратегия mock: аналогична test_analyze_inline_weights.py DB mock через Стратегия mock: аналогична test_analyze_inline_weights.py DB mock через
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch. dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.
Порядок db.execute calls в analyze_parcel (v3.7 + #33 + #29 G2): Порядок db.execute в analyze_parcel меняется при добавлении блоков (SF-B5 добавил
0. UNION ALL geom + source .mappings().first() red_lines / district_price / risk_zones между market-trend и market-price), поэтому
1. WKT query .mappings().first() жёсткая нумерация быстро устаревает. Mock диспетчеризует строки по СИГНАТУРЕ SQL
2. District .mappings().first() (а не по позиционному индексу) устойчиво к реордерингу:
3. POI rows .mappings().all() geom UNION ALL / WKT / district / centroid по содержимому SELECT
4. Competitor rows .mappings().all() market-price (#33) — по уникальной сигнатуре "median_6m, median_12m, median_24m"
5. Pipeline rows .mappings().all() Прочие запросы (POI/competitors/noise/red_lines/risk/permits/neighbors) обёрнуты в
6. Centroid lat/lon .mappings().first() try/except SAVEPOINT в коде дефолтный пустой mock (first=None, all=[], scalar=0)
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()
""" """
from __future__ import annotations 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 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: def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
idx = call_idx[0] # Нормализуем SQL для сигнатурного матчинга (collapse whitespace).
call_idx[0] += 1 sql = " ".join(str(args[0]).split()) if args else ""
if idx >= len(responses):
r = MagicMock() first_val: Any = None
r.mappings.return_value.first.return_value = None all_val: list[Any] = []
r.mappings.return_value.all.return_value = []
r.scalar.return_value = 0 # Структурные запросы (нужны чтобы обработчик дошёл до market-price без 404/202).
return r if "AS geom_geojson" in sql:
kind, data = responses[idx] 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 = MagicMock()
r.mappings.return_value.first.return_value = data r.mappings.return_value.first.return_value = first_val
r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] r.mappings.return_value.all.return_value = all_val
r.scalar.return_value = data if kind == "scalar" else 0 r.scalar.return_value = 0
return r return r
db.execute.side_effect = _execute_side_effect db.execute.side_effect = _execute_side_effect

View file

@ -5,26 +5,14 @@
2. analyze с quarter без permits recent_permits=[], summary={rns_count=0, ...} 2. analyze с quarter без permits recent_permits=[], summary={rns_count=0, ...}
3. analyze 404 на invalid cad (no regression) 3. analyze 404 на invalid cad (no regression)
Порядок db.execute calls в analyze_parcel (после #105 Phase 5 + #29 G2): Порядок db.execute в analyze_parcel меняется при добавлении блоков (SF-B5 вклинил
0. UNION ALL geom + source .mappings().first() red_lines / district_price / risk_zones), поэтому mock диспетчеризует строки по
1. WKT query .mappings().first() СИГНАТУРЕ SQL, а не по позиционному индексу устойчиво к реордерингу:
2. District .mappings().first() geom UNION ALL / WKT / district / centroid по содержимому SELECT
3. POI rows .mappings().all() recent permits (#105) — по уникальной сигнатуре "permit_type, permit_number ...
4. Competitor rows .mappings().all() developer_inn"
5. Pipeline rows .mappings().all() Прочие запросы обёрнуты в try/except SAVEPOINT в коде дефолтный пустой mock
6. Centroid lat/lon .mappings().first() (first=None, all=[], scalar=0) их не валит.
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()
""" """
from __future__ import annotations from __future__ import annotations
@ -84,43 +72,29 @@ def _make_db_for_analyze(
raw_permits = [_make_mapping(p) for p in (permits_rows or [])] 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: def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
idx = call_idx[0] sql = " ".join(str(args[0]).split()) if args else ""
call_idx[0] += 1
if idx >= len(responses): first_val: Any = None
r = MagicMock() all_val: list[Any] = []
r.mappings.return_value.first.return_value = None
r.mappings.return_value.all.return_value = [] # Структурные запросы (нужны чтобы обработчик дошёл до permits без 404/202).
r.scalar.return_value = 0 if "AS geom_geojson" in sql:
return r first_val = geom_row
kind, data = responses[idx] 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 = MagicMock()
r.mappings.return_value.first.return_value = data r.mappings.return_value.first.return_value = first_val
r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] r.mappings.return_value.all.return_value = all_val
r.scalar.return_value = data if kind == "scalar" else 0 r.scalar.return_value = 0
return r return r
db.execute.side_effect = _execute_side_effect db.execute.side_effect = _execute_side_effect

View file

@ -263,7 +263,10 @@ def _make_db_for_analyze() -> MagicMock:
"dist_to_center": 1500.0, "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] call_idx = [0]
responses: list[Any] = [ responses: list[Any] = [
@ -389,9 +392,9 @@ def test_analyze_custom_poi_increases_score() -> None:
# contribution = 2.0 * 0.5 = 1.0 # contribution = 2.0 * 0.5 = 1.0
assert items[0]["contribution"] == pytest.approx(1.0, abs=0.01) assert items[0]["contribution"] == pytest.approx(1.0, abs=0.01)
# score должен учитывать contribution custom POI # Центроид >15км от центра ЕКБ → center_bonus = 0.0, поэтому итоговый score
# (базовый score без POI = center_bonus из 1500м → 1.5; + custom 1.0 = 2.5) # == вклад custom-POI (1.0). Так тест изолирует custom-contribution.
assert body["score"] == pytest.approx(2.5, abs=0.1) assert body["score"] == pytest.approx(1.0, abs=0.1)
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
_stop_analyze_patches() _stop_analyze_patches()
@ -450,8 +453,9 @@ def test_analyze_custom_poi_negative_weight_decreases_score() -> None:
items = body["custom_poi_score_items"] items = body["custom_poi_score_items"]
assert len(items) == 1 assert len(items) == 1
assert items[0]["contribution"] == pytest.approx(-1.5, abs=0.01) assert items[0]["contribution"] == pytest.approx(-1.5, abs=0.01)
# center_bonus = 1.5 (1500м), custom = -1.5 → итого ≈ 0 # Центроид >15км от центра ЕКБ → center_bonus = 0.0, поэтому итоговый score
assert body["score"] == pytest.approx(0.0, abs=0.1) # == вклад custom-POI (-1.5). score_final НЕ клампится снизу (parcels.py:2410).
assert body["score"] == pytest.approx(-1.5, abs=0.1)
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
_stop_analyze_patches() _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: def test_time_window_velocity_scaling() -> None:
"""last_month vs last_year дают разный velocity_per_month для одних deals.""" """last_month vs last_year дают разный velocity_per_month для одних deals.
# sum_deals=24 → last_month: 24/24=1.0, last_year: 24/2=12.0
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)] 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 from app.core.db import get_db
@ -291,10 +296,10 @@ def test_time_window_velocity_scaling() -> None:
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
# last_year velocity должна быть выше (делитель меньше: 2 vs 24) # last_month velocity выше (делитель меньше: 1 vs 12) при одинаковом deals_window
assert v_year > v_month assert v_month > v_year
assert v_month == pytest.approx(1.0) assert v_month == pytest.approx(24.0)
assert v_year == pytest.approx(12.0) assert v_year == pytest.approx(2.0)
def test_obj_class_filter_passes_through() -> None: 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.""" """target_total_flats=100 → abs_units заполнен в mix, sum примерно = 100."""
id_rows = [_obj_id_row(1), _obj_id_row(2)] id_rows = [_obj_id_row(1), _obj_id_row(2)]
vel_rows = [ vel_rows = [
_vel_row("1", sum_deals=60.0, obj_ids=[1]), _vel_row("1", deals_window=60.0, obj_ids=[1]),
_vel_row("2", sum_deals=40.0, obj_ids=[2]), _vel_row("2", deals_window=40.0, obj_ids=[2]),
] ]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows) db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
from app.core.db import get_db 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: 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.""" """raw sold_pct > 100 → returned sold_pct_of_supply=100.0, is_oversold=True."""
id_rows = [_obj_id_row(1)] id_rows = [_obj_id_row(1)]
# sum_deals=199, supply=100 → raw = 199% (несопоставимые окна) # deals_window=199, supply=100 → raw = 199% (несопоставимые окна)
vel_rows = [_vel_row("2", sum_deals=199.0, obj_ids=[1])] vel_rows = [_vel_row("2", deals_window=199.0, obj_ids=[1])]
supply_rows = [_supply_row("2", "40-60", 100)] supply_rows = [_supply_row("2", "40-60", 100)]
db = _make_db( db = _make_db(
coord=_coord_row(), 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: def test_sold_pct_below_100_is_not_oversold() -> None:
"""raw sold_pct <= 100 → sold_pct_of_supply возвращается as-is, is_oversold=False.""" """raw sold_pct <= 100 → sold_pct_of_supply возвращается as-is, is_oversold=False."""
id_rows = [_obj_id_row(1)] id_rows = [_obj_id_row(1)]
# sum_deals=50, supply=100 → raw = 50% # deals_window=50, supply=100 → raw = 50%
vel_rows = [_vel_row("1", sum_deals=50.0, obj_ids=[1])] vel_rows = [_vel_row("1", deals_window=50.0, obj_ids=[1])]
supply_rows = [_supply_row("1", "40-60", 100)] supply_rows = [_supply_row("1", "40-60", 100)]
db = _make_db( db = _make_db(
coord=_coord_row(), coord=_coord_row(),
@ -424,7 +429,7 @@ def test_filter_competitor_obj_ids_applied() -> None:
"""filter_competitor_obj_ids=[1] оставляет только obj_id=1.""" """filter_competitor_obj_ids=[1] оставляет только obj_id=1."""
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)] id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
# После фильтрации остаётся только obj_id=1, velocity запрос получит [1] # После фильтрации остаётся только 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) db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
from app.core.db import get_db 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 datetime as dt
import pytest
from app.services.exporters.report_pdf import ( from app.services.exporters.report_pdf import (
_ADVISORY_MARKER, _ADVISORY_MARKER,
_TITLE_FUTURE_MARKET, _TITLE_FUTURE_MARKET,
@ -44,6 +46,31 @@ from app.services.forecasting.report import (
SiteFinderReport, 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). # Шесть ожидаемых заголовков блоков (по одному на содержательную секцию §13).
_EXPECTED_TITLES: tuple[str, ...] = ( _EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_SUMMARY, _TITLE_SUMMARY,
@ -133,6 +160,7 @@ def _full_report() -> SiteFinderReport:
# ── Полный отчёт: PDF-байты (магия %PDF), нетривиальная длина ─────────────────── # ── Полный отчёт: PDF-байты (магия %PDF), нетривиальная длина ───────────────────
@_skip_if_no_weasyprint
class TestFullReportExport: class TestFullReportExport:
def test_returns_pdf_magic_bytes(self) -> None: def test_returns_pdf_magic_bytes(self) -> None:
payload = export_report_pdf(_full_report()) payload = export_report_pdf(_full_report())
@ -195,6 +223,7 @@ class TestRenderedHtmlContent:
class TestGracefulPartialReport: class TestGracefulPartialReport:
@_skip_if_no_weasyprint
def test_empty_report_still_valid_pdf(self) -> None: def test_empty_report_still_valid_pdf(self) -> None:
# Полностью дефолтный (пустой) отчёт — валидный PDF, не падает. # Полностью дефолтный (пустой) отчёт — валидный PDF, не падает.
payload = export_report_pdf(SiteFinderReport()) payload = export_report_pdf(SiteFinderReport())
@ -208,6 +237,7 @@ class TestGracefulPartialReport:
# ADVISORY-маркер остаётся даже у пустого отчёта. # ADVISORY-маркер остаётся даже у пустого отчёта.
assert "ADVISORY" in html_str assert "ADVISORY" in html_str
@_skip_if_no_weasyprint
def test_partial_report_some_sections(self) -> None: def test_partial_report_some_sections(self) -> None:
# Заполнены только meta + exec_summary — остальные пусты, PDF валиден. # Заполнены только meta + exec_summary — остальные пусты, PDF валиден.
report = SiteFinderReport( report = SiteFinderReport(
@ -220,6 +250,7 @@ class TestGracefulPartialReport:
assert "Тонкий анализ" in html_str assert "Тонкий анализ" in html_str
assert "66:41:0000000:2" in html_str assert "66:41:0000000:2" in html_str
@_skip_if_no_weasyprint
def test_garbage_input_does_not_crash(self) -> None: def test_garbage_input_does_not_crash(self) -> None:
# Мусор (None / не-отчёт) → пустой, но валидный PDF (нормализация в {}). # Мусор (None / не-отчёт) → пустой, но валидный PDF (нормализация в {}).
for junk in (None, 123, "not a report"): 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() db = MagicMock()
executed_sqls: list[str] = [] 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") 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() 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( db.execute = MagicMock(
return_value=MagicMock( return_value=MagicMock(
mappings=MagicMock( mappings=MagicMock(
return_value=MagicMock( return_value=MagicMock(
first=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() db = MagicMock()
# phase_state = None → начинаем с нуля # 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( db.execute = MagicMock(
return_value=MagicMock( return_value=MagicMock(
mappings=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() 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( db.execute = MagicMock(
return_value=MagicMock( return_value=MagicMock(
mappings=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() 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( db.execute = MagicMock(
return_value=MagicMock( return_value=MagicMock(
mappings=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() 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( db.execute = MagicMock(
return_value=MagicMock( return_value=MagicMock(
mappings=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() 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( db.execute = MagicMock(
return_value=MagicMock( return_value=MagicMock(
mappings=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. All bind parameters use CAST(:x AS type) never :x::type.
""" """
import os
from decimal import Decimal from decimal import Decimal
import psycopg import psycopg
import pytest 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: def _get_dsn() -> str:
import os raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
return os.environ.get(
"DATABASE_URL", "DATABASE_URL",
"postgresql://gendesign@localhost:15432/gendesign", "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() @pytest.fixture()
@ -79,6 +109,7 @@ ORDER BY room_bucket;
# Test cases # Test cases
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestWeightedAvgFormula: class TestWeightedAvgFormula:
"""Unit-level verification of the count-weighted formula using a temp table.""" """Unit-level verification of the count-weighted formula using a temp table."""
@ -98,8 +129,8 @@ class TestWeightedAvgFormula:
cur.execute(_CREATE_TEMP) cur.execute(_CREATE_TEMP)
rows = [ rows = [
("studio", 10, 25.0, 180.0), ("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.executemany(_INSERT_ROW, rows)
cur.execute(_WEIGHTED_QUERY) cur.execute(_WEIGHTED_QUERY)
@ -131,9 +162,7 @@ class TestWeightedAvgFormula:
assert weighted_area != naive_area assert weighted_area != naive_area
assert weighted_price != naive_price assert weighted_price != naive_price
def test_weighted_differs_from_naive_sparse_project( def test_weighted_differs_from_naive_sparse_project(self, conn: psycopg.Connection) -> None:
self, conn: psycopg.Connection
) -> None:
""" """
Scenario inspired by real data: 3-room flat, project with 5 deals Scenario inspired by real data: 3-room flat, project with 5 deals
spread across 5 active months out of 15 total months. spread across 5 active months out of 15 total months.
@ -150,7 +179,7 @@ class TestWeightedAvgFormula:
result = cur.fetchone() result = cur.fetchone()
assert result is not None 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 room == "3"
assert total_deals == 5 assert total_deals == 5
@ -164,13 +193,9 @@ class TestWeightedAvgFormula:
# Correction factor > 2x # Correction factor > 2x
ratio = float(weighted_area) / float(naive_area) ratio = float(weighted_area) / float(naive_area)
assert ratio > 2.5, ( assert ratio > 2.5, f"Expected correction factor > 2.5x for sparse project, got {ratio:.2f}"
f"Expected correction factor > 2.5x for sparse project, got {ratio:.2f}"
)
def test_no_zero_months_weighted_equals_naive( def test_no_zero_months_weighted_equals_naive(self, conn: psycopg.Connection) -> None:
self, conn: psycopg.Connection
) -> None:
""" """
When every month has deals, weighted and naive AVG should be equal When every month has deals, weighted and naive AVG should be equal
(within numeric(10,2) rounding) only if counts are uniform. (within numeric(10,2) rounding) only if counts are uniform.
@ -199,9 +224,7 @@ class TestWeightedAvgFormula:
) )
assert weighted_price == naive_price assert weighted_price == naive_price
def test_nullif_prevents_division_by_zero( def test_nullif_prevents_division_by_zero(self, conn: psycopg.Connection) -> None:
self, conn: psycopg.Connection
) -> None:
""" """
When all months have 0 deals, NULLIF(SUM(cnt), 0) NULL instead of divide-by-zero. 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 The buggy AVG() also returns 0 (not NULL) for all-zero rows, which is arguably
@ -218,7 +241,7 @@ class TestWeightedAvgFormula:
result = cur.fetchone() result = cur.fetchone()
assert result is not None 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 assert total_deals == 0
# Weighted formula returns NULL for all-zero-deal case (correct — no real data) # 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 # Naive AVG returns 0.00 — a misleading non-NULL value
assert naive_area == Decimal("0.00") assert naive_area == Decimal("0.00")
def test_hand_computed_weighted_average( def test_hand_computed_weighted_average(self, conn: psycopg.Connection) -> None:
self, conn: psycopg.Connection
) -> None:
""" """
End-to-end hand-computed check with multiple room buckets and End-to-end hand-computed check with multiple room buckets and
mixed deal counts to verify the formula is exactly correct. mixed deal counts to verify the formula is exactly correct.
@ -247,11 +268,11 @@ class TestWeightedAvgFormula:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(_CREATE_TEMP) cur.execute(_CREATE_TEMP)
rows = [ rows = [
("studio", 8, 30.0, 160.0), ("studio", 8, 30.0, 160.0),
("studio", 12, 28.0, 170.0), ("studio", 12, 28.0, 170.0),
("studio", 3, 27.0, 155.0), ("studio", 3, 27.0, 155.0),
("1", 15, 38.0, 175.0), ("1", 15, 38.0, 175.0),
("1", 5, 40.0, 180.0), ("1", 5, 40.0, 180.0),
] ]
cur.executemany(_INSERT_ROW, rows) cur.executemany(_INSERT_ROW, rows)
cur.execute(_WEIGHTED_QUERY) cur.execute(_WEIGHTED_QUERY)

View file

@ -404,8 +404,18 @@ def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> N
# ── Auth ─────────────────────────────────────────────────────────────────────── # ── 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: 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) monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
client = TestClient(app) client = TestClient(app)
r = client.get( r = client.get(
@ -416,8 +426,9 @@ def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None:
assert r.status_code == 401 assert r.status_code == 401
@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON)
def test_unauthorized_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None: 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) monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
client = TestClient(app) client = TestClient(app)
r = client.get( r = client.get(

View file

@ -9,19 +9,40 @@ import datetime as dt
import pytest 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, BestLayoutsResponse,
LayoutDataQuality, LayoutDataQuality,
LayoutTzMixRow, LayoutTzMixRow,
LayoutTzRecommendation, LayoutTzRecommendation,
TopLayoutRow, TopLayoutRow,
) )
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf # noqa: E402
def _sample_response() -> BestLayoutsResponse: def _sample_response() -> BestLayoutsResponse:
@ -40,6 +61,7 @@ def _sample_response() -> BestLayoutsResponse:
avg_area_m2=38.5, avg_area_m2=38.5,
supply_units_in_radius=312, supply_units_in_radius=312,
sold_pct_of_supply=21.5, sold_pct_of_supply=21.5,
is_oversold=False,
), ),
TopLayoutRow( TopLayoutRow(
rank=2, rank=2,
@ -54,6 +76,7 @@ def _sample_response() -> BestLayoutsResponse:
avg_area_m2=22.0, avg_area_m2=22.0,
supply_units_in_radius=100, supply_units_in_radius=100,
sold_pct_of_supply=40.0, sold_pct_of_supply=40.0,
is_oversold=False,
), ),
], ],
recommendation_for_tz=LayoutTzRecommendation( 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 "flooding": [_make_feat("fl1"), _make_feat("fl2"), _make_feat("fl3")], # 3
"landslide": [_make_feat("ls1")], # 1 "landslide": [_make_feat("ls1")], # 1
}, },
opportunity={}, # 0
layers_fetched=("search", "parcels", "buildings"), layers_fetched=("search", "parcels", "buildings"),
bbox_3857=(6700000.0, 7700000.0, 6800000.0, 7800000.0), bbox_3857=(6700000.0, 7700000.0, 6800000.0, 7800000.0),
fetched_at_utc="2026-05-12T00:00:00+00:00", fetched_at_utc="2026-05-12T00:00:00+00:00",
@ -432,6 +433,7 @@ def test_quarter_dump_frozen() -> None:
engineering_structures=[], engineering_structures=[],
zouit={}, zouit={},
risks={}, risks={},
opportunity={},
layers_fetched=("search",), layers_fetched=("search",),
bbox_3857=None, bbox_3857=None,
fetched_at_utc="2026-05-12T00:00:00+00:00", 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.""" """Дамп старше 180 дней → stale=True, harvest triggered, available=False."""
old_date = datetime.now(UTC) - timedelta(days=200) old_date = datetime.now(UTC) - timedelta(days=200)
# row: (quarter_cad, fetched_at_utc, total_features, harvest_error, # row: (quarter_cad, fetched_at_utc, total_features, harvest_error,
# territorial_zones_count, zouit_count, engineering_count) # territorial_zones_count, zouit_count, engineering_count,
db = _make_db_mock(row=("66:41:0204016", old_date, 50, None, 5, 2, 1)) # 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( with patch(
"app.services.site_finder.quarter_dump_lookup._trigger_harvest", "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: def test_get_quarter_dump_with_harvest_error() -> None:
"""Дамп с harvest_error → harvest_triggered=True, available=False.""" """Дамп с harvest_error → harvest_triggered=True, available=False."""
fresh_date = datetime.now(UTC) - timedelta(days=10) 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( with patch(
"app.services.site_finder.quarter_dump_lookup._trigger_harvest", "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: def test_get_quarter_dump_no_parcel_geometry() -> None:
"""parcel_wkt=None → metadata only, нет spatial queries.""" """parcel_wkt=None → metadata only, нет spatial queries."""
fresh_date = datetime.now(UTC) - timedelta(days=5) 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) result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt=None)
@ -162,9 +163,10 @@ def _make_db_mock_with_spatial(
Хрупко при реорганизации порядка запросов в production-коде. При изменении Хрупко при реорганизации порядка запросов в production-коде. При изменении
порядка в get_quarter_dump_data обновить индексы call_count здесь. порядка в get_quarter_dump_data обновить индексы call_count здесь.
dump_row должен быть 7-элементным tuple: dump_row должен быть 10-элементным tuple:
(quarter_cad, fetched_at_utc, total_features, harvest_error, (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() db = MagicMock()
call_count = 0 call_count = 0
@ -196,7 +198,7 @@ def _make_db_mock_with_spatial(
def test_get_quarter_dump_fresh_zoning() -> None: def test_get_quarter_dump_fresh_zoning() -> None:
"""Свежий дамп с territorial_zone → nspd_zoning populated.""" """Свежий дамп с territorial_zone → nspd_zoning populated."""
fresh_date = datetime.now(UTC) - timedelta(days=30) 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 JSONB в виде dict (SQLAlchemy вернёт dict)
zone_props: dict[str, Any] = { zone_props: dict[str, Any] = {
"reg_numb_border": "Ж-3", "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: def test_get_quarter_dump_fresh_zoning_no_match() -> None:
"""Свежий дамп, centroid вне всех territorial_zones → nspd_zoning=None.""" """Свежий дамп, centroid вне всех territorial_zones → nspd_zoning=None."""
fresh_date = datetime.now(UTC) - timedelta(days=30) 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( db = _make_db_mock_with_spatial(
dump_row=dump_row, 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) fresh_date = datetime.now(UTC) - timedelta(days=10)
# territorial_zones_count > 0 чтобы все 3 spatial запроса вызывались # territorial_zones_count > 0 чтобы все 3 spatial запроса вызывались
# (иначе call_count в mock смещается и zouit попадает не в тот слот) # (иначе 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"} props1: dict[str, Any] = {"name": "ОКН Дом купца", "subcategory": "okn"}
props2: dict[str, Any] = {"name": "ОЗ газопровода", "subcategory": "engineering"} 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: def test_get_quarter_dump_includes_nspd_fields() -> None:
"""Базовая проверка что все 4 nspd-ключа присутствуют в возвращаемом dict.""" """Базовая проверка что все 4 nspd-ключа присутствуют в возвращаемом dict."""
fresh_date = datetime.now(UTC) - timedelta(days=1) 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( db = _make_db_mock_with_spatial(
dump_row=dump_row, dump_row=dump_row,
@ -345,7 +347,7 @@ def test_early_exit_all_counts_zero_no_spatial_queries() -> None:
"""Если все denormalized counts == 0 — spatial queries не вызываются.""" """Если все denormalized counts == 0 — spatial queries не вызываются."""
fresh_date = datetime.now(UTC) - timedelta(days=5) fresh_date = datetime.now(UTC) - timedelta(days=5)
# territorial_zones_count=0, zouit_count=0, engineering_count=0 # 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) 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))" 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 пропущены # 2 execute-вызова: чтение dump row (0) + cad_zouit fallback (#232) — при
assert db.execute.call_count == 1 # 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_zoning"] is None
assert result["nspd_zouit_overlaps"] == [] assert result["nspd_zouit_overlaps"] == []
assert result["nspd_engineering_nearby"] == [] 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 вызывается, """territorial_zones_count > 0, zouit_count == 0 → zoning query вызывается,
zouit и engineering нет.""" zouit и engineering нет."""
fresh_date = datetime.now(UTC) - timedelta(days=5) 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": "Зона жилая"} zone_props: dict[str, Any] = {"reg_numb_border": "Ж-1", "type_zone": "Зона жилая"}
zoning_row_mock = MagicMock() zoning_row_mock = MagicMock()
zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None 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))" 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 — пропущены # dump lookup (0) + zoning (1) + cad_zouit fallback (2) = 3 вызова;
assert db.execute.call_count == 2 # 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"] is not None
assert result["nspd_zoning"]["zone_code"] == "Ж-1" assert result["nspd_zoning"]["zone_code"] == "Ж-1"
assert result["nspd_zouit_overlaps"] == [] assert result["nspd_zouit_overlaps"] == []