All checks were successful
CI / changes (pull_request) Successful in 11s
CI Trade-In / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m1s
CI / backend-tests (pull_request) Successful in 16m4s
One layer's caught DB error left the shared §22 report Session in Postgres's "aborted transaction" state with no rollback, silently emptying every later layer on the same report. Wrap each failure point in db.begin_nested() (SAVEPOINT): orchestrator._safe_call, macro_series _query_key_rate_monthly / _query_inflation_monthly / _query_mortgage_monthly (per indicator), special_indices._run (per index), and sales_series _query_source_a / _query_source_b — so a sibling builder's failure no longer cascades. Also close the RELEASE-SAVEPOINT trap: inner helpers that swallowed a db.execute failure without their own savepoint left the tx aborted, so the outer builder's savepoint failed at RELEASE (illegal in an aborted tx) and poisoning still cascaded. Add savepoints at the inner catch sites — special_indices._query_parcel_centroid and district_resolver._admin_names / resolve_objective_districts. Refs #2464 (cluster A session-poisoning).
352 lines
17 KiB
Python
352 lines
17 KiB
Python
"""Unit-тесты district-resolver (Step 2 — мост admin↔micro вокабуляров ЕКБ).
|
||
|
||
Резолвер лечит баг §9.x: /analyze передаёт ОФИЦИАЛЬНОЕ админ-имя («Кировский»), а
|
||
objective_lots.district хранит НЕФОРМАЛЬНЫЕ микро («Втузгородок»/«ЖБИ») → админ-имя
|
||
давало 0 строк → молча пустой прогноз (см. data/sql/131_district_vocab_map.sql).
|
||
|
||
Контракт resolve_objective_districts(db, district):
|
||
• None / 'не определён' → None (EKB-wide, без district-фильтра)
|
||
• админ-имя с чистыми алиасами → [micro, …] (note IS NULL)
|
||
• админ-имя без чистых алиасов → None (EKB-wide fallback, НЕ пустой набор)
|
||
• не-админ (raw микро, legacy) → [district] (фильтр-по-микро как есть)
|
||
|
||
Тесты бьют МОК-сессию (без живой БД): резолвер делает максимум 2 execute —
|
||
admin-names (.scalars().all()) + micro-lookup (.scalars().all()). КРИТИЧНО: тест на
|
||
админ-вход ОБЯЗАН падать, если резолвер регрессирует к pass-through админ-имени.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import os
|
||
from collections.abc import Callable, Iterator
|
||
from typing import Any
|
||
from unittest.mock import MagicMock
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import pytest
|
||
|
||
from app.services.site_finder import district_resolver
|
||
from app.services.site_finder.district_resolver import (
|
||
_admin_names,
|
||
_reset_admin_cache,
|
||
resolve_objective_districts,
|
||
)
|
||
|
||
# 8 официальных админ-районов ЕКБ (district_name из ekb_districts с непустым geom).
|
||
_ADMIN_NAMES = [
|
||
"Академический",
|
||
"Верх-Исетский",
|
||
"Железнодорожный",
|
||
"Кировский",
|
||
"Ленинский",
|
||
"Октябрьский",
|
||
"Орджоникидзевский",
|
||
"Чкаловский",
|
||
]
|
||
|
||
# Чистые микро (note IS NULL) для админов, которые тестим (срез prod-сида 131_*).
|
||
_CLEAN_MICROS: dict[str, list[str]] = {
|
||
"Кировский": ["Втузгородок", "ЖБИ"],
|
||
"Ленинский": ["Автовокзал", "УНЦ", "Юго-Западный"],
|
||
"Орджоникидзевский": ["Уралмаш", "Эльмаш"],
|
||
}
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_cache_between_tests() -> None:
|
||
"""Сбрасывать per-process кеш админ-имён — иначе мок одного теста течёт в другой."""
|
||
_reset_admin_cache()
|
||
yield
|
||
_reset_admin_cache()
|
||
|
||
|
||
def _make_db(
|
||
admin_names: list[str] | None = None,
|
||
micros: list[str] | None = None,
|
||
) -> MagicMock:
|
||
"""Мок-сессия: 1-й execute → admin-names; 2-й (если есть) → micro-lookup.
|
||
|
||
Оба читаются через .scalars().all(). admin_names=None → дефолтные 8.
|
||
"""
|
||
names = _ADMIN_NAMES if admin_names is None else admin_names
|
||
db = MagicMock()
|
||
admin_result = MagicMock()
|
||
admin_result.scalars.return_value.all.return_value = names
|
||
micro_result = MagicMock()
|
||
micro_result.scalars.return_value.all.return_value = micros if micros is not None else []
|
||
db.execute.side_effect = [admin_result, micro_result]
|
||
return db
|
||
|
||
|
||
def _micro_lookup_param(db: MagicMock) -> str | None:
|
||
"""Достать bind :d из ВТОРОГО execute (micro-lookup). None если его не было."""
|
||
if len(db.execute.call_args_list) < 2:
|
||
return None
|
||
args, _kwargs = db.execute.call_args_list[1]
|
||
return args[1]["d"]
|
||
|
||
|
||
# ── None / 'не определён' → None (EKB-wide) ───────────────────────────────────
|
||
|
||
|
||
class TestNoneAndUndetermined:
|
||
def test_none_returns_none(self) -> None:
|
||
db = _make_db()
|
||
assert resolve_objective_districts(db, None) is None
|
||
# ранний выход — БД даже не трогается
|
||
assert db.execute.call_count == 0
|
||
|
||
def test_undetermined_returns_none(self) -> None:
|
||
db = _make_db()
|
||
assert resolve_objective_districts(db, "не определён") is None
|
||
assert db.execute.call_count == 0
|
||
|
||
|
||
# ── админ-имя → его чистые микро (THE FIX) ─────────────────────────────────────
|
||
|
||
|
||
class TestAdminExpandsToMicros:
|
||
@pytest.mark.parametrize("admin", list(_CLEAN_MICROS.keys()))
|
||
def test_admin_expands_to_clean_micros(self, admin: str) -> None:
|
||
expected = _CLEAN_MICROS[admin]
|
||
db = _make_db(micros=expected)
|
||
out = resolve_objective_districts(db, admin)
|
||
assert out == expected
|
||
|
||
def test_does_not_pass_admin_name_through(self) -> None:
|
||
# РЕГРЕССИОННЫЙ guard: резолвер НЕ должен возвращать [admin] (это и был баг —
|
||
# админ-имя → 0 строк). Должен вернуть микро, а админ-имя НЕ должно быть в наборе.
|
||
db = _make_db(micros=["Втузгородок", "ЖБИ"])
|
||
out = resolve_objective_districts(db, "Кировский")
|
||
assert out == ["Втузгородок", "ЖБИ"]
|
||
assert out != ["Кировский"]
|
||
assert "Кировский" not in out
|
||
|
||
def test_micro_lookup_bound_with_admin_name(self) -> None:
|
||
# micro-lookup параметризован именно админ-именем (CAST(:d AS text)).
|
||
db = _make_db(micros=["Уралмаш", "Эльмаш"])
|
||
resolve_objective_districts(db, "Орджоникидзевский")
|
||
assert _micro_lookup_param(db) == "Орджоникидзевский"
|
||
|
||
|
||
# ── админ без чистых алиасов → None (EKB-wide fallback, НЕ пустой набор) ───────
|
||
|
||
|
||
class TestAdminNoCleanAliasesFallback:
|
||
def test_admin_without_clean_micros_returns_none(self) -> None:
|
||
# Админ в ekb_districts, но micro-lookup пуст (note IS NULL отсёк всё) →
|
||
# EKB-wide fallback. НИКОГДА не пустой список (иначе гарантированно 0 строк).
|
||
db = _make_db(micros=[])
|
||
out = resolve_objective_districts(db, "Чкаловский")
|
||
assert out is None
|
||
|
||
def test_never_returns_empty_list(self) -> None:
|
||
db = _make_db(micros=[])
|
||
out = resolve_objective_districts(db, "Ленинский")
|
||
assert out != []
|
||
assert out is None
|
||
|
||
|
||
# ── raw микро (legacy внутренний вызов) → [district] как есть ──────────────────
|
||
|
||
|
||
class TestRawMicroPassThrough:
|
||
def test_raw_micro_returned_as_is(self) -> None:
|
||
# 'Втузгородок' — НЕ админ-имя → фильтруем по нему как есть (legacy-поведение).
|
||
db = _make_db()
|
||
out = resolve_objective_districts(db, "Втузгородок")
|
||
assert out == ["Втузгородок"]
|
||
|
||
def test_raw_micro_does_not_hit_micro_lookup(self) -> None:
|
||
# raw-микро не разворачивается → второй execute (micro-lookup) НЕ делается.
|
||
db = _make_db()
|
||
resolve_objective_districts(db, "Уралмаш")
|
||
assert db.execute.call_count == 1 # только admin-names
|
||
|
||
|
||
# ── кеш админ-имён ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestAdminNamesCache:
|
||
def test_admin_names_cached_across_calls(self) -> None:
|
||
# admin-names читается ОДИН раз и кешируется per-process: второй резолв
|
||
# raw-микро НЕ делает повторный admin-names запрос.
|
||
db = MagicMock()
|
||
admin_result = MagicMock()
|
||
admin_result.scalars.return_value.all.return_value = _ADMIN_NAMES
|
||
db.execute.return_value = admin_result # любой вызов вернёт admin-names
|
||
|
||
resolve_objective_districts(db, "Уралмаш") # raw micro → 1 execute (admins)
|
||
resolve_objective_districts(db, "Эльмаш") # raw micro → 0 execute (cache hit)
|
||
assert db.execute.call_count == 1
|
||
|
||
def test_admin_lookup_failure_treats_input_as_raw_micro(self) -> None:
|
||
# Сбой admin-names lookup → пустой admin-set → ЛЮБОЙ вход трактуется как raw
|
||
# микро (pass-through), НЕ роняем /analyze. Кеш-от-сбоя не залипает.
|
||
db = MagicMock()
|
||
db.execute.side_effect = RuntimeError("db down")
|
||
out = resolve_objective_districts(db, "Кировский")
|
||
# admin-set пуст → 'Кировский' не распознан как админ → [district] как есть.
|
||
assert out == ["Кировский"]
|
||
|
||
|
||
# ── сбой micro-lookup → EKB-wide fallback (не crash) ──────────────────────────
|
||
|
||
|
||
class TestMicroLookupFailure:
|
||
def test_micro_lookup_exception_returns_none(self) -> None:
|
||
db = MagicMock()
|
||
admin_result = MagicMock()
|
||
admin_result.scalars.return_value.all.return_value = _ADMIN_NAMES
|
||
db.execute.side_effect = [admin_result, RuntimeError("micro lookup down")]
|
||
out = resolve_objective_districts(db, "Кировский")
|
||
# админ распознан, но lookup упал → EKB-wide (None), а не crash/пустой набор.
|
||
assert out is None
|
||
|
||
|
||
# ── форма SQL: CAST(:d AS text), без :d::text ─────────────────────────────────
|
||
|
||
|
||
class TestResolverSqlShape:
|
||
def test_micro_sql_uses_cast_not_double_colon(self) -> None:
|
||
sql = str(district_resolver._MICROS_FOR_ADMIN_SQL)
|
||
assert "CAST(:d AS text)" in sql
|
||
assert ":d::" not in sql
|
||
|
||
def test_micro_sql_filters_note_is_null(self) -> None:
|
||
# cross-city/ambiguous (note IS NOT NULL) ОБЯЗАНЫ быть отсечены.
|
||
sql = str(district_resolver._MICROS_FOR_ADMIN_SQL)
|
||
assert "note IS NULL" in sql
|
||
|
||
def test_admin_sql_filters_non_null_geom(self) -> None:
|
||
# 8 админов = ekb_districts с непустым geom ('не определён' без geom исключён).
|
||
sql = str(district_resolver._ADMIN_NAMES_SQL)
|
||
assert "geom IS NOT NULL" in sql
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# RELEASE-SAVEPOINT trap (#2464 cluster A, second-order): резолвер зовётся §22-
|
||
# билдерами (sales_series `_query_source_a/_b`, `_query_artificial_demand`) на общей
|
||
# сессии отчёта ПОД внешним SAVEPOINT. Если внутренний swallow (_admin_names /
|
||
# micro-lookup) съедает db-сбой БЕЗ своего savepoint, транзакция остаётся aborted;
|
||
# внешний savepoint выходит «нормально» и делает RELEASE SAVEPOINT, который САМ падает
|
||
# в aborted-tx (Postgres допускает лишь ROLLBACK / ROLLBACK TO SAVEPOINT) — внешний
|
||
# except ловит RELEASE-ошибку, но tx так и не откачена → отравление каскадит. Fix =
|
||
# savepoint В ТОЧКЕ ПЕРЕХВАТА. Тест воспроизводит ИМЕННО RELEASE-trap (не плоский
|
||
# swallow): `_ReleaseTrapSession` моделирует, что RELEASE в aborted-tx падает.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class _FakeResult:
|
||
"""Мини-результат db.execute() — покрывает `.scalars().all()` и `.mappings().first()`."""
|
||
|
||
def __init__(self, rows: list[Any]) -> None:
|
||
self._rows = list(rows)
|
||
|
||
def scalars(self) -> _FakeResult:
|
||
return self
|
||
|
||
def mappings(self) -> _FakeResult:
|
||
return self
|
||
|
||
def all(self) -> list[Any]:
|
||
return list(self._rows)
|
||
|
||
def first(self) -> Any:
|
||
return self._rows[0] if self._rows else None
|
||
|
||
|
||
class _ReleaseTrapSession:
|
||
"""Fake Session, моделирующая Postgres SAVEPOINT/RELEASE/ROLLBACK в aborted-tx.
|
||
|
||
- `execute()` на `fail_on_call`-м вызове бросает РОВНО раз и помечает tx aborted
|
||
(poisoned). Пока poisoned — любой execute бросает 'current transaction is aborted'.
|
||
- `begin_nested()` моделирует SAVEPOINT:
|
||
• ВХОД в poisoned-tx → SAVEPOINT запрещён → бросает (как реальный Postgres);
|
||
• ВЫХОД с исключением → ROLLBACK TO SAVEPOINT (снимает poison), пробрасывает;
|
||
• ВЫХОД нормально → RELEASE SAVEPOINT, и если tx poisoned — RELEASE ПАДАЕТ
|
||
(это и есть RELEASE-trap; без внутреннего savepoint тест краснеет).
|
||
"""
|
||
|
||
def __init__(
|
||
self, *, fail_on_call: int = 1, results: dict[int, list[Any]] | None = None
|
||
) -> None:
|
||
self.poisoned = False
|
||
self.calls = 0
|
||
self.fail_on_call = fail_on_call
|
||
self._results = results or {}
|
||
|
||
def execute(self, *args: Any, **kwargs: Any) -> _FakeResult:
|
||
self.calls += 1
|
||
if self.poisoned:
|
||
raise RuntimeError(
|
||
"current transaction is aborted, commands ignored until end of transaction block"
|
||
)
|
||
if self.calls == self.fail_on_call:
|
||
self.poisoned = True
|
||
raise RuntimeError("simulated inner helper query failure")
|
||
return _FakeResult(self._results.get(self.calls, []))
|
||
|
||
@contextlib.contextmanager
|
||
def begin_nested(self) -> Iterator[None]:
|
||
if self.poisoned:
|
||
# SAVEPOINT запрещён в aborted-tx.
|
||
raise RuntimeError(
|
||
"current transaction is aborted, commands ignored until end of transaction block"
|
||
)
|
||
try:
|
||
yield
|
||
except Exception:
|
||
# ROLLBACK TO SAVEPOINT — разрешён в aborted, снимает poison.
|
||
self.poisoned = False
|
||
raise
|
||
else:
|
||
if self.poisoned:
|
||
# RELEASE SAVEPOINT в aborted-tx ПАДАЕТ — тот самый trap.
|
||
raise RuntimeError(
|
||
"current transaction is aborted, "
|
||
"commands ignored until end of transaction block"
|
||
)
|
||
|
||
|
||
def _outer_savepoint_call(db: Any, fn: Callable[[], Any], *, fallback: Any) -> Any:
|
||
"""Зеркало `_run`/`_safe_call`: обернуть fn() во ВНЕШНИЙ savepoint (RELEASE на норм-выходе).
|
||
|
||
Именно этот RELEASE падает в aborted-tx, если внутренний helper отравил сессию без
|
||
своего savepoint → возвращается `fallback` (сигнал, что trap сработал).
|
||
"""
|
||
try:
|
||
with db.begin_nested():
|
||
return fn()
|
||
except Exception:
|
||
return fallback
|
||
|
||
|
||
class TestReleaseSavepointTrap:
|
||
"""Внутренние swallow-сайты резолвера НЕ отравляют внешний SAVEPOINT (RELEASE-trap)."""
|
||
|
||
def test_admin_names_failure_survives_outer_release(self) -> None:
|
||
_reset_admin_cache()
|
||
db = _ReleaseTrapSession(fail_on_call=1) # admin-names lookup падает
|
||
result = _outer_savepoint_call(db, lambda: _admin_names(db), fallback="OUTER_FALLBACK")
|
||
# _admin_names вернул СВОЙ fallback (пустой frozenset) — внешний RELEASE прошёл,
|
||
# значит внутренний savepoint откатил сбой (иначе result был бы OUTER_FALLBACK).
|
||
assert result == frozenset()
|
||
# КЛЮЧЕВОЙ ассерт: сессия НЕ отравлена — follow-up execute без 'aborted'.
|
||
db.execute("SELECT 1")
|
||
|
||
def test_micro_lookup_failure_survives_outer_release(self) -> None:
|
||
_reset_admin_cache()
|
||
# call 1 = admin-names (успех, "Кировский" распознан); call 2 = micro-lookup (падает).
|
||
db = _ReleaseTrapSession(fail_on_call=2, results={1: ["Кировский"]})
|
||
result = _outer_savepoint_call(
|
||
db,
|
||
lambda: resolve_objective_districts(db, "Кировский"),
|
||
fallback="OUTER_FALLBACK",
|
||
)
|
||
# micro-lookup проглочен → EKB-wide fallback (None), НЕ внешний OUTER_FALLBACK.
|
||
assert result is None
|
||
# КЛЮЧЕВОЙ ассерт: сессия НЕ отравлена — follow-up execute без 'aborted'.
|
||
db.execute("SELECT 1")
|