Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
232 lines
9.5 KiB
Python
232 lines
9.5 KiB
Python
"""Тесты parcel_krt_requisites (#1060) — КРТ-реквизиты из ekb_krt_sites + planning_projects.
|
||
|
||
DB/сеть не дёргаются: мокаем db.execute через FakeDB.
|
||
Проверяем:
|
||
- exact-матч → matched=True, confidence='exact', реквизиты заполнены;
|
||
- planning overlap без матча → matched=False, confidence='none', без застройщика;
|
||
- пустой WKT → [];
|
||
- OperationalError (таблица не задеплоена) → graceful [];
|
||
- дубликаты строк (один pp_id × krt_id) → дедублируются.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from sqlalchemy.exc import OperationalError
|
||
|
||
from app.services.site_finder.krt_lookup import parcel_krt_requisites
|
||
|
||
|
||
class _MappingRow(dict): # type: ignore[type-arg]
|
||
"""Имитирует sqlalchemy MappingResult row (dict-like)."""
|
||
|
||
|
||
class _Result:
|
||
def __init__(self, rows: list[dict[str, Any]]) -> None:
|
||
self._rows = [_MappingRow(r) for r in rows]
|
||
|
||
def mappings(self) -> _Result:
|
||
return self
|
||
|
||
def all(self) -> list[_MappingRow]:
|
||
return self._rows
|
||
|
||
|
||
class _FakeDB:
|
||
"""Мок SQLAlchemy Session: первый вызов execute → first_rows; второй → second_rows."""
|
||
|
||
def __init__(
|
||
self,
|
||
first_rows: list[dict[str, Any]] | None = None,
|
||
second_rows: list[dict[str, Any]] | None = None,
|
||
raise_first: Exception | None = None,
|
||
raise_second: Exception | None = None,
|
||
) -> None:
|
||
self._calls = 0
|
||
self._first = first_rows or []
|
||
self._second = second_rows or []
|
||
self._raise_first = raise_first
|
||
self._raise_second = raise_second
|
||
self.begin_nested_calls = 0
|
||
|
||
@contextlib.contextmanager
|
||
def begin_nested(self) -> Any:
|
||
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
|
||
self.begin_nested_calls += 1
|
||
yield
|
||
|
||
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result:
|
||
self._calls += 1
|
||
if self._calls == 1:
|
||
if self._raise_first:
|
||
raise self._raise_first
|
||
return _Result(self._first)
|
||
if self._raise_second:
|
||
raise self._raise_second
|
||
return _Result(self._second)
|
||
|
||
|
||
_WKT = "POLYGON((60.5 56.8, 60.6 56.8, 60.6 56.9, 60.5 56.9, 60.5 56.8))"
|
||
|
||
# Типичная строка exact-матча (planning_projects ⋈ ekb_krt_sites)
|
||
_EXACT_ROW = {
|
||
"pp_id": 1166,
|
||
"pp_full_name": (
|
||
" ПАГЕ № 1970 от 16.08.2024\nПроект межевания территории в районе улицы Летчиков"
|
||
),
|
||
"dmd_actual_year": 2024,
|
||
"doc_status_name": "Действующий",
|
||
"krt_id": 7,
|
||
"site_name": "2. КРТ жилой застройки в районе ул. Летчиков",
|
||
"developer_name": "ООО СЗ Летчиков",
|
||
"contract_number": "2-7/21.7-14",
|
||
"auction_final_price": Decimal("5000000"),
|
||
"gradpotential_sqm": Decimal("25000"),
|
||
"area_ha": Decimal("1.50"),
|
||
"krt_kind": "жилой застройки",
|
||
}
|
||
|
||
|
||
def test_exact_match_returns_requisites() -> None:
|
||
"""Exact-матч по ПАГЕ-номер+год → matched=True, реквизиты застройщика заполнены."""
|
||
db = _FakeDB(first_rows=[_EXACT_ROW])
|
||
result = parcel_krt_requisites(db, _WKT)
|
||
|
||
assert len(result) == 1
|
||
r = result[0]
|
||
assert r["matched"] is True
|
||
assert r["confidence"] == "exact"
|
||
assert r["krt_geom_ref"] == 1166
|
||
assert r["site_name"] == "2. КРТ жилой застройки в районе ул. Летчиков"
|
||
assert r["developer_name"] == "ООО СЗ Летчиков"
|
||
assert r["contract_number"] == "2-7/21.7-14"
|
||
assert r["auction_final_price"] == 5_000_000.0
|
||
assert r["gradpotential_sqm"] == 25_000.0
|
||
assert r["area_ha"] == 1.50
|
||
|
||
|
||
def test_exact_match_deduplicates_pp_krt_pair() -> None:
|
||
"""Дублирующиеся строки (pp_id × krt_id) дедублируются → один результат."""
|
||
db = _FakeDB(first_rows=[_EXACT_ROW, _EXACT_ROW])
|
||
result = parcel_krt_requisites(db, _WKT)
|
||
assert len(result) == 1
|
||
|
||
|
||
def test_no_match_returns_geom_only() -> None:
|
||
"""Первый запрос пуст (нет exact-матча) → второй запрос → matched=False без реквизитов."""
|
||
no_match_row = {
|
||
"pp_id": 999,
|
||
"pp_full_name": " ПАГЕ № 4033 от 27.12.2022\n"
|
||
"Проект планировки территории в районе улицы Краснофлотцев",
|
||
"dmd_actual_year": 2022,
|
||
"doc_status_name": "Действующий",
|
||
}
|
||
db = _FakeDB(first_rows=[], second_rows=[no_match_row])
|
||
result = parcel_krt_requisites(db, _WKT)
|
||
|
||
assert len(result) == 1
|
||
r = result[0]
|
||
assert r["matched"] is False
|
||
assert r["confidence"] == "none"
|
||
assert r["krt_geom_ref"] == 999
|
||
assert r["developer_name"] is None
|
||
assert r["auction_final_price"] is None
|
||
assert r["gradpotential_sqm"] is None
|
||
assert r["site_name"] is None
|
||
|
||
|
||
def test_empty_wkt_returns_empty_list() -> None:
|
||
"""parcel_wkt=None → пустой список без вызова DB."""
|
||
db = _FakeDB(first_rows=[_EXACT_ROW])
|
||
assert parcel_krt_requisites(db, None) == []
|
||
assert db._calls == 0 # DB не вызывалась
|
||
|
||
|
||
def test_empty_string_wkt_returns_empty_list() -> None:
|
||
"""parcel_wkt='' → пустой список."""
|
||
assert parcel_krt_requisites(_FakeDB(), "") == []
|
||
|
||
|
||
def test_operational_error_first_query_returns_empty() -> None:
|
||
"""OperationalError на первом запросе (таблица не задеплоена) → []."""
|
||
db = _FakeDB(raise_first=OperationalError("stmt", {}, Exception("relation does not exist")))
|
||
result = parcel_krt_requisites(db, _WKT)
|
||
assert result == []
|
||
|
||
|
||
def test_operational_error_second_query_returns_empty() -> None:
|
||
"""OperationalError на втором запросе (no-match fallback) → []."""
|
||
db = _FakeDB(
|
||
first_rows=[],
|
||
raise_second=OperationalError("stmt", {}, Exception("relation does not exist")),
|
||
)
|
||
result = parcel_krt_requisites(db, _WKT)
|
||
assert result == []
|
||
|
||
|
||
def test_none_decimal_fields_become_none() -> None:
|
||
"""NULL Decimal-поля → None в результате, не падают при float()."""
|
||
row = {**_EXACT_ROW, "auction_final_price": None, "gradpotential_sqm": None, "area_ha": None}
|
||
db = _FakeDB(first_rows=[row])
|
||
result = parcel_krt_requisites(db, _WKT)
|
||
assert result[0]["auction_final_price"] is None
|
||
assert result[0]["gradpotential_sqm"] is None
|
||
assert result[0]["area_ha"] is None
|
||
|
||
|
||
def test_no_overlap_at_all_returns_empty() -> None:
|
||
"""Оба запроса возвращают пустые списки → []."""
|
||
db = _FakeDB(first_rows=[], second_rows=[])
|
||
result = parcel_krt_requisites(db, _WKT)
|
||
assert result == []
|
||
|
||
|
||
# ── #2464 cluster A: SAVEPOINT-регрессия (session-poisoning) ────────────────────
|
||
# Оба db.execute (exact-матч + no-match fallback) раньше глотали сбой БЕЗ SAVEPOINT/
|
||
# rollback — на реальном Postgres это отравляет shared session отчёта: следующий
|
||
# db.execute на ТОЙ ЖЕ сессии падает "current transaction is aborted". Фикс: оба
|
||
# сайта обёрнуты в db.begin_nested().
|
||
|
||
|
||
def test_first_query_uses_savepoint_and_session_reusable_after_failure() -> None:
|
||
"""Сбой на первом (exact-матч) запросе: begin_nested использован, [] отдан, и
|
||
follow-up вызов на ТОЙ ЖЕ db-сессии успевает (не отравлен)."""
|
||
db = _FakeDB(raise_first=OperationalError("stmt", {}, Exception("relation does not exist")))
|
||
result = parcel_krt_requisites(db, _WKT)
|
||
assert result == []
|
||
assert db.begin_nested_calls == 1
|
||
|
||
# follow-up на той же сессии (первый запрос теперь отдаёт exact-матч). Сбрасываем
|
||
# _calls — _FakeDB считает execute() 1-based ОТ НАЧАЛА каждого прогона.
|
||
db._calls = 0
|
||
db._raise_first = None
|
||
db._first = [_EXACT_ROW]
|
||
result2 = parcel_krt_requisites(db, _WKT)
|
||
assert len(result2) == 1
|
||
assert result2[0]["matched"] is True
|
||
assert db.begin_nested_calls == 2
|
||
|
||
|
||
def test_second_query_uses_savepoint_and_session_reusable_after_failure() -> None:
|
||
"""Сбой на втором (no-match fallback) запросе: begin_nested использован, [] отдан,
|
||
и follow-up вызов на ТОЙ ЖЕ db-сессии успевает (не отравлен)."""
|
||
db = _FakeDB(
|
||
first_rows=[],
|
||
raise_second=OperationalError("stmt", {}, Exception("relation does not exist")),
|
||
)
|
||
result = parcel_krt_requisites(db, _WKT)
|
||
assert result == []
|
||
# begin_nested вызван для обоих запросов (первый успешен, второй сбоит).
|
||
assert db.begin_nested_calls == 2
|
||
|
||
# follow-up на той же сессии: и первый, и второй запрос теперь успешны.
|
||
db._calls = 0
|
||
db._raise_second = None
|
||
db._second = [{"pp_id": 42, "pp_full_name": "no-match", "dmd_actual_year": 2023}]
|
||
result2 = parcel_krt_requisites(db, _WKT)
|
||
assert len(result2) == 1
|
||
assert result2[0]["matched"] is False
|
||
assert db.begin_nested_calls == 4
|