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:
Dmitry
2026-09-18 13:43:49 +03:00
parent 3fc7a954b9
commit c55fe19e48
31 changed files with 3713 additions and 0 deletions
+62
View File
@@ -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