+
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo
index 31c3655..ec12067 100644
--- a/frontend/tsconfig.tsbuildinfo
+++ b/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
\ No newline at end of file
diff --git a/tests/unit/test_alert_service.py b/tests/unit/test_alert_service.py
new file mode 100644
index 0000000..c1ac460
--- /dev/null
+++ b/tests/unit/test_alert_service.py
@@ -0,0 +1,120 @@
+"""Tests for the Telegram alert service: config, dedup, watermark, dispatch."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+from app.models.alert import AlertLog
+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 import alert_service as svc
+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 test_config_defaults(session):
+ cfg = await svc.get_alert_config(session)
+ assert cfg["enabled"] is False
+ assert cfg["bot_token_configured"] is False
+ assert cfg["bot_token_source"] == "none"
+ # trigger toggles default on
+ assert cfg["qualified_enabled"] is True
+ assert cfg["digest_enabled"] is True
+
+
+async def test_update_config_token_write_only(session):
+ cfg = await svc.update_alert_config(
+ session, enabled=True, bot_token="secret123", telegram_chat_id="42",
+ )
+ assert cfg["enabled"] is True
+ assert cfg["telegram_chat_id"] == "42"
+ assert cfg["bot_token_configured"] is True
+ assert cfg["bot_token_source"] == "database"
+ # raw token never surfaced
+ assert "bot_token" not in cfg
+ assert "secret123" not in str(cfg)
+
+
+async def test_update_empty_token_keeps_existing(session):
+ await svc.update_alert_config(session, bot_token="keepme", telegram_chat_id="1")
+ cfg = await svc.update_alert_config(session, bot_token="") # empty → keep
+ assert cfg["bot_token_configured"] is True
+
+
+async def test_recently_alerted_cooldown(session):
+ assert await svc._recently_alerted(session, "qualified", "AAA:long") is False
+ svc._log_alert(session, "qualified", "AAA:long")
+ await session.commit()
+ assert await svc._recently_alerted(session, "qualified", "AAA:long") is True
+ # different key is independent
+ assert await svc._recently_alerted(session, "qualified", "BBB:short") is False
+
+
+async def test_recently_alerted_expires(session):
+ old = datetime.now(timezone.utc) - timedelta(hours=100)
+ session.add(AlertLog(alert_type="qualified", dedup_key="old", created_at=old))
+ await session.commit()
+ # default cooldown 72h → the 100h-old entry no longer suppresses
+ assert await svc._recently_alerted(session, "qualified", "old") is False
+
+
+async def _seed_watchlisted_ticker(session, symbol: str, score: float) -> None:
+ user = await session.get(User, 1)
+ if user is None:
+ user = User(id=1, username="u", password_hash="x", role="user", has_access=True)
+ session.add(user)
+ await session.flush()
+ t = Ticker(symbol=symbol)
+ session.add(t)
+ await session.flush()
+ session.add(WatchlistEntry(user_id=1, ticker_id=t.id, entry_type="manual",
+ added_at=datetime.now(timezone.utc)))
+ session.add(CompositeScore(ticker_id=t.id, score=score, is_stale=False,
+ weights_json="{}", computed_at=datetime.now(timezone.utc)))
+ await session.commit()
+
+
+async def test_score_drop_seeds_then_alerts(session):
+ await _seed_watchlisted_ticker(session, "AAA", 80.0)
+
+ # First pass seeds the watermark, no alert
+ msgs = await svc._collect_score_drops(session)
+ await session.commit()
+ assert msgs == []
+ assert await svc._watermark(session, "AAA") == 80.0
+
+ # Drop the composite well past the threshold
+ row = (await session.execute(
+ CompositeScore.__table__.update().values(score=60.0)
+ ))
+ await session.commit()
+ assert row.rowcount == 1
+
+ msgs = await svc._collect_score_drops(session)
+ await session.commit()
+ assert len(msgs) == 1
+ key, text = msgs[0]
+ assert key == "scoredrop:AAA"
+ assert "AAA" in text
+ # rebaselined to the new (lower) level
+ assert await svc._watermark(session, "AAA") == 60.0
+
+
+async def test_dispatch_disabled_short_circuits(session):
+ res = await svc.dispatch_alerts(session)
+ assert res["status"] == "disabled"
+
+
+async def test_dispatch_no_credentials(session):
+ await svc.update_alert_config(session, enabled=True) # enabled but no token/chat
+ res = await svc.dispatch_alerts(session)
+ assert res["status"] == "no_credentials"
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
index d43bab1..6bc3edd 100644
--- a/tests/unit/test_scheduler.py
+++ b/tests/unit/test_scheduler.py
@@ -68,7 +68,7 @@ class TestResumeTickers:
class TestConfigureScheduler:
- def test_configure_adds_six_jobs(self):
+ def test_configure_adds_all_jobs(self):
# Remove any existing jobs first
scheduler.remove_all_jobs()
configure_scheduler()
@@ -81,6 +81,7 @@ class TestConfigureScheduler:
"rr_scanner",
"ticker_universe_sync",
"outcome_evaluator",
+ "alerts",
}
def test_configure_is_idempotent(self):
@@ -90,6 +91,7 @@ class TestConfigureScheduler:
job_ids = [j.id for j in scheduler.get_jobs()]
# Each ID should appear exactly once
assert sorted(job_ids) == sorted([
+ "alerts",
"data_collector",
"fundamental_collector",
"outcome_evaluator",