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).
169 lines
6 KiB
Python
169 lines
6 KiB
Python
"""Тесты riasurt_lookup (#108) — is_in_aglomeration_but_not_ekb + parcel_riasurt_gate.
|
||
|
||
БД мокается фейк-сессией, возвращающей заранее заданные mapping-строки. Проверяем
|
||
агломерация-гейтинг (ЕКБ-сити исключён), topic-бакеты, graceful при ошибке БД.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
from typing import Any
|
||
|
||
from sqlalchemy.exc import ProgrammingError
|
||
|
||
from app.services.site_finder import riasurt_lookup
|
||
from app.services.site_finder.riasurt_lookup import (
|
||
is_in_aglomeration_but_not_ekb,
|
||
parcel_riasurt_gate,
|
||
)
|
||
|
||
_WKT = "POLYGON((60 56,60.1 56,60.1 56.1,60 56.1,60 56))"
|
||
|
||
|
||
def test_is_in_aglomeration_excludes_ekb_city() -> None:
|
||
"""ЕКБ-сити (66:41) → False."""
|
||
assert is_in_aglomeration_but_not_ekb("66:41:0204016:10") is False
|
||
assert is_in_aglomeration_but_not_ekb("66:41") is False
|
||
|
||
|
||
def test_is_in_aglomeration_includes_outskirts() -> None:
|
||
"""Окраины (66:35 Берёзовский, 66:62 В.Пышма, 66:25 Сысерть) → True."""
|
||
assert is_in_aglomeration_but_not_ekb("66:35:0101001:5") is True
|
||
assert is_in_aglomeration_but_not_ekb("66:62:1234567:1") is True
|
||
assert is_in_aglomeration_but_not_ekb("66:25:0000001:99") is True
|
||
|
||
|
||
def test_is_in_aglomeration_rejects_non_sverdl_and_empty() -> None:
|
||
"""Не-Свердл (не 66:*) и пусто → False."""
|
||
assert is_in_aglomeration_but_not_ekb("50:21:0000001:1") is False
|
||
assert is_in_aglomeration_but_not_ekb(None) is False
|
||
assert is_in_aglomeration_but_not_ekb("") is False
|
||
|
||
|
||
class _Row(dict):
|
||
"""mapping-строка: поддерживает r["key"]."""
|
||
|
||
|
||
class _Result:
|
||
def __init__(self, rows: list[_Row]) -> None:
|
||
self._rows = rows
|
||
|
||
def mappings(self) -> _Result:
|
||
return self
|
||
|
||
def all(self) -> list[_Row]:
|
||
return self._rows
|
||
|
||
|
||
class _FakeDB:
|
||
def __init__(self, rows: list[_Row] | 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] | None = None) -> _Result:
|
||
if isinstance(self._rows, Exception):
|
||
raise self._rows
|
||
return _Result(self._rows)
|
||
|
||
|
||
def test_gate_not_applicable_for_ekb_city() -> None:
|
||
"""ЕКБ-сити → applicable=False, БД не дёргается."""
|
||
db = _FakeDB([])
|
||
gate = parcel_riasurt_gate(db, _WKT, "66:41:0204016:10") # type: ignore[arg-type]
|
||
assert gate["applicable"] is False
|
||
assert gate["overlaps"] == []
|
||
|
||
|
||
def test_gate_buckets_topics() -> None:
|
||
"""Пересечения раскладываются по topic-бакетам."""
|
||
rows = [
|
||
_Row(
|
||
source_layer_id=845274,
|
||
layer_topic="territorial_zone",
|
||
mo_name="Берёзовский",
|
||
obshnz="Ж-1",
|
||
description="Зона застройки",
|
||
),
|
||
_Row(
|
||
source_layer_id=846381,
|
||
layer_topic="functional_zone",
|
||
mo_name="Берёзовский",
|
||
obshnz=None,
|
||
description="Жилая функц.зона",
|
||
),
|
||
_Row(
|
||
source_layer_id=844759,
|
||
layer_topic="red_lines",
|
||
mo_name="Берёзовский",
|
||
obshnz=None,
|
||
description=None,
|
||
),
|
||
_Row(
|
||
source_layer_id=846365,
|
||
layer_topic="szz",
|
||
mo_name="Берёзовский",
|
||
obshnz=None,
|
||
description=None,
|
||
),
|
||
_Row(
|
||
source_layer_id=845425,
|
||
layer_topic="flood_zone",
|
||
mo_name="Берёзовский",
|
||
obshnz=None,
|
||
description=None,
|
||
),
|
||
_Row(
|
||
source_layer_id=844478,
|
||
layer_topic="krt",
|
||
mo_name="Берёзовский",
|
||
obshnz=None,
|
||
description=None,
|
||
),
|
||
]
|
||
db = _FakeDB(rows)
|
||
gate = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
|
||
|
||
assert gate["applicable"] is True
|
||
assert gate["tier"] == "Зона застройки"
|
||
assert gate["func_zone"] == "Жилая функц.зона"
|
||
assert gate["red_lines"] is True
|
||
assert gate["szz"] is True
|
||
assert gate["szo"] is False
|
||
assert gate["flood_zone"] is True
|
||
assert gate["krt"] is True
|
||
assert len(gate["overlaps"]) == 6
|
||
|
||
|
||
def test_gate_graceful_on_db_error() -> None:
|
||
"""Ошибка БД (pre-migration) → пустой gate, не падаем."""
|
||
db = _FakeDB(ProgrammingError("stmt", {}, Exception("no table")))
|
||
gate = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
|
||
assert gate == riasurt_lookup.RIASURT_EMPTY_GATE
|
||
|
||
|
||
def test_gate_empty_wkt_returns_empty() -> None:
|
||
"""Нет WKT → пустой gate."""
|
||
db = _FakeDB([])
|
||
gate = parcel_riasurt_gate(db, None, "66:35:0101001:5") # type: ignore[arg-type]
|
||
assert gate["applicable"] is False
|
||
|
||
|
||
def test_gate_uses_savepoint_and_session_reusable_after_failure() -> None:
|
||
"""#2464 cluster A: db.execute обёрнут в begin_nested (SAVEPOINT) — сбой не
|
||
отравляет сессию. Follow-up вызов на ТОЙ ЖЕ db-инстансе успевает."""
|
||
db = _FakeDB(ProgrammingError("stmt", {}, Exception("no table")))
|
||
gate = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
|
||
assert gate == riasurt_lookup.RIASURT_EMPTY_GATE
|
||
assert db.begin_nested_calls == 1
|
||
|
||
# follow-up на той же сессии (таблица теперь "доступна").
|
||
db._rows = []
|
||
gate2 = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
|
||
assert gate2["applicable"] is True
|
||
assert db.begin_nested_calls == 2
|