Files
fin-tracker/backend/tests/sources/test_cbr.py
T
Dmitry bc5d5c0811 feat(cbr): USD и CNY тянутся всегда, новая валюта — с начала леджера
Валюта, впервые появившаяся в ZenMoney между двумя запусками, раньше показывала «нет курса» до следующего синка. Для валюты без сохранённых курсов история берётся с начала леджера, а не от общего курсора.
2026-09-19 21:54:51 +03:00

189 lines
7.5 KiB
Python

"""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"
CNY_ID = "R01375"
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",
CNY_ID: "dynamic_cny.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"<ValCurs></ValCurs>")
return httpx.Response(200, content=fixture_bytes("cbr", name))
return daily, mock_http.get(DYNAMIC_URL).mock(side_effect=_dynamic)
async def test_currencies_in_use_are_requested_besides_the_always_quoted_pair(
app, mock_http, fixture_bytes, run_sync
):
await make_account(currency="RUB")
await make_account(currency="JPY", source_id="jpy-cash")
await make_txn(date(2026, 9, 1), outcome="100", outcome_currency="JPY")
_, 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, CNY_ID, JPY_ID} # EUR is quoted by CBR, but nobody uses it
assert result.counts["currencies"] == 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, CNY_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"] == 2
assert result.counts["rates"] == 6
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_the_dollar_and_the_yuan_are_fetched_even_with_only_roubles(
app, mock_http, fixture_bytes, run_sync
):
"""A currency that shows up in ZenMoney between two runs must already have its rate."""
await make_account(currency="RUB")
daily, dynamic = mock_cbr(mock_http, fixture_bytes)
result = await run_sync(CbrSource(), settings=settings())
assert daily.called
assert {params["VAL_NM_RQ"] for params in route_params(dynamic)} == {USD_ID, CNY_ID}
assert result.changed is True
assert result.counts == {"currencies": 2, "rates": 6}
rates = await stored_rates()
assert rates[("CNY", "2026-09-03")] == (1, Decimal("12.7654"))
async def test_a_currency_new_to_the_table_is_backfilled_from_the_start_of_the_ledger(
app, mock_http, fixture_bytes, run_sync
):
"""The cursor only knows what the other currencies have; a new one needs its whole past."""
first = today_local() - timedelta(days=10)
await make_account(currency="USD", source_id="usd")
await make_txn(first, outcome="10", outcome_currency="USD")
_, dynamic = mock_cbr(mock_http, fixture_bytes)
earlier = await run_sync(CbrSource(), settings=settings())
await make_account(currency="JPY", source_id="jpy")
await make_txn(first + timedelta(days=2), outcome="1000", outcome_currency="JPY")
seen = len(dynamic.calls)
await run_sync(CbrSource(), settings=settings(), cursor=earlier.cursor_after)
starts = {p["VAL_NM_RQ"]: p["date_req1"] for p in route_params(dynamic)[seen:]}
incremental = date.fromisoformat(earlier.cursor_after or "") - timedelta(days=3)
assert starts[JPY_ID] == (first - timedelta(days=7)).strftime("%d/%m/%Y")
assert starts[USD_ID] == incremental.strftime("%d/%m/%Y")
assert starts[CNY_ID] == incremental.strftime("%d/%m/%Y")
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)) == []