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).
471 lines
25 KiB
Python
471 lines
25 KiB
Python
"""Unit-тесты §9.x → §22 оркестратора (`build_site_finder_report`, step 3a, 955-A2).
|
||
|
||
Оркестратор — ШОВ: считает §9.x-слои и раскладывает их в ЧИСТЫЙ §22-сборщик
|
||
(`assemble_report`). Тесты пинят ТРИ контракта шва, БЕЗ живой БД (db = sentinel,
|
||
§9.x-вызовы запатчены):
|
||
|
||
• МОДАЛЬНЫЙ дефолт класса (audit Q1): competitors [Комфорт, Комфорт, Бизнес] →
|
||
obj_class="Комфорт"; пустые competitors → None (ЕКБ-wide). Явный obj_class имеет
|
||
приоритет над выводом.
|
||
• ПРОВОДКА/СИГНАТУРЫ (ловушки audit): патчим §9.x на sentinel'ы, ассертим что
|
||
`assemble_report` получил КАЖДЫЙ вход в правильном слоте, а §9.x вызваны с
|
||
ПРАВИЛЬНОЙ ГЕТЕРОГЕННОЙ сигнатурой (forecasts/scenarios/special_indices ← `horizons=`;
|
||
score_card/future_supply ← `horizon_months=`; overlay ← `target_class=`). Регрессия
|
||
(не тот kwarg) → тест падает.
|
||
• GRACEFUL: один §9.x-слой бросает → отчёт всё равно собирается (его слот None),
|
||
исключение НЕ всплывает.
|
||
|
||
Детерминированно, без сети, без LLM. DATABASE_URL до импорта app-модулей (зеркало
|
||
соседних тестов — на случай side-effect'ов импорта пакета forecasting).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import os
|
||
from collections.abc import Iterator, Mapping
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services.forecasting.orchestrator import (
|
||
_modal_obj_class,
|
||
_normalize_district,
|
||
_primary_horizon,
|
||
_summarize_supply_layers,
|
||
build_site_finder_report,
|
||
)
|
||
from app.services.forecasting.report import SiteFinderReport
|
||
|
||
# Полностью-квалифицированный путь к каждому §9.x-символу как он ИМПОРТИРОВАН в
|
||
# orchestrator (патчим там, где используется, не там, где определён).
|
||
_MOD = "app.services.forecasting.orchestrator"
|
||
|
||
|
||
def _analyze(competitors: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||
"""Минимальный analyze-dict (только то, что читает оркестратор/сборщик)."""
|
||
return {
|
||
"cad_num": "66:41:0702048:27",
|
||
"district": {"district_name": "Ленинский"},
|
||
"competitors": competitors if competitors is not None else [],
|
||
}
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Модальный дефолт класса (audit Q1).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestModalObjClass:
|
||
def test_modal_picks_most_common(self) -> None:
|
||
comps = [{"obj_class": "Комфорт"}, {"obj_class": "Комфорт"}, {"obj_class": "Бизнес"}]
|
||
assert _modal_obj_class(comps) == "Комфорт"
|
||
|
||
def test_empty_competitors_none(self) -> None:
|
||
assert _modal_obj_class([]) is None
|
||
|
||
def test_competitors_without_class_none(self) -> None:
|
||
# есть конкуренты, но ни у одного нет класса → None (честно «не уточнён»).
|
||
assert _modal_obj_class([{"obj_id": 1}, {"comm_name": "ЖК Альфа"}]) is None
|
||
|
||
def test_legacy_class_key_supported(self) -> None:
|
||
# legacy-ключ `class` (а не `obj_class`) тоже читается.
|
||
assert _modal_obj_class([{"class": "Стандарт"}, {"class": "Стандарт"}]) == "Стандарт"
|
||
|
||
def test_obj_class_preferred_over_legacy(self) -> None:
|
||
comps = [{"obj_class": "Бизнес", "class": "Комфорт"}]
|
||
assert _modal_obj_class(comps) == "Бизнес"
|
||
|
||
def test_non_list_none(self) -> None:
|
||
assert _modal_obj_class(None) is None
|
||
assert _modal_obj_class("Комфорт") is None # type: ignore[arg-type]
|
||
|
||
|
||
class TestPrimaryHorizon:
|
||
def test_prefers_12_when_present(self) -> None:
|
||
assert _primary_horizon([6, 12, 18, 24]) == 12
|
||
|
||
def test_falls_back_to_first_when_no_12(self) -> None:
|
||
assert _primary_horizon([6, 18, 24]) == 6
|
||
|
||
def test_empty_defaults_to_12(self) -> None:
|
||
assert _primary_horizon([]) == 12
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Хелпер сводки supply-слоёв (#950 → слот supply_layers сборщика).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestSummarizeSupplyLayers:
|
||
def test_none_or_empty_none(self) -> None:
|
||
assert _summarize_supply_layers(None) is None
|
||
assert _summarize_supply_layers([]) is None
|
||
|
||
def test_aggregates_units_by_layer(self) -> None:
|
||
rows = [
|
||
MagicMock(layer=1, units_estimate=1200, as_dict=lambda: {"layer": 1}),
|
||
MagicMock(layer=2, units_estimate=800, as_dict=lambda: {"layer": 2}),
|
||
MagicMock(layer=3, units_estimate=500, as_dict=lambda: {"layer": 3}),
|
||
MagicMock(layer=1, units_estimate=None, as_dict=lambda: {"layer": 1}), # None skip
|
||
]
|
||
summary = _summarize_supply_layers(rows)
|
||
assert summary is not None
|
||
assert summary["open_units"] == 1200
|
||
assert summary["hidden_units"] == 800
|
||
assert summary["future_units"] == 500
|
||
assert summary["n_rows"] == 4
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# ПРОВОДКА + СИГНАТУРНЫЕ ловушки (audit): §9.x → sentinel, ассерт слот+kwarg.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _patched_layers() -> dict[str, Any]:
|
||
"""Контекст-менеджеры-патчи всех §9.x на распознаваемые sentinel-возвраты."""
|
||
# supply_layers возвращает СПИСОК строк (оркестратор свернёт в сводку-dict).
|
||
supply_row = MagicMock(layer=1, units_estimate=10, as_dict=lambda: {"layer": 1})
|
||
return {
|
||
"compute_market_metrics": MagicMock(return_value="MM"),
|
||
"compute_all_layers": MagicMock(return_value=[supply_row]),
|
||
"compute_future_supply_pressure": MagicMock(return_value="FSP"),
|
||
"compute_demand_supply_forecast": MagicMock(return_value=["F6", "F12"]),
|
||
# #1871 P1: compute_scenarios теперь возвращает кортеж
|
||
# `(scenarios, collapsed, collapse_reason)`. Sentinel: scenarios=["SC"], не схлопнуто.
|
||
"compute_scenarios": MagicMock(return_value=(["SC"], False, None)),
|
||
"compute_score_card": MagicMock(return_value="PSC"),
|
||
"compute_special_indices": MagicMock(return_value="SI"),
|
||
"build_forecast_overlay": MagicMock(return_value="OVL"),
|
||
"get_monthly_macro": MagicMock(return_value=[]),
|
||
}
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def _patch_layers(mocks: Mapping[str, Any], assemble: Any) -> Iterator[None]:
|
||
"""Запатчить ВСЕ §9.x-символы + assemble_report (по `mocks`-словарю). Компактно.
|
||
|
||
Патчим там, где символы ИМПОРТИРОВАНЫ в orchestrator (`_MOD`), не там, где определены.
|
||
"""
|
||
with contextlib.ExitStack() as stack:
|
||
for name, mock in mocks.items():
|
||
stack.enter_context(patch(f"{_MOD}.{name}", mock))
|
||
stack.enter_context(patch(f"{_MOD}.assemble_report", side_effect=assemble))
|
||
yield
|
||
|
||
|
||
class TestWiringAndSignatures:
|
||
"""Патчим §9.x на sentinel'ы + перехватываем assemble_report → ассертим слоты+kwargs."""
|
||
|
||
def _run(
|
||
self, *, obj_class: str | None = None, competitors: list[dict[str, Any]] | None = None
|
||
) -> tuple[SiteFinderReport, dict[str, Any], dict[str, Any]]:
|
||
mocks = _patched_layers()
|
||
captured: dict[str, Any] = {}
|
||
|
||
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
|
||
captured["analyze"] = analyze
|
||
captured.update(kwargs)
|
||
return SiteFinderReport()
|
||
|
||
with _patch_layers(mocks, _fake_assemble):
|
||
report = build_site_finder_report(
|
||
MagicMock(name="db"),
|
||
analyze=_analyze(competitors),
|
||
cad_num="66:41:0702048:27",
|
||
district="Ленинский",
|
||
obj_class=obj_class,
|
||
horizons=[6, 12, 18],
|
||
)
|
||
return report, captured, mocks
|
||
|
||
def test_each_layer_lands_in_correct_slot(self) -> None:
|
||
_report, captured, _mocks = self._run(obj_class="Комфорт")
|
||
# assemble_report вызван (captured заполнен) — каждый sentinel в СВОЁМ слоте.
|
||
assert captured["market_metrics"] == "MM"
|
||
assert captured["future_supply"] == "FSP"
|
||
assert captured["forecasts"] == ["F6", "F12"]
|
||
assert captured["scenarios"] == ["SC"]
|
||
assert captured["product_scores"] == "PSC"
|
||
assert captured["special_indices"] == "SI"
|
||
# overlay идёт именно в recommendation_overlay (НЕ product_tz/product_scores).
|
||
assert captured["recommendation_overlay"] == "OVL"
|
||
# supply_layers свёрнут в сводку-dict (не сырой список строк).
|
||
assert isinstance(captured["supply_layers"], dict)
|
||
assert captured["supply_layers"]["open_units"] == 10
|
||
# meta-проводка.
|
||
assert captured["cad_num"] == "66:41:0702048:27"
|
||
assert captured["district"] == "Ленинский"
|
||
assert captured["horizons"] == [6, 12, 18]
|
||
assert captured["advisory"] is True
|
||
assert captured["segment"] == {
|
||
"obj_class": "Комфорт",
|
||
"room_bucket": None,
|
||
"district": "Ленинский",
|
||
"price_bucket": None,
|
||
}
|
||
|
||
def test_horizons_services_get_horizons_kwarg(self) -> None:
|
||
"""forecasts/scenarios/special_indices ДОЛЖНЫ получить `horizons=` (Sequence)."""
|
||
_report, _captured, mocks = self._run(obj_class="Комфорт")
|
||
horizons_services = (
|
||
"compute_demand_supply_forecast",
|
||
"compute_scenarios",
|
||
"compute_special_indices",
|
||
)
|
||
for name in horizons_services:
|
||
_args, kwargs = mocks[name].call_args
|
||
assert kwargs.get("horizons") == [6, 12, 18], f"{name} must take horizons="
|
||
assert "horizon_months" not in kwargs, f"{name} must NOT take horizon_months"
|
||
assert kwargs.get("cad_num") == "66:41:0702048:27"
|
||
assert kwargs.get("district") == "Ленинский"
|
||
# spec прокинут как SegmentSpec.
|
||
assert kwargs["spec"].obj_class == "Комфорт"
|
||
|
||
def test_horizon_months_services_get_int_kwarg(self) -> None:
|
||
"""score_card/future_supply ДОЛЖНЫ получить `horizon_months=` (int=primary=12)."""
|
||
_report, _captured, mocks = self._run(obj_class="Комфорт")
|
||
_args, sc_kwargs = mocks["compute_score_card"].call_args
|
||
assert sc_kwargs.get("horizon_months") == 12 # 12 в сетке [6,12,18] → primary
|
||
assert "horizons" not in sc_kwargs
|
||
assert sc_kwargs["spec"].obj_class == "Комфорт"
|
||
_args, fsp_kwargs = mocks["compute_future_supply_pressure"].call_args
|
||
assert fsp_kwargs.get("horizon_months") == 12
|
||
assert "horizons" not in fsp_kwargs
|
||
|
||
def test_overlay_gets_target_class_not_spec(self) -> None:
|
||
"""overlay ДОЛЖЕН получить `target_class=` (str) + `horizon_months=`, НЕ spec."""
|
||
_report, _captured, mocks = self._run(obj_class="Бизнес")
|
||
_args, kwargs = mocks["build_forecast_overlay"].call_args
|
||
assert kwargs.get("target_class") == "Бизнес"
|
||
assert kwargs.get("horizon_months") == 12
|
||
assert "spec" not in kwargs
|
||
assert "horizons" not in kwargs
|
||
|
||
def test_market_metrics_and_supply_use_premise_kind(self) -> None:
|
||
"""§9.2/§9.3 ходят по premise_kind, без spec/horizons."""
|
||
_report, _captured, mocks = self._run(obj_class="Комфорт")
|
||
_args, mm_kwargs = mocks["compute_market_metrics"].call_args
|
||
assert mm_kwargs.get("premise_kind") == "квартира"
|
||
assert mm_kwargs.get("district") == "Ленинский"
|
||
_args, al_kwargs = mocks["compute_all_layers"].call_args
|
||
assert al_kwargs.get("premise_kind") == "квартира"
|
||
|
||
def test_modal_class_default_flows_into_spec_and_overlay(self) -> None:
|
||
"""obj_class=None + конкуренты [Комфорт×2, Бизнес] → модальный 'Комфорт' всюду."""
|
||
comps = [{"obj_class": "Комфорт"}, {"obj_class": "Комфорт"}, {"obj_class": "Бизнес"}]
|
||
_report, captured, mocks = self._run(obj_class=None, competitors=comps)
|
||
assert captured["segment"]["obj_class"] == "Комфорт"
|
||
_args, ovl_kwargs = mocks["build_forecast_overlay"].call_args
|
||
assert ovl_kwargs.get("target_class") == "Комфорт"
|
||
_args, f_kwargs = mocks["compute_demand_supply_forecast"].call_args
|
||
assert f_kwargs["spec"].obj_class == "Комфорт"
|
||
|
||
def test_empty_competitors_default_class_none(self) -> None:
|
||
"""obj_class=None + пустые конкуренты → None (ЕКБ-wide all-class)."""
|
||
_report, captured, mocks = self._run(obj_class=None, competitors=[])
|
||
assert captured["segment"]["obj_class"] is None
|
||
_args, ovl_kwargs = mocks["build_forecast_overlay"].call_args
|
||
assert ovl_kwargs.get("target_class") is None
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# GRACEFUL: один §9.x-слой бросает → отчёт всё равно собирается, слот None.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestGracefulDegradation:
|
||
def test_one_layer_raises_report_still_assembles(self) -> None:
|
||
mocks = _patched_layers()
|
||
# forecasts-слой бросает — отчёт ДОЛЖЕН собраться с forecasts=None.
|
||
mocks["compute_demand_supply_forecast"] = MagicMock(side_effect=RuntimeError("boom"))
|
||
captured: dict[str, Any] = {}
|
||
|
||
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
|
||
captured.update(kwargs)
|
||
return SiteFinderReport()
|
||
|
||
with _patch_layers(mocks, _fake_assemble):
|
||
# НЕ должно бросить, несмотря на падение слоя.
|
||
report = build_site_finder_report(
|
||
MagicMock(name="db"),
|
||
analyze=_analyze([{"obj_class": "Комфорт"}]),
|
||
cad_num="66:41:0702048:27",
|
||
district="Ленинский",
|
||
horizons=[6, 12, 18],
|
||
)
|
||
assert isinstance(report, SiteFinderReport)
|
||
# упавший слой → None в слоте; остальные сохранены.
|
||
assert captured["forecasts"] is None
|
||
assert captured["market_metrics"] == "MM"
|
||
assert captured["scenarios"] == ["SC"]
|
||
|
||
def test_all_layers_raise_report_still_assembles(self) -> None:
|
||
"""Даже если ВСЕ §9.x-слои бросают — отчёт собирается (все слоты None)."""
|
||
boom = MagicMock(side_effect=RuntimeError("boom"))
|
||
# Каждый §9.x-символ → бросающий mock (имена зеркалят _patched_layers).
|
||
mocks = {name: boom for name in _patched_layers()}
|
||
captured: dict[str, Any] = {}
|
||
|
||
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
|
||
captured.update(kwargs)
|
||
return SiteFinderReport()
|
||
|
||
with _patch_layers(mocks, _fake_assemble):
|
||
report = build_site_finder_report(
|
||
MagicMock(name="db"),
|
||
analyze=_analyze([{"obj_class": "Комфорт"}]),
|
||
cad_num="66:41:0702048:27",
|
||
district="Ленинский",
|
||
)
|
||
assert isinstance(report, SiteFinderReport)
|
||
for slot in (
|
||
"market_metrics",
|
||
"supply_layers",
|
||
"future_supply",
|
||
"forecasts",
|
||
"scenarios",
|
||
"product_scores",
|
||
"special_indices",
|
||
"recommendation_overlay",
|
||
):
|
||
assert captured[slot] is None, f"{slot} should degrade to None"
|
||
# сегмент всё равно проброшен (он PURE, не от §9.x).
|
||
assert captured["segment"]["obj_class"] == "Комфорт"
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Defensive district normalization (#1130 follow-up — silent-degrade guard).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestNormalizeDistrict:
|
||
def test_str_passes_through(self) -> None:
|
||
assert _normalize_district("Ленинский") == "Ленинский"
|
||
|
||
def test_none_passes_through(self) -> None:
|
||
assert _normalize_district(None) is None
|
||
|
||
def test_dict_with_district_name_extracted(self) -> None:
|
||
d = {
|
||
"district_name": "Верх-Исетский",
|
||
"dist_to_center": 0.0,
|
||
"median_price_per_m2": 139908.25,
|
||
}
|
||
assert _normalize_district(d) == "Верх-Исетский"
|
||
|
||
def test_dict_without_district_name_returns_none(self) -> None:
|
||
# Защитный fallback: dict без ожидаемого поля → None + warning.
|
||
# Не падаем (silent-degrade лучше чем TypeError в forecast_cache.wrapper).
|
||
assert _normalize_district({"dist_to_center": 0.0}) is None
|
||
|
||
def test_dict_with_empty_district_name_returns_none(self) -> None:
|
||
assert _normalize_district({"district_name": ""}) is None
|
||
|
||
def test_dict_with_non_string_district_name_returns_none(self) -> None:
|
||
# «district_name»: 42 — не строка, защищаемся.
|
||
assert _normalize_district({"district_name": 42}) is None
|
||
|
||
def test_unsupported_type_returns_none(self) -> None:
|
||
# list / int / прочие неподдерживаемые типы → None + warning.
|
||
assert _normalize_district([1, 2, 3]) is None
|
||
assert _normalize_district(42) is None
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# SAVEPOINT (#2464 cluster A finding #1, orchestrator.py `_safe_call`): один сбойный
|
||
# §9.x-слой НЕ должен «отравить» общую §22-сессию для ОСТАЛЬНЫХ слоёв. Без
|
||
# `db.begin_nested()` сбойный `db.execute` внутри слоя оставляет транзакцию Postgres
|
||
# aborted («current transaction is aborted, commands ignored until end of
|
||
# transaction block») — каждый ПОСЛЕДУЮЩИЙ слой на той же сессии тоже падал бы.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class _FakeResult:
|
||
"""Мини-результат db.execute() — покрывает `.mappings().first()`/`.all()`."""
|
||
|
||
def mappings(self) -> _FakeResult:
|
||
return self
|
||
|
||
def first(self) -> None:
|
||
return None
|
||
|
||
def all(self) -> list[Any]:
|
||
return []
|
||
|
||
|
||
class _PoisonableSession:
|
||
"""Fake Session, симулирующая Postgres 'aborted transaction' семантику.
|
||
|
||
`execute()` бросает РОВНО один раз (на первый вызов) и, как реальный Postgres,
|
||
«отравляет» сессию: БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()` (даже
|
||
совершенно несвязанный) тоже бросает — `current transaction is aborted`.
|
||
`begin_nested()` зеркалит `Session.begin_nested()`: при исключении откатывает
|
||
ТОЛЬКО SAVEPOINT (сбрасывает poison-флаг) и пробрасывает исключение дальше — как
|
||
настоящий `ROLLBACK TO SAVEPOINT`, НЕ общий `db.rollback()` (который откатил бы
|
||
всю внешнюю транзакцию — established anti-pattern, `backend.md`).
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self.poisoned = False
|
||
self._raised = False
|
||
self.calls = 0
|
||
|
||
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 not self._raised:
|
||
self._raised = True
|
||
self.poisoned = True
|
||
raise RuntimeError("simulated §9.x layer query failure")
|
||
return _FakeResult()
|
||
|
||
@contextlib.contextmanager
|
||
def begin_nested(self) -> Iterator[None]:
|
||
try:
|
||
yield
|
||
except Exception:
|
||
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
|
||
# сессия остаётся рабочей для следующего слоя.
|
||
self.poisoned = False
|
||
raise
|
||
|
||
|
||
class TestSafeCallSavepointDoesNotPoisonSession:
|
||
"""`_safe_call` оборачивает вызов слоя в `db.begin_nested()` (SAVEPOINT)."""
|
||
|
||
def test_layer_db_failure_leaves_session_usable_for_later_layers(self) -> None:
|
||
db = _PoisonableSession()
|
||
mocks = _patched_layers()
|
||
# market_metrics — единственный слой в этом тесте, реально трогающий `db`:
|
||
# его мок сам вызывает db.execute (через _PoisonableSession бросает один раз).
|
||
# Остальные слои остаются sentinel-моками из _patched_layers() (db не трогают).
|
||
mocks["compute_market_metrics"] = MagicMock(
|
||
side_effect=lambda *a, **kw: db.execute("SELECT 1")
|
||
)
|
||
captured: dict[str, Any] = {}
|
||
|
||
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
|
||
captured.update(kwargs)
|
||
return SiteFinderReport()
|
||
|
||
with _patch_layers(mocks, _fake_assemble):
|
||
report = build_site_finder_report(
|
||
db,
|
||
analyze=_analyze([{"obj_class": "Комфорт"}]),
|
||
cad_num="66:41:0702048:27",
|
||
district="Ленинский",
|
||
horizons=[6, 12, 18],
|
||
)
|
||
assert isinstance(report, SiteFinderReport)
|
||
# упавший слой → None в слоте (fallback), остальные слои — sentinel'ы, целы.
|
||
assert captured["market_metrics"] is None
|
||
assert captured["scenarios"] == ["SC"]
|
||
assert captured["forecasts"] == ["F6", "F12"]
|
||
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
|
||
# сбойного слоя проходит без 'current transaction aborted' (не poisoned).
|
||
db.execute("SELECT 1")
|