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