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
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).
122 lines
4.6 KiB
Python
122 lines
4.6 KiB
Python
"""Тесты 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"}
|