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