gendesign/backend/tests/services/forecasting/test_orchestrator.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

471 lines
25 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-тесты §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")