"""Tests for the user-curated watchlist service. The watchlist is fully manual: the user adds and removes tickers, capped at WATCHLIST_MAX. Removals are hard deletes and must stick. Entries are enriched on read with the latest price and day-over-day move. """ from __future__ import annotations from datetime import date, datetime, timedelta, timezone import pytest from app.exceptions import DuplicateError, NotFoundError, ValidationError from app.models.ohlcv import OHLCVRecord from app.models.score import CompositeScore from app.models.ticker import Ticker from app.models.user import User from app.services.watchlist_service import ( WATCHLIST_MAX, add_manual_entry, get_watchlist, remove_entry, ) # Plain (committing) session — the watchlist service commits, so the rolling-back # db_session fixture would fight it. Reuse the shared in-memory engine. from tests.conftest import _test_session_factory # type: ignore @pytest.fixture async def session(): async with _test_session_factory() as s: yield s async def _make_user(session) -> int: user = User(username="u", password_hash="x", role="user", has_access=True) session.add(user) await session.flush() await session.commit() return user.id async def _make_ticker(session, symbol: str, *, score: float | None = None) -> int: t = Ticker(symbol=symbol) session.add(t) await session.flush() if score is not None: session.add( CompositeScore( ticker_id=t.id, score=score, is_stale=False, weights_json="{}", computed_at=datetime.now(timezone.utc), ) ) await session.commit() return t.id async def test_add_and_remove_sticks(session): user_id = await _make_user(session) await _make_ticker(session, "AAA", score=80.0) await add_manual_entry(session, user_id, "AAA") rows = await get_watchlist(session, user_id) assert {r["symbol"] for r in rows} == {"AAA"} # Removal is permanent — nothing re-adds it. await remove_entry(session, user_id, "AAA") rows = await get_watchlist(session, user_id) assert rows == [] async def test_duplicate_add_rejected(session): user_id = await _make_user(session) await _make_ticker(session, "AAA") await add_manual_entry(session, user_id, "AAA") with pytest.raises(DuplicateError): await add_manual_entry(session, user_id, "AAA") async def test_remove_absent_raises(session): user_id = await _make_user(session) await _make_ticker(session, "AAA") with pytest.raises(NotFoundError): await remove_entry(session, user_id, "AAA") async def test_cap_enforced(session): user_id = await _make_user(session) for i in range(WATCHLIST_MAX): sym = f"T{i:02d}" await _make_ticker(session, sym) await add_manual_entry(session, user_id, sym) await _make_ticker(session, "OVER") with pytest.raises(ValidationError): await add_manual_entry(session, user_id, "OVER") async def test_enrichment_includes_price_and_change(session): user_id = await _make_user(session) ticker_id = await _make_ticker(session, "AAA", score=70.0) today = date.today() session.add(OHLCVRecord( ticker_id=ticker_id, date=today - timedelta(days=1), open=100, high=100, low=100, close=100.0, volume=1, )) session.add(OHLCVRecord( ticker_id=ticker_id, date=today, open=110, high=110, low=110, close=110.0, volume=1, )) await session.commit() await add_manual_entry(session, user_id, "AAA") rows = await get_watchlist(session, user_id) row = rows[0] assert row["last_close"] == 110.0 assert row["change_pct"] == pytest.approx(10.0) assert row["price_date"] == today