"""Tests for notifier module: FanoutNotifier, DiscordNotifier, TelegramNotifier.""" from __future__ import annotations import json import time from pathlib import Path import pytest from atm.notifier import Alert, _alert_prefix from atm.notifier.fanout import FanoutNotifier # --------------------------------------------------------------------------- # Fake backends # --------------------------------------------------------------------------- class FakeBackend: """Configurable fake backend for testing.""" def __init__( self, name: str = "fake", always_fail: bool = False, fail_first_n: int = 0, sleep_s: float = 0.0, ) -> None: self.name = name self._always_fail = always_fail self._fail_first_n = fail_first_n self._sleep_s = sleep_s self._call_count = 0 def send(self, alert: Alert) -> None: self._call_count += 1 if self._sleep_s: time.sleep(self._sleep_s) if self._always_fail: raise RuntimeError(f"{self.name}: simulated failure") if self._call_count <= self._fail_first_n: raise RuntimeError(f"{self.name}: simulated failure #{self._call_count}") def _alert(title: str = "test", kind: str = "trigger") -> Alert: return Alert(kind=kind, title=title, body="body text") # --------------------------------------------------------------------------- # FanoutNotifier tests # --------------------------------------------------------------------------- def test_fanout_both_delivered(tmp_path: Path) -> None: dl = tmp_path / "dead.jsonl" b1 = FakeBackend("b1") b2 = FakeBackend("b2") fan = FanoutNotifier([b1, b2], dl, backoff_base=0.01) for i in range(3): fan.send(_alert(f"alert-{i}")) fan.stop(timeout=5.0) s = fan.stats() assert s["b1"]["sent"] == 3 assert s["b2"]["sent"] == 3 assert s["b1"]["failed"] == 0 assert s["b2"]["failed"] == 0 def test_one_backend_down_other_delivers(tmp_path: Path) -> None: dl = tmp_path / "dead.jsonl" ok_backend = FakeBackend("ok") bad_backend = FakeBackend("bad", always_fail=True) fan = FanoutNotifier( [ok_backend, bad_backend], dl, max_retries=1, backoff_base=0.01 ) for i in range(2): fan.send(_alert(f"a{i}")) fan.stop(timeout=5.0) s = fan.stats() assert s["ok"]["sent"] == 2 assert s["bad"]["failed"] == 2 # dead letter file should have entries for the bad backend assert dl.exists() lines = [json.loads(l) for l in dl.read_text().splitlines()] assert all(e["backend"] == "bad" for e in lines) assert len(lines) == 2 def test_dead_letter_on_exhausted_retries(tmp_path: Path) -> None: dl = tmp_path / "dead.jsonl" bad = FakeBackend("bad", always_fail=True) fan = FanoutNotifier([bad], dl, max_retries=3, backoff_base=0.01) fan.send(_alert("my-alert")) fan.stop(timeout=5.0) s = fan.stats() assert s["bad"]["failed"] == 1 # retries = max_retries (3 extra attempts after first) assert s["bad"]["retries"] == 3 assert dl.exists() lines = [json.loads(l) for l in dl.read_text().splitlines()] assert len(lines) == 1 entry = lines[0] assert entry["backend"] == "bad" assert entry["alert_title"] == "my-alert" assert "error_str" in entry assert "timestamp" in entry def test_queue_drop_oldest(tmp_path: Path) -> None: dl = tmp_path / "dead.jsonl" # slow backend: each send takes 0.5s so queue fills fast slow = FakeBackend("slow", sleep_s=0.5) fan = FanoutNotifier([slow], dl, queue_size=2, backoff_base=0.01) # Pump 10 alerts rapidly; worker can't keep up for i in range(10): fan.send(_alert(f"a{i}")) fan.stop(timeout=10.0) s = fan.stats() assert s["slow"]["dropped"] > 0 assert s["slow"]["sent"] <= 2 + 1 # queue_size + possibly 1 in-flight def test_retry_backoff_recovers(tmp_path: Path) -> None: dl = tmp_path / "dead.jsonl" # Fails only the very first call, succeeds after b = FakeBackend("b", fail_first_n=1) fan = FanoutNotifier([b], dl, max_retries=3, backoff_base=0.01) fan.send(_alert("recover")) fan.stop(timeout=5.0) s = fan.stats() assert s["b"]["sent"] == 1 assert s["b"]["retries"] == 1 assert s["b"]["failed"] == 0 assert not dl.exists() def test_stop_drains(tmp_path: Path) -> None: dl = tmp_path / "dead.jsonl" b = FakeBackend("b") fan = FanoutNotifier([b], dl, backoff_base=0.01) for i in range(5): fan.send(_alert(f"a{i}")) fan.stop(timeout=5.0) # All items should have been processed before stop returned assert fan.stats()["b"]["sent"] == 5 # --------------------------------------------------------------------------- # DiscordNotifier unit tests (no real HTTP) # --------------------------------------------------------------------------- class _MockResponse: def __init__( self, status_code: int, text: str = "", json_body: dict | None = None, raise_on_json: bool = False, ) -> None: self.status_code = status_code self.text = text self._json_body = json_body if json_body is not None else {"ok": True, "result": {}} self._raise_on_json = raise_on_json def json(self): if self._raise_on_json: raise ValueError("no JSON body") return self._json_body class _MockSession: def __init__( self, status_code: int = 204, json_body: dict | None = None, raise_on_json: bool = False, ) -> None: self.status_code = status_code self._json_body = json_body self._raise_on_json = raise_on_json self.calls: list[dict] = [] def post(self, url: str, **kwargs): self.calls.append({"url": url, **kwargs}) return _MockResponse( self.status_code, json_body=self._json_body, raise_on_json=self._raise_on_json, ) def test_discord_send_ok() -> None: from atm.notifier.discord import DiscordNotifier session = _MockSession(204) n = DiscordNotifier("https://discord.example/hook", session=session) n.send(_alert("Hello")) assert len(session.calls) == 1 assert "**Hello**" in session.calls[0]["json"]["content"] def test_discord_429_raises() -> None: from atm.notifier.discord import DiscordNotifier n = DiscordNotifier("https://discord.example/hook", session=_MockSession(429)) with pytest.raises(RuntimeError, match="429"): n.send(_alert("x")) def test_discord_5xx_raises() -> None: from atm.notifier.discord import DiscordNotifier n = DiscordNotifier("https://discord.example/hook", session=_MockSession(500)) with pytest.raises(RuntimeError, match="500"): n.send(_alert("x")) # --------------------------------------------------------------------------- # TelegramNotifier unit tests (no real HTTP) # --------------------------------------------------------------------------- def test_telegram_send_ok() -> None: from atm.notifier.telegram import TelegramNotifier session = _MockSession(200) n = TelegramNotifier("token", "chat123", session=session) n.send(_alert("Hi")) assert len(session.calls) == 1 assert "Hi" in session.calls[0]["json"]["text"] def test_telegram_429_raises() -> None: from atm.notifier.telegram import TelegramNotifier n = TelegramNotifier("token", "chat123", session=_MockSession(429)) with pytest.raises(RuntimeError, match="429"): n.send(_alert("x")) def test_telegram_5xx_raises() -> None: from atm.notifier.telegram import TelegramNotifier n = TelegramNotifier("token", "chat123", session=_MockSession(500)) with pytest.raises(RuntimeError, match="500"): n.send(_alert("x")) # Telegram returns 200 OK with {"ok": false, ...} for logical failures (bot # blocked, invalid chat_id, parse_mode errors). Previously silent — now raises # so FanoutNotifier retries + DLQs + stats count the failure. def test_telegram_ok_true_passes() -> None: """200 + {ok:true} → success, no raise.""" from atm.notifier.telegram import TelegramNotifier session = _MockSession(200, json_body={"ok": True, "result": {"message_id": 42}}) n = TelegramNotifier("token", "chat123", session=session) n.send(_alert("ok body")) # must not raise assert len(session.calls) == 1 def test_telegram_ok_false_raises() -> None: """200 + {ok:false, ...} → RuntimeError with code + description.""" from atm.notifier.telegram import TelegramNotifier session = _MockSession( 200, json_body={ "ok": False, "error_code": 403, "description": "Forbidden: bot was blocked by the user", }, ) n = TelegramNotifier("token", "chat123", session=session) with pytest.raises(RuntimeError, match="logical failure.*403.*blocked"): n.send(_alert("x")) def test_telegram_malformed_json_treated_as_success() -> None: """200 with non-JSON body → no raise (edge case, shouldn't happen in practice).""" from atm.notifier.telegram import TelegramNotifier session = _MockSession(200, raise_on_json=True) n = TelegramNotifier("token", "chat123", session=session) n.send(_alert("x")) # must not raise def test_telegram_ok_false_goes_to_dlq(tmp_path: Path) -> None: """Integration: ok:false → 3 retries → DLQ entry written with description.""" from atm.notifier.telegram import TelegramNotifier session = _MockSession( 200, json_body={"ok": False, "error_code": 400, "description": "chat not found"}, ) backend = TelegramNotifier("token", "chat123", session=session) dl = tmp_path / "dead.jsonl" fan = FanoutNotifier([backend], dl, max_retries=3, backoff_base=0.01) fan.send(_alert("will-fail")) fan.stop(timeout=5.0) # 4 HTTP calls (1 initial + 3 retries) assert len(session.calls) == 4 s = fan.stats() assert s["telegram"]["failed"] == 1 assert s["telegram"]["retries"] == 3 assert s["telegram"]["sent"] == 0 assert dl.exists() lines = [json.loads(l) for l in dl.read_text().splitlines()] assert len(lines) == 1 entry = lines[0] assert entry["backend"] == "telegram" assert entry["alert_title"] == "will-fail" assert "chat not found" in entry["error_str"] assert "400" in entry["error_str"] # --------------------------------------------------------------------------- # on_drop callback — queue overflow audit trail # --------------------------------------------------------------------------- def test_fanout_on_drop_callback_invoked(tmp_path: Path) -> None: """Queue-overflow drop calls on_drop(backend_name, dropped_alert).""" drops: list[tuple[str, Alert]] = [] def on_drop(name: str, alert: Alert) -> None: drops.append((name, alert)) dl = tmp_path / "dead.jsonl" slow = FakeBackend("slow", sleep_s=0.2) fan = FanoutNotifier( [slow], dl, queue_size=2, backoff_base=0.01, on_drop=on_drop, ) for i in range(10): fan.send(_alert(f"a{i}")) fan.stop(timeout=10.0) assert len(drops) > 0 assert all(name == "slow" for name, _ in drops) # Oldest alerts are the ones dropped dropped_titles = {a.title for _, a in drops} assert "a0" in dropped_titles or "a1" in dropped_titles def test_fanout_on_drop_exception_swallowed(tmp_path: Path) -> None: """on_drop raising must not break dispatch — audit failure must not silence alerts.""" def bad_on_drop(_name: str, _alert: Alert) -> None: raise RuntimeError("audit broken") dl = tmp_path / "dead.jsonl" slow = FakeBackend("slow", sleep_s=0.2) fan = FanoutNotifier( [slow], dl, queue_size=2, backoff_base=0.01, on_drop=bad_on_drop, ) # Must not raise despite every drop invoking bad_on_drop for i in range(10): fan.send(_alert(f"a{i}")) fan.stop(timeout=10.0) s = fan.stats() # Some alerts still went through assert s["slow"]["sent"] > 0 or s["slow"]["dropped"] > 0 # --------------------------------------------------------------------------- # Alert.chart_id + _alert_prefix # --------------------------------------------------------------------------- def test_alert_chart_id_default() -> None: assert Alert(kind="arm", title="t", body="b").chart_id == "" def test_alert_chart_id_set() -> None: assert Alert(kind="arm", title="t", body="b", chart_id="left").chart_id == "left" def test_alert_prefix_empty() -> None: assert _alert_prefix("") == "" def test_alert_prefix_left() -> None: assert _alert_prefix("left") == "[stânga] " def test_alert_prefix_right() -> None: assert _alert_prefix("right") == "[dreapta] " def test_alert_prefix_chart_n() -> None: assert _alert_prefix("chart_0") == "[chart 1] " assert _alert_prefix("chart_1") == "[chart 2] "