154 lines
5.5 KiB
Python
154 lines
5.5 KiB
Python
"""Сервисы + настоящие репозитории на временном 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"]
|