gendesign/backend/tests/services/site_finder/test_premises_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

207 lines
8.2 KiB
Python
Raw 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.

"""Тесты premises_lookup (#96 MVP on-demand per-building помещения/паркинг).
Сетевые вызовы НЕ делаем — мокаем sync NSPDClient.search_by_cad (резолв objdoc)
и async NSPDBulkClient.list_objects_in_building (tab-group counts). Проверяем
happy path + graceful-фоллбэки (здание не найдено / нет objdoc / WAF).
"""
from __future__ import annotations
import contextlib
from typing import Any
from unittest.mock import MagicMock, patch
from app.schemas.nspd_bulk import ObjectsListing
from app.services.scrapers.nspd_client import NspdLiteWafError
from app.services.site_finder.premises_lookup import resolve_cad_for_domrf
MODULE = "app.services.site_finder.premises_lookup"
def _run_returns(listing: ObjectsListing | None) -> Any:
"""side_effect для мока asyncio.run: закрывает переданную корутину и отдаёт listing.
Без close() реальная корутина _fetch_listing(...) остаётся never-awaited
(RuntimeWarning), т.к. мок asyncio.run её не исполняет. Закрываем явно.
"""
def _side_effect(coro: Any) -> ObjectsListing | None:
coro.close()
return listing
return _side_effect
def _search_result(options: dict[str, Any] | None, feature_id: str | None = "999") -> MagicMock:
"""Замокать NSPDSearchResult.first → NSPDFeature-подобный объект."""
result = MagicMock()
if options is None and feature_id is None:
result.first = None
return result
feature = MagicMock()
feature.properties = {"options": options or {}}
feature.feature_id = feature_id
result.first = feature
return result
def test_get_building_premises_happy_path() -> None:
"""search → objdocId → list_objects → ObjectsListing с parking_ratio."""
from app.services.site_finder.premises_lookup import get_building_premises
listing = ObjectsListing(objdoc_id=40995027, flats_count=200, parking_count=100)
with (
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)) as mock_run,
):
mock_client_cls.return_value.search_by_cad.return_value = _search_result(
{"objdocId": 40995027}
)
out = get_building_premises("66:41:0106036:183")
assert out is listing
assert out.parking_ratio == 0.5
mock_run.assert_called_once()
def test_get_building_premises_snake_case_objdoc() -> None:
"""objdoc_id в snake_case тоже резолвится (bulk-shape)."""
from app.services.site_finder.premises_lookup import get_building_premises
listing = ObjectsListing(objdoc_id=777, flats_count=10, parking_count=2)
with (
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)),
):
mock_client_cls.return_value.search_by_cad.return_value = _search_result({"objdoc_id": 777})
out = get_building_premises("66:41:0106036:183")
assert out is listing
def test_get_building_premises_fallback_to_feature_id() -> None:
"""Нет objdocId в options → fallback на feature.id (verified live: id=objdoc)."""
from app.services.site_finder.premises_lookup import get_building_premises
listing = ObjectsListing(objdoc_id=12345, flats_count=5, parking_count=0)
with (
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)) as mock_run,
):
mock_client_cls.return_value.search_by_cad.return_value = _search_result(
{}, feature_id="12345"
)
out = get_building_premises("66:41:0106036:183")
assert out is listing
# objdoc_id=12345 должен был уйти в _fetch_listing
mock_run.assert_called_once()
def test_get_building_premises_not_found_returns_none() -> None:
"""Здание не найдено в НСПД (result.first=None) → None, async не дёргается."""
from app.services.site_finder.premises_lookup import get_building_premises
with (
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
patch(f"{MODULE}.asyncio.run") as mock_run,
):
mock_client_cls.return_value.search_by_cad.return_value = _search_result(
None, feature_id=None
)
out = get_building_premises("66:41:0106036:183")
assert out is None
mock_run.assert_not_called()
def test_get_building_premises_no_objdoc_returns_none() -> None:
"""Нет ни objdocId, ни feature.id → None (нельзя дёрнуть tab-group)."""
from app.services.site_finder.premises_lookup import get_building_premises
with (
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
patch(f"{MODULE}.asyncio.run") as mock_run,
):
mock_client_cls.return_value.search_by_cad.return_value = _search_result(
{}, feature_id=None
)
out = get_building_premises("66:41:0106036:183")
assert out is None
mock_run.assert_not_called()
def test_get_building_premises_waf_returns_none() -> None:
"""WAF/сеть на search_by_cad → None (graceful, analyze не падает)."""
from app.services.site_finder.premises_lookup import get_building_premises
with (
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
patch(f"{MODULE}.asyncio.run") as mock_run,
):
mock_client_cls.return_value.search_by_cad.side_effect = NspdLiteWafError("HTTP 403 WAF")
out = get_building_premises("66:41:0106036:183")
assert out is None
mock_run.assert_not_called()
# ── #2464 cluster A: resolve_cad_for_domrf SAVEPOINT-регрессия ──────────────────
# db.execute внутри resolve_cad_for_domrf раньше глотал сбой БЕЗ SAVEPOINT/rollback —
# на реальном Postgres это отравляет shared session (lazy-эндпоинт может дёргать
# resolve_cad_for_domrf на несколько конкурентов подряд, ОДНА и та же db-сессия):
# следующий db.execute падал "current transaction is aborted". Фикс: db.execute
# обёрнут в db.begin_nested().
class _ResolveCadFakeDB:
"""Фейковая сессия: execute().mappings().first() + begin_nested()."""
def __init__(self, row: dict[str, Any] | Exception | None) -> None:
self._row = row
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]) -> Any:
if isinstance(self._row, Exception):
raise self._row
row = self._row
class _Result:
def mappings(self) -> _Result:
return self
def first(self) -> dict[str, Any] | None:
return row
return _Result()
def test_resolve_cad_for_domrf_db_failure_returns_none() -> None:
"""DB-сбой → None (документированный fallback), analyze не падает."""
db = _ResolveCadFakeDB(RuntimeError("connection refused"))
assert resolve_cad_for_domrf(db, lat=56.8, lon=60.6) is None # type: ignore[arg-type]
def test_resolve_cad_for_domrf_uses_savepoint_and_session_reusable_after_failure() -> None:
"""begin_nested обёрнут вокруг db.execute — сбой не отравляет сессию: follow-up
вызов resolve_cad_for_domrf на ТОЙ ЖЕ db-сессии успевает."""
db = _ResolveCadFakeDB(RuntimeError("boom"))
out = resolve_cad_for_domrf(db, lat=56.8, lon=60.6) # type: ignore[arg-type]
assert out is None
assert db.begin_nested_calls == 1
db._row = {"cad_num": "66:41:0106036:183", "objdoc_id": 40995027, "distance_m": 12.5}
out2 = resolve_cad_for_domrf(db, lat=56.8, lon=60.6) # type: ignore[arg-type]
assert out2 is not None
assert out2.cad_num == "66:41:0106036:183"
assert db.begin_nested_calls == 2