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:
@@ -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
|
||||
Reference in New Issue
Block a user