DRRK/tests/unit/test_db.py

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