Files
signal-platform/tests/unit/test_watchlist_service.py
T
dennisthiessen 0e9f1846f6
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 34s
Deploy / deploy (push) Successful in 23s
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>
2026-06-14 14:17:27 +02:00

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}