gendesign/backend/tests/services/site_finder/test_quarter_dump_savepoint.py
bot-backend 22f3c44dc2
All checks were successful
Deploy / changes (push) Successful in 11s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m42s
Deploy / build-worker (push) Successful in 3m53s
Deploy / deploy (push) Successful in 1m54s
fix(site_finder): SAVEPOINT-isolate quarter-dump lookup db.execute (#2464 Wave 2) (#2467)
2026-07-08 06:30:32 +00:00

224 lines
11 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.

"""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",)]