"""Тесты 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