244 lines
11 KiB
Python
244 lines
11 KiB
Python
"""Тесты read-only retrieval чата (#957): §22-отчёт + курируемый паспорт участка.
|
||
|
||
Покрываем `get_parcel_context_for_chat` (§19 data-residency):
|
||
• строит ПЛОСКИЙ whitelist-дикт из фиксированных градо-полей analyze-рана;
|
||
• НЕ протаскивает наружу сырые под-словари / геометрию / любой лишний ключ payload;
|
||
• ЗОУИТ: dedup названий, cap, разрешение противоречия encumbrance↔overlaps;
|
||
• None, если analyze-рана/результата нет (эндпоинт работает как раньше).
|
||
|
||
Стратегия: latest_run_for патчится (никакой прод-БД); db — MagicMock.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from app.services.chat import retrieval
|
||
|
||
_CAD = "66:41:0204016:10"
|
||
|
||
|
||
def _analyze_payload() -> dict[str, Any]:
|
||
"""analyze-1.0 result с курируемыми полями И «шумом», который наружу уходить НЕ должен."""
|
||
return {
|
||
"egrn": {
|
||
"address": "Екатеринбург, ул. Ленина, 1",
|
||
"area_m2": 5400,
|
||
"land_category": "земли населённых пунктов",
|
||
"permitted_use_text": "многоэтажная жилая застройка",
|
||
"cadastral_value_rub": 123456789,
|
||
"parcel_status": "учтённый",
|
||
"ownership_type": "муниципальная",
|
||
# Шум: сырой под-словарь + PII-подобные поля — НЕ должны попасть в срез.
|
||
"raw": {"owner_name": "Иванов И.И.", "internal_id": 42},
|
||
},
|
||
"nspd_zoning": {
|
||
"zone_code": "Ж-4",
|
||
"zone_name": "Зона многоэтажной жилой застройки",
|
||
"max_far": 2.5,
|
||
"max_floors": 25,
|
||
"max_height_m": 75,
|
||
"max_building_pct": 40,
|
||
"min_parcel_area_m2": 1500,
|
||
"regulation_source": "ПЗЗ Екатеринбурга",
|
||
"main_vri": ["2.6", "3.1"], # список ВРИ — в срез НЕ тянем (не в whitelist)
|
||
},
|
||
"encumbrance": {"has_zouit": True, "zouit_count": 2},
|
||
"nspd_zouit_overlaps": [
|
||
{"type_zone": "Приаэродромная территория", "reg_numb_border": "66:00-6.1"},
|
||
{"name": "Охранная зона ЛЭП", "coverage_pct": 12.3},
|
||
{"type_zone": "Приаэродромная территория"}, # дубль → dedup
|
||
],
|
||
# Тяжёлый сырой блоб + геометрия — НЕ курируемые, наружу НЕЛЬЗЯ.
|
||
"geometry": {"type": "Polygon", "coordinates": [[[1, 2], [3, 4]]]},
|
||
"market_now": {"deals": [{"price": 1}]},
|
||
}
|
||
|
||
|
||
def _make_run(result: Any, run_id: int = 7) -> MagicMock:
|
||
run = MagicMock()
|
||
run.result = result
|
||
run.id = run_id
|
||
return run
|
||
|
||
|
||
def _get_context(result: Any) -> dict[str, Any] | None:
|
||
with patch(
|
||
"app.services.chat.retrieval.latest_run_for",
|
||
return_value=_make_run(result) if result is not None else None,
|
||
):
|
||
return retrieval.get_parcel_context_for_chat(MagicMock(), _CAD)
|
||
|
||
|
||
# ── whitelist: нужные поля есть, лишних нет ──────────────────────────────────────
|
||
|
||
|
||
def test_builds_curated_whitelist_dict() -> None:
|
||
ctx = _get_context(_analyze_payload())
|
||
assert ctx is not None
|
||
# ЕГРН-скаляры.
|
||
assert ctx["address"] == "Екатеринбург, ул. Ленина, 1"
|
||
assert ctx["area_m2"] == 5400
|
||
assert ctx["land_category"] == "земли населённых пунктов"
|
||
assert ctx["permitted_use_text"] == "многоэтажная жилая застройка"
|
||
assert ctx["cadastral_value_rub"] == 123456789
|
||
assert ctx["parcel_status"] == "учтённый"
|
||
assert ctx["ownership_type"] == "муниципальная"
|
||
# Градрегламент ПЗЗ.
|
||
assert ctx["zone_code"] == "Ж-4"
|
||
assert ctx["zone_name"] == "Зона многоэтажной жилой застройки"
|
||
assert ctx["max_far"] == 2.5
|
||
assert ctx["max_floors"] == 25
|
||
assert ctx["max_height_m"] == 75
|
||
assert ctx["max_building_pct"] == 40
|
||
assert ctx["min_parcel_area_m2"] == 1500
|
||
assert ctx["regulation_source"] == "ПЗЗ Екатеринбурга"
|
||
# ЗОУИТ-сводка + dedup названий.
|
||
assert ctx["has_zouit"] is True
|
||
assert ctx["zouit_count"] == 2
|
||
assert ctx["zouit_zone_names"] == ["Приаэродромная территория", "Охранная зона ЛЭП"]
|
||
|
||
|
||
def test_does_not_leak_geometry_raw_or_extra_keys() -> None:
|
||
"""§19: срез — ТОЛЬКО фиксированный whitelist; geometry/raw/market и т.п. НЕ протекают."""
|
||
ctx = _get_context(_analyze_payload())
|
||
assert ctx is not None
|
||
allowed = {
|
||
"address",
|
||
"area_m2",
|
||
"land_category",
|
||
"permitted_use_text",
|
||
"cadastral_value_rub",
|
||
"parcel_status",
|
||
"ownership_type",
|
||
"zone_code",
|
||
"zone_name",
|
||
"max_far",
|
||
"max_floors",
|
||
"max_height_m",
|
||
"max_building_pct",
|
||
"min_parcel_area_m2",
|
||
"regulation_source",
|
||
"has_zouit",
|
||
"zouit_count",
|
||
"zouit_zone_names",
|
||
}
|
||
assert set(ctx) <= allowed
|
||
# Явно: ни сырых под-словарей, ни геометрии, ни PII/ВРИ-списка.
|
||
assert "geometry" not in ctx
|
||
assert "raw" not in ctx
|
||
assert "market_now" not in ctx
|
||
assert "main_vri" not in ctx
|
||
# Все значения — скаляры или список строк (никаких вложенных dict/геометрии).
|
||
for key, value in ctx.items():
|
||
if key == "zouit_zone_names":
|
||
assert isinstance(value, list)
|
||
assert all(isinstance(n, str) for n in value)
|
||
else:
|
||
assert isinstance(value, str | int | float | bool)
|
||
|
||
|
||
# ── §19 type-guard: не-скаляры отбрасываются, а не протаскиваются ─────────────────
|
||
|
||
|
||
def test_dict_value_in_egrn_field_is_dropped() -> None:
|
||
"""Под-словарь в egrn.address (напр. с PII) НЕ должен утечь в срез — ключ отсутствует."""
|
||
payload = {
|
||
"egrn": {
|
||
"address": {"owner_pii": "Иванов И.И."}, # вложенный dict — §19-угроза
|
||
"area_m2": 500,
|
||
}
|
||
}
|
||
ctx = _get_context(payload)
|
||
assert ctx is not None
|
||
assert "address" not in ctx # dict-значение отброшено гейтом типа
|
||
assert ctx["area_m2"] == 500 # скаляр рядом при этом сохраняется
|
||
|
||
|
||
def test_list_with_nested_dict_in_zone_code_is_dropped() -> None:
|
||
"""Список (тем более с вложенным dict) в zone_code НЕ протаскивается — ключа нет."""
|
||
payload = {"nspd_zoning": {"zone_code": ["Ж-4", {"nested": "leak"}], "max_floors": 25}}
|
||
ctx = _get_context(payload)
|
||
assert ctx is not None
|
||
assert "zone_code" not in ctx # list-значение отброшено
|
||
assert ctx["max_floors"] == 25 # соседний скаляр цел
|
||
|
||
|
||
def test_zouit_names_drops_non_string_elements() -> None:
|
||
"""В списке ЗОУИТ не-строковые элементы (dict/число) отбрасываются, строки остаются."""
|
||
payload = {
|
||
"nspd_zouit_overlaps": [
|
||
{"type_zone": "Санитарно-защитная зона"},
|
||
{"type_zone": {"leak": "нельзя"}}, # dict вместо строки — отбросить
|
||
{"name": 12345}, # число вместо строки — отбросить
|
||
{"name": "Водоохранная зона"},
|
||
],
|
||
}
|
||
ctx = _get_context(payload)
|
||
assert ctx is not None
|
||
assert ctx["zouit_zone_names"] == ["Санитарно-защитная зона", "Водоохранная зона"]
|
||
# Ни одного не-строкового элемента / repr'а вложенной структуры.
|
||
assert all(isinstance(n, str) for n in ctx["zouit_zone_names"])
|
||
assert "leak" not in " ".join(ctx["zouit_zone_names"])
|
||
|
||
|
||
# ── зона: fallback zone_code → regulation_zone_index ─────────────────────────────
|
||
|
||
|
||
def test_zone_code_falls_back_to_regulation_zone_index() -> None:
|
||
payload = {"nspd_zoning": {"regulation_zone_index": "ОД-1"}}
|
||
ctx = _get_context(payload)
|
||
assert ctx is not None
|
||
assert ctx["zone_code"] == "ОД-1"
|
||
|
||
|
||
# ── ЗОУИТ: противоречие encumbrance=нет, но пересечения есть ──────────────────────
|
||
|
||
|
||
def test_zouit_contradiction_resolved_from_overlaps() -> None:
|
||
payload = {
|
||
"encumbrance": {"has_zouit": False, "zouit_count": 0},
|
||
"nspd_zouit_overlaps": [
|
||
{"type_zone": "Санитарно-защитная зона"},
|
||
{"name": "Водоохранная зона"},
|
||
],
|
||
}
|
||
ctx = _get_context(payload)
|
||
assert ctx is not None
|
||
assert ctx["has_zouit"] is True
|
||
assert ctx["zouit_count"] == 2
|
||
assert ctx["zouit_zone_names"] == ["Санитарно-защитная зона", "Водоохранная зона"]
|
||
|
||
|
||
def test_zouit_names_capped() -> None:
|
||
payload = {
|
||
"nspd_zouit_overlaps": [{"type_zone": f"Зона {i}"} for i in range(30)],
|
||
}
|
||
ctx = _get_context(payload)
|
||
assert ctx is not None
|
||
assert len(ctx["zouit_zone_names"]) == retrieval._MAX_ZOUIT_NAMES
|
||
|
||
|
||
# ── graceful: отсутствие полей / рана ────────────────────────────────────────────
|
||
|
||
|
||
def test_missing_fields_are_omitted_not_none() -> None:
|
||
# Пустой ЕГРН/зонирование → эти ключи просто отсутствуют (не None).
|
||
ctx = _get_context({"egrn": {"address": "Тест"}})
|
||
assert ctx == {"address": "Тест"}
|
||
assert "zone_code" not in ctx
|
||
assert "area_m2" not in ctx
|
||
|
||
|
||
def test_returns_none_when_no_run() -> None:
|
||
assert _get_context(None) is None
|
||
|
||
|
||
def test_returns_none_when_result_not_dict() -> None:
|
||
assert _get_context(["not", "a", "dict"]) is None
|
||
|
||
|
||
def test_returns_none_when_no_curated_fields() -> None:
|
||
# Ран есть, но ни одного курируемого поля → None (нечего вливать в отчёт).
|
||
assert _get_context({"market_now": {"x": 1}, "geometry": {}}) is None
|