"""Сервисы + настоящие репозитории на временном SQLite (схема — миграцией Alembic, с сидами). Внешние API — фейки на реальных фикстурах MOEX/ЦБ.""" from __future__ import annotations from dataclasses import replace from datetime import date from decimal import Decimal from pathlib import Path from types import SimpleNamespace import pytest from sqlalchemy import func, select from app.db.base import run_db from app.db.models import ExchangeRate, ExternalRequestLog, MarketData from app.integrations.cbr import parse_dynamic from app.services import fx_rates, market_data, securities from tests.unit.fakes import FakeCbr, FakeMoex ON_DATE = date(2026, 10, 8) ISIN = "RU000A106169" def _count(session_local, model) -> int: async def _q() -> int: async with session_local() as session: return ( await session.execute(select(func.count()).select_from(model)) ).scalar_one() return run_db(_q()) def test_full_flow_security_quotes_and_rates( session_local, fixture_moex: FakeMoex, fixtures_dir: Path ) -> None: added = run_db(securities.add_by_isin(ISIN, "analyst@rshb.ru", moex=fixture_moex)) rows = run_db(securities.list_securities()) assert added.security_id == 1 assert [(r.isin, r.name, r.currency, r.coupon_frequency) for r in rows] == [ (ISIN, "РЭО-02", "RUB", 4) ] assert rows[0].initial_face_value == Decimal("1000") result = run_db(market_data.update(ON_DATE, "system", moex=fixture_moex)) assert (result.updated, result.no_trades, result.errors) == (1, 0, 0) [quote_row] = run_db(market_data.list_for_date(ON_DATE)) assert quote_row.board == "TQCB" assert quote_row.clean_price == Decimal("101.42") assert quote_row.yield_value == Decimal("17.78") assert quote_row.face_value == Decimal("645.19") assert quote_row.num_trades == 3 cbr = FakeCbr(parse_dynamic((fixtures_dir / "cbr_dynamic_R01375.xml").read_bytes())) fx_result = run_db(fx_rates.update(ON_DATE, "system", cbr=cbr)) assert fx_result.saved == 6 recent = run_db(fx_rates.list_recent(limit=3)) assert [r.rate_date for r in recent] == [ date(2026, 10, 8), date(2026, 10, 7), date(2026, 10, 6), ] assert recent[0].rate_per_unit == Decimal("12.7516") again = run_db(fx_rates.update(ON_DATE, "system", cbr=cbr)) assert again.saved == 0 and len(cbr.calls) == 1 # 2 запроса при добавлении + 1 история + 1 ЦБ assert _count(session_local, ExternalRequestLog) == 4 def test_market_data_reload_is_upsert(session_local, fixture_moex: FakeMoex) -> None: run_db(securities.add_by_isin(ISIN, "analyst@rshb.ru", moex=fixture_moex)) run_db(market_data.update(ON_DATE, "analyst@rshb.ru", moex=fixture_moex)) first_quote = fixture_moex._history[ISIN] fixture_moex._history[ISIN] = replace(first_quote, clean_price=Decimal("100.5")) result = run_db(market_data.update(ON_DATE, "system", moex=fixture_moex)) async def _rows(): async with session_local() as session: return (await session.execute(select(MarketData))).scalars().all() [row] = run_db(_rows()) assert result.updated == 1 assert row.clean_price == Decimal("100.5") assert row.created_by == "analyst@rshb.ru" assert row.updated_by == "system" def test_exchange_rate_reload_is_upsert(session_local, fixtures_dir: Path) -> None: rates = parse_dynamic((fixtures_dir / "cbr_dynamic_R01375.xml").read_bytes()) run_db(fx_rates.update(date(2026, 10, 7), "system", cbr=FakeCbr(rates))) # Тот же день повторно (например, после ручной правки последней даты). async def _drop_last() -> None: async with session_local() as session: row = ( await session.execute( select(ExchangeRate).where( ExchangeRate.rate_date == date(2026, 10, 7) ) ) ).scalar_one() await session.delete(row) await session.commit() run_db(_drop_last()) run_db(fx_rates.update(ON_DATE, "system", cbr=FakeCbr(rates))) assert _count(session_local, ExchangeRate) == 6 def test_duplicate_isin_is_rejected_by_service( session_local, fixture_moex: FakeMoex ) -> None: run_db(securities.add_by_isin(ISIN, "analyst@rshb.ru", moex=fixture_moex)) with pytest.raises(securities.SecurityServiceError, match="уже добавлена"): run_db(securities.add_by_isin(ISIN, "analyst@rshb.ru", moex=fixture_moex)) def test_request_log_saves_gravitee_transaction_id(session_local) -> None: from app.integrations.gateway import RequestRecord from app.services import request_log record = RequestRecord( source="moex", endpoint="/external/moex-iss/iss/securities.json", params={"q": ISIN}, http_status=200, duration_ms=12, ok=True, error=None, transaction_id="0b5c7d3e-tx", ) gateway = SimpleNamespace(drain_records=lambda: [record]) run_db(request_log.save_request_log(gateway, "system")) async def _saved() -> list[str | None]: async with session_local() as session: return list( ( await session.execute( select(ExternalRequestLog.gateway_transaction_id) ) ).scalars() ) assert run_db(_saved()) == ["0b5c7d3e-tx"]