201 lines
6.7 KiB
Python
201 lines
6.7 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
from sqlalchemy.dialects import postgresql
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
from sqlalchemy.schema import CreateIndex, CreateTable
|
|
|
|
from app.db import base
|
|
from app.db.base import Base, run_db
|
|
from app.db.models import (
|
|
Currency,
|
|
MarketData,
|
|
Operation,
|
|
OperationType,
|
|
Portfolio,
|
|
Security,
|
|
)
|
|
|
|
|
|
def _sqlite_session_factory(tmp_path: Path):
|
|
"""Отдельный файловый SQLite на тест: NullPool + :memory: потерял бы схему
|
|
между соединениями."""
|
|
engine = create_async_engine(
|
|
f"sqlite+aiosqlite:///{tmp_path / 'test.db'}", poolclass=NullPool
|
|
)
|
|
|
|
async def _create() -> None:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
run_db(_create())
|
|
return async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
|
|
|
|
def _audit(user: str = "tester@rshb.ru") -> dict[str, str]:
|
|
return {"created_by": user, "updated_by": user}
|
|
|
|
|
|
async def _seed(session) -> tuple[Currency, Portfolio, OperationType, Security]:
|
|
rub = Currency(code="RUB", name="Российский рубль", **_audit())
|
|
uw = Portfolio(code="UW", name="Андеррайтинг", **_audit())
|
|
buy = OperationType(code="BUY", name="Покупка", **_audit())
|
|
session.add_all([rub, uw, buy])
|
|
await session.flush()
|
|
security = Security(
|
|
isin="RU000A106169",
|
|
secid="RU000A106169",
|
|
name="РЭО-02",
|
|
currency_id=rub.id,
|
|
**_audit(),
|
|
)
|
|
session.add(security)
|
|
await session.flush()
|
|
return rub, uw, buy, security
|
|
|
|
|
|
def test_engine_uses_null_pool() -> None:
|
|
# Ключевое решение про event loop'ы — см. комментарий в app/db/base.py.
|
|
assert isinstance(base.engine.pool, NullPool)
|
|
|
|
|
|
def test_run_db_works_across_separate_event_loops(tmp_path: Path) -> None:
|
|
session_factory = _sqlite_session_factory(tmp_path)
|
|
|
|
async def _insert() -> int:
|
|
async with session_factory() as session:
|
|
_, _, _, security = await _seed(session)
|
|
await session.commit()
|
|
return security.id
|
|
|
|
async def _read(security_id: int) -> Security | None:
|
|
async with session_factory() as session:
|
|
return await session.get(Security, security_id)
|
|
|
|
security_id = run_db(_insert())
|
|
loaded = run_db(_read(security_id))
|
|
|
|
assert security_id == 1 # автоинкремент в SQLite работает (Integer-вариант)
|
|
assert loaded is not None
|
|
assert loaded.is_active is True
|
|
assert loaded.created_at is not None
|
|
|
|
|
|
def test_run_db_refuses_inside_running_loop() -> None:
|
|
async def _noop() -> int:
|
|
return 1
|
|
|
|
async def _outer() -> None:
|
|
with pytest.raises(RuntimeError, match="await"):
|
|
run_db(_noop())
|
|
|
|
asyncio.run(_outer())
|
|
|
|
|
|
def test_market_data_unique_per_security_and_date(tmp_path: Path) -> None:
|
|
session_factory = _sqlite_session_factory(tmp_path)
|
|
|
|
async def _scenario() -> None:
|
|
async with session_factory() as session:
|
|
_, _, _, security = await _seed(session)
|
|
for _ in range(2):
|
|
session.add(
|
|
MarketData(
|
|
security_id=security.id,
|
|
market_date=date(2026, 10, 8),
|
|
board="TQCB",
|
|
clean_price=Decimal("101.42"),
|
|
raw={"BOARDID": "TQCB", "WAPRICE": 101.42},
|
|
**_audit(),
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
with pytest.raises(IntegrityError):
|
|
run_db(_scenario())
|
|
|
|
|
|
def test_market_data_json_and_numeric_roundtrip(tmp_path: Path) -> None:
|
|
session_factory = _sqlite_session_factory(tmp_path)
|
|
|
|
async def _scenario() -> MarketData:
|
|
async with session_factory() as session:
|
|
_, _, _, security = await _seed(session)
|
|
session.add(
|
|
MarketData(
|
|
security_id=security.id,
|
|
market_date=date(2026, 10, 8),
|
|
board="TQCB",
|
|
clean_price=Decimal("101.42"),
|
|
raw={"BOARDID": "TQCB", "SHORTNAME": "РЭО-02"},
|
|
**_audit(),
|
|
)
|
|
)
|
|
await session.commit()
|
|
result = await session.execute(select(MarketData))
|
|
return result.scalar_one()
|
|
|
|
row = run_db(_scenario())
|
|
|
|
assert row.raw == {"BOARDID": "TQCB", "SHORTNAME": "РЭО-02"}
|
|
assert row.clean_price == Decimal("101.42")
|
|
|
|
|
|
def test_operation_trade_number_unique_only_when_present(tmp_path: Path) -> None:
|
|
session_factory = _sqlite_session_factory(tmp_path)
|
|
|
|
def _operation(security, uw, buy, trade_number):
|
|
return Operation(
|
|
security_id=security.id,
|
|
portfolio_id=uw.id,
|
|
operation_type_id=buy.id,
|
|
currency_id=security.currency_id,
|
|
trade_date=date(2026, 10, 8),
|
|
trade_number=trade_number,
|
|
**_audit(),
|
|
)
|
|
|
|
async def _without_numbers() -> None:
|
|
async with session_factory() as session:
|
|
_, uw, buy, security = await _seed(session)
|
|
session.add(_operation(security, uw, buy, None))
|
|
session.add(_operation(security, uw, buy, None))
|
|
session.add(_operation(security, uw, buy, "123"))
|
|
await session.commit()
|
|
|
|
async def _duplicate_number() -> None:
|
|
async with session_factory() as session:
|
|
security = (await session.execute(select(Security))).scalar_one()
|
|
uw = (await session.execute(select(Portfolio))).scalar_one()
|
|
buy = (await session.execute(select(OperationType))).scalar_one()
|
|
session.add(_operation(security, uw, buy, "123"))
|
|
await session.commit()
|
|
|
|
run_db(_without_numbers())
|
|
with pytest.raises(IntegrityError):
|
|
run_db(_duplicate_number())
|
|
|
|
|
|
def test_postgres_ddl_uses_jsonb_identity_and_partial_index() -> None:
|
|
dialect = postgresql.dialect()
|
|
|
|
market_ddl = str(CreateTable(MarketData.__table__).compile(dialect=dialect))
|
|
operation_index = next(
|
|
index
|
|
for index in Operation.__table__.indexes
|
|
if index.name == "ux_operation_portfolio_trade_number"
|
|
)
|
|
index_ddl = str(CreateIndex(operation_index).compile(dialect=dialect))
|
|
|
|
assert "raw JSONB" in market_ddl
|
|
assert "id BIGINT GENERATED BY DEFAULT AS IDENTITY" in market_ddl
|
|
assert "WHERE trade_number IS NOT NULL" in index_ddl
|