"""Tests for watchlist remove → dismiss → revive behaviour. Regression cover for the bug where removing a top-ranked ticker did nothing, because get_watchlist re-runs auto_populate on every read and would instantly re-add it. Removals are now tombstoned as "dismissed" so auto-population skips them; a later manual add revives the row. """ from __future__ import annotations from datetime import datetime, timezone import pytest from sqlalchemy import select from app.models.score import CompositeScore from app.models.ticker import Ticker from app.models.user import User from app.models.watchlist import WatchlistEntry from app.services.watchlist_service import ( DISMISSED, add_manual_entry, auto_populate, 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 _seed(session) -> tuple[int, list[int]]: user = User(username="u", password_hash="x", role="user", has_access=True) session.add(user) await session.flush() now = datetime.now(timezone.utc) ticker_ids: list[int] = [] for i, sym in enumerate(["AAA", "BBB", "CCC"]): t = Ticker(symbol=sym) session.add(t) await session.flush() ticker_ids.append(t.id) session.add( CompositeScore( ticker_id=t.id, score=90.0 - i, # AAA highest is_stale=False, weights_json="{}", computed_at=now, ) ) await session.commit() return user.id, ticker_ids async def test_remove_dismisses_and_survives_auto_populate(session): user_id, _ = await _seed(session) await auto_populate(session, user_id) await session.commit() rows = await get_watchlist(session, user_id) assert {r["symbol"] for r in rows} == {"AAA", "BBB", "CCC"} # Remove a top-ranked ticker, then force another auto-population pass. await remove_entry(session, user_id, "AAA") await auto_populate(session, user_id) await session.commit() rows = await get_watchlist(session, user_id) symbols = {r["symbol"] for r in rows} assert "AAA" not in symbols assert symbols == {"BBB", "CCC"} # The row is tombstoned, not deleted. res = await session.execute( select(WatchlistEntry).join(Ticker).where(Ticker.symbol == "AAA") ) entry = res.scalar_one() assert entry.entry_type == DISMISSED async def test_manual_add_revives_dismissed(session): user_id, _ = await _seed(session) await auto_populate(session, user_id) await session.commit() await remove_entry(session, user_id, "AAA") # Re-adding the dismissed ticker should succeed (not DuplicateError) and # reappear as a manual entry. revived = await add_manual_entry(session, user_id, "AAA") assert revived.entry_type == "manual" rows = await get_watchlist(session, user_id) assert "AAA" in {r["symbol"] for r in rows}