"""Unit-тесты для reader макропоказателей (#945 PR A, ТЗ §8.8). Чистые тесты (без живой БД): • classify_cbr_series — pure mapping cbr series_id → indicator_type (+ skip unmapped). • Консистентность Python-зеркала с SQL-миграцией 123_macro_indicator.sql. • Форма SQL reader-а (CAST(:x AS type), ORDER BY, params) через MagicMock-сессию, как в test_best_layouts.py — без подключения к Postgres. psycopg v3 правило проверяется явно: bind-параметры должны быть CAST(:x AS type), а не :x::type. """ from __future__ import annotations import datetime as dt import pathlib from unittest.mock import MagicMock from app.services.site_finder.macro import ( _CBR_SERIES_MAP, classify_cbr_series, get_latest_macro, get_macro_series, ) # Путь к миграции — для теста-консистентности зеркала. _MIGRATION = ( pathlib.Path(__file__).resolve().parents[4] / "data" / "sql" / "123_macro_indicator.sql" ) # ── pure: classify_cbr_series ───────────────────────────────────────────────── class TestClassifyCbrSeries: def test_weighted_rate(self) -> None: assert classify_cbr_series("02_13_Rates_mortgage_ставка_в_рублях") == ( "mortgage_rate_weighted", "%", ) def test_term(self) -> None: assert classify_cbr_series("02_13_Rates_mortgage_срок_в_руб") == ( "mortgage_term", "мес", ) def test_issued_count(self) -> None: assert classify_cbr_series("02_10_Quantity_mortgage_в_рублях") == ( "mortgage_issued_count", "шт", ) def test_issued_volume(self) -> None: assert classify_cbr_series("02_11_New_loans_mortgage_в_рублях") == ( "mortgage_issued_volume", "млн руб", ) def test_debt(self) -> None: assert classify_cbr_series("02_14_Debt_mortgage_в_рублях") == ( "mortgage_debt", "млн руб", ) def test_overdue(self) -> None: assert classify_cbr_series("02_14_Debt_mortgage_в_т.ч._просроч._в_рублях") == ( "mortgage_overdue", "млн руб", ) def test_none_and_empty(self) -> None: assert classify_cbr_series(None) is None assert classify_cbr_series("") is None def test_itogo_variants_excluded(self) -> None: """_итого-варианты НЕ маппятся (иначе коллизия по PK с _в_рублях).""" assert classify_cbr_series("02_13_Rates_mortgage_ставка_итого") is None assert classify_cbr_series("02_10_Quantity_mortgage_итого") is None assert classify_cbr_series("02_14_Debt_mortgage_итого") is None def test_other_families_excluded(self) -> None: """_housing_ / _scpa_mortgage_ / _Share_ — шире/иное, чем _mortgage_ ИЖК-семья.""" assert classify_cbr_series("02_06_Quantity_housing_в_рублях") is None assert classify_cbr_series("02_15_Rates_scpa_mortgage_ставка_в_рублях") is None assert classify_cbr_series("02_44_Share_mortgage_в_рублях") is None def test_unknown_series_id_skipped(self) -> None: """Неизвестный / посторонний series_id → None (в backfill не попадает).""" assert classify_cbr_series("Доля ипотечных кредитов на ИЖС, %") is None assert classify_cbr_series("02_99_unknown_series") is None # ── консистентность Python-зеркала с SQL-миграцией ──────────────────────────── class TestMirrorConsistency: def test_migration_file_exists(self) -> None: assert _MIGRATION.is_file(), f"migration not found: {_MIGRATION}" def test_all_indicator_types_present_in_migration(self) -> None: """Каждый indicator_type из Python-зеркала встречается в тексте миграции.""" sql = _MIGRATION.read_text(encoding="utf-8") for indicator_type, _unit in _CBR_SERIES_MAP.values(): assert indicator_type in sql, ( f"indicator_type {indicator_type!r} есть в Python-зеркале, " f"но отсутствует в миграции 123_macro_indicator.sql" ) def test_all_series_ids_present_in_migration(self) -> None: """Каждый series_id-ключ Python-зеркала присутствует в CASE миграции.""" sql = _MIGRATION.read_text(encoding="utf-8") for series_id in _CBR_SERIES_MAP: assert series_id in sql, ( f"series_id {series_id!r} есть в Python-зеркале, " f"но отсутствует в миграции 123_macro_indicator.sql" ) def test_domrf_primary_type_present(self) -> None: sql = _MIGRATION.read_text(encoding="utf-8") assert "mortgage_rate_primary_domrf" in sql def test_migration_balanced_transaction(self) -> None: sql = _MIGRATION.read_text(encoding="utf-8") assert sql.count("BEGIN;") == 1 assert sql.count("COMMIT;") == 1 assert "ON CONFLICT" in sql assert "macro_indicator" in sql # ── reader SQL shape (mock Session, без БД) ─────────────────────────────────── def _mock_db(rows: list) -> MagicMock: """MagicMock-сессия: db.execute(...).all() и .first() возвращают подготовленное.""" db = MagicMock() result = MagicMock() result.all.return_value = rows result.first.return_value = rows[0] if rows else None db.execute.return_value = result return db def _executed_sql(db: MagicMock) -> str: """Текст SQL первого db.execute (TextClause → str).""" args, _kwargs = db.execute.call_args return str(args[0]) class TestGetMacroSeries: def test_returns_ordered_float_tuples(self) -> None: rows = [(dt.date(2019, 2, 1), 9.5), (dt.date(2019, 3, 1), 9.4)] db = _mock_db(rows) out = get_macro_series(db, "mortgage_rate_weighted") assert out == [(dt.date(2019, 2, 1), 9.5), (dt.date(2019, 3, 1), 9.4)] assert all(isinstance(v, float) for _d, v in out) def test_empty(self) -> None: db = _mock_db([]) assert get_macro_series(db, "key_rate") == [] def test_default_region_and_params(self) -> None: db = _mock_db([]) get_macro_series(db, "mortgage_debt") _args, kwargs = db.execute.call_args params = _args[1] if len(_args) > 1 else kwargs assert params["itype"] == "mortgage_debt" assert params["region"] == "rf" assert params["since"] is None def test_since_passed_through(self) -> None: db = _mock_db([]) since = dt.date(2024, 1, 1) get_macro_series(db, "mortgage_debt", region="sverdl", since=since) _args, _kwargs = db.execute.call_args params = _args[1] assert params["region"] == "sverdl" assert params["since"] == since def test_sql_uses_cast_not_double_colon(self) -> None: db = _mock_db([]) get_macro_series(db, "mortgage_debt") sql = _executed_sql(db) 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_sql_orders_ascending(self) -> None: db = _mock_db([]) get_macro_series(db, "mortgage_debt") assert "ORDER BY obs_date ASC" in _executed_sql(db) class TestGetLatestMacro: def test_returns_tuple(self) -> None: db = _mock_db([(dt.date(2026, 2, 1), 11.0)]) assert get_latest_macro(db, "mortgage_rate_weighted") == (dt.date(2026, 2, 1), 11.0) def test_none_when_empty(self) -> None: db = _mock_db([]) assert get_latest_macro(db, "key_rate") is None def test_value_coerced_to_float(self) -> None: from decimal import Decimal db = _mock_db([(dt.date(2026, 2, 1), Decimal("11.0"))]) result = get_latest_macro(db, "mortgage_rate_weighted") assert result is not None _d, v = result assert isinstance(v, float) assert v == 11.0 def test_sql_orders_desc_limit_1(self) -> None: db = _mock_db([]) get_latest_macro(db, "mortgage_debt") sql = _executed_sql(db) assert "ORDER BY obs_date DESC" in sql assert "LIMIT 1" in sql assert "CAST(:itype AS text)" in sql