feat(sources): контракт источников, worker и синк ZenMoney + ЦБ
Source.sync(ctx) -> SyncResult пишет только raw_* и возвращает курсор; локи, журнал, ошибки и продвижение курсора берёт на себя worker/runner. ZenMoney читается единственным доступным способом — POST /v8/diff/ по serverTimestamp; токен живёт сутки, поэтому worker ротирует refresh_token через source_credential. Маппер всегда пересобирает core из полных raw_*, так что удаление в ZenMoney исчезает и у нас. ЦБ ходит мимо прокси (trust_env=False) и отдаёт cp1251 с делением на Nominal. Курсы только по рабочим дням — протяжку по календарю делает аналитика. Планировщик — APScheduler в отдельном процессе, на источник advisory-лок sync:<name>, чтобы ручной запуск не пересёкся с плановым.
This commit is contained in:
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="windows-1251"?>
|
||||
<ValCurs Date="17.09.2026" name="Foreign Currency Market">
|
||||
<Valute ID="R01235">
|
||||
<NumCode>840</NumCode>
|
||||
<CharCode>USD</CharCode>
|
||||
<Nominal>1</Nominal>
|
||||
<Name>Äîëëàð ÑØÀ</Name>
|
||||
<Value>92,1234</Value>
|
||||
<VunitRate>92,1234</VunitRate>
|
||||
</Valute>
|
||||
<Valute ID="R01820">
|
||||
<NumCode>392</NumCode>
|
||||
<CharCode>JPY</CharCode>
|
||||
<Nominal>100</Nominal>
|
||||
<Name>ßïîíñêèõ èåí</Name>
|
||||
<Value>61,5432</Value>
|
||||
<VunitRate>0,615432</VunitRate>
|
||||
</Valute>
|
||||
<Valute ID="R01239">
|
||||
<NumCode>978</NumCode>
|
||||
<CharCode>EUR</CharCode>
|
||||
<Nominal>1</Nominal>
|
||||
<Name>Åâðî</Name>
|
||||
<Value>101,4567</Value>
|
||||
<VunitRate>101,4567</VunitRate>
|
||||
</Valute>
|
||||
</ValCurs>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="windows-1251"?>
|
||||
<ValCurs ID="R01820" DateRange1="01.09.2026" DateRange2="03.09.2026" name="Foreign Currency Market Dynamic">
|
||||
<Record Date="01.09.2026" Id="R01820">
|
||||
<Nominal>100</Nominal>
|
||||
<Value>61,5432</Value>
|
||||
<VunitRate>0,615432</VunitRate>
|
||||
</Record>
|
||||
<Record Date="02.09.2026" Id="R01820">
|
||||
<Nominal>100</Nominal>
|
||||
<Value>62,0000</Value>
|
||||
<VunitRate>0,62</VunitRate>
|
||||
</Record>
|
||||
</ValCurs>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="windows-1251"?>
|
||||
<ValCurs ID="R01235" DateRange1="01.09.2026" DateRange2="03.09.2026" name="Foreign Currency Market Dynamic">
|
||||
<Record Date="01.09.2026" Id="R01235">
|
||||
<Nominal>1</Nominal>
|
||||
<Value>92,1234</Value>
|
||||
<VunitRate>92,1234</VunitRate>
|
||||
</Record>
|
||||
<Record Date="02.09.2026" Id="R01235">
|
||||
<Nominal>1</Nominal>
|
||||
<Value>92,5000</Value>
|
||||
<VunitRate>92,5</VunitRate>
|
||||
</Record>
|
||||
<Record Date="03.09.2026" Id="R01235">
|
||||
<Nominal>1</Nominal>
|
||||
<Value>93,0100</Value>
|
||||
<VunitRate>93,01</VunitRate>
|
||||
</Record>
|
||||
</ValCurs>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"serverTimestamp": 1750001000,
|
||||
"account": [
|
||||
{
|
||||
"id": "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
"changed": 1750000900,
|
||||
"user": 555,
|
||||
"instrument": 2,
|
||||
"type": "ccard",
|
||||
"title": "Карта Т-Банк",
|
||||
"syncID": ["4377"],
|
||||
"balance": 24000.5,
|
||||
"startBalance": 0,
|
||||
"creditLimit": 100000,
|
||||
"inBalance": true,
|
||||
"savings": false,
|
||||
"archive": false,
|
||||
"enableCorrection": false,
|
||||
"enableSMS": true
|
||||
}
|
||||
],
|
||||
"deletion": [
|
||||
{
|
||||
"id": "dddddddd-0000-0000-0000-000000000006",
|
||||
"object": "transaction",
|
||||
"stamp": 1750000950,
|
||||
"user": 555
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"serverTimestamp": 1750000500,
|
||||
"deletion": []
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
{
|
||||
"serverTimestamp": 1750000000,
|
||||
"instrument": [
|
||||
{"id": 2, "title": "Российский рубль", "shortTitle": "RUB", "symbol": "₽", "rate": 1, "changed": 1700000000},
|
||||
{"id": 1, "title": "Доллар США", "shortTitle": "USD", "symbol": "$", "rate": 92.5, "changed": 1700000000}
|
||||
],
|
||||
"company": [
|
||||
{"id": 4711, "title": "Тинькофф", "changed": 1700000000}
|
||||
],
|
||||
"user": [
|
||||
{"id": 555, "country": 1, "login": "ada", "currency": 2, "changed": 1700000000}
|
||||
],
|
||||
"account": [
|
||||
{
|
||||
"id": "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
"changed": 1749000000,
|
||||
"user": 555,
|
||||
"instrument": 2,
|
||||
"type": "ccard",
|
||||
"title": "Карта Тинькофф",
|
||||
"syncID": ["4377"],
|
||||
"balance": 25000.5,
|
||||
"startBalance": 0,
|
||||
"creditLimit": 100000,
|
||||
"inBalance": true,
|
||||
"savings": false,
|
||||
"archive": false,
|
||||
"enableCorrection": false,
|
||||
"enableSMS": true
|
||||
},
|
||||
{
|
||||
"id": "aaaaaaaa-0000-0000-0000-000000000002",
|
||||
"changed": 1749000001,
|
||||
"user": 555,
|
||||
"instrument": 1,
|
||||
"type": "cash",
|
||||
"title": "Наличные USD",
|
||||
"balance": 300.25,
|
||||
"startBalance": 0,
|
||||
"creditLimit": 0,
|
||||
"inBalance": true,
|
||||
"savings": false,
|
||||
"archive": false,
|
||||
"enableCorrection": false,
|
||||
"enableSMS": false
|
||||
},
|
||||
{
|
||||
"id": "aaaaaaaa-0000-0000-0000-000000000003",
|
||||
"changed": 1749000002,
|
||||
"user": 555,
|
||||
"instrument": 2,
|
||||
"type": "deposit",
|
||||
"title": "Вклад",
|
||||
"balance": 500000,
|
||||
"startBalance": 400000,
|
||||
"creditLimit": 0,
|
||||
"inBalance": true,
|
||||
"savings": true,
|
||||
"archive": false,
|
||||
"enableCorrection": false,
|
||||
"enableSMS": false,
|
||||
"capitalization": true,
|
||||
"percent": 16.5,
|
||||
"startDate": "2025-02-01",
|
||||
"endDateOffset": 12,
|
||||
"endDateOffsetInterval": "month",
|
||||
"payoffStep": 1,
|
||||
"payoffInterval": "month"
|
||||
},
|
||||
{
|
||||
"id": "aaaaaaaa-0000-0000-0000-000000000009",
|
||||
"changed": 1749000003,
|
||||
"user": 555,
|
||||
"instrument": 2,
|
||||
"type": "debt",
|
||||
"title": "Долги",
|
||||
"balance": 0,
|
||||
"startBalance": 0,
|
||||
"creditLimit": 0,
|
||||
"inBalance": false,
|
||||
"savings": false,
|
||||
"archive": false,
|
||||
"enableCorrection": false,
|
||||
"enableSMS": false
|
||||
}
|
||||
],
|
||||
"tag": [
|
||||
{
|
||||
"id": "bbbbbbbb-0000-0000-0000-000000000001",
|
||||
"changed": 1748000000,
|
||||
"user": 555,
|
||||
"title": "Еда",
|
||||
"parent": null,
|
||||
"icon": "food",
|
||||
"color": 4294198070,
|
||||
"showIncome": false,
|
||||
"showOutcome": true,
|
||||
"budgetIncome": false,
|
||||
"budgetOutcome": true,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"id": "bbbbbbbb-0000-0000-0000-000000000002",
|
||||
"changed": 1748000001,
|
||||
"user": 555,
|
||||
"title": "Кафе",
|
||||
"parent": "bbbbbbbb-0000-0000-0000-000000000001",
|
||||
"icon": "cafe",
|
||||
"color": null,
|
||||
"showIncome": false,
|
||||
"showOutcome": true,
|
||||
"budgetIncome": false,
|
||||
"budgetOutcome": true,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"id": "bbbbbbbb-0000-0000-0000-000000000003",
|
||||
"changed": 1748000002,
|
||||
"user": 555,
|
||||
"title": "Зарплата",
|
||||
"parent": null,
|
||||
"icon": null,
|
||||
"color": null,
|
||||
"showIncome": true,
|
||||
"showOutcome": false,
|
||||
"budgetIncome": true,
|
||||
"budgetOutcome": false,
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"merchant": [
|
||||
{"id": "cccccccc-0000-0000-0000-000000000001", "changed": 1748500000, "user": 555, "title": "Кофейня"}
|
||||
],
|
||||
"budget": [],
|
||||
"reminder": [],
|
||||
"reminderMarker": [],
|
||||
"transaction": [
|
||||
{
|
||||
"id": "dddddddd-0000-0000-0000-000000000001",
|
||||
"changed": 1749500000,
|
||||
"created": 1749500000,
|
||||
"user": 555,
|
||||
"deleted": false,
|
||||
"hold": false,
|
||||
"income": 0,
|
||||
"outcome": 350.4,
|
||||
"incomeAccount": "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
"outcomeAccount": "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
"incomeInstrument": 2,
|
||||
"outcomeInstrument": 2,
|
||||
"tag": ["bbbbbbbb-0000-0000-0000-000000000002", "bbbbbbbb-0000-0000-0000-000000000001"],
|
||||
"merchant": "cccccccc-0000-0000-0000-000000000001",
|
||||
"payee": "COFFEE HOUSE",
|
||||
"originalPayee": "COFFEE HOUSE MOSCOW",
|
||||
"comment": "флэт уайт",
|
||||
"date": "2025-09-01",
|
||||
"mcc": 5812,
|
||||
"latitude": 55.75,
|
||||
"longitude": 37.61
|
||||
},
|
||||
{
|
||||
"id": "dddddddd-0000-0000-0000-000000000002",
|
||||
"changed": 1749500100,
|
||||
"created": 1749500100,
|
||||
"user": 555,
|
||||
"deleted": false,
|
||||
"hold": false,
|
||||
"income": 180000,
|
||||
"outcome": 0,
|
||||
"incomeAccount": "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
"outcomeAccount": "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
"incomeInstrument": 2,
|
||||
"outcomeInstrument": 2,
|
||||
"tag": ["bbbbbbbb-0000-0000-0000-000000000003"],
|
||||
"merchant": null,
|
||||
"payee": null,
|
||||
"originalPayee": null,
|
||||
"comment": "зарплата за август",
|
||||
"date": "2025-09-05",
|
||||
"mcc": null
|
||||
},
|
||||
{
|
||||
"id": "dddddddd-0000-0000-0000-000000000003",
|
||||
"changed": 1749500200,
|
||||
"created": 1749500200,
|
||||
"user": 555,
|
||||
"deleted": false,
|
||||
"hold": false,
|
||||
"income": 50000,
|
||||
"outcome": 50000,
|
||||
"incomeAccount": "aaaaaaaa-0000-0000-0000-000000000003",
|
||||
"outcomeAccount": "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
"incomeInstrument": 2,
|
||||
"outcomeInstrument": 2,
|
||||
"tag": null,
|
||||
"merchant": null,
|
||||
"payee": null,
|
||||
"comment": "на вклад",
|
||||
"date": "2025-09-06",
|
||||
"mcc": null
|
||||
},
|
||||
{
|
||||
"id": "dddddddd-0000-0000-0000-000000000004",
|
||||
"changed": 1749500300,
|
||||
"created": 1749500300,
|
||||
"user": 555,
|
||||
"deleted": false,
|
||||
"hold": false,
|
||||
"income": 0,
|
||||
"outcome": 19.99,
|
||||
"incomeAccount": "aaaaaaaa-0000-0000-0000-000000000002",
|
||||
"outcomeAccount": "aaaaaaaa-0000-0000-0000-000000000002",
|
||||
"incomeInstrument": 1,
|
||||
"outcomeInstrument": 1,
|
||||
"opOutcome": 1850.5,
|
||||
"opOutcomeInstrument": 2,
|
||||
"tag": [],
|
||||
"merchant": null,
|
||||
"payee": "APPLE",
|
||||
"comment": null,
|
||||
"date": "2025-09-07",
|
||||
"mcc": 5734
|
||||
},
|
||||
{
|
||||
"id": "dddddddd-0000-0000-0000-000000000005",
|
||||
"changed": 1749500400,
|
||||
"created": 1749500400,
|
||||
"user": 555,
|
||||
"deleted": true,
|
||||
"hold": false,
|
||||
"income": 0,
|
||||
"outcome": 999,
|
||||
"incomeAccount": "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
"outcomeAccount": "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
"incomeInstrument": 2,
|
||||
"outcomeInstrument": 2,
|
||||
"tag": null,
|
||||
"merchant": null,
|
||||
"payee": "ОШИБКА",
|
||||
"comment": null,
|
||||
"date": "2025-09-08",
|
||||
"mcc": null
|
||||
},
|
||||
{
|
||||
"id": "dddddddd-0000-0000-0000-000000000006",
|
||||
"changed": 1749500500,
|
||||
"created": 1749500500,
|
||||
"user": 555,
|
||||
"deleted": false,
|
||||
"hold": true,
|
||||
"income": 0,
|
||||
"outcome": 1200,
|
||||
"incomeAccount": "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
"outcomeAccount": "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
"incomeInstrument": 2,
|
||||
"outcomeInstrument": 2,
|
||||
"tag": null,
|
||||
"merchant": null,
|
||||
"payee": "АЗС",
|
||||
"comment": null,
|
||||
"date": "2025-09-09",
|
||||
"mcc": 5541
|
||||
}
|
||||
],
|
||||
"deletion": []
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Shared helpers for source tests: fixture loading and a SyncContext around a real session.
|
||||
|
||||
Exposed as fixtures rather than importable functions: `tests/sources` is a package, and a
|
||||
module named `sources` on sys.path next to `fintracker.sources` reads like a trap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from fintracker.config import Settings
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.sources.base import SyncContext, SyncResult
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent.parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixture_json() -> Callable[..., dict[str, Any]]:
|
||||
def _load(*parts: str) -> dict[str, Any]:
|
||||
return json.loads(FIXTURES.joinpath(*parts).read_text(encoding="utf-8"))
|
||||
|
||||
return _load
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixture_bytes() -> Callable[..., bytes]:
|
||||
def _load(*parts: str) -> bytes:
|
||||
return FIXTURES.joinpath(*parts).read_bytes()
|
||||
|
||||
return _load
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_http():
|
||||
"""respx router without `assert_all_called`: some tests deliberately prove that a
|
||||
route (e.g. the token endpoint) was NOT hit."""
|
||||
with respx.mock(assert_all_called=False) as router:
|
||||
yield router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_sync():
|
||||
"""Run a source the way the worker does, but without the lock/run-log bookkeeping."""
|
||||
|
||||
async def _run(source: Any, *, settings: Settings, cursor: str | None = None) -> SyncResult:
|
||||
async with get_sessionmaker()() as session:
|
||||
ctx = SyncContext(
|
||||
session=session,
|
||||
settings=settings,
|
||||
cursor_before=cursor,
|
||||
triggered_by="test",
|
||||
)
|
||||
return await source.sync(ctx)
|
||||
|
||||
return _run
|
||||
@@ -0,0 +1,153 @@
|
||||
"""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"<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_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)) == []
|
||||
@@ -0,0 +1,517 @@
|
||||
"""ZenMoney source: the diff loop, the raw tier, the core mapping and both auth modes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from fintracker.config import Settings
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
Account,
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
CashTxn,
|
||||
CashTxnTag,
|
||||
Category,
|
||||
FlowType,
|
||||
Merchant,
|
||||
RawZenmoneyDeletion,
|
||||
RawZenmoneyEntity,
|
||||
SourceCredential,
|
||||
SyncState,
|
||||
)
|
||||
from fintracker.sources.zenmoney.client import (
|
||||
DIFF_URL,
|
||||
TOKEN_URL,
|
||||
ZenmoneyAuthError,
|
||||
)
|
||||
from fintracker.sources.zenmoney.sync import ZenmoneySource
|
||||
|
||||
CARD = "aaaaaaaa-0000-0000-0000-000000000001"
|
||||
USD_CASH = "aaaaaaaa-0000-0000-0000-000000000002"
|
||||
DEPOSIT = "aaaaaaaa-0000-0000-0000-000000000003"
|
||||
DEBT = "aaaaaaaa-0000-0000-0000-000000000009"
|
||||
TAG_FOOD = "bbbbbbbb-0000-0000-0000-000000000001"
|
||||
TAG_CAFE = "bbbbbbbb-0000-0000-0000-000000000002"
|
||||
TXN_EXPENSE = "dddddddd-0000-0000-0000-000000000001"
|
||||
TXN_INCOME = "dddddddd-0000-0000-0000-000000000002"
|
||||
TXN_TRANSFER = "dddddddd-0000-0000-0000-000000000003"
|
||||
TXN_USD = "dddddddd-0000-0000-0000-000000000004"
|
||||
TXN_DELETED = "dddddddd-0000-0000-0000-000000000005"
|
||||
TXN_HOLD = "dddddddd-0000-0000-0000-000000000006"
|
||||
|
||||
|
||||
def static_settings() -> Settings:
|
||||
return Settings(
|
||||
zenmoney_token="static-access-token",
|
||||
zenmoney_client_id=None,
|
||||
zenmoney_client_secret=None,
|
||||
zenmoney_refresh_token=None,
|
||||
)
|
||||
|
||||
|
||||
def oauth_settings() -> Settings:
|
||||
return Settings(
|
||||
zenmoney_token=None,
|
||||
zenmoney_client_id="client-id",
|
||||
zenmoney_client_secret="client-secret",
|
||||
zenmoney_refresh_token="seed-refresh-token",
|
||||
)
|
||||
|
||||
|
||||
async def counts() -> dict[str, int]:
|
||||
async with get_sessionmaker()() as session:
|
||||
out = {}
|
||||
for name, model in (
|
||||
("raw", RawZenmoneyEntity),
|
||||
("accounts", Account),
|
||||
("categories", Category),
|
||||
("merchants", Merchant),
|
||||
("transactions", CashTxn),
|
||||
("tags", CashTxnTag),
|
||||
):
|
||||
out[name] = (
|
||||
await session.execute(select(func.count()).select_from(model))
|
||||
).scalar_one()
|
||||
return out
|
||||
|
||||
|
||||
async def account_by_source_id(source_id: str) -> Account:
|
||||
async with get_sessionmaker()() as session:
|
||||
return (
|
||||
await session.execute(select(Account).where(Account.source_id == source_id))
|
||||
).scalar_one()
|
||||
|
||||
|
||||
async def txn_by_source_id(source_id: str) -> CashTxn:
|
||||
async with get_sessionmaker()() as session:
|
||||
return (
|
||||
await session.execute(select(CashTxn).where(CashTxn.source_id == source_id))
|
||||
).scalar_one()
|
||||
|
||||
|
||||
async def test_first_sync_asks_for_everything_and_maps_core_rows(
|
||||
app, mock_http, fixture_json, run_sync
|
||||
):
|
||||
diff = fixture_json("zenmoney", "diff_full.json")
|
||||
route = mock_http.post(DIFF_URL).mock(return_value=httpx.Response(200, json=diff))
|
||||
|
||||
result = await run_sync(ZenmoneySource(), settings=static_settings())
|
||||
|
||||
body = json.loads(route.calls.last.request.content)
|
||||
assert body["serverTimestamp"] == 0
|
||||
assert "transaction" in body["forceFetch"] and "account" in body["forceFetch"]
|
||||
assert body["currentClientTimestamp"] > 1_700_000_000
|
||||
assert route.calls.last.request.headers["authorization"] == "Bearer static-access-token"
|
||||
|
||||
assert result.cursor_after == "1750000000"
|
||||
assert result.changed is True
|
||||
assert (
|
||||
result.counts["raw_upserted"] == 18
|
||||
) # 2 instruments + company + user + 4 acc + 3 tags + 1 merchant + 6 txns
|
||||
assert result.counts["transactions"] == 6
|
||||
assert result.counts["accounts"] == 4
|
||||
assert result.counts["categories"] == 3
|
||||
|
||||
rows = await counts()
|
||||
assert rows["accounts"] == 4
|
||||
assert rows["categories"] == 3
|
||||
assert rows["merchants"] == 1
|
||||
assert rows["transactions"] == 6
|
||||
assert rows["tags"] == 3 # 2 on the expense, 1 on the income
|
||||
|
||||
|
||||
async def test_account_mapping(app, mock_http, fixture_json, run_sync):
|
||||
mock_http.post(DIFF_URL).mock(
|
||||
return_value=httpx.Response(200, json=fixture_json("zenmoney", "diff_full.json"))
|
||||
)
|
||||
await run_sync(ZenmoneySource(), settings=static_settings())
|
||||
|
||||
card = await account_by_source_id(CARD)
|
||||
assert card.kind is AccountKind.zm_card # ZenMoney says "ccard"
|
||||
assert card.role is AccountRole.liquid
|
||||
assert card.currency == "RUB"
|
||||
assert card.include_in_net_worth is True
|
||||
assert card.balance == Decimal("25000.5")
|
||||
assert card.credit_limit == Decimal("100000")
|
||||
assert card.balance_as_of is not None
|
||||
assert card.deposit_terms is None
|
||||
|
||||
usd = await account_by_source_id(USD_CASH)
|
||||
assert usd.currency == "USD"
|
||||
assert usd.kind is AccountKind.zm_cash
|
||||
|
||||
deposit = await account_by_source_id(DEPOSIT)
|
||||
assert deposit.role is AccountRole.savings
|
||||
assert deposit.deposit_terms is not None
|
||||
assert deposit.deposit_terms["percent"] == 16.5
|
||||
assert deposit.opened_at is not None and deposit.opened_at.isoformat() == "2025-02-01"
|
||||
|
||||
debt = await account_by_source_id(DEBT)
|
||||
assert debt.kind is AccountKind.zm_debt
|
||||
assert debt.role is AccountRole.debt
|
||||
assert debt.include_in_net_worth is False # inBalance = false on the system debt account
|
||||
|
||||
|
||||
async def test_category_tree_and_transaction_mapping(app, mock_http, fixture_json, run_sync):
|
||||
mock_http.post(DIFF_URL).mock(
|
||||
return_value=httpx.Response(200, json=fixture_json("zenmoney", "diff_full.json"))
|
||||
)
|
||||
await run_sync(ZenmoneySource(), settings=static_settings())
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
food = (
|
||||
await session.execute(select(Category).where(Category.source_id == TAG_FOOD))
|
||||
).scalar_one()
|
||||
cafe = (
|
||||
await session.execute(select(Category).where(Category.source_id == TAG_CAFE))
|
||||
).scalar_one()
|
||||
assert food.parent_id is None
|
||||
assert cafe.parent_id == food.id
|
||||
assert food.color == 4294198070
|
||||
|
||||
expense = (
|
||||
await session.execute(select(CashTxn).where(CashTxn.source_id == TXN_EXPENSE))
|
||||
).scalar_one()
|
||||
tags = (
|
||||
(
|
||||
await session.execute(
|
||||
select(CashTxnTag)
|
||||
.where(CashTxnTag.txn_id == expense.id)
|
||||
.order_by(CashTxnTag.ord)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
assert expense.flow_type is FlowType.expense
|
||||
assert expense.outcome == Decimal("350.4")
|
||||
assert expense.income == Decimal(0)
|
||||
assert expense.outcome_currency == "RUB"
|
||||
assert expense.mcc == 5812
|
||||
assert expense.payee == "COFFEE HOUSE"
|
||||
assert expense.original_payee == "COFFEE HOUSE MOSCOW"
|
||||
assert expense.merchant_id is not None
|
||||
assert expense.ts == datetime.fromtimestamp(1749500000, UTC)
|
||||
assert expense.date.isoformat() == "2025-09-01"
|
||||
assert expense.meta == {"latitude": 55.75, "longitude": 37.61}
|
||||
# primary category is the FIRST tag as ZenMoney ordered it, not the parent
|
||||
assert [t.category_id for t in tags] == [cafe.id, food.id]
|
||||
assert expense.primary_category_id == cafe.id
|
||||
assert expense.category_id == cafe.id
|
||||
|
||||
income = await txn_by_source_id(TXN_INCOME)
|
||||
assert income.flow_type is FlowType.income
|
||||
assert income.income == Decimal("180000")
|
||||
|
||||
transfer = await txn_by_source_id(TXN_TRANSFER)
|
||||
assert transfer.flow_type is FlowType.internal_transfer
|
||||
assert transfer.income_account_id != transfer.outcome_account_id
|
||||
assert transfer.income == Decimal("50000") and transfer.outcome == Decimal("50000")
|
||||
|
||||
usd = await txn_by_source_id(TXN_USD)
|
||||
assert usd.outcome_currency == "USD"
|
||||
assert usd.outcome == Decimal("19.99")
|
||||
assert usd.op_outcome == Decimal("1850.5")
|
||||
assert usd.op_outcome_currency == "RUB"
|
||||
|
||||
deleted = await txn_by_source_id(TXN_DELETED)
|
||||
assert deleted.deleted is True
|
||||
assert deleted.flow_type is FlowType.deleted
|
||||
|
||||
hold = await txn_by_source_id(TXN_HOLD)
|
||||
assert hold.hold is True
|
||||
assert hold.flow_type is FlowType.expense
|
||||
|
||||
|
||||
async def test_second_sync_with_empty_diff_changes_nothing(app, mock_http, fixture_json, run_sync):
|
||||
mock_http.post(DIFF_URL).mock(
|
||||
return_value=httpx.Response(200, json=fixture_json("zenmoney", "diff_full.json"))
|
||||
)
|
||||
first = await run_sync(ZenmoneySource(), settings=static_settings())
|
||||
before = await counts()
|
||||
|
||||
mock_http.post(DIFF_URL).mock(
|
||||
return_value=httpx.Response(200, json=fixture_json("zenmoney", "diff_empty.json"))
|
||||
)
|
||||
second = await run_sync(ZenmoneySource(), settings=static_settings(), cursor=first.cursor_after)
|
||||
|
||||
assert second.changed is False
|
||||
assert second.cursor_after == "1750000500"
|
||||
assert second.counts == {"raw_upserted": 0, "deleted": 0}
|
||||
assert await counts() == before
|
||||
|
||||
|
||||
async def test_rerunning_the_same_diff_is_idempotent(app, mock_http, fixture_json, run_sync):
|
||||
mock_http.post(DIFF_URL).mock(
|
||||
return_value=httpx.Response(200, json=fixture_json("zenmoney", "diff_full.json"))
|
||||
)
|
||||
await run_sync(ZenmoneySource(), settings=static_settings())
|
||||
before = await counts()
|
||||
await run_sync(ZenmoneySource(), settings=static_settings())
|
||||
assert await counts() == before
|
||||
|
||||
|
||||
async def test_deletion_flags_the_core_row_and_keeps_the_fact(
|
||||
app, mock_http, fixture_json, run_sync
|
||||
):
|
||||
mock_http.post(DIFF_URL).mock(
|
||||
return_value=httpx.Response(200, json=fixture_json("zenmoney", "diff_full.json"))
|
||||
)
|
||||
first = await run_sync(ZenmoneySource(), settings=static_settings())
|
||||
|
||||
mock_http.post(DIFF_URL).mock(
|
||||
return_value=httpx.Response(200, json=fixture_json("zenmoney", "diff_deletion.json"))
|
||||
)
|
||||
third = await run_sync(ZenmoneySource(), settings=static_settings(), cursor=first.cursor_after)
|
||||
|
||||
assert third.changed is True
|
||||
assert third.counts["deleted"] == 1
|
||||
assert third.cursor_after == "1750001000"
|
||||
|
||||
gone = await txn_by_source_id(TXN_HOLD)
|
||||
assert gone.deleted is True
|
||||
assert gone.flow_type is FlowType.deleted
|
||||
|
||||
card = await account_by_source_id(CARD)
|
||||
# the upstream rename to "Карта Т-Банк" is NOT applied: `name` is user-owned after the
|
||||
# first insert (see test_sync_does_not_overwrite_user_edits_of_an_account)
|
||||
assert card.name == "Карта Тинькофф"
|
||||
assert card.balance == Decimal("24000.5")
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
raw = await session.get(RawZenmoneyEntity, {"entity_type": "transaction", "id": TXN_HOLD})
|
||||
assert raw is None
|
||||
deletion = await session.get(
|
||||
RawZenmoneyDeletion, {"entity_type": "transaction", "id": TXN_HOLD}
|
||||
)
|
||||
assert deletion is not None and deletion.stamp == 1750000950
|
||||
|
||||
# the transaction count is unchanged: a deleted row is flagged, never dropped
|
||||
assert (await counts())["transactions"] == 6
|
||||
|
||||
|
||||
async def test_cursor_is_stored_by_the_worker(app, mock_http, fixture_json, monkeypatch):
|
||||
from fintracker.worker import runner
|
||||
|
||||
monkeypatch.setattr(runner, "get_settings", static_settings)
|
||||
|
||||
mock_http.post(DIFF_URL).mock(
|
||||
return_value=httpx.Response(200, json=fixture_json("zenmoney", "diff_full.json"))
|
||||
)
|
||||
run = await runner.run_source("zenmoney", triggered_by="cli")
|
||||
assert run.status.value == "ok", run.error
|
||||
assert run.cursor_after == "1750000000"
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
state = await session.get(SyncState, "zenmoney")
|
||||
assert state is not None and state.cursor == "1750000000"
|
||||
|
||||
|
||||
# --- auth -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_static_token_401_tells_the_user_to_renew_it(app, mock_http, fixture_json, run_sync):
|
||||
mock_http.post(DIFF_URL).mock(return_value=httpx.Response(401, json={"error": "nope"}))
|
||||
|
||||
with pytest.raises(ZenmoneyAuthError) as exc:
|
||||
await run_sync(ZenmoneySource(), settings=static_settings())
|
||||
assert "ZENMONEY_TOKEN" in str(exc.value)
|
||||
|
||||
|
||||
async def test_missing_static_token_is_a_clear_error(app, run_sync):
|
||||
settings = Settings(
|
||||
zenmoney_token=None,
|
||||
zenmoney_client_id=None,
|
||||
zenmoney_client_secret=None,
|
||||
zenmoney_refresh_token=None,
|
||||
)
|
||||
with pytest.raises(ZenmoneyAuthError) as exc:
|
||||
await run_sync(ZenmoneySource(), settings=settings)
|
||||
assert "ZENMONEY_TOKEN is not set" in str(exc.value)
|
||||
|
||||
|
||||
async def test_oauth_seeds_from_settings_and_stores_the_new_pair(
|
||||
app, mock_http, fixture_json, run_sync
|
||||
):
|
||||
token_route = mock_http.post(TOKEN_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"access_token": "fresh-access",
|
||||
"refresh_token": "fresh-refresh",
|
||||
"expires_in": 86400,
|
||||
"token_type": "bearer",
|
||||
},
|
||||
)
|
||||
)
|
||||
diff_route = mock_http.post(DIFF_URL).mock(
|
||||
return_value=httpx.Response(200, json=fixture_json("zenmoney", "diff_empty.json"))
|
||||
)
|
||||
|
||||
await run_sync(ZenmoneySource(), settings=oauth_settings())
|
||||
|
||||
form = dict(httpx.QueryParams(token_route.calls.last.request.content.decode()))
|
||||
assert form["grant_type"] == "refresh_token"
|
||||
assert form["refresh_token"] == "seed-refresh-token"
|
||||
assert form["client_id"] == "client-id"
|
||||
assert form["client_secret"] == "client-secret"
|
||||
assert diff_route.calls.last.request.headers["authorization"] == "Bearer fresh-access"
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
cred = await session.get(SourceCredential, "zenmoney")
|
||||
assert cred is not None
|
||||
assert cred.payload["access_token"] == "fresh-access"
|
||||
assert cred.payload["refresh_token"] == "fresh-refresh"
|
||||
assert cred.payload["expires_at"] > datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
async def test_oauth_refreshes_only_when_expired(app, mock_http, fixture_json, run_sync):
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
SourceCredential(
|
||||
source="zenmoney",
|
||||
payload={
|
||||
"access_token": "still-good",
|
||||
"refresh_token": "stored-refresh",
|
||||
"expires_at": (datetime.now(UTC) + timedelta(hours=5)).isoformat(),
|
||||
},
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
token_route = mock_http.post(TOKEN_URL).mock(return_value=httpx.Response(200, json={}))
|
||||
diff_route = mock_http.post(DIFF_URL).mock(
|
||||
return_value=httpx.Response(200, json=fixture_json("zenmoney", "diff_empty.json"))
|
||||
)
|
||||
|
||||
await run_sync(ZenmoneySource(), settings=oauth_settings())
|
||||
|
||||
assert not token_route.called
|
||||
assert diff_route.calls.last.request.headers["authorization"] == "Bearer still-good"
|
||||
|
||||
|
||||
async def test_oauth_refreshes_an_expired_token_and_retries_a_401(
|
||||
app, mock_http, fixture_json, run_sync
|
||||
):
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
SourceCredential(
|
||||
source="zenmoney",
|
||||
payload={
|
||||
"access_token": "expired-access",
|
||||
"refresh_token": "stored-refresh",
|
||||
"expires_at": (datetime.now(UTC) - timedelta(minutes=1)).isoformat(),
|
||||
},
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
token_route = mock_http.post(TOKEN_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"access_token": "rotated-access",
|
||||
"refresh_token": "rotated-refresh",
|
||||
"expires_in": 86400,
|
||||
},
|
||||
)
|
||||
)
|
||||
diff_route = mock_http.post(DIFF_URL).mock(
|
||||
side_effect=[
|
||||
httpx.Response(401, json={"error": "expired"}),
|
||||
httpx.Response(200, json=fixture_json("zenmoney", "diff_empty.json")),
|
||||
]
|
||||
)
|
||||
|
||||
result = await run_sync(ZenmoneySource(), settings=oauth_settings())
|
||||
|
||||
assert result.cursor_after == "1750000500"
|
||||
assert token_route.call_count == 2 # once because expired, once after the 401
|
||||
assert diff_route.call_count == 2
|
||||
assert diff_route.calls.last.request.headers["authorization"] == "Bearer rotated-access"
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
cred = await session.get(SourceCredential, "zenmoney")
|
||||
assert cred is not None and cred.payload["refresh_token"] == "rotated-refresh"
|
||||
|
||||
|
||||
# --- user-owned account fields --------------------------------------------------------
|
||||
|
||||
|
||||
async def test_sync_does_not_overwrite_user_edits_of_an_account(
|
||||
app, client, auth_headers, mock_http, fixture_json, run_sync
|
||||
):
|
||||
"""`name`, `role` and `include_in_net_worth` belong to the user (PATCH /accounts/{id});
|
||||
a later sync may only refresh what the source owns, such as the balance."""
|
||||
mock_http.post(DIFF_URL).mock(
|
||||
return_value=httpx.Response(200, json=fixture_json("zenmoney", "diff_full.json"))
|
||||
)
|
||||
first = await run_sync(ZenmoneySource(), settings=static_settings())
|
||||
card = await account_by_source_id(CARD)
|
||||
assert card.name == "Карта Тинькофф"
|
||||
|
||||
patched = await client.patch(
|
||||
f"/api/v1/accounts/{card.id}",
|
||||
headers=auth_headers,
|
||||
json={"name": "Основная карта", "role": "savings", "include_in_net_worth": False},
|
||||
)
|
||||
assert patched.status_code == 200, patched.text
|
||||
|
||||
# the same account comes back renamed and re-balanced upstream
|
||||
mock_http.post(DIFF_URL).mock(
|
||||
return_value=httpx.Response(200, json=fixture_json("zenmoney", "diff_deletion.json"))
|
||||
)
|
||||
await run_sync(ZenmoneySource(), settings=static_settings(), cursor=first.cursor_after)
|
||||
|
||||
card = await account_by_source_id(CARD)
|
||||
assert card.name == "Основная карта"
|
||||
assert card.role is AccountRole.savings
|
||||
assert card.include_in_net_worth is False
|
||||
assert card.balance == Decimal("24000.5") # source-owned: still updated
|
||||
assert card.archived is False
|
||||
|
||||
|
||||
async def test_archived_upstream_still_leaves_net_worth(
|
||||
app, client, auth_headers, mock_http, fixture_json, run_sync
|
||||
):
|
||||
"""The one exception to user ownership: an account the source archived cannot stay in
|
||||
net worth, the same way a deleted one does not."""
|
||||
mock_http.post(DIFF_URL).mock(
|
||||
return_value=httpx.Response(200, json=fixture_json("zenmoney", "diff_full.json"))
|
||||
)
|
||||
first = await run_sync(ZenmoneySource(), settings=static_settings())
|
||||
card = await account_by_source_id(CARD)
|
||||
assert card.include_in_net_worth is True
|
||||
|
||||
patched = await client.patch(
|
||||
f"/api/v1/accounts/{card.id}", headers=auth_headers, json={"role": "savings"}
|
||||
)
|
||||
assert patched.status_code == 200, patched.text
|
||||
|
||||
archived_diff = fixture_json("zenmoney", "diff_deletion.json")
|
||||
archived_diff["account"][0]["archive"] = True
|
||||
mock_http.post(DIFF_URL).mock(return_value=httpx.Response(200, json=archived_diff))
|
||||
await run_sync(ZenmoneySource(), settings=static_settings(), cursor=first.cursor_after)
|
||||
|
||||
card = await account_by_source_id(CARD)
|
||||
assert card.archived is True
|
||||
assert card.include_in_net_worth is False
|
||||
assert card.role is AccountRole.savings # still the user's choice
|
||||
|
||||
|
||||
async def test_instrument_id_zero_is_a_valid_currency(app):
|
||||
from fintracker.sources.zenmoney.mapper import _ccy, instrument_codes
|
||||
|
||||
codes = instrument_codes([{"id": 0, "shortTitle": "RUB"}, {"id": 2, "shortTitle": "USD"}])
|
||||
assert _ccy(codes, 0) == "RUB"
|
||||
assert _ccy(codes, 2) == "USD"
|
||||
assert _ccy(codes, None) is None
|
||||
assert _ccy(codes, 99) is None
|
||||
@@ -0,0 +1,115 @@
|
||||
import pytest
|
||||
|
||||
from fintracker.sources import registry
|
||||
from fintracker.sources.base import SyncContext, SyncResult
|
||||
|
||||
|
||||
class FakeSource:
|
||||
name = "fake"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def sync(self, ctx: SyncContext) -> SyncResult:
|
||||
self.calls += 1
|
||||
before = int(ctx.cursor_before or 0)
|
||||
return SyncResult(cursor_after=str(before + 1), counts={"rows": 3})
|
||||
|
||||
|
||||
class FailingSource:
|
||||
name = "boom"
|
||||
|
||||
async def sync(self, ctx: SyncContext) -> SyncResult:
|
||||
raise RuntimeError("upstream down")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_source():
|
||||
src = registry.register(FakeSource())
|
||||
registry.register(FailingSource())
|
||||
yield src
|
||||
registry.unregister("fake")
|
||||
registry.unregister("boom")
|
||||
|
||||
|
||||
async def test_trigger_dedupes_queued_jobs(client, auth_headers, fake_source):
|
||||
r1 = await client.post("/api/v1/sync/fake", headers=auth_headers)
|
||||
r2 = await client.post("/api/v1/sync/fake", headers=auth_headers)
|
||||
assert r1.status_code == 202 and r2.status_code == 202
|
||||
assert r1.json()["id"] == r2.json()["id"]
|
||||
|
||||
r = await client.post("/api/v1/sync/unknown", headers=auth_headers)
|
||||
assert r.status_code == 404
|
||||
|
||||
status = await client.get("/api/v1/sync/status", headers=auth_headers)
|
||||
assert status.status_code == 200
|
||||
by_name = {s["source"]: s for s in status.json()}
|
||||
assert by_name["fake"]["queued"] is True
|
||||
assert by_name["fake"]["cursor"] is None
|
||||
|
||||
|
||||
async def test_worker_runs_queued_job_and_advances_cursor(client, auth_headers, fake_source):
|
||||
from fintracker.worker.scheduler import _process_queued_jobs
|
||||
|
||||
await client.post("/api/v1/sync/fake", headers=auth_headers)
|
||||
await _process_queued_jobs()
|
||||
assert fake_source.calls == 1
|
||||
|
||||
status = await client.get("/api/v1/sync/status", headers=auth_headers)
|
||||
fake = next(s for s in status.json() if s["source"] == "fake")
|
||||
assert fake["cursor"] == "1"
|
||||
assert fake["queued"] is False
|
||||
assert fake["last_run_status"] == "ok"
|
||||
|
||||
runs = await client.get("/api/v1/sync/runs", params={"source": "fake"}, headers=auth_headers)
|
||||
assert runs.json()[0]["counts"] == {"rows": 3}
|
||||
assert runs.json()[0]["triggered_by"] == "manual"
|
||||
|
||||
|
||||
async def test_failed_sync_is_recorded_not_raised(client, auth_headers, fake_source):
|
||||
from fintracker.worker.runner import run_source
|
||||
|
||||
run = await run_source("boom", triggered_by="cli")
|
||||
assert run.status.value == "error"
|
||||
assert run.error is not None and "upstream down" in run.error
|
||||
|
||||
status = await client.get("/api/v1/sync/status", headers=auth_headers)
|
||||
boom = next(s for s in status.json() if s["source"] == "boom")
|
||||
assert boom["last_run_status"] == "error"
|
||||
assert boom["last_success_at"] is None
|
||||
|
||||
|
||||
async def test_metrics_failure_after_a_sync_marks_the_run_as_error(app, fake_source):
|
||||
"""The download succeeded, so the cursor stays advanced; the run is still an error,
|
||||
because the metrics the UI reads are stale."""
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.metrics import refresh as refresh_module
|
||||
from fintracker.models import SyncState
|
||||
from fintracker.worker.runner import run_source
|
||||
|
||||
async def failing_step(session) -> None:
|
||||
raise RuntimeError("step exploded")
|
||||
|
||||
refresh_module.STEPS.append(("test_failing_step", failing_step))
|
||||
try:
|
||||
run = await run_source("fake", triggered_by="cli")
|
||||
finally:
|
||||
refresh_module.STEPS[:] = [s for s in refresh_module.STEPS if s[0] != "test_failing_step"]
|
||||
|
||||
assert run.status.value == "error"
|
||||
assert run.error is not None
|
||||
assert run.error.startswith("metrics refresh failed:")
|
||||
assert "step exploded" in run.error
|
||||
# the sync itself succeeded: the cursor must not replay
|
||||
assert run.cursor_after == "1"
|
||||
async with get_sessionmaker()() as session:
|
||||
state = await session.get(SyncState, "fake")
|
||||
assert state is not None and state.cursor == "1"
|
||||
|
||||
|
||||
async def test_a_later_refresh_is_clean_again(app, fake_source):
|
||||
"""The temporary failing step is gone, so nothing leaks into other tests."""
|
||||
from fintracker.worker.runner import run_source
|
||||
|
||||
run = await run_source("fake", triggered_by="cli")
|
||||
assert run.status.value == "ok", run.error
|
||||
Reference in New Issue
Block a user