gendesign/backend/tests/services/forecasting/test_macro_series.py
bot-backend 3a13bce468
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
fix(forecasting): savepoint db.execute failures to stop §22 session poisoning (#2464)
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).
2026-07-07 17:50:17 +05:00

592 lines
28 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-тесты monthly макро-ряда + классификатора режима ставки (#951b, ТЗ §9.5/§9.6).
Чистые тесты (без живой БД):
• classify_regime — синтетический путь down→stable→up, пороги eps, None-дыры.
• macro_at_lag — точный месяц, отсутствующий месяц → None, лаг за пределами → None.
• is_confounded_window — пересекает / не пересекает confounder-даты.
• MonthlyMacro.as_dict — округление + None survive.
• get_monthly_macro через MagicMock-сессию: форма SQL (DISTINCT ON ресэмпл,
CAST(:x AS type) не :x::type), carry-forward, graceful empty → [].
psycopg v3 правило проверяется явно: bind-параметры — CAST(:x AS type).
"""
from __future__ import annotations
import contextlib
import datetime as dt
import os
from collections.abc import Iterator
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.macro_series import (
_CONFOUNDER_DATES,
_MORTGAGE_FIELDS,
_REGIME_EPS_PP,
MonthlyMacro,
_carry_forward,
_month_grid,
_query_inflation_monthly,
_query_key_rate_monthly,
_query_mortgage_monthly,
_shift_months,
classify_regime,
get_monthly_macro,
is_confounded_window,
macro_at_lag,
)
_GET_SERIES = "app.services.forecasting.macro_series.get_macro_series"
def _macro(month: dt.date, **kw: float | None) -> MonthlyMacro:
"""Хелпер: MonthlyMacro с None-полями по умолчанию (переопределяем нужные)."""
defaults: dict[str, float | None] = {
"key_rate": None,
"mortgage_rate_weighted": None,
"mortgage_issued_count": None,
"mortgage_issued_volume": None,
"mortgage_debt": None,
"mortgage_overdue": None,
}
defaults.update(kw)
return MonthlyMacro(month=month, **defaults)
# ── pure: _carry_forward (LOCF) ───────────────────────────────────────────────
class TestCarryForward:
def test_fills_gaps_with_last_known(self) -> None:
assert _carry_forward([1.0, None, None, 2.0, None]) == [1.0, 1.0, 1.0, 2.0, 2.0]
def test_leading_none_stays_none(self) -> None:
# До первой известной точки carry невозможен.
assert _carry_forward([None, None, 5.0, None]) == [None, None, 5.0, 5.0]
def test_all_none(self) -> None:
assert _carry_forward([None, None]) == [None, None]
def test_empty(self) -> None:
assert _carry_forward([]) == []
def test_does_not_mutate_input(self) -> None:
src: list[float | None] = [1.0, None]
_carry_forward(src)
assert src == [1.0, None]
# ── pure: classify_regime ─────────────────────────────────────────────────────
class TestClassifyRegime:
def test_down_stable_up_path(self) -> None:
# Синтетический путь: смягчение → плато → ужесточение.
# 9.0 → 8.5 (down) → 8.5 (stable) → 8.5 (stable) → 9.0 (up) → 9.5 (up)
rates: list[float | None] = [9.0, 8.5, 8.5, 8.5, 9.0, 9.5]
out = classify_regime(rates)
assert out == ["stable", "down", "stable", "stable", "up", "up"]
def test_first_month_stable(self) -> None:
# i=0 нет базы сравнения → 'stable' (нейтрально, не выдумываем тренд).
assert classify_regime([10.0])[0] == "stable"
def test_eps_threshold_exact_down(self) -> None:
# Δ ровно -eps → 'down' (граница включительно: delta <= -eps).
assert classify_regime([10.0, 10.0 - _REGIME_EPS_PP]) == ["stable", "down"]
def test_eps_threshold_exact_up(self) -> None:
# Δ ровно +eps → 'up' (delta >= +eps).
assert classify_regime([10.0, 10.0 + _REGIME_EPS_PP]) == ["stable", "up"]
def test_just_below_eps_is_stable(self) -> None:
# |Δ| < eps → шум → 'stable'.
below = _REGIME_EPS_PP - 0.01
assert classify_regime([10.0, 10.0 + below]) == ["stable", "stable"]
assert classify_regime([10.0, 10.0 - below]) == ["stable", "stable"]
def test_custom_eps(self) -> None:
# При eps=1.0 шаг 0.5 п.п. больше не тренд.
assert classify_regime([10.0, 10.5], eps_pp=1.0) == ["stable", "stable"]
assert classify_regime([10.0, 11.0], eps_pp=1.0) == ["stable", "up"]
def test_none_gap_carried_then_classified(self) -> None:
# 9.0 → None(carry 9.0, stable) → 9.5 (up vs carried 9.0).
out = classify_regime([9.0, None, 9.5])
assert out == ["stable", "stable", "up"]
def test_leading_none_is_stable(self) -> None:
# None до первой точки: нет ни cur, ни базы → 'stable'.
out = classify_regime([None, None, 7.0, 8.0])
assert out == ["stable", "stable", "stable", "up"]
def test_empty(self) -> None:
assert classify_regime([]) == []
def test_window_months_two(self) -> None:
# window=2: сравниваем с точкой 2 месяца назад.
# i=2: 9.0 vs rates[0]=8.0 → +1.0 → up; i=3: 9.5 vs rates[1]=8.5 → up.
out = classify_regime([8.0, 8.5, 9.0, 9.5], window_months=2)
assert out == ["stable", "stable", "up", "up"]
# ── pure: macro_at_lag ────────────────────────────────────────────────────────
class TestMacroAtLag:
def _series(self) -> list[MonthlyMacro]:
return [
_macro(dt.date(2024, 1, 1), key_rate=16.0),
_macro(dt.date(2024, 2, 1), key_rate=16.0),
_macro(dt.date(2024, 3, 1), key_rate=16.0),
_macro(dt.date(2024, 4, 1), key_rate=16.0),
]
def test_lag_zero_returns_target(self) -> None:
m = macro_at_lag(self._series(), dt.date(2024, 3, 1), 0)
assert m is not None and m.month == dt.date(2024, 3, 1)
def test_lag_two_months(self) -> None:
m = macro_at_lag(self._series(), dt.date(2024, 3, 1), 2)
assert m is not None and m.month == dt.date(2024, 1, 1)
def test_target_normalised_to_first_of_month(self) -> None:
# Передаём середину месяца — нормализуется к 1-му числу.
m = macro_at_lag(self._series(), dt.date(2024, 3, 17), 0)
assert m is not None and m.month == dt.date(2024, 3, 1)
def test_lag_beyond_series_returns_none(self) -> None:
# 2024-03 минус 6 мес = 2023-09 — нет в series.
assert macro_at_lag(self._series(), dt.date(2024, 3, 1), 6) is None
def test_missing_intermediate_month_returns_none(self) -> None:
# Дыра в ряду: лаг указывает на отсутствующий месяц → None.
sparse = [
_macro(dt.date(2024, 1, 1), key_rate=16.0),
_macro(dt.date(2024, 3, 1), key_rate=16.0),
]
assert macro_at_lag(sparse, dt.date(2024, 3, 1), 1) is None # 2024-02 отсутствует
def test_empty_series(self) -> None:
assert macro_at_lag([], dt.date(2024, 3, 1), 0) is None
def test_lag_six_present(self) -> None:
# Полугодовой лаг, когда месяц есть.
long_series = [_macro(_shift_months(dt.date(2024, 7, 1), -k)) for k in range(8)]
m = macro_at_lag(long_series, dt.date(2024, 7, 1), 6)
assert m is not None and m.month == dt.date(2024, 1, 1)
# ── pure: is_confounded_window ────────────────────────────────────────────────
class TestIsConfoundedWindow:
def test_straddles_covid_2020_03(self) -> None:
assert is_confounded_window(dt.date(2020, 1, 1), dt.date(2020, 6, 1)) is True
def test_straddles_lgota_cut_2024_07(self) -> None:
assert is_confounded_window(dt.date(2024, 5, 1), dt.date(2024, 9, 1)) is True
def test_straddles_geopolitics_2022_02(self) -> None:
assert is_confounded_window(dt.date(2022, 1, 1), dt.date(2022, 4, 1)) is True
def test_clean_window_not_confounded(self) -> None:
# 2023-Q1 — между шоками, чисто.
assert is_confounded_window(dt.date(2023, 1, 1), dt.date(2023, 4, 1)) is False
def test_boundary_inclusive_start(self) -> None:
# confounder ровно на start → попадает (включительно).
assert is_confounded_window(dt.date(2024, 7, 1), dt.date(2024, 8, 1)) is True
def test_boundary_inclusive_end(self) -> None:
# confounder ровно на end → попадает.
assert is_confounded_window(dt.date(2024, 6, 1), dt.date(2024, 7, 1)) is True
def test_mid_month_dates_normalised(self) -> None:
# Даты не на 1-м числе нормализуются к месяцу; шок 2024-07 внутри.
assert is_confounded_window(dt.date(2024, 6, 15), dt.date(2024, 7, 20)) is True
def test_reversed_args_normalised(self) -> None:
# start>end → меняем местами, не падаем.
assert is_confounded_window(dt.date(2020, 6, 1), dt.date(2020, 1, 1)) is True
def test_confounder_dates_have_rationale(self) -> None:
# Каждая hardcoded дата несёт «почему» (sql.md «нужен why»).
assert len(_CONFOUNDER_DATES) >= 3
for cdate, why in _CONFOUNDER_DATES:
assert isinstance(cdate, dt.date)
assert isinstance(why, str) and len(why) > 10
# ── pure: _shift_months / _month_grid ─────────────────────────────────────────
class TestShiftMonths:
def test_forward(self) -> None:
assert _shift_months(dt.date(2024, 1, 1), 2) == dt.date(2024, 3, 1)
def test_backward(self) -> None:
assert _shift_months(dt.date(2024, 1, 1), -1) == dt.date(2023, 12, 1)
def test_year_wrap_back(self) -> None:
assert _shift_months(dt.date(2024, 3, 1), -6) == dt.date(2023, 9, 1)
def test_normalises_day(self) -> None:
assert _shift_months(dt.date(2024, 3, 17), 0) == dt.date(2024, 3, 1)
class TestMonthGrid:
def test_inclusive_range(self) -> None:
grid = _month_grid(dt.date(2024, 1, 1), dt.date(2024, 4, 1))
assert grid == [
dt.date(2024, 1, 1),
dt.date(2024, 2, 1),
dt.date(2024, 3, 1),
dt.date(2024, 4, 1),
]
def test_single_month(self) -> None:
assert _month_grid(dt.date(2024, 5, 1), dt.date(2024, 5, 1)) == [dt.date(2024, 5, 1)]
def test_start_after_end_empty(self) -> None:
assert _month_grid(dt.date(2024, 5, 1), dt.date(2024, 1, 1)) == []
# ── MonthlyMacro.as_dict ──────────────────────────────────────────────────────
class TestAsDict:
def test_rounds_and_serialises(self) -> None:
m = MonthlyMacro(
month=dt.date(2024, 3, 1),
key_rate=16.123456,
mortgage_rate_weighted=7.987654,
mortgage_issued_count=1234.6,
mortgage_issued_volume=98765.4321,
mortgage_debt=123456.789,
mortgage_overdue=234.5678,
inflation_yoy=5.876543,
)
d = m.as_dict()
assert d["month"] == "2024-03-01"
assert d["key_rate"] == 16.12
assert d["inflation_yoy"] == 5.88 # round to 2 digits
assert d["mortgage_rate_weighted"] == 7.99
assert d["mortgage_issued_count"] == 1235 # round to 0 digits
assert d["mortgage_issued_volume"] == 98765.4
assert d["mortgage_debt"] == 123456.8
assert d["mortgage_overdue"] == 234.6
def test_none_fields_survive(self) -> None:
m = _macro(dt.date(2024, 3, 1))
d = m.as_dict()
assert d["month"] == "2024-03-01"
assert d["key_rate"] is None
assert d["inflation_yoy"] is None
assert d["mortgage_rate_weighted"] is None
assert d["mortgage_debt"] is None
# ── get_monthly_macro: MagicMock-сессия (форма SQL + carry + graceful) ────────
def _key_rate_result(rows: list[tuple[dt.date, float]]) -> MagicMock:
"""Результат db.execute(_KEY_RATE_MONTHLY_SQL): .all() → [(month, value), ...]."""
res = MagicMock()
res.all.return_value = rows
return res
class TestGetMonthlyMacroShape:
def test_key_rate_sql_uses_distinct_on_resample(self) -> None:
db = MagicMock()
db.execute.return_value = _key_rate_result([])
with patch(_GET_SERIES, return_value=[]):
get_monthly_macro(db, months_back=3)
sql = str(db.execute.call_args_list[0].args[0])
# Ресэмпл daily→monthly: DISTINCT ON по месяцу + последняя точка месяца.
assert "DISTINCT ON (date_trunc('month', obs_date))" in sql
assert "ORDER BY date_trunc('month', obs_date), obs_date DESC" in sql
def test_key_rate_sql_uses_cast_not_double_colon(self) -> None:
db = MagicMock()
db.execute.return_value = _key_rate_result([])
with patch(_GET_SERIES, return_value=[]):
get_monthly_macro(db, months_back=3)
sql = str(db.execute.call_args_list[0].args[0])
assert "CAST(:itype AS text)" in sql
assert "CAST(:region AS text)" in sql
assert "CAST(:since AS date)" in sql
# psycopg v3 trap: никаких :name::type
assert ":itype::" not in sql
assert ":region::" not in sql
assert ":since::" not in sql
def test_key_rate_query_params(self) -> None:
db = MagicMock()
db.execute.return_value = _key_rate_result([])
with patch(_GET_SERIES, return_value=[]):
get_monthly_macro(db, months_back=12)
params = db.execute.call_args_list[0].args[1]
assert params["itype"] == "key_rate"
assert params["region"] == "rf"
assert isinstance(params["since"], dt.date)
def test_mortgage_series_read_from_sverdl(self) -> None:
db = MagicMock()
db.execute.return_value = _key_rate_result([])
with patch(_GET_SERIES, return_value=[]) as gs:
get_monthly_macro(db, months_back=3)
# MORTGAGE-ряды читаются регионом 'sverdl' (inflation_yoy — отдельно 'rf', ниже).
assert gs.called
region_by_type = {call.args[1]: call.kwargs["region"] for call in gs.call_args_list}
assert region_by_type.get("mortgage_rate_weighted") == "sverdl"
assert region_by_type.get("mortgage_issued_count") == "sverdl"
def test_inflation_series_read_from_rf(self) -> None:
# #946: inflation_yoy — национальный ряд (region 'rf'), читается через get_macro_series.
db = MagicMock()
db.execute.return_value = _key_rate_result([])
with patch(_GET_SERIES, return_value=[]) as gs:
get_monthly_macro(db, months_back=3)
region_by_type = {call.args[1]: call.kwargs["region"] for call in gs.call_args_list}
assert region_by_type.get("inflation_yoy") == "rf"
class TestGetMonthlyMacroLogic:
def test_grid_is_continuous_and_carries_key_rate(self) -> None:
# key_rate известен только в первом и последнем месяце сетки → carry между.
today = dt.date.today()
first = _shift_months(today, -3)
last = _shift_months(today, -1)
db = MagicMock()
db.execute.return_value = _key_rate_result([(first, 16.0), (last, 18.0)])
with patch(_GET_SERIES, return_value=[]):
out = get_monthly_macro(db, months_back=3)
by_month = {m.month: m for m in out}
# Сетка непрерывна (4 месяца: -3..0).
assert len(out) == 4
assert out == sorted(out, key=lambda m: m.month)
# carry-forward: месяц между first и last наследует 16.0; последний = 18.0.
mid = _shift_months(today, -2)
assert by_month[first].key_rate == 16.0
assert by_month[mid].key_rate == 16.0 # LOCF из first
assert by_month[last].key_rate == 18.0
def test_leading_months_without_key_rate_are_none(self) -> None:
# key_rate появляется только в последнем месяце → ранние = None (carry невозможен).
today = dt.date.today()
last = _shift_months(today, -1)
db = MagicMock()
db.execute.return_value = _key_rate_result([(last, 20.0)])
with patch(_GET_SERIES, return_value=[]):
out = get_monthly_macro(db, months_back=3)
assert out[0].key_rate is None
assert out[-2].key_rate == 20.0 # last месяц сетки (idx -2 т.к. -1 = today)
def test_mortgage_joined_by_month(self) -> None:
today = dt.date.today()
target = _shift_months(today, -2)
def fake_series(_db: object, indicator_type: str, **_kw: object) -> list:
if indicator_type == "mortgage_rate_weighted":
return [(target, 7.5)]
if indicator_type == "mortgage_issued_count":
return [(target, 1200.0)]
return []
db = MagicMock()
db.execute.return_value = _key_rate_result([])
with patch(_GET_SERIES, side_effect=fake_series):
out = get_monthly_macro(db, months_back=3)
row = next(m for m in out if m.month == target)
assert row.mortgage_rate_weighted == 7.5
assert row.mortgage_issued_count == 1200.0
# Месяц без mortgage-данных → None по этим полям.
other = next(m for m in out if m.month != target)
assert other.mortgage_rate_weighted is None
def test_inflation_carries_forward_on_grid(self) -> None:
# #946: inflation_yoy известен в первом месяце сетки → carry-forward вперёд
# (медленный месячный ряд, последняя известная точка = текущий режим).
today = dt.date.today()
first = _shift_months(today, -3)
def fake_series(_db: object, indicator_type: str, **_kw: object) -> list:
if indicator_type == "inflation_yoy":
return [(first, 6.0)]
return []
db = MagicMock()
db.execute.return_value = _key_rate_result([])
with patch(_GET_SERIES, side_effect=fake_series):
out = get_monthly_macro(db, months_back=3)
by_month = {m.month: m for m in out}
assert by_month[first].inflation_yoy == 6.0
# LOCF: последующий месяц наследует 6.0 (нет своей точки).
mid = _shift_months(today, -2)
assert by_month[mid].inflation_yoy == 6.0
def test_leading_months_without_inflation_are_none(self) -> None:
# inflation появляется только в последнем месяце → ранние = None (carry невозможен).
today = dt.date.today()
last = _shift_months(today, -1)
def fake_series(_db: object, indicator_type: str, **_kw: object) -> list:
if indicator_type == "inflation_yoy":
return [(last, 5.5)]
return []
db = MagicMock()
db.execute.return_value = _key_rate_result([])
with patch(_GET_SERIES, side_effect=fake_series):
out = get_monthly_macro(db, months_back=3)
assert out[0].inflation_yoy is None
assert out[-2].inflation_yoy == 5.5
class TestGetMonthlyMacroGraceful:
def test_empty_table_returns_rows_with_none(self) -> None:
# Пустые данные, но months_back>0 → сетка строится, поля None (не crash).
db = MagicMock()
db.execute.return_value = _key_rate_result([])
with patch(_GET_SERIES, return_value=[]):
out = get_monthly_macro(db, months_back=2)
assert len(out) == 3 # -2..0
assert all(m.key_rate is None for m in out)
assert all(m.mortgage_debt is None for m in out)
def test_months_back_zero_single_month(self) -> None:
db = MagicMock()
db.execute.return_value = _key_rate_result([])
with patch(_GET_SERIES, return_value=[]):
out = get_monthly_macro(db, months_back=0)
assert len(out) == 1 # только текущий месяц
def test_key_rate_query_exception_graceful(self) -> None:
# Сбой key_rate-запроса → пустой ресэмпл, но ряд строится (НЕ crash).
db = MagicMock()
db.execute.side_effect = RuntimeError("db down")
with patch(_GET_SERIES, return_value=[]):
out = get_monthly_macro(db, months_back=2)
assert len(out) == 3
assert all(m.key_rate is None for m in out)
def test_mortgage_series_exception_graceful(self) -> None:
# Сбой одного mortgage-ряда не валит весь сбор.
db = MagicMock()
db.execute.return_value = _key_rate_result([])
with patch(_GET_SERIES, side_effect=RuntimeError("boom")):
out = get_monthly_macro(db, months_back=1)
assert len(out) == 2
assert all(m.mortgage_rate_weighted is None for m in out)
# ──────────────────────────────────────────────────────────────────────────────
# SAVEPOINT (#2464 cluster A finding #2, `_query_mortgage_monthly`): сбой ОДНОГО
# mortgage-поля НЕ должен «отравить» общую §22-сессию для остальных 4 полей / для
# последующих §9.x-слоёв, переиспользующих ту же `db`-Session. Без `db.begin_nested()`
# сбойный `db.execute` (внутри `get_macro_series`) оставляет транзакцию Postgres
# aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии тоже падал бы.
# ──────────────────────────────────────────────────────────────────────────────
class _FakeMortgageResult:
"""Мини-результат db.execute() — покрывает `.all()` (см. `get_macro_series`)."""
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()` (откатил бы всю внешнюю
транзакцию).
"""
def __init__(self) -> None:
self.poisoned = False
self._raised = False
self.calls = 0
def execute(self, *args: Any, **kwargs: Any) -> _FakeMortgageResult:
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 mortgage field query failure")
return _FakeMortgageResult()
@contextlib.contextmanager
def begin_nested(self) -> Iterator[None]:
try:
yield
except Exception:
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
# сессия остаётся рабочей для следующего поля.
self.poisoned = False
raise
class TestQueryMortgageMonthlySavepoint:
"""`_query_mortgage_monthly` оборачивает КАЖДОЕ поле в `db.begin_nested()`."""
def test_first_field_failure_leaves_session_usable_for_later_fields(self) -> None:
db = _PoisonableSession()
out = _query_mortgage_monthly(db, since=dt.date(2024, 1, 1))
# все 5 полей присутствуют в результате (контракт функции не изменён).
assert set(out.keys()) == {field for _itype, field in _MORTGAGE_FIELDS}
# сбойное (первое) поле → пустой fallback-словарь, НЕ crash.
first_field = _MORTGAGE_FIELDS[0][1]
assert out[first_field] == {}
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбойного поля проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")
class TestQueryKeyRateMonthlySavepoint:
"""`_query_key_rate_monthly` оборачивает db.execute в `db.begin_nested()`.
Тот же poisoning-класс, что и mortgage-цикл: key_rate — ПЕРВЫЙ запрос
get_monthly_macro; без SAVEPOINT его сбой отравил бы транзакцию для inflation +
mortgage + всех последующих §9.x-слоёв на общей §22-сессии.
"""
def test_db_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
out = _query_key_rate_monthly(db, since=dt.date(2024, 1, 1))
# сбой запроса → пустой ресэмпл (fallback, НЕ crash).
assert out == {}
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбоя проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")
class TestQueryInflationMonthlySavepoint:
"""`_query_inflation_monthly` оборачивает db.execute (через get_macro_series) в SAVEPOINT.
Тот же poisoning-класс: inflation — ВТОРОЙ запрос get_monthly_macro; без SAVEPOINT
его сбой отравил бы транзакцию для mortgage-полей + последующих §9.x-слоёв.
"""
def test_db_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
out = _query_inflation_monthly(db, since=dt.date(2024, 1, 1))
# сбой запроса → пустой ряд (fallback, НЕ crash).
assert out == {}
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбоя проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")