gendesign/backend/tests/services/site_finder/test_premises_lookup.py
bot-backend ce714ecf79
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m1s
CI / backend-tests (pull_request) Successful in 15m19s
fix(backend): SAVEPOINT-isolate cluster-A db.execute swallows (batch 2) (#2464)
krt_lookup (×2), okn_lookup, ppt_tep_lookup, riasurt_lookup, premises_lookup,
ird_analyze._krt_at, and product_scoring._poi_weight_sum (both the centroid
lookup and the compute_poi_weighted_top7 call) swallowed db.execute failures
without a SAVEPOINT, leaving the shared analyze/report session aborted for
later queries. Wrap each swallow site in db.begin_nested(). locations.py's
pass-1 infra/POI block uses a plain db.rollback() instead — its session is
owned by the weekly location_refresh Celery task (own SessionLocal, verified
single caller-chain), not shared, and nothing is pending at that point.

Refs #2464 (cluster A session-poisoning, Wave 2).
2026-07-08 11:19:20 +05: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