gendesign/backend/tests/workers/test_cbr_macro_sync.py
Light1YT 25e21c2bff
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m45s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / deploy (push) Successful in 1m15s
CI / backend-tests (push) Successful in 6m30s
CI / backend-tests (pull_request) Successful in 6m32s
feat(macro): CBR inflation (ИПЦ YoY) -> macro_indicator + activate §9.5 channel (#946)
fedstat ИПЦ is reCAPTCHA-blocked; CBR publishes inflation openly. Add
fetch_inflation + parse_inflation_xlsx (CBR UniDbQuery DownloadExcel/132934,
monthly % г/г, region=rf, source=cbr) to cbr_macro.py; upsert
indicator_type=inflation_yoy via the existing cbr_macro_sync task (per-series
guard, SAVEPOINT-per-row, CAST not ::, ON CONFLICT on the PK).

Surface inflation_yoy in MonthlyMacro (frozen, carry-forward) and ACTIVATE the
reserved §9.5 inflation channel (macro_coefficient f_inflation: level-vs-4%-target
nudge, non-positive to avoid double-counting f_rate, excluded from
_RATE_DRIVEN_FACTORS). Channel was DEGRADED (no data) -> now BACKED + consumed;
_CONF_HIGH_MIN_BACKED 4->5. Deterministic (§16/§26); renorm claims the reserved
0.08 slice as designed. Live-verified (2026-04 5.58%); 194 macro + 902 forecasting
tests green. No migration, no new deps.

Refs #946.
2026-06-08 11:41:14 +05:00

213 lines
8.3 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.

"""Тесты Celery-таски cbr_macro_sync (mock session + mock fetch, без живой сети).
Покрывают: контракт upsert-SQL обоих рядов (key_rate + inflation_yoy, region='rf',
source='cbr', ON CONFLICT, CAST not ::), счётчики fetched/upserted (пер-ряд),
per-series guard (сбой одного ряда не мешает другому, но пробрасывается).
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Any
from unittest.mock import MagicMock, patch
def _make_mock_db() -> tuple[MagicMock, list[dict[str, Any]]]:
"""Mock DB session с поддержкой db.begin_nested() context manager.
Возвращает (db, captured_params) — captured содержит params каждого
db.execute(..., params).
"""
captured: list[dict[str, Any]] = []
def execute_router(stmt: Any, params: Any = None) -> MagicMock:
if params is not None:
captured.append(params)
return MagicMock()
db = MagicMock()
db.execute = execute_router
# begin_nested() → context manager (SAVEPOINT)
db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
return db, captured
def test_upsert_sql_contract() -> None:
"""Smoke: UPSERT_KEY_RATE_SQL содержит обязательные литералы контракта
macro_indicator и НЕ использует :x::type (psycopg v3)."""
from app.workers.tasks.cbr_macro_sync import UPSERT_KEY_RATE_SQL
sql = str(UPSERT_KEY_RATE_SQL)
upper = sql.upper()
assert "INTO MACRO_INDICATOR" in upper
# литералы контракта — в SQL они lowercase
assert "'key_rate'" in sql
assert "'rf'" in sql
assert "'cbr'" in sql
assert "'daily'" in sql
# ON CONFLICT по полному PK + обновление value/updated_at
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper
assert "VALUE = EXCLUDED.VALUE" in upper
assert "UPDATED_AT = NOW()" in upper
# psycopg v3: CAST(:x AS type), НИКОГДА :x::type
assert "CAST(:D AS DATE)" in upper
assert "CAST(:V AS NUMERIC)" in upper
assert "::" not in sql
def test_task_upserts_each_row_with_correct_params() -> None:
"""Каждая (date, rate) → один execute с bind-параметрами :d и :v."""
from app.workers.tasks import cbr_macro_sync as task_mod
db, captured = _make_mock_db()
fake_rows = [
(date(2023, 8, 14), Decimal("8.5")),
(date(2023, 8, 15), Decimal("12.0")),
]
with (
patch.object(task_mod, "SessionLocal", return_value=db),
patch.object(task_mod, "fetch_key_rate", return_value=fake_rows) as mock_fetch,
patch.object(task_mod, "fetch_inflation", return_value=[]) as mock_infl,
):
result = task_mod.cbr_macro_sync.run()
mock_fetch.assert_called_once()
mock_infl.assert_called_once()
assert result["fetched"] == 2
assert result["upserted"] == 2
assert result["key_rate"] == {"fetched": 2, "upserted": 2}
assert result["inflation"] == {"fetched": 0, "upserted": 0}
# параметры каждого upsert
assert captured == [
{"d": date(2023, 8, 14), "v": Decimal("8.5")},
{"d": date(2023, 8, 15), "v": Decimal("12.0")},
]
db.commit.assert_called()
db.close.assert_called_once()
def test_task_passes_date_range_to_fetch() -> None:
"""from_date / to_date (ISO-строки) парсятся в date и прокидываются в fetch."""
from app.workers.tasks import cbr_macro_sync as task_mod
db, _ = _make_mock_db()
with (
patch.object(task_mod, "SessionLocal", return_value=db),
patch.object(task_mod, "fetch_key_rate", return_value=[]) as mock_fetch,
patch.object(task_mod, "fetch_inflation", return_value=[]) as mock_infl,
):
result = task_mod.cbr_macro_sync.run(from_date="2020-01-01", to_date="2020-12-31")
mock_fetch.assert_called_once_with(from_date=date(2020, 1, 1), to_date=date(2020, 12, 31))
# период прокидывается и в inflation fetch
mock_infl.assert_called_once_with(from_date=date(2020, 1, 1), to_date=date(2020, 12, 31))
assert result["from_date"] == "2020-01-01"
assert result["to_date"] == "2020-12-31"
assert result["fetched"] == 0
assert result["upserted"] == 0
def test_task_reraises_fetch_failure() -> None:
"""Ошибка fetch'а ставки пробрасывается (surfaces в Celery/GlitchTip), не глотается.
DB-сессия при этом закрывается."""
from app.workers.tasks import cbr_macro_sync as task_mod
db, _ = _make_mock_db()
with (
patch.object(task_mod, "SessionLocal", return_value=db),
patch.object(task_mod, "fetch_key_rate", side_effect=RuntimeError("CBR down")),
patch.object(task_mod, "fetch_inflation", return_value=[]),
):
try:
task_mod.cbr_macro_sync.run()
raise AssertionError("expected RuntimeError to propagate")
except RuntimeError as e:
assert "CBR down" in str(e)
db.close.assert_called_once()
def test_inflation_failure_does_not_block_key_rate() -> None:
"""Per-series guard: сбой инфляции НЕ мешает ставке апсертнуться, но пробрасывается."""
from app.workers.tasks import cbr_macro_sync as task_mod
db, captured = _make_mock_db()
kr_rows = [(date(2024, 1, 1), Decimal("16.0"))]
with (
patch.object(task_mod, "SessionLocal", return_value=db),
patch.object(task_mod, "fetch_key_rate", return_value=kr_rows),
patch.object(task_mod, "fetch_inflation", side_effect=RuntimeError("infl down")),
):
try:
task_mod.cbr_macro_sync.run()
raise AssertionError("expected RuntimeError to propagate")
except RuntimeError as e:
assert "infl down" in str(e)
# key_rate всё равно апсертнулся ДО проброса inflation-ошибки
assert captured == [{"d": date(2024, 1, 1), "v": Decimal("16.0")}]
db.close.assert_called_once()
def test_task_upserts_inflation_rows_with_correct_params() -> None:
"""Каждая (month1st, %) инфляции → execute UPSERT_INFLATION_SQL с :d и :v."""
from app.workers.tasks import cbr_macro_sync as task_mod
db, captured = _make_mock_db()
infl_rows = [
(date(2026, 3, 1), Decimal("5.86")),
(date(2026, 4, 1), Decimal("5.58")),
]
with (
patch.object(task_mod, "SessionLocal", return_value=db),
patch.object(task_mod, "fetch_key_rate", return_value=[]),
patch.object(task_mod, "fetch_inflation", return_value=infl_rows),
):
result = task_mod.cbr_macro_sync.run()
assert result["inflation"] == {"fetched": 2, "upserted": 2}
assert captured == [
{"d": date(2026, 3, 1), "v": Decimal("5.86")},
{"d": date(2026, 4, 1), "v": Decimal("5.58")},
]
def test_upsert_inflation_sql_contract() -> None:
"""UPSERT_INFLATION_SQL: indicator_type='inflation_yoy', monthly, CAST not ::."""
from app.workers.tasks.cbr_macro_sync import UPSERT_INFLATION_SQL
sql = str(UPSERT_INFLATION_SQL)
upper = sql.upper()
assert "INTO MACRO_INDICATOR" in upper
assert "'inflation_yoy'" in sql
assert "'rf'" in sql
assert "'cbr'" in sql
assert "'monthly'" in sql
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper
assert "VALUE = EXCLUDED.VALUE" in upper
assert "UPDATED_AT = NOW()" in upper
# psycopg v3: CAST(:x AS type), НИКОГДА :x::type
assert "CAST(:D AS DATE)" in upper
assert "CAST(:V AS NUMERIC)" in upper
assert "::" not in sql
def test_task_registered_in_beat_weekly() -> None:
"""Задача зарегистрирована в beat как еженедельная (понедельник)."""
from app.workers.beat_schedule import build_beat_schedule
schedule = build_beat_schedule()
assert "cbr-macro-sync-weekly" in schedule
entry = schedule["cbr-macro-sync-weekly"]
assert entry["task"] == "tasks.cbr_macro_sync.cbr_macro_sync"
# crontab day_of_week = понедельник (1)
assert 1 in entry["schedule"].day_of_week