gendesign/backend/tests/workers/test_cbr_macro_sync.py
Light1YT ea42d29a4a feat(site-finder): live CBR key-rate scraper → macro_indicator (#945 PR B)
Site Finder v2 / GG-форсайт. Deterministic, no LLM. Fills the key_rate series
(empty after PR A) into macro_indicator from the live CBR source.

- services/scrapers/cbr_macro.py: fetch_key_rate via CBR SOAP DailyInfoWebServ
  KeyRateXML(fromDate, ToDate) (POST soap+xml; ToDate capital-T + dateTime
  suffix required — flat GET returns empty). Pure parse_key_rate_xml (TZ-safe
  date, empty/malformed → []). Live-verified: 85 rows, current rate 14.5%.
- workers/tasks/cbr_macro_sync.py: upsert (key_rate, rf, daily, %) into
  macro_indicator ON CONFLICT DO UPDATE; SAVEPOINT per row; fetch-fail → raise.
- beat: cbr-macro-sync-weekly (Mon 05:30 МСК, Europe/Moscow tz).
- 15 tests (pure parse + mocked upsert). No migration (table from #963 PR A).
2026-06-03 00:05:25 +05:00

137 lines
5.1 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 (indicator_type='key_rate', region='rf',
source='cbr', ON CONFLICT, CAST not ::), счётчики fetched/upserted, проброс
ошибки fetch'а.
"""
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,
):
result = task_mod.cbr_macro_sync.run()
mock_fetch.assert_called_once()
assert result["fetched"] == 2
assert result["upserted"] == 2
# параметры каждого 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,
):
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))
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")),
):
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_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