gendesign/tradein-mvp/backend/tests/test_cbr_macro.py
bot-backend 2abab8218e
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m16s
feat(mera): макро-ряды ЦБ РФ — ипотека по субъектам и ключевая ставка
Три XLSX ЦБ (выдачи, ставка, задолженность) в разрезе субъектов, помесячно
с 01.2019, + ключевая ставка через SOAP DailyInfo.asmx (метод KeyRate; GET
на нём не работает, только POST). Всё анонимно, без ключа.

Таблицы: cbr_mortgage_series (region, period_month, series) и cbr_key_rate.
fetched_at НЕ обновляется в DO UPDATE — по уроку #2846 колонка значит
«когда мы ВПЕРВЫЕ увидели период» и по ней меряется такт публикации источника.

Раскладка листов у ЦБ РАЗНАЯ, и это ловушка: в 02_11/02_13 шапка периодов в
строке 3 текстом («Январь 2019»), а в 02_14 (задолженность) — уже в строке 2 и
настоящими datetime. С захардкоженным индексом строки серия debt_rub молча
давала НОЛЬ строк при зелёных тестах. Теперь строка шапки ищется динамически:
берётся строка с наибольшим числом распознанных периодов среди первых шести.

Проверено на живых файлах: каждая из трёх серий даёт 8 736 точек
(96 территорий × 91 месяц, 01.2019–07.2026); по Свердловской области 91 месяц,
последняя ставка 11.49.

estimator.py не тронут: как ипотечная ставка войдёт в оценку — отдельное
продуктовое решение, этот PR только про данные.

Миграции 292 (таблицы) и 293 (сид расписания, enabled=false, interval_days 7).
Новая зависимость: openpyxl.
2026-09-09 00:33:12 +03:00

380 lines
16 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.

"""Tests for cbr_macro service — pure unit tests, no network/DB.
Coverage:
(a) parse_ru_period: русские имена месяцев → date(y, m, 1), включая мусор.
(b) parse_mortgage_sheet: unpivot мини-листа (реальные значения Свердловской обл.,
замерены вживую 08.09.2026: 11, 10.64, 10.98, 10.84, 9.2, 11.49).
(c) parse_key_rate_response: разбор реального SOAP-ответа KeyRate (namespace-агностично).
(d) build_key_rate_envelope: SOAP-конверт содержит нужные теги/значения.
(e) upsert SQL — статические asserts: ON CONFLICT, CAST(:x AS type), нет `::`-кастов,
fetched_at отсутствует в DO UPDATE SET.
(f) load_cbr_mortgage_series / load_key_rate — httpx.MockTransport round-trip.
(g) db.commit() отсутствует в исходниках сервисных функций (коммитит caller).
"""
from __future__ import annotations
import inspect
import os
import re
from datetime import date, datetime
from unittest.mock import MagicMock
# DATABASE_URL required by config before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import httpx
import openpyxl
import pytest
from app.services import cbr_macro
# ---------------------------------------------------------------------------
# parse_ru_period
# ---------------------------------------------------------------------------
class TestParseRuPeriod:
def test_january(self) -> None:
assert cbr_macro.parse_ru_period("Январь 2019") == date(2019, 1, 1)
def test_december(self) -> None:
assert cbr_macro.parse_ru_period("Декабрь 2026") == date(2026, 12, 1)
def test_july_2026(self) -> None:
assert cbr_macro.parse_ru_period("Июль 2026") == date(2026, 7, 1)
def test_unknown_month(self) -> None:
with pytest.raises(ValueError, match="месяца"):
cbr_macro.parse_ru_period("Смогтябрь 2019")
def test_garbage_no_year(self) -> None:
with pytest.raises(ValueError, match="период"):
cbr_macro.parse_ru_period("Январь")
def test_bad_year(self) -> None:
with pytest.raises(ValueError, match="период"):
cbr_macro.parse_ru_period("Январь двадцать")
# ---------------------------------------------------------------------------
# parse_mortgage_sheet — unpivot
# ---------------------------------------------------------------------------
# Мини-лист по форме реального 02_13_Rates_mortgage.xlsx («ставка в рублях»):
# строка 1 пустая, строка 2 заголовок, строка 3 — шапка периодов, строки 4+ территории.
# Последние 6 значений Свердловской обл. — реальные, замерены 08.09.2026.
_RATE_SHEET_ROWS: list[tuple] = [
(None, None, None, None, None, None, None, None),
(
"Средневзвешенная ставка по ипотечным жилищным кредитам, %",
None,
None,
None,
None,
None,
None,
None,
),
(None, "Февраль 2026", "Март 2026", "Апрель 2026", "Май 2026", "Июнь 2026", "Июль 2026", None),
("РОССИЙСКАЯ ФЕДЕРАЦИЯ", 21.5, 21.1, 20.8, 19.9, 18.6, 17.9, None),
("Свердловская область", 11, 10.64, 10.98, 10.84, 9.2, 11.49, None),
]
class TestParseMortgageSheet:
def test_too_short_returns_empty(self) -> None:
assert cbr_macro.parse_mortgage_sheet([(1,), (2,)], series_slug="rate_rub") == []
def test_unpivot_counts(self) -> None:
rows = cbr_macro.parse_mortgage_sheet(_RATE_SHEET_ROWS, series_slug="rate_rub")
# 2 территории × 6 месяцев (последний столбец None — trailing empty col пропущен)
assert len(rows) == 12
def test_sverdlovsk_values(self) -> None:
rows = cbr_macro.parse_mortgage_sheet(_RATE_SHEET_ROWS, series_slug="rate_rub")
sverdlovsk = [r for r in rows if r.region == "Свердловская область"]
sverdlovsk.sort(key=lambda r: r.period_month)
values = [r.value for r in sverdlovsk]
assert values == [11.0, 10.64, 10.98, 10.84, 9.2, 11.49]
assert sverdlovsk[0].period_month == date(2026, 2, 1)
assert sverdlovsk[-1].period_month == date(2026, 7, 1)
assert all(r.series == "rate_rub" for r in sverdlovsk)
def test_skips_empty_region_rows(self) -> None:
rows_in = [*_RATE_SHEET_ROWS, (None, 1, 2, 3, 4, 5, 6, None), ("", 1, 2, 3, 4, 5, 6, None)]
rows = cbr_macro.parse_mortgage_sheet(rows_in, series_slug="rate_rub")
assert len(rows) == 12 # мусорные строки без региона не добавляют записей
def test_skips_non_numeric_cell(self) -> None:
bad_rows = [*_RATE_SHEET_ROWS[:-1], ("Москва", "n/a", 10.0, None, None, None, None, None)]
rows = cbr_macro.parse_mortgage_sheet(bad_rows, series_slug="rate_rub")
moscow = [r for r in rows if r.region == "Москва"]
assert len(moscow) == 1 # только числовая ячейка попала в результат
assert moscow[0].value == 10.0
# ---------------------------------------------------------------------------
# SOAP key rate
# ---------------------------------------------------------------------------
_KEY_RATE_XML = """<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<KeyRateResponse xmlns="http://web.cbr.ru/">
<KeyRateResult>
<diffgr:diffgram xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"
xmlns:diffgr="urn:schemas-microsoft-com:xml-diffgram-v1">
<KeyRate xmlns="">
<KR>
<DT>2024-01-01T00:00:00+03:00</DT>
<Rate>16</Rate>
</KR>
<KR>
<DT>2024-07-29T00:00:00+03:00</DT>
<Rate>18</Rate>
</KR>
</KeyRate>
</diffgr:diffgram>
</KeyRateResult>
</KeyRateResponse>
</soap:Body>
</soap:Envelope>"""
class TestKeyRateSoap:
def test_parse_real_response_shape(self) -> None:
rows = cbr_macro.parse_key_rate_response(_KEY_RATE_XML)
assert rows == [(date(2024, 1, 1), 16.0), (date(2024, 7, 29), 18.0)]
def test_parse_empty_response(self) -> None:
empty = (
"<soap:Envelope xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'>"
"<soap:Body></soap:Body></soap:Envelope>"
)
assert cbr_macro.parse_key_rate_response(empty) == []
def test_build_envelope_contains_dates_and_soapaction_target(self) -> None:
envelope = cbr_macro.build_key_rate_envelope(date(2024, 1, 1), date(2024, 12, 31))
assert "<fromDate>2024-01-01</fromDate>" in envelope
assert "<ToDate>2024-12-31</ToDate>" in envelope
assert "http://web.cbr.ru/" in envelope
assert "<KeyRate " in envelope or "<KeyRate>" in envelope
# ---------------------------------------------------------------------------
# Upsert SQL — статические asserts (без БД)
# ---------------------------------------------------------------------------
class TestUpsertSql:
def test_mortgage_sql_no_double_colon_cast(self) -> None:
sql = re.sub(r"\s+", " ", str(cbr_macro._UPSERT_MORTGAGE_SQL.text))
assert not re.search(r":\w+::", sql)
def test_mortgage_sql_has_cast_and_on_conflict(self) -> None:
sql = re.sub(r"\s+", " ", str(cbr_macro._UPSERT_MORTGAGE_SQL.text))
assert "CAST(:region AS text)" in sql
assert "CAST(:period_month AS date)" in sql
assert "CAST(:value AS double precision)" in sql
assert "ON CONFLICT (region, period_month, series)" in sql
assert "DO UPDATE SET" in sql
def test_mortgage_sql_does_not_touch_fetched_at_on_conflict(self) -> None:
sql = str(cbr_macro._UPSERT_MORTGAGE_SQL.text)
do_update_part = sql.split("DO UPDATE SET", 1)[1]
# fetched_at упоминается только в SQL-комментарии (объясняет почему НЕ обновляем),
# но не как цель присваивания.
assert not re.search(r"fetched_at\s*=", do_update_part)
def test_key_rate_sql_no_double_colon_cast(self) -> None:
sql = re.sub(r"\s+", " ", str(cbr_macro._UPSERT_KEY_RATE_SQL.text))
assert not re.search(r":\w+::", sql)
def test_key_rate_sql_shape(self) -> None:
sql = re.sub(r"\s+", " ", str(cbr_macro._UPSERT_KEY_RATE_SQL.text))
assert "CAST(:rate_date AS date)" in sql
assert "CAST(:rate AS double precision)" in sql
assert "ON CONFLICT (rate_date)" in sql
def test_key_rate_sql_does_not_touch_fetched_at_on_conflict(self) -> None:
sql = str(cbr_macro._UPSERT_KEY_RATE_SQL.text)
do_update_part = sql.split("DO UPDATE SET", 1)[1]
assert not re.search(r"fetched_at\s*=", do_update_part)
# ---------------------------------------------------------------------------
# Services do not commit — caller commits
# ---------------------------------------------------------------------------
class TestNoCommitInService:
@pytest.mark.parametrize(
"fn",
[
cbr_macro.load_cbr_mortgage_series,
cbr_macro.pull_cbr_mortgage,
cbr_macro.load_key_rate,
cbr_macro._upsert_mortgage_rows,
cbr_macro._upsert_key_rate_rows,
],
)
def test_no_commit_call(self, fn) -> None:
src = inspect.getsource(fn)
assert "db.commit()" not in src
# ---------------------------------------------------------------------------
# Network round-trip via httpx.MockTransport
# ---------------------------------------------------------------------------
def _xlsx_bytes_for(rows: list[tuple], *, sheet_name: str) -> bytes:
import io
wb = openpyxl.Workbook()
ws = wb.active
ws.title = sheet_name
for row in rows:
ws.append(row)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
class TestMortgageNetworkRoundtrip:
def test_load_series_upserts_expected_rows(self) -> None:
series = cbr_macro.CbrMortgageSeries(
"rate_rub", "https://www.cbr.ru/fake.xlsx", "ставка в рублях"
)
xlsx_bytes = _xlsx_bytes_for(_RATE_SHEET_ROWS, sheet_name="ставка в рублях")
def handler(request: httpx.Request) -> httpx.Response:
assert str(request.url) == series.url
return httpx.Response(200, content=xlsx_bytes)
client = httpx.Client(transport=httpx.MockTransport(handler))
db = MagicMock()
db.execute.return_value.rowcount = 1
result = cbr_macro.load_cbr_mortgage_series(db, series, client=client)
assert result["rows"] == 12
assert result["upserted"] == 12
assert db.execute.call_count == 12
def test_load_series_dry_run_skips_upsert(self) -> None:
series = cbr_macro.CbrMortgageSeries(
"rate_rub", "https://www.cbr.ru/fake.xlsx", "ставка в рублях"
)
xlsx_bytes = _xlsx_bytes_for(_RATE_SHEET_ROWS, sheet_name="ставка в рублях")
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=xlsx_bytes)
client = httpx.Client(transport=httpx.MockTransport(handler))
db = MagicMock()
result = cbr_macro.load_cbr_mortgage_series(db, series, client=client, dry_run=True)
assert result["rows"] == 12
assert result["upserted"] == 0
db.execute.assert_not_called()
def test_missing_sheet_raises(self) -> None:
series = cbr_macro.CbrMortgageSeries(
"rate_rub", "https://www.cbr.ru/fake.xlsx", "нет такого листа"
)
xlsx_bytes = _xlsx_bytes_for(_RATE_SHEET_ROWS, sheet_name="ставка в рублях")
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=xlsx_bytes)
client = httpx.Client(transport=httpx.MockTransport(handler))
db = MagicMock()
with pytest.raises(ValueError, match="лист"):
cbr_macro.load_cbr_mortgage_series(db, series, client=client)
def test_pull_isolates_series_failure(self) -> None:
good = cbr_macro.CbrMortgageSeries(
"rate_rub", "https://www.cbr.ru/good.xlsx", "ставка в рублях"
)
bad = cbr_macro.CbrMortgageSeries("debt_rub", "https://www.cbr.ru/bad.xlsx", "в рублях")
xlsx_bytes = _xlsx_bytes_for(_RATE_SHEET_ROWS, sheet_name="ставка в рублях")
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url) == bad.url:
return httpx.Response(500)
return httpx.Response(200, content=xlsx_bytes)
client = httpx.Client(transport=httpx.MockTransport(handler))
db = MagicMock()
db.execute.return_value.rowcount = 1
counters = cbr_macro.pull_cbr_mortgage(db, series_list=[good, bad], client=client)
assert counters["upserted"] == 12
assert counters["errors"] == 1
db.rollback.assert_called_once()
class TestKeyRateNetworkRoundtrip:
def test_load_key_rate_upserts_parsed_rows(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.headers["soapaction"] == "http://web.cbr.ru/KeyRate"
return httpx.Response(200, content=_KEY_RATE_XML.encode("utf-8"))
client = httpx.Client(transport=httpx.MockTransport(handler))
db = MagicMock()
db.execute.return_value.rowcount = 1
result = cbr_macro.load_key_rate(
db, from_date=date(2024, 1, 1), to_date=date(2024, 12, 31), client=client
)
assert result["rows"] == 2
assert result["upserted"] == 2
assert db.execute.call_count == 2
def test_parse_mortgage_sheet_debt_layout_datetime_header() -> None:
"""02_14_Debt_mortgage.xlsx: шапка в строке 2 и периоды — datetime, не «Январь 2019».
Реальная раскладка (проверено на живом файле 08.09.2026): в 02_11/02_13 строка 1
пустая, описание в строке 2, шапка в строке 3 текстом; в 02_14 описание в строке 1,
а шапка уже в строке 2 и хранит настоящие даты. Захардкоженный индекс строки давал
по серии задолженности ноль строк — молча, только с warning'ами.
"""
months = [
datetime(2019, 2, 1),
datetime(2019, 3, 1),
datetime(2019, 4, 1),
datetime(2019, 5, 1),
datetime(2019, 6, 1),
datetime(2019, 7, 1),
]
rows: list[tuple[object, ...]] = [
("Задолженность по ипотечным жилищным кредитам", None, None, None, None, None, None),
(None, *months),
("РОССИЙСКАЯ ФЕДЕРАЦИЯ", 6605685, 6719335, 6823490, 6937855, 7018162, 7095485),
("Свердловская область", 100, 101, 102, 103, 104, 105),
]
out = cbr_macro.parse_mortgage_sheet(rows, series_slug="debt_rub")
sverdlovsk = [r for r in out if r.region == "Свердловская область"]
assert len(sverdlovsk) == 6
assert sverdlovsk[0].period_month == date(2019, 2, 1)
assert sverdlovsk[0].value == 100.0
assert {r.region for r in out} == {"РОССИЙСКАЯ ФЕДЕРАЦИЯ", "Свердловская область"}
def test_parse_mortgage_sheet_returns_empty_when_no_header_found() -> None:
"""Нет распознаваемой шапки — пустой результат и предупреждение, а не мусор."""
rows: list[tuple[object, ...]] = [
("Какой-то заголовок", None, None),
("РОССИЙСКАЯ ФЕДЕРАЦИЯ", 1, 2),
]
assert cbr_macro.parse_mortgage_sheet(rows, series_slug="debt_rub") == []