gendesign/backend/tests/services/test_krt_lookup.py
bot-backend 01360e9c3d
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
fix(site_finder): SAVEPOINT-isolate gate-lookup db.execute swallows (#2464 Wave 2) (#2468)
2026-07-08 06:46:03 +00:00

232 lines
9.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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