"""Unit-тесты future-supply-pressure service (#950 EPIC 6 Step6, ТЗ §9.3). Чистые тесты (без живой БД): • pure-арифметика: _horizon_weight (вкл. NULL-дату → нейтраль, вне/в окне), _months_between, _months_of_pressure (guard div-by-zero → None), _saturating_index (монотонность + clamp [0,1] + named-saturation), _monthly_absorption_units (оба пути из market_metrics), _min_confidence (худшая тянет вниз, whitelisted). • compute_future_supply_pressure через MagicMock-сессию + mock compute_market_metrics + mock resolve_objective_districts: форма SQL (читает VIEW v_supply_layers_latest, НЕ базовую таблицу / recompute; district-фильтр через ANY(CAST(:names AS text[])), НЕ скаляр :district::type), resolver-wiring (админ → микро + сам админ, дедуп; None → EKB-wide без фильтра), агрегация по слоям, горизонт-взвешивание L3, graceful empty/thin → index None + confidence 'low', confidence=MIN. Детерминированно, без LLM. Мокаем compute_market_metrics + resolve_objective_districts + db (нет живой БД). """ from __future__ import annotations import os from collections.abc import Iterator from contextlib import contextmanager from datetime import date from unittest.mock import MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import pytest from app.services.site_finder.future_supply import ( _NULL_HORIZON_WEIGHT, _PRESSURE_SATURATION_MONTHS, FutureSupplyPressure, _horizon_weight, _min_confidence, _monthly_absorption_units, _months_between, _months_of_pressure, _query_supply_rows, _round_or_none, _saturating_index, compute_future_supply_pressure, ) # Путь патча reused-сервиса (импортирован в модуль future_supply). _MARKET = "app.services.site_finder.future_supply.compute_market_metrics" # Путь патча district-резолвера (импортирован в модуль future_supply). Резолвер бьёт # живую БД (ekb_districts / ekb_district_alias) — в unit-тестах всегда мокаем. _RESOLVE = "app.services.site_finder.future_supply.resolve_objective_districts" def _resolve_passthrough(_db: object, district: str | None) -> list[str] | None: """Дефолтная заглушка резолвера: raw-микро pass-through (зеркало legacy-ветки). district — raw-микро («X»/«Автовокзал» в большинстве тестов) → `[district]` (фильтр по самому имени, как было до фикса); None → None (EKB-wide). Тесты, проверяющие admin→micro разворот, передают свой side_effect через `_patched(resolve=…)`. """ return [district] if district is not None else None # Разрешённый vocab confidence (зеркало CHECK м.125 / market_metrics). _ALLOWED_CONFIDENCE = {"high", "medium", "low"} # ── pure: _months_between ───────────────────────────────────────────────────── class TestMonthsBetween: def test_same_month_zero(self) -> None: assert _months_between(date(2026, 6, 1), date(2026, 6, 30)) == 0 def test_forward_months(self) -> None: assert _months_between(date(2026, 6, 1), date(2026, 12, 1)) == 6 def test_across_year(self) -> None: assert _months_between(date(2026, 6, 1), date(2027, 6, 1)) == 12 def test_past_is_negative(self) -> None: assert _months_between(date(2026, 6, 1), date(2026, 1, 1)) == -5 # ── pure: _horizon_weight (инверсия stage_at_horizon) ───────────────────────── class TestHorizonWeight: _TODAY = date(2026, 6, 1) def test_within_horizon_full_weight(self) -> None: # выход через 6 мес, горизонт 12 → в окне → полностью давит (1.0). assert _horizon_weight(date(2026, 12, 1), self._TODAY, 12) == 1.0 def test_exactly_at_horizon_edge_included(self) -> None: # ровно на границе горизонта (12 мес) → включаем (≤). assert _horizon_weight(date(2027, 6, 1), self._TODAY, 12) == 1.0 def test_beyond_horizon_zero_weight(self) -> None: # выход через 24 мес, горизонт 12 → вне окна → 0.0. assert _horizon_weight(date(2028, 6, 1), self._TODAY, 12) == 0.0 def test_already_online_full_weight(self) -> None: # дата в прошлом (уже онлайн) → давит полностью. assert _horizon_weight(date(2025, 1, 1), self._TODAY, 12) == 1.0 def test_null_date_neutral_weight(self) -> None: # тайминг неизвестен → нейтральный вес (не игнор, не полный счёт). assert _horizon_weight(None, self._TODAY, 12) == _NULL_HORIZON_WEIGHT assert _NULL_HORIZON_WEIGHT == 0.5 def test_shorter_horizon_excludes_more(self) -> None: # тот же объект (выход через 9 мес): горизонт 12 включает, горизонт 6 — нет. d = date(2027, 3, 1) # +9 мес assert _horizon_weight(d, self._TODAY, 12) == 1.0 assert _horizon_weight(d, self._TODAY, 6) == 0.0 # ── pure: _months_of_pressure ───────────────────────────────────────────────── class TestMonthsOfPressure: def test_basic(self) -> None: # (200 hidden + 100 future) / 50 ед/мес = 6 мес давления. assert _months_of_pressure(200, 100.0, 50.0) == pytest.approx(6.0) def test_zero_absorption_returns_none(self) -> None: # нет поглощения → давление неизмеримо (None, не ∞ и не 0). assert _months_of_pressure(200, 100.0, 0.0) is None def test_none_absorption_returns_none(self) -> None: assert _months_of_pressure(200, 100.0, None) is None def test_negative_absorption_returns_none(self) -> None: assert _months_of_pressure(200, 100.0, -5.0) is None def test_zero_queue_is_zero_not_none(self) -> None: # рынок измерим, очереди нет → 0.0 мес давления (валидно, НЕ None). assert _months_of_pressure(0, 0.0, 50.0) == 0.0 def test_none_layers_treated_as_zero(self) -> None: # отсутствие слоя ≠ нет данных о рынке: None-объёмы → 0 в сумме. assert _months_of_pressure(None, None, 50.0) == 0.0 assert _months_of_pressure(None, 100.0, 50.0) == pytest.approx(2.0) # ── pure: _saturating_index (нормировка [0,1]) ──────────────────────────────── class TestSaturatingIndex: def test_zero_pressure_zero_index(self) -> None: assert _saturating_index(0.0) == 0.0 def test_at_saturation_is_one(self) -> None: assert _saturating_index(_PRESSURE_SATURATION_MONTHS) == 1.0 def test_half_saturation_is_half(self) -> None: assert _saturating_index(_PRESSURE_SATURATION_MONTHS / 2.0) == pytest.approx(0.5) def test_beyond_saturation_clamps_to_one(self) -> None: # давление выше насыщения → clamp 1.0 (не >1). assert _saturating_index(_PRESSURE_SATURATION_MONTHS * 3.0) == 1.0 def test_negative_clamps_to_zero(self) -> None: # артефактно-отрицательное давление → clamp 0.0 (не negative). assert _saturating_index(-5.0) == 0.0 def test_none_passthrough(self) -> None: assert _saturating_index(None) is None def test_monotonic_non_decreasing(self) -> None: # ключевое свойство: больше месяцев давления → не меньший индекс, всё в [0,1]. prev = -1.0 for months in [0.0, 1.0, 3.0, 6.0, 9.0, 12.0, 18.0, 30.0, 100.0]: idx = _saturating_index(months) assert idx is not None assert 0.0 <= idx <= 1.0 assert idx >= prev prev = idx # ── pure: _monthly_absorption_units (вывод из market_metrics) ───────────────── class TestMonthlyAbsorptionUnits: def test_from_months_of_supply(self) -> None: # available 120, months_of_supply 6 → 20 ед/мес. assert _monthly_absorption_units(None, 6.0, 120) == pytest.approx(20.0) def test_prefers_months_of_supply_over_rate(self) -> None: # оба заданы → берём прямой путь available/mos (детерминированно). # available 120, mos 6 → 20 (а не absorption_rate*available). assert _monthly_absorption_units(0.5, 6.0, 120) == pytest.approx(20.0) def test_falls_back_to_absorption_rate(self) -> None: # mos нет → absorption_rate(0.1) * available(200) = 20 ед/мес. assert _monthly_absorption_units(0.1, None, 200) == pytest.approx(20.0) def test_no_available_returns_none(self) -> None: assert _monthly_absorption_units(0.1, 6.0, 0) is None assert _monthly_absorption_units(0.1, 6.0, None) is None def test_no_metrics_returns_none(self) -> None: # есть сток, но ни mos, ни rate → поглощение неизмеримо (None). assert _monthly_absorption_units(None, None, 120) is None def test_zero_metrics_returns_none(self) -> None: assert _monthly_absorption_units(0.0, 0.0, 120) is None # ── pure: _min_confidence ───────────────────────────────────────────────────── class TestMinConfidence: def test_all_high(self) -> None: assert _min_confidence(["high", "high"]) == "high" def test_low_drags_down(self) -> None: # тонкий L3 (low) тянет общий в low даже при high остальных. assert _min_confidence(["high", "medium", "low"]) == "low" def test_medium_floor(self) -> None: assert _min_confidence(["high", "medium"]) == "medium" def test_none_ignored(self) -> None: # None-компонент (нет сигнала) не учитывается — берём min из остальных. assert _min_confidence(["high", None, "medium"]) == "medium" def test_all_none_low(self) -> None: assert _min_confidence([None, None]) == "low" def test_empty_low(self) -> None: assert _min_confidence([]) == "low" def test_only_whitelisted(self) -> None: for vals in (["high"], ["medium"], ["low"], ["high", "low"], []): assert _min_confidence(vals) in _ALLOWED_CONFIDENCE # type: ignore[arg-type] # ── pure: _round_or_none ────────────────────────────────────────────────────── class TestRoundOrNone: def test_rounds(self) -> None: assert _round_or_none(1.23456, 3) == 1.235 def test_none_passthrough(self) -> None: assert _round_or_none(None, 3) is None # ── FutureSupplyPressure.as_dict / breakdown ────────────────────────────────── def _make_pressure(**over: object) -> FutureSupplyPressure: base: dict[str, object] = { "district": "Автовокзал", "horizon_months": 12, "premise_kind": "квартира", "confidence": "medium", "open_units": 300, "hidden_units": 200, "future_units_by_horizon": 100.0, "monthly_absorption_units": 50.0, "months_of_pressure": 6.0, "index": 0.3333, } base.update(over) return FutureSupplyPressure(**base) # type: ignore[arg-type] class TestAsDictBreakdown: def test_breakdown_has_all_components_rounded(self) -> None: d = _make_pressure().as_dict() assert d["index"] == 0.333 bd = d["breakdown"] assert bd["open_units"] == 300 assert bd["hidden_units"] == 200 assert bd["future_units_by_horizon"] == 100.0 assert bd["monthly_absorption_units"] == 50.0 assert bd["months_of_pressure"] == 6.0 assert bd["index"] == 0.333 def test_none_index_survives(self) -> None: d = _make_pressure(index=None, months_of_pressure=None).as_dict() assert d["index"] is None assert d["breakdown"]["months_of_pressure"] is None def test_confidence_whitelisted(self) -> None: assert _make_pressure().as_dict()["confidence"] in _ALLOWED_CONFIDENCE # ── MagicMock-сессия helpers (зеркало test_supply_layers) ───────────────────── def _mock_db(rows: list[dict]) -> MagicMock: """Сессия с одним execute → .mappings().all() == rows.""" db = MagicMock() result = MagicMock() result.mappings.return_value.all.return_value = rows db.execute.return_value = result return db def _executed_sql(db: MagicMock, call_index: int = 0) -> str: args, _kwargs = db.execute.call_args_list[call_index] return str(args[0]) def _executed_params(db: MagicMock, call_index: int = 0) -> dict: args, _kwargs = db.execute.call_args_list[call_index] return args[1] def _assert_no_double_colon_cast(sql: str) -> None: """psycopg v3: запрещён :name::type (SQLAlchemy его молча роняет).""" import re assert re.search(r":[a-z_]+::[a-z]", sql) is None, f"double-colon cast in SQL:\n{sql}" def _metrics_stub( *, absorption_rate: float | None = 0.1, months_of_supply: float | None = 6.0, n_available: int = 120, confidence: str = "high", ) -> MagicMock: """Заглушка результата compute_market_metrics (нужные поля для absorption).""" m = MagicMock() m.absorption_rate = absorption_rate m.months_of_supply = months_of_supply m.n_available = n_available m.confidence = confidence return m @contextmanager def _patched( *, metrics: MagicMock | None = None, resolve: object = _resolve_passthrough ) -> Iterator[tuple[MagicMock, MagicMock]]: """Патчит compute_market_metrics + resolve_objective_districts разом. `resolve`: либо callable (side_effect — резолвит вход в набор микро), либо значение (return_value). По умолчанию pass-through `[district]` — legacy фильтр-по-микро, под который написано большинство compute-тестов (district там — raw-микро «X»/«Автовокзал»). """ m = metrics if metrics is not None else _metrics_stub() with patch(_MARKET, return_value=m) as mm, patch(_RESOLVE) as rs: if callable(resolve): rs.side_effect = resolve else: rs.return_value = resolve yield mm, rs # ── compute_future_supply_pressure: SQL форма (читает VIEW) ──────────────────── class TestComputeReadsView: def test_reads_latest_view_not_base_table(self) -> None: # КРИТИЧНО (ТЗ §9.3 / м.125): читаем VIEW v_supply_layers_latest, которая # включает невыводимые L3 graddoc_stub(#956)/insider_manual. НЕ базовую # таблицу supply_layers напрямую и НЕ пересчёт compute_all_layers. db = _mock_db([]) with _patched(): compute_future_supply_pressure(db, district="Автовокзал") sql = _executed_sql(db).lower() assert "v_supply_layers_latest" in sql # НЕ читаем источники recompute (objective_lots/domrf_kn_objects) — это была бы # пересборка слоёв, теряющая stub/insider строки. assert "objective_lots" not in sql assert "domrf_kn_objects" not in sql def test_sql_uses_array_bind_not_double_colon(self) -> None: # psycopg v3: district-фильтр через ANY(CAST(:names AS text[])) — массивный bind, # НЕ :x::type (SQLAlchemy его молча роняет → syntax error). db = _mock_db([]) with _patched(): compute_future_supply_pressure(db, district="Автовокзал") sql = _executed_sql(db) assert "ANY(CAST(:names AS text[]))" in sql assert "CAST(:has_district AS boolean)" in sql _assert_no_double_colon_cast(sql) def test_district_not_a_bare_scalar_filter(self) -> None: # Regression guard: МИКС-вокабулярный view нельзя фильтровать скаляром по одному # имени (промахивается мимо L1 микро ИЛИ L2/3 админ строк → ложный дефицит, баг # парцеля 66:41:0702048:27 / Кировский). Фильтр обязан идти по НАБОРУ имён. db = _mock_db([]) with _patched(): compute_future_supply_pressure(db, district="Автовокзал") sql = _executed_sql(db) # ни один из старых скалярных вариантов фильтра по district не должен вернуться. assert "district_name = CAST(:district AS text)" not in sql assert "district_name = :district" not in sql def test_params_pass_district_to_market_metrics(self) -> None: # district (админ-имя) пробрасывается как есть в reused market_metrics — там свой # резолвер развернёт его в микро (единый район для absorption). db = _mock_db([]) with _patched() as (mm, _rs): compute_future_supply_pressure( db, district="Академический", horizon_months=24, premise_kind="квартира" ) assert mm.call_args.kwargs["district"] == "Академический" # ── compute_future_supply_pressure: resolver-wiring (admin→micro+admin) ──────── class TestComputeResolverWiring: def test_admin_resolves_to_micros_plus_admin(self) -> None: # Баг-репро: «Кировский» (АДМИН) → резолвер даёт микро [Втузгородок, ЖБИ]. Фильтр # :names обязан быть микро + сам админ (для L2/3 admin-named строк) и дедупнут. db = _mock_db([]) with _patched(resolve=lambda _db, _d: ["Втузгородок", "ЖБИ"]) as (_mm, rs): compute_future_supply_pressure(db, district="Кировский") params = _executed_params(db) assert params["names"] == ["Втузгородок", "ЖБИ", "Кировский"] assert params["has_district"] is True # резолвер вызван именно с входным district. assert rs.call_args.args[1] == "Кировский" def test_admin_micros_already_containing_admin_deduped(self) -> None: # Если резолвер вернул набор, уже содержащий сам админ, — не дублируем имя. db = _mock_db([]) with _patched(resolve=lambda _db, _d: ["Кировский", "Втузгородок"]): compute_future_supply_pressure(db, district="Кировский") assert _executed_params(db)["names"] == ["Кировский", "Втузгородок"] def test_raw_micro_input_filters_by_itself(self) -> None: # raw-микро вход (legacy внутренний вызов): резолвер вернул [micro] → names=[micro] # (сам админ == вход → дедуп оставляет одно имя). Legacy фильтр-по-микро сохранён. db = _mock_db([]) with _patched(resolve=lambda _db, _d: ["Втузгородок"]): compute_future_supply_pressure(db, district="Втузгородок") assert _executed_params(db)["names"] == ["Втузгородок"] assert _executed_params(db)["has_district"] is True def test_none_district_no_filter(self) -> None: # district None → резолвер None → has_district False, names=[] (EKB-wide, без # district-фильтра). Зеркало market_metrics EKB-wide ветки. db = _mock_db([]) with _patched(resolve=lambda _db, _d: None): compute_future_supply_pressure(db, district=None) params = _executed_params(db) assert params["has_district"] is False assert params["names"] == [] def test_admin_without_aliases_ekb_wide(self) -> None: # Админ-имя, но резолвер вернул None (дыра в словаре) → EKB-wide fallback (НЕ # пустой фильтр-«0 строк гарантированно»). has_district False. db = _mock_db([]) with _patched(resolve=lambda _db, _d: None): compute_future_supply_pressure(db, district="Кировский") params = _executed_params(db) assert params["has_district"] is False assert params["names"] == [] # ── compute_future_supply_pressure: агрегация + композит ─────────────────────── class TestComputeAggregation: def test_aggregates_layers_and_weights_future(self) -> None: # L1=300 open, L2=200 hidden, L3 две строки: 100 в горизонте (вес 1.0) + # 80 за горизонтом (вес 0.0) + 40 с NULL-датой (вес 0.5) = 100+0+20 = 120. rows = [ {"layer": 1, "units_estimate": 300, "expected_online_date": None, "confidence": "high"}, { "layer": 2, "units_estimate": 200, "expected_online_date": None, "confidence": "medium", }, { "layer": 3, "units_estimate": 100, "expected_online_date": date(date.today().year + 1, 1, 1), "confidence": "low", }, { "layer": 3, "units_estimate": 80, "expected_online_date": date(date.today().year + 5, 1, 1), "confidence": "low", }, {"layer": 3, "units_estimate": 40, "expected_online_date": None, "confidence": "low"}, ] db = _mock_db(rows) # absorption: available 120, mos 6 → 20 ед/мес. with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)): res = compute_future_supply_pressure(db, district="X", horizon_months=12) assert res.open_units == 300 assert res.hidden_units == 200 # 100*1.0 + 80*0.0 + 40*0.5 = 120.0 assert res.future_units_by_horizon == pytest.approx(120.0) assert res.monthly_absorption_units == pytest.approx(20.0) # (200 + 120) / 20 = 16 мес давления. assert res.months_of_pressure == pytest.approx(16.0) # index = 16 / 18 saturation. assert res.index == pytest.approx(16.0 / _PRESSURE_SATURATION_MONTHS) assert 0.0 <= res.index <= 1.0 # confidence = MIN(high L1, medium L2, low L3×3, high market) = low. assert res.confidence == "low" def test_open_units_excluded_from_pressure(self) -> None: # Только hidden+future формируют давление; open (L1) — лишь контекст. rows = [ { "layer": 1, "units_estimate": 9999, "expected_online_date": None, "confidence": "high", }, {"layer": 2, "units_estimate": 100, "expected_online_date": None, "confidence": "high"}, ] db = _mock_db(rows) with _patched(metrics=_metrics_stub(months_of_supply=5.0, n_available=100)): res = compute_future_supply_pressure(db, district="X") # absorption 100/5=20; давление только от hidden 100 → 100/20=5 мес. assert res.monthly_absorption_units == pytest.approx(20.0) assert res.months_of_pressure == pytest.approx(5.0) assert res.open_units == 9999 # учтён в контексте, но не в давлении def test_confidence_is_min_of_components(self) -> None: # все supply high, но market_metrics low → итог low. rows = [ {"layer": 2, "units_estimate": 50, "expected_online_date": None, "confidence": "high"}, ] db = _mock_db(rows) with _patched(metrics=_metrics_stub(confidence="low")): res = compute_future_supply_pressure(db, district="X") assert res.confidence == "low" def test_all_high_yields_high(self) -> None: rows = [ {"layer": 2, "units_estimate": 50, "expected_online_date": None, "confidence": "high"}, ] db = _mock_db(rows) with _patched(metrics=_metrics_stub(confidence="high")): res = compute_future_supply_pressure(db, district="X") assert res.confidence == "high" assert res.confidence in _ALLOWED_CONFIDENCE # ── compute_future_supply_pressure: graceful (empty / thin / div-by-zero) ────── class TestComputeGraceful: def test_empty_supply_and_no_absorption_none_low(self) -> None: # Пустой склад (worker ещё не наполнил м.125 на prod) + нет поглощения → # index None, months_of_pressure None, confidence low. НЕ crash. db = _mock_db([]) with _patched( metrics=_metrics_stub( absorption_rate=None, months_of_supply=None, n_available=0, confidence="low" ) ): res = compute_future_supply_pressure(db, district="Пусто") assert res.index is None assert res.months_of_pressure is None assert res.monthly_absorption_units is None assert res.confidence == "low" assert res.open_units == 0 assert res.hidden_units == 0 assert res.future_units_by_horizon == 0.0 def test_empty_supply_but_market_measurable_zero_pressure(self) -> None: # Склад пуст, НО рынок измерим → очереди нет → давление 0.0, index 0.0 # (валидное «нет будущего давления», НЕ None). db = _mock_db([]) with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)): res = compute_future_supply_pressure(db, district="X") assert res.months_of_pressure == 0.0 assert res.index == 0.0 def test_supply_present_but_no_absorption_index_none(self) -> None: # Есть hidden/future, но рынок неизмерим (нет поглощения) → div-by-zero # guarded → months_of_pressure None → index None (НЕ crash, НЕ ∞). rows = [ { "layer": 2, "units_estimate": 500, "expected_online_date": None, "confidence": "medium", }, ] db = _mock_db(rows) with _patched( metrics=_metrics_stub( absorption_rate=None, months_of_supply=None, n_available=0, confidence="medium" ) ): res = compute_future_supply_pressure(db, district="X") assert res.hidden_units == 500 assert res.monthly_absorption_units is None assert res.months_of_pressure is None assert res.index is None # confidence всё равно whitelisted (MIN medium supply + medium market). assert res.confidence in _ALLOWED_CONFIDENCE def test_view_query_exception_graceful(self) -> None: # Сбой чтения view → [] (graceful), index по absorption (нет очереди → 0.0). # Резолвер мокаем отдельно (иначе RuntimeError-side_effect db.execute пробил бы # и резолвер) — изолируем именно сбой view-запроса. db = MagicMock() db.execute.side_effect = RuntimeError("view gone") with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)): res = compute_future_supply_pressure(db, district="Сбой") assert res.hidden_units == 0 assert res.future_units_by_horizon == 0.0 assert res.months_of_pressure == 0.0 assert res.index == 0.0 def test_null_units_estimate_treated_as_zero(self) -> None: # L3-строка только с датой (units_estimate None — частый случай supply_layers) → # 0 в объёме, но строка не валит расчёт. rows = [ { "layer": 3, "units_estimate": None, "expected_online_date": date(date.today().year + 1, 1, 1), "confidence": "low", }, ] db = _mock_db(rows) with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)): res = compute_future_supply_pressure(db, district="X") assert res.future_units_by_horizon == 0.0 assert res.months_of_pressure == 0.0 def test_returns_dataclass_always(self) -> None: db = _mock_db([]) with _patched(resolve=lambda _db, _d: None): res = compute_future_supply_pressure(db, district=None) assert isinstance(res, FutureSupplyPressure) assert res.district is None # ── SAVEPOINT regression (#2464 cluster A) ───────────────────────────────────── # # db shared с caller (compute_future_supply_pressure в цепочке §22-форсайта / # analyze). Раньше _query_supply_rows ловил db.execute-сбой в bare # ``except Exception`` без SAVEPOINT/rollback — на реальном Postgres это отравляет # db shared с caller: следующий db.execute на этой же сессии (напр. # compute_market_metrics, вызываемый следом в compute_future_supply_pressure) # падает "current transaction is aborted". Фикс — db.execute обёрнут в # ``with db.begin_nested():``. MagicMock не эмулирует реальный aborted-transaction # Postgres — тесты проверяют (1) graceful [] fallback при сбое, (2) что # begin_nested() реально вызван, (3) что db остаётся usable для следующего вызова # на той же mock-сессии. class TestQuerySupplyRowsSavepoint: def test_uses_begin_nested_savepoint(self) -> None: db = MagicMock() db.execute.return_value.mappings.return_value.all.return_value = [] _query_supply_rows(db, {"district": "X", "names": ["X"]}) assert db.begin_nested.call_count == 1 def test_query_failure_degrades_to_empty_list(self) -> None: db = MagicMock() db.execute.side_effect = RuntimeError("simulated DB failure") assert _query_supply_rows(db, {"district": "X", "names": ["X"]}) == [] def test_session_usable_after_failure(self) -> None: """db.execute сбоит один раз (supply-layers view), но db остаётся usable для следующего вызова на этой же сессии (имитирует _query_supply_rows → compute_market_metrics в compute_future_supply_pressure).""" db = MagicMock() db.execute.side_effect = RuntimeError("boom") assert _query_supply_rows(db, {"district": "X", "names": ["X"]}) == [] db.execute.side_effect = None next_result = MagicMock() next_result.mappings.return_value.all.return_value = [{"marker": "ok"}] db.execute.return_value = next_result assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}] class TestComputeFutureSupplyPressureSessionUsableAfterFailure: """End-to-end: view-запрос сбоит → SAVEPOINT (не bare rollback) → reused compute_market_metrics (отдельный db.execute на той же сессии) не задет.""" def test_view_failure_does_not_poison_market_metrics_call(self) -> None: db = MagicMock() db.execute.side_effect = RuntimeError("view gone") with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)) as (mm, _rs): res = compute_future_supply_pressure(db, district="Сбой") assert db.begin_nested.call_count >= 1 # compute_market_metrics (reused) вызван несмотря на сбой view-запроса — # сессия не отравлена (в реальном Postgres это была бы "aborted transaction"). assert mm.called assert res.hidden_units == 0 assert res.index == 0.0