"""CBR source: which currencies are asked for, windows-1251 parsing, nominal handling.""" from __future__ import annotations from datetime import date, timedelta from decimal import Decimal from itertools import pairwise import httpx from sqlalchemy import select from factories import make_account, make_txn from fintracker.analytics import today_local from fintracker.config import Settings from fintracker.db import get_sessionmaker from fintracker.models import RawCbrRate from fintracker.sources.cbr.client import DAILY_URL, DYNAMIC_URL, split_range from fintracker.sources.cbr.sync import CbrSource USD_ID = "R01235" JPY_ID = "R01820" def settings() -> Settings: return Settings() async def stored_rates() -> dict[tuple[str, str], tuple[int, Decimal]]: async with get_sessionmaker()() as session: rows = (await session.execute(select(RawCbrRate))).scalars().all() return {(r.ccy, r.rate_date.isoformat()): (r.nominal, r.value) for r in rows} def route_params(route) -> list[dict[str, str]]: return [dict(call.request.url.params) for call in route.calls] def mock_cbr(mock_http, fixture_bytes, *, dynamic: dict[str, str] | None = None): daily = mock_http.get(DAILY_URL).mock( return_value=httpx.Response(200, content=fixture_bytes("cbr", "daily.xml")) ) files = dynamic or {USD_ID: "dynamic_usd.xml", JPY_ID: "dynamic_jpy.xml"} def _dynamic(request: httpx.Request) -> httpx.Response: cbr_id = request.url.params.get("VAL_NM_RQ", "") name = files.get(cbr_id) if name is None: return httpx.Response(200, content=b"") return httpx.Response(200, content=fixture_bytes("cbr", name)) return daily, mock_http.get(DYNAMIC_URL).mock(side_effect=_dynamic) async def test_only_currencies_in_use_are_requested(app, mock_http, fixture_bytes, run_sync): await make_account(currency="RUB") await make_account(currency="USD", source_id="usd-cash") await make_txn(date(2026, 9, 1), outcome="100", outcome_currency="USD") _, dynamic = mock_cbr(mock_http, fixture_bytes) result = await run_sync(CbrSource(), settings=settings()) requested = {params["VAL_NM_RQ"] for params in route_params(dynamic)} assert requested == {USD_ID} # EUR and JPY are quoted by CBR but unused here assert result.counts == {"currencies": 1, "rates": 3} assert result.warnings == [] assert result.changed is True assert result.cursor_after is not None async def test_values_and_nominal_are_stored_as_printed(app, mock_http, fixture_bytes, run_sync): await make_account(currency="JPY", source_id="jpy") await make_txn(date(2026, 9, 1), outcome="1000", outcome_currency="JPY") mock_cbr(mock_http, fixture_bytes) await run_sync(CbrSource(), settings=settings()) rates = await stored_rates() # JPY is quoted per 100 units: both parts are kept, pricing/fx.py does the division assert rates[("JPY", "2026-09-01")] == (100, Decimal("61.5432")) assert rates[("JPY", "2026-09-02")] == (100, Decimal("62.0000")) async def test_currency_cbr_does_not_quote_is_a_warning(app, mock_http, fixture_bytes, run_sync): await make_account(currency="USD", source_id="usd") await make_account(currency="XAU", source_id="gold") await make_txn(date(2026, 9, 1), outcome="1", outcome_currency="BTC") _, dynamic = mock_cbr(mock_http, fixture_bytes) result = await run_sync(CbrSource(), settings=settings()) assert {params["VAL_NM_RQ"] for params in route_params(dynamic)} == {USD_ID} assert sorted(result.warnings) == [ "BTC is not quoted by CBR — skipped (crypto or metal?)", "XAU is not quoted by CBR — skipped (crypto or metal?)", ] assert result.counts["currencies"] == 1 assert result.counts["rates"] == 3 async def test_range_starts_before_the_first_transaction(app, mock_http, fixture_bytes, run_sync): first = today_local() - timedelta(days=100) await make_account(currency="USD", source_id="usd") await make_txn(first, outcome="10", outcome_currency="USD") _, dynamic = mock_cbr(mock_http, fixture_bytes) await run_sync(CbrSource(), settings=settings()) params = route_params(dynamic)[0] assert params["date_req1"] == (first - timedelta(days=7)).strftime("%d/%m/%Y") assert params["date_req2"] == today_local().strftime("%d/%m/%Y") async def test_cursor_shortens_the_range_and_reruns_are_idempotent( app, mock_http, fixture_bytes, run_sync ): await make_account(currency="USD", source_id="usd") await make_txn(today_local() - timedelta(days=10), outcome="10", outcome_currency="USD") _, dynamic = mock_cbr(mock_http, fixture_bytes) first = await run_sync(CbrSource(), settings=settings()) before = await stored_rates() second = await run_sync(CbrSource(), settings=settings(), cursor=first.cursor_after) assert second.counts == first.counts assert await stored_rates() == before params = route_params(dynamic)[-1] expected_start = date.fromisoformat(first.cursor_after or "") - timedelta(days=3) assert params["date_req1"] == expected_start.strftime("%d/%m/%Y") async def test_no_foreign_currency_skips_the_network(app, mock_http, fixture_bytes, run_sync): await make_account(currency="RUB") daily, dynamic = mock_cbr(mock_http, fixture_bytes) result = await run_sync(CbrSource(), settings=settings()) assert not daily.called and not dynamic.called assert result.changed is False assert result.counts == {"currencies": 0, "rates": 0} def test_long_ranges_are_chunked_by_year(): windows = split_range(date(2020, 1, 1), date(2023, 6, 1)) assert len(windows) == 4 assert windows[0][0] == date(2020, 1, 1) assert windows[-1][1] == date(2023, 6, 1) for start, end in windows: assert (end - start).days < 366 # windows are contiguous, no day is fetched twice or skipped for (_, end), (start, _) in pairwise(windows): assert start == end + timedelta(days=1) assert split_range(date(2026, 1, 2), date(2026, 1, 1)) == []