"""SAVEPOINT-регрессия для quarter_dump_lookup.py (#2464 cluster A). _get_zoning / _get_zouit_overlaps / _get_cad_zouit_overlaps / _get_engineering_nearby / _get_risk_zones / _get_opportunity_parcels / _get_red_lines / _get_engineering_structures_ by_boundary когда-то ловили db.execute-сбой в bare ``except Exception`` (или ``except (OperationalError, ProgrammingError, DataError)``) БЕЗ SAVEPOINT/rollback — на реальном Postgres это отравляет db shared с caller'ом (`get_quarter_dump_data(db, ...)` → вызывается из `/analyze` эндпоинта, `Depends(get_db)`): следующий `db.execute` в этом же запросе (сиблинг `_get_*` хелпер, либо downstream `get_or_fetch_zone_regulation`, либо `begin_nested` "9f cad_parcels" блок) падает с "current transaction is aborted" / PendingRollbackError. Референс-корректный сиблинг — `connection_capacity_lookup.py:: _query_nearby_network_zones` (уже был обёрнут в `db.begin_nested()`). MagicMock-сессия не эмулирует реальный aborted-transaction Postgres — тесты проверяют (1) graceful fallback при сбое (документированный контракт функции не меняется), (2) что begin_nested() реально вызван вокруг db.execute (SAVEPOINT-обёртка присутствует в коде), (3) что db остаётся usable для следующего вызова на той же mock-сессии — ошибка не проглатывает control flow и не роняет остальную сессию. Покрывает представительное подмножество (per #2464 audit): _get_zoning, _get_zouit_overlaps, _get_engineering_structures_by_boundary и _get_zouit_engineering_overlaps (последние два — 2 db.execute на функцию, каждый под своей savepoint, epic #2445 A1 honest-count + row-fetch). """ from __future__ import annotations import os from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from sqlalchemy.exc import OperationalError from app.services.site_finder.quarter_dump_lookup import ( _get_engineering_structures_by_boundary, _get_zoning, _get_zouit_engineering_overlaps, _get_zouit_overlaps, ) def _op_error(msg: str) -> OperationalError: """OperationalError фабрика — единственный тип, который ловит except (OperationalError, ProgrammingError, DataError) в _get_engineering_structures_ by_boundary (bare RuntimeError туда НЕ провалится — это намеренно узкий except).""" return OperationalError(msg, {}, Exception(msg)) _QUARTER = "66:41:0204016" _WKT = "POLYGON((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.9, 60.6 56.8))" class TestGetZoningSavepoint: """_get_zoning: db.execute().first(), except Exception → return None.""" def test_query_failure_degrades_to_none(self) -> None: db = MagicMock() db.execute.side_effect = RuntimeError("nspd_quarter_dumps query failed") assert _get_zoning(db, _QUARTER, _WKT) is None def test_uses_begin_nested_savepoint(self) -> None: db = MagicMock() db.execute.return_value.first.return_value = None _get_zoning(db, _QUARTER, _WKT) assert db.begin_nested.call_count == 1 def test_session_usable_after_failure(self) -> None: """db.execute сбоит один раз, но db остаётся usable для следующего (не связанного) вызова на этой же сессии — имитирует sibling _get_* хелпер, вызванный сразу после в get_quarter_dump_data().""" db = MagicMock() db.execute.side_effect = RuntimeError("boom") result = _get_zoning(db, _QUARTER, _WKT) assert result is None # Следующий вызов на том же db.execute отрабатывает (сессия не отравлена). db.execute.side_effect = None next_result = MagicMock() next_result.first.return_value = ("marker",) db.execute.return_value = next_result assert db.execute("SELECT 1").first() == ("marker",) class TestGetZouitOverlapsSavepoint: """_get_zouit_overlaps: db.execute().fetchall(), except Exception → return [].""" def test_query_failure_degrades_to_empty_list(self) -> None: db = MagicMock() db.execute.side_effect = RuntimeError("nspd zouit query failed") result = _get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3}) assert result == [] def test_uses_begin_nested_savepoint(self) -> None: db = MagicMock() db.execute.return_value.fetchall.return_value = [] _get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3}) assert db.begin_nested.call_count == 1 def test_session_usable_after_failure(self) -> None: """Сбой на dump-path (zouit_count>0) не отравляет сессию для следующего sibling-запроса (например _get_engineering_nearby сразу после).""" db = MagicMock() db.execute.side_effect = RuntimeError("boom") result = _get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3}) assert result == [] db.execute.side_effect = None next_result = MagicMock() next_result.fetchall.return_value = [("marker",)] db.execute.return_value = next_result assert db.execute("SELECT 1").fetchall() == [("marker",)] class TestGetEngineeringStructuresByBoundarySavepoint: """_get_engineering_structures_by_boundary: 2 db.execute (COUNT + row-fetch) в одном try, except (OperationalError, ProgrammingError, DataError) → return ([], 0). Каждый db.execute нуждается в СОБСТВЕННОЙ savepoint (RELEASE SAVEPOINT trap): если COUNT сбоит, ROLLBACK TO SAVEPOINT должен откатить только COUNT, не всю функцию — и наоборот, если COUNT прошёл, а row-fetch сбоил, savepoint row-fetch'а изолирует именно его сбой. """ def test_count_query_failure_degrades_to_empty(self) -> None: db = MagicMock() db.execute.side_effect = _op_error("COUNT(*) query failed") items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500) assert items == [] assert total == 0 def test_rowfetch_failure_after_successful_count_degrades_to_empty(self) -> None: """COUNT succeeds, row-fetch fails — fallback ([], 0), не total_count-протечка.""" db = MagicMock() count_result = MagicMock() count_result.scalar.return_value = 42 db.execute.side_effect = [count_result, _op_error("row-fetch failed")] items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500) assert items == [] assert total == 0 def test_uses_two_separate_begin_nested_savepoints(self) -> None: """COUNT и row-fetch — каждый под своей savepoint (2 отдельных begin_nested).""" db = MagicMock() count_result = MagicMock() count_result.scalar.return_value = 0 rows_result = MagicMock() rows_result.fetchall.return_value = [] db.execute.side_effect = [count_result, rows_result] _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500) assert db.begin_nested.call_count == 2 def test_session_usable_after_count_failure(self) -> None: """COUNT сбоит → db остаётся usable для следующего sibling-запроса (например _get_zouit_engineering_overlaps сразу после в get_connection_points).""" db = MagicMock() db.execute.side_effect = _op_error("boom") items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500) assert (items, total) == ([], 0) db.execute.side_effect = None next_result = MagicMock() next_result.fetchall.return_value = [("marker",)] db.execute.return_value = next_result assert db.execute("SELECT 1").fetchall() == [("marker",)] class TestGetZouitEngineeringOverlapsSavepoint: """_get_zouit_engineering_overlaps: 2 db.execute (COUNT + row-fetch) в одном try, except (OperationalError, ProgrammingError, DataError) → return ([], 0). Идентичная 2-запросная форма _get_engineering_structures_by_boundary — вызывается сразу после него в get_connection_points() на той же shared session, поэтому каждый db.execute обёрнут в свою savepoint (RELEASE SAVEPOINT trap, epic #2445 A1). """ def test_count_query_failure_degrades_to_empty(self) -> None: db = MagicMock() db.execute.side_effect = _op_error("COUNT(*) query failed") items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT) assert items == [] assert total == 0 def test_rowfetch_failure_after_successful_count_degrades_to_empty(self) -> None: """COUNT succeeds, row-fetch fails — fallback ([], 0), не total_count-протечка.""" db = MagicMock() count_result = MagicMock() count_result.scalar.return_value = 137 db.execute.side_effect = [count_result, _op_error("row-fetch failed")] items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT) assert items == [] assert total == 0 def test_uses_two_separate_begin_nested_savepoints(self) -> None: """COUNT и row-fetch — каждый под своей savepoint (2 отдельных begin_nested).""" db = MagicMock() count_result = MagicMock() count_result.scalar.return_value = 0 rows_result = MagicMock() rows_result.fetchall.return_value = [] db.execute.side_effect = [count_result, rows_result] _get_zouit_engineering_overlaps(db, _QUARTER, _WKT) assert db.begin_nested.call_count == 2 def test_session_usable_after_failure(self) -> None: """Сбой не отравляет сессию — get_connection_points после этого хелпера ещё строит summary и (в /analyze-контексте) сессия shared с downstream-запросами.""" db = MagicMock() db.execute.side_effect = _op_error("boom") items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT) assert (items, total) == ([], 0) db.execute.side_effect = None next_result = MagicMock() next_result.fetchall.return_value = [("marker",)] db.execute.return_value = next_result assert db.execute("SELECT 1").fetchall() == [("marker",)]