Files
fin-tracker/backend/tests/api/test_imports_api.py
T
Dmitry 4a0b4e0532 feat(deploy): healthcheck api и worker, отдельный migrate-сервис, лимит загрузки отчёта
Миграции вынесены из команды api в one-shot сервис migrate, api и worker стартуют после него. /health отвечает 503 при недоступной БД. Отчёт больше MAX_UPLOAD_BYTES (25 МиБ) получает 413, Caddy режет на 30 МБ раньше. Том uploads убран, секреты env_file передаются сервисам явно. В CI добавлена сборка образа бэкенда без push, test_migrations сверяет модели с историей Alembic.
2026-09-19 21:55:33 +03:00

475 lines
18 KiB
Python

"""`/imports` and `/instruments/pending` against `docs/ai/import-contract.md`.
The parser is a stub registered for the duration of a test: the real ones are a separate
contract, and these tests are about the HTTP surface the Flutter client was written against.
`create_app` mounts both routers, and the order it mounts them in matters — that is what
`test_pending_path_is_not_eaten_by_the_instrument_id_route` guards.
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
import pytest
from httpx import AsyncClient
from sqlalchemy import func, select
from factories import make_instrument
from fintracker.db import get_sessionmaker
from fintracker.models import (
Account,
AccountKind,
AccountRole,
Event,
EventKind,
EventSource,
EventStatus,
Instrument,
PendingInstrument,
RawReportFile,
)
from fintracker.sources.reports import registry
from fintracker.sources.reports.base import (
BrokerEvent,
CashEnd,
InstrumentRef,
ParsedReport,
ParseError,
PositionEnd,
)
ACCOUNT_NO = "S930W42"
UNKNOWN_ISIN = "RU000A1035S8"
PREFIX = "/api/v1"
GAZP = InstrumentRef(ticker="GAZP", board="TQBR", source_key="TICKER:GAZP/TQBR", name="Газпром")
UNKNOWN = InstrumentRef(
isin=UNKNOWN_ISIN,
ticker="STME",
name="Первая-ВечныйПортф БПИФ",
currency="RUB",
asset_class_hint="fund",
source_key=f"ISIN:{UNKNOWN_ISIN}",
)
UNKNOWN2_ISIN = "RU000A105KR6"
UNKNOWN2 = InstrumentRef(
isin=UNKNOWN2_ISIN,
ticker="TRUR",
name="Тинькофф Вечный портфель",
currency="RUB",
asset_class_hint="fund",
source_key=f"ISIN:{UNKNOWN2_ISIN}",
)
UNKNOWN_BY_TRADE = {"U1": UNKNOWN, "U2": UNKNOWN2}
class StubParser:
"""Reads `FAKE|<account>|<trade numbers>|<padding>` and yields one buy per trade number.
The padding exists only so two files with the same trades can have different bytes —
which is how the overlapping-periods case is expressed without a real report.
"""
broker = "sber"
name = "report_sber"
formats: tuple[str, ...] = ("txt",)
version = "1"
def sniff(self, data: bytes, filename: str) -> bool:
return data.startswith(b"FAKE|")
def parse(self, data: bytes, filename: str) -> ParsedReport:
text = data.decode("utf-8")
_, account_no, trades, *_ = text.split("|")
if account_no == "BROKEN":
raise ParseError("раздел «Сделки» не найден")
events = []
for line_no, trade_no in enumerate(t for t in trades.split(",") if t):
ref = UNKNOWN_BY_TRADE.get(trade_no, GAZP)
events.append(
BrokerEvent(
kind=EventKind.buy,
trade_date=date(2026, 2, 24),
settle_date=date(2026, 2, 25),
amount=Decimal("-1000"),
currency="RUB",
instrument=ref,
quantity=Decimal("10"),
price=Decimal("100"),
price_currency="RUB",
fee=Decimal("0.39"),
trade_no=trade_no,
description="Покупка",
raw_line_no=line_no + 1,
)
)
return ParsedReport(
broker="sber",
account_external_id=account_no,
period_from=date(2026, 2, 11),
period_to=date(2026, 9, 17),
parser_version="1",
events=events,
positions_end=[PositionEnd(instrument=GAZP, qty=Decimal("10"))],
cash_end=[CashEnd(currency="RUB", balance=Decimal("-1000"))],
warnings=["Раздел «Купонный доход» отсутствует в файле"],
)
@pytest.fixture
def parser():
# first in the list, so `registry.pick` and `registry.get("report_sber")` both find the
# stub rather than the real Sber parser, whose `sniff` is deliberately generous.
stub = StubParser()
registry.PARSERS.insert(0, stub)
yield stub
registry.PARSERS.remove(stub)
@pytest.fixture
async def http(app, client: AsyncClient) -> AsyncClient:
"""The import routes come mounted by `create_app` — nothing to wire up here.
Kept as a named fixture so every test in this module reads the same way, and so the
day the mounting changes there is one place to look.
"""
return client
def content(trades: str = "T1", account_no: str = ACCOUNT_NO, pad: str = "") -> bytes:
return f"FAKE|{account_no}|{trades}|{pad}".encode()
async def make_account(external_id: str = ACCOUNT_NO) -> int:
async with get_sessionmaker()() as session:
account = Account(
kind=AccountKind.broker,
source="report_sber",
source_id=external_id,
name="Сбер ИИС",
currency="RUB",
role=AccountRole.investment,
include_in_net_worth=False,
primary_event_source=EventSource.report_sber,
)
session.add(account)
await session.commit()
return account.id
async def upload(http: AsyncClient, headers, data: bytes, **form):
return await http.post(
f"{PREFIX}/imports",
headers=headers,
files={"file": ("report.txt", data, "text/plain")},
data={k: str(v) for k, v in form.items()},
)
async def test_same_bytes_twice_return_the_first_import(app, http, auth_headers, parser):
await make_account()
await make_instrument(ticker="GAZP", name="Газпром")
first = await upload(http, auth_headers, content())
second = await upload(http, auth_headers, content())
assert first.status_code == 200, first.text
assert second.status_code == 200, second.text
assert first.json()["duplicate_of_id"] is None
assert second.json()["duplicate_of_id"] == first.json()["id"]
assert second.json()["id"] == first.json()["id"]
async with get_sessionmaker()() as session:
assert await session.scalar(select(func.count()).select_from(RawReportFile)) == 1
async def test_overlapping_reports_import_each_trade_once(app, http, auth_headers, parser):
"""The second file repeats T1 and adds T2: committing it creates exactly one event."""
await make_account()
await make_instrument(ticker="GAZP", name="Газпром")
january = (await upload(http, auth_headers, content("T1"))).json()
commit_one = await http.post(
f"{PREFIX}/imports/{january['id']}/commit", headers=auth_headers, json={}
)
assert commit_one.status_code == 200, commit_one.text
assert commit_one.json()["events_created"] == 1
april = (await upload(http, auth_headers, content("T1,T2", pad="april"))).json()
assert april["counts"]["events_duplicate"] == 1
assert april["counts"]["events_new"] == 1
commit_two = await http.post(
f"{PREFIX}/imports/{april['id']}/commit", headers=auth_headers, json={}
)
assert commit_two.status_code == 200, commit_two.text
assert commit_two.json()["events_created"] == 1
assert commit_two.json()["events_updated"] == 1
async with get_sessionmaker()() as session:
assert await session.scalar(select(func.count()).select_from(Event)) == 2
async def test_committing_a_committed_import_is_a_conflict(app, http, auth_headers, parser):
await make_account()
await make_instrument(ticker="GAZP", name="Газпром")
created = (await upload(http, auth_headers, content())).json()
first = await http.post(
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
)
second = await http.post(
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
)
assert first.status_code == 200, first.text
assert second.status_code == 409, second.text
assert second.json()["status"] == 409
async with get_sessionmaker()() as session:
assert await session.scalar(select(func.count()).select_from(Event)) == 1
async def test_commit_without_an_account_is_refused(app, http, auth_headers, parser):
await make_instrument(ticker="GAZP", name="Газгром")
created = (await upload(http, auth_headers, content(account_no="NOSUCH"))).json()
assert created["account_id"] is None
assert created["account_suggestions"] == []
response = await http.post(
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
)
assert response.status_code == 422, response.text
body = response.json()
assert body["status"] == 422
assert "account_id" in body["detail"]
async def test_unreadable_file_is_kept_as_a_failed_import(app, http, auth_headers, parser):
response = await upload(http, auth_headers, content(account_no="BROKEN"))
assert response.status_code == 422, response.text
body = response.json()
import_id = body["errors"][0]["import_id"]
async with get_sessionmaker()() as session:
row = await session.get(RawReportFile, import_id)
assert row is not None
assert row.parse_status.value == "failed"
assert "Сделки" in (row.error or "")
async def test_unknown_format_is_415(app, http, auth_headers, parser):
response = await upload(http, auth_headers, b"not a report at all")
assert response.status_code == 415, response.text
async def test_pending_instrument_flows_through_all_three_modes(app, http, auth_headers, parser):
account_id = await make_account()
await make_instrument(ticker="GAZP", name="Газпром")
created = (await upload(http, auth_headers, content("T1,U1"))).json()
assert created["counts"]["events_pending"] == 1
assert [p["source_key"] for p in created["pending_instruments"]] == [f"ISIN:{UNKNOWN_ISIN}"]
commit = await http.post(
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
)
assert commit.status_code == 200, commit.text
assert commit.json()["events_skipped"] == 1
assert commit.json()["pending_instruments"] == 1
listing = await http.get(f"{PREFIX}/instruments/pending", headers=auth_headers)
assert listing.status_code == 200, listing.text
row = listing.json()[0]
assert row["status"] == "pending"
assert row["asset_class_hint"] == "fund"
assert isinstance(row["sample_price"], str)
# (a) ignore: the events stop waiting but gain no instrument
ignored = await http.post(
f"{PREFIX}/instruments/pending/{row['id']}/resolve",
headers=auth_headers,
json={"action": "ignore"},
)
assert ignored.status_code == 200, ignored.text
assert ignored.json()["status"] == "ignored"
assert ignored.json()["events_bound"] == 1
assert ignored.json()["alias_created"] is False
async with get_sessionmaker()() as session:
statuses = set(
(await session.execute(select(Event.status).where(Event.instrument_id.is_(None))))
.scalars()
.all()
)
assert EventStatus.pending not in statuses
# (b) create: a second unknown paper becomes a real instrument
second = (await upload(http, auth_headers, content("U2", pad="second"))).json()
pending_id = second["pending_instruments"][0]["id"]
await http.post(f"{PREFIX}/imports/{second['id']}/commit", headers=auth_headers, json={})
created_row = await http.post(
f"{PREFIX}/instruments/pending/{pending_id}/resolve",
headers=auth_headers,
json={
"action": "create",
"instrument": {
"asset_class": "fund",
"isin": UNKNOWN2_ISIN,
"ticker": "TRUR",
"board": "TQTF",
"name": "Тинькофф Вечный портфель",
"currency": "RUB",
"lot": 1,
},
},
)
assert created_row.status_code == 200, created_row.text
assert created_row.json()["status"] == "resolved"
assert created_row.json()["alias_created"] is True
instrument_id = created_row.json()["instrument_id"]
# (c) link: resolving the same key again is a conflict, a fresh one links
again = await http.post(
f"{PREFIX}/instruments/pending/{pending_id}/resolve",
headers=auth_headers,
json={"action": "link", "instrument_id": instrument_id},
)
assert again.status_code == 409, again.text
async with get_sessionmaker()() as session:
instrument = await session.get(Instrument, instrument_id)
assert instrument is not None
assert instrument.asset_class.value == "fund"
bound = (
(await session.execute(select(Event).where(Event.instrument_id == instrument_id)))
.scalars()
.all()
)
assert bound
assert all(e.status == EventStatus.confirmed for e in bound)
assert all(e.account_id == account_id for e in bound)
async def test_link_mode_binds_the_waiting_events(app, http, auth_headers, parser):
await make_account()
target = await make_instrument(ticker="STME", name="БПИФ", board="TQTF")
created = (await upload(http, auth_headers, content("U1"))).json()
await http.post(f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={})
pending_id = created["pending_instruments"][0]["id"]
response = await http.post(
f"{PREFIX}/instruments/pending/{pending_id}/resolve",
headers=auth_headers,
json={"action": "link", "instrument_id": target},
)
assert response.status_code == 200, response.text
assert response.json() == {
"id": pending_id,
"status": "resolved",
"instrument_id": target,
"events_bound": 1,
"alias_created": True,
"metrics_refreshed": True,
}
async def test_money_is_a_string_and_asset_class_is_not_an_enum(app, http, auth_headers, parser):
await make_account()
await make_instrument(ticker="GAZP", name="Газпром")
body = (await upload(http, auth_headers, content("T1,U1"))).json()
sample = body["sample_events"][0]
assert sample["amount"] == "-1000"
assert sample["quantity"] == "10"
assert sample["price"] == "100"
assert isinstance(sample["kind"], str)
pending = body["pending_instruments"][0]
assert isinstance(pending["asset_class_hint"], str)
assert isinstance(pending["sample_quantity"], str)
recon = body["reconciliation"]
assert isinstance(recon["cash"][0]["balance_report"], str)
assert body["warnings"]
async def test_reconciliation_compares_the_report_against_the_ledger(
app, http, auth_headers, parser
):
await make_account()
await make_instrument(ticker="GAZP", name="Газпром")
created = (await upload(http, auth_headers, content("T1"))).json()
result = await http.post(
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
)
recon = result.json()["reconciliation"]
assert recon["as_of"] == "2026-09-17"
assert recon["positions"][0]["qty_report"] == "10"
assert recon["positions"][0]["qty_derived"] == "10.0000000000"
assert recon["cash"][0]["currency"] == "RUB"
assert recon["matches"] is True
async def test_uncommitted_import_can_be_deleted_and_a_committed_one_cannot(
app, http, auth_headers, parser
):
await make_account()
await make_instrument(ticker="GAZP", name="Газпром")
created = (await upload(http, auth_headers, content("T1"))).json()
dropped = await http.delete(f"{PREFIX}/imports/{created['id']}", headers=auth_headers)
assert dropped.status_code == 204, dropped.text
again = (await upload(http, auth_headers, content("T1"))).json()
await http.post(f"{PREFIX}/imports/{again['id']}/commit", headers=auth_headers, json={})
refused = await http.delete(f"{PREFIX}/imports/{again['id']}", headers=auth_headers)
assert refused.status_code == 409, refused.text
async def test_listing_and_fetching_one_import(app, http, auth_headers, parser):
await make_account()
await make_instrument(ticker="GAZP", name="Газпром")
created = (await upload(http, auth_headers, content("T1"))).json()
listing = await http.get(f"{PREFIX}/imports", headers=auth_headers)
one = await http.get(f"{PREFIX}/imports/{created['id']}", headers=auth_headers)
assert [r["id"] for r in listing.json()] == [created["id"]]
assert listing.json()[0]["account_name"] == "Сбер ИИС"
assert one.json()["id"] == created["id"]
assert one.json()["parse_status"] == "parsed"
async def test_pending_path_is_not_eaten_by_the_instrument_id_route(app, http, auth_headers):
"""`pending_router` is mounted first on purpose — otherwise this 422s on int('pending')."""
response = await http.get(f"{PREFIX}/instruments/pending", headers=auth_headers)
assert response.status_code == 200, response.text
async def test_unknown_import_is_404(app, http, auth_headers):
response = await http.get(f"{PREFIX}/imports/999", headers=auth_headers)
assert response.status_code == 404, response.text
async def test_pending_rows_are_created_by_the_preview_alone(app, http, auth_headers, parser):
await make_account()
await upload(http, auth_headers, content("U1"))
async with get_sessionmaker()() as session:
assert await session.scalar(select(func.count()).select_from(PendingInstrument)) == 1
assert await session.scalar(select(func.count()).select_from(Event)) == 0
async def test_oversized_report_is_413(app, http, auth_headers, parser, monkeypatch):
from fintracker.config import get_settings
monkeypatch.setenv("MAX_UPLOAD_BYTES", "1024")
get_settings.cache_clear()
try:
response = await upload(http, auth_headers, b"x" * 2048)
finally:
monkeypatch.undo()
get_settings.cache_clear()
assert response.status_code == 413, response.text