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