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).
592 lines
28 KiB
Python
592 lines
28 KiB
Python
"""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")
|