"""Unit tests for ticker_universe_service bootstrap logic.""" from __future__ import annotations import json from collections.abc import AsyncGenerator import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from app.database import Base from app.exceptions import ProviderError from app.models.settings import SystemSetting from app.models.ticker import Ticker from app.services import ticker_universe_service _engine = create_async_engine("sqlite+aiosqlite://", echo=False) _session_factory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False) @pytest.fixture(autouse=True) async def _setup_tables() -> AsyncGenerator[None, None]: async with _engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield async with _engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) @pytest.fixture async def session() -> AsyncGenerator[AsyncSession, None]: async with _session_factory() as s: yield s @pytest.mark.asyncio async def test_bootstrap_universe_adds_missing_symbols(session: AsyncSession, monkeypatch: pytest.MonkeyPatch): session.add(Ticker(symbol="AAPL")) await session.commit() async def _fake_fetch(_db: AsyncSession, _universe: str) -> list[str]: return ["AAPL", "MSFT", "NVDA"] monkeypatch.setattr(ticker_universe_service, "fetch_universe_symbols", _fake_fetch) result = await ticker_universe_service.bootstrap_universe(session, "sp500") assert result["added"] == 2 assert result["already_tracked"] == 1 assert result["deleted"] == 0 rows = await session.execute(select(Ticker.symbol).order_by(Ticker.symbol.asc())) assert list(rows.scalars().all()) == ["AAPL", "MSFT", "NVDA"] @pytest.mark.asyncio async def test_bootstrap_universe_prunes_missing_symbols(session: AsyncSession, monkeypatch: pytest.MonkeyPatch): session.add_all([Ticker(symbol="AAPL"), Ticker(symbol="MSFT"), Ticker(symbol="TSLA")]) await session.commit() async def _fake_fetch(_db: AsyncSession, _universe: str) -> list[str]: return ["AAPL", "MSFT"] monkeypatch.setattr(ticker_universe_service, "fetch_universe_symbols", _fake_fetch) result = await ticker_universe_service.bootstrap_universe( session, "sp500", prune_missing=True, ) assert result["added"] == 0 assert result["already_tracked"] == 2 assert result["deleted"] == 1 rows = await session.execute(select(Ticker.symbol).order_by(Ticker.symbol.asc())) assert list(rows.scalars().all()) == ["AAPL", "MSFT"] @pytest.mark.asyncio async def test_fetch_universe_symbols_uses_cached_snapshot_when_live_sources_fail( session: AsyncSession, monkeypatch: pytest.MonkeyPatch, ): session.add( SystemSetting( key="ticker_universe_cache_sp500", value=json.dumps({"symbols": ["AAPL", "MSFT"], "source": "test"}), ) ) await session.commit() async def _fake_public(_universe: str): return [], ["public failed"], None async def _fake_fmp(_universe: str): raise ProviderError("fmp failed") monkeypatch.setattr(ticker_universe_service, "_fetch_universe_symbols_from_public", _fake_public) monkeypatch.setattr(ticker_universe_service, "_fetch_universe_symbols_from_fmp", _fake_fmp) symbols = await ticker_universe_service.fetch_universe_symbols(session, "sp500") assert symbols == ["AAPL", "MSFT"] @pytest.mark.asyncio async def test_fetch_universe_symbols_uses_seed_when_live_and_cache_fail( session: AsyncSession, monkeypatch: pytest.MonkeyPatch, ): async def _fake_public(_universe: str): return [], ["public failed"], None async def _fake_fmp(_universe: str): raise ProviderError("fmp failed") monkeypatch.setattr(ticker_universe_service, "_fetch_universe_symbols_from_public", _fake_public) monkeypatch.setattr(ticker_universe_service, "_fetch_universe_symbols_from_fmp", _fake_fmp) symbols = await ticker_universe_service.fetch_universe_symbols(session, "sp500") assert "AAPL" in symbols assert len(symbols) > 10