0e9f1846f6
Removing a ticker did nothing because get_watchlist re-runs auto_populate on every read, instantly re-adding any top-ranked ticker the user had just removed. Removals are now tombstoned as a "dismissed" entry_type: auto-population skips them, the list hides them, and a later manual add revives the row. Also exposes an Add/Remove-watchlist toggle in the ticker detail header. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
105 lines
3.1 KiB
Python
105 lines
3.1 KiB
Python
"""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}
|