"""Тесты 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 включает period_type (migration 163) assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) 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 # CBR-строки используют period_type='unknown' (литерал в SQL) assert "'unknown'" 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, PERIOD_TYPE) 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 assert "'unknown'" 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