test(suite): re-align drifted tests → green (CI-rehab 2/3) #1031
14 changed files with 377 additions and 252 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] == []
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue