gendesign/backend/tests/services/site_finder/test_district_resolver.py
bot-backend 82fdabccba
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m35s
Deploy / build-worker (push) Successful in 3m1s
Deploy / deploy (push) Successful in 1m34s
fix(forecasting): SAVEPOINT-isolate §22 db.execute failures + close RELEASE-trap (#2464) (#2466)
2026-07-08 05:41:35 +00:00

352 lines
17 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.

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