fix watchlist remove (was undone by auto-populate); add watch toggle on ticker page
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>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user