"""`/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|||` 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