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

122 lines
4.6 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.

"""Тесты parcel_ppt_tep (#1136 follow-up #1133) — ТЭП ППТ/ПМТ overlap по участку.
БД не дёргается: фейковая сессия с .execute().mappings().all(). Проверяем graceful на
отсутствие участка/таблицы и проброс строк.
"""
from __future__ import annotations
import contextlib
from typing import Any
from sqlalchemy.exc import ProgrammingError
from app.services.site_finder.ppt_tep_lookup import parcel_ppt_tep
class _Result:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self._rows = rows
def mappings(self) -> _Result:
return self
def all(self) -> list[dict[str, Any]]:
return self._rows
class _DB:
def __init__(self, rows: list[dict[str, Any]] | Exception) -> None:
self._rows = rows
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]) -> _Result:
if isinstance(self._rows, Exception):
raise self._rows
return _Result(self._rows)
_PPT_TEP_ROW: dict[str, Any] = {
"project_type": "ppt",
"doc_status_name": "действующий",
"full_name": "ПАГЕ № 22823 от 12.10.2018",
"doc_full_name": "ППТ ул. Челюскинцев",
"source_key": 22823,
"doc_ref": "ppt2018_22823",
"zone_balance": [{"zone_name": "Зона Ж-1", "project_ha_num": 5.0}],
"tep": [{"indicator": "Площадь территории", "target": "36,16"}],
"phasing": [{"phase": "I этап", "zone": "Зона Ж-1", "area_ha_num": 5.0}],
"source_pdf": "https://example.com/ppt22823.pdf",
"fetched_at": "2026-06-07T11:55:00Z",
}
def test_returns_ppt_tep_overlap() -> None:
"""Один match по геометрии и doc_ref↔source_key → проброс строки."""
out = parcel_ppt_tep(_DB([_PPT_TEP_ROW]), "POINT(60 56)") # type: ignore[arg-type]
assert len(out) == 1
row = out[0]
assert row["doc_ref"] == "ppt2018_22823"
assert row["source_key"] == 22823
assert row["zone_balance"] == [{"zone_name": "Зона Ж-1", "project_ha_num": 5.0}]
assert row["tep"][0]["target"] == "36,16"
assert row["phasing"][0]["zone"] == "Зона Ж-1"
def test_empty_wkt_returns_empty() -> None:
"""parcel_wkt=None → []."""
assert parcel_ppt_tep(_DB([]), None) == [] # type: ignore[arg-type]
def test_empty_string_wkt_returns_empty() -> None:
"""parcel_wkt='' → []."""
assert parcel_ppt_tep(_DB([]), "") == [] # type: ignore[arg-type]
def test_no_overlap_returns_empty() -> None:
"""Запрос отработал, но пересечений нет → []."""
assert parcel_ppt_tep(_DB([]), "POINT(60 56)") == [] # type: ignore[arg-type]
def test_missing_table_is_graceful() -> None:
"""ekb_ppt_tep ещё не задеплоена → ProgrammingError → [] (analyze не падает)."""
db = _DB(ProgrammingError("stmt", {}, Exception("relation ekb_ppt_tep does not exist")))
assert parcel_ppt_tep(db, "POINT(60 56)") == [] # type: ignore[arg-type]
def test_unexpected_exception_is_graceful() -> None:
"""Любая неожиданная ошибка БД → [] (analyze не падает)."""
db = _DB(RuntimeError("connection refused"))
assert parcel_ppt_tep(db, "POINT(60 56)") == [] # type: ignore[arg-type]
def test_uses_savepoint_and_session_reusable_after_failure() -> None:
"""#2464 cluster A: db.execute обёрнут в begin_nested (SAVEPOINT) — сбой не
отравляет сессию: follow-up вызов на ТОЙ ЖЕ db-сессии успевает."""
db = _DB(RuntimeError("connection refused"))
assert parcel_ppt_tep(db, "POINT(60 56)") == [] # type: ignore[arg-type]
assert db.begin_nested_calls == 1
db._rows = [_PPT_TEP_ROW]
out = parcel_ppt_tep(db, "POINT(60 56)") # type: ignore[arg-type]
assert len(out) == 1
assert db.begin_nested_calls == 2
def test_multiple_rows_preserved() -> None:
"""Если match'ит несколько ППТ — все возвращаются."""
row2: dict[str, Any] = {
**_PPT_TEP_ROW,
"doc_ref": "ppt2020_99999",
"source_key": 99999,
"full_name": "ПАГЕ № 99999 от 01.06.2020",
}
out = parcel_ppt_tep(_DB([_PPT_TEP_ROW, row2]), "POINT(60 56)") # type: ignore[arg-type]
assert len(out) == 2
assert {r["doc_ref"] for r in out} == {"ppt2018_22823", "ppt2020_99999"}