diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..8299241
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,62 @@
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+os.environ.update({
+ "BOT_TOKEN": "123456:TEST-token-for-unit-tests-only",
+ "ALLOWED_IDS": "1,2",
+ "AM_ALERTS_URL": "https://example.test/ambot/api/v2/alerts",
+ "PROM_QUERY_URL": "https://example.test/prombot/api/v1/query",
+ "AM_TOKEN": "am-token",
+ "PROM_TOKEN": "prom-token",
+ "SERVERS_ORDER": "spb,ams,dorm",
+ "ALERTBOT_DB": str(Path(tempfile.mkdtemp()) / "test.db"),
+ "ALERT_WATCHDOG_FAILURES": "3",
+})
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+import pytest # noqa: E402
+
+import handlers # noqa: E402
+
+
+@pytest.fixture(autouse=True)
+def fresh_db():
+ with handlers.db._conn() as conn:
+ conn.execute("DELETE FROM users")
+ conn.execute("DELETE FROM alert_seen")
+ handlers.db.add_user(1, "owner")
+ handlers.db.add_user(2, "friend")
+ handlers.db.set_alerts(2, False)
+
+
+@pytest.fixture
+def sent(monkeypatch):
+ """Messages the bot would send: (user_id, text)."""
+ out = []
+
+ async def fake_send(uid, text):
+ out.append((uid, text))
+
+ monkeypatch.setattr(handlers.bot, "send_message", fake_send)
+ return out
+
+
+@pytest.fixture
+def prom(monkeypatch):
+ """Fake Prometheus: maps a substring of the query to a result vector."""
+ answers = {}
+
+ async def fake_promq(session, query):
+ for key, value in answers.items():
+ if key in query:
+ return value
+ return []
+
+ monkeypatch.setattr(handlers, "_promq", fake_promq)
+ return answers
+
+
+def vec(**by_server):
+ return [{"metric": {"server": k}, "value": [0, str(v)]} for k, v in by_server.items()]
diff --git a/tests/test_alerts.py b/tests/test_alerts.py
new file mode 100644
index 0000000..9cbd018
--- /dev/null
+++ b/tests/test_alerts.py
@@ -0,0 +1,61 @@
+import asyncio
+
+import handlers
+
+
+def alert(fp, name="NodeDown", state="active", server="spb", summary="Узел не отвечает"):
+ return {"fingerprint": fp, "status": {"state": state},
+ "labels": {"alertname": name, "severity": "critical", "server": server},
+ "annotations": {"summary": summary}}
+
+
+def run(coro):
+ return asyncio.run(coro)
+
+
+def test_new_alert_is_sent_once_to_subscribers(sent):
+ run(handlers.process_alerts([alert("a1")]))
+ run(handlers.process_alerts([alert("a1")]))
+ assert [uid for uid, _ in sent] == [1]
+ assert "Проблема · NodeDown" in sent[0][1]
+
+
+def test_resolved_alert_is_announced(sent):
+ run(handlers.process_alerts([alert("a1")]))
+ run(handlers.process_alerts([]))
+ assert "В норме · NodeDown" in sent[-1][1]
+ assert handlers.db.list_firing_fingerprints() == []
+
+
+def test_suppressed_alerts_are_ignored(sent):
+ run(handlers.process_alerts([alert("a1", state="suppressed"), {"status": {"state": "active"}}]))
+ assert sent == []
+
+
+def test_alert_text_is_html_escaped():
+ text = handlers._fmt_firing(alert("a1", name="x", summary="a < b & c"))
+ assert "<b>x</b>" in text
+ assert "a < b & c" in text
+
+
+def test_watchdog_reports_once_and_recovers(sent):
+ wd = handlers.Watchdog(threshold=3)
+ for _ in range(5):
+ run(wd.failed("timeout"))
+ assert len(sent) == 1 and "Мониторинг недоступен" in sent[0][1]
+ run(wd.ok())
+ assert "Мониторинг снова доступен" in sent[-1][1]
+ run(wd.ok())
+ assert len(sent) == 2
+
+
+def test_watchdog_ignores_short_glitches(sent):
+ wd = handlers.Watchdog(threshold=3)
+ run(wd.failed("timeout"))
+ run(wd.failed("timeout"))
+ run(wd.ok())
+ assert sent == []
+
+
+def test_token_is_sent_in_header_not_url():
+ assert handlers._auth("secret") == {"Authorization": "Bearer secret"}
diff --git a/tests/test_reports.py b/tests/test_reports.py
new file mode 100644
index 0000000..0213a4b
--- /dev/null
+++ b/tests/test_reports.py
@@ -0,0 +1,69 @@
+import asyncio
+
+import handlers
+from tests.conftest import vec
+
+
+def run(coro):
+ return asyncio.run(coro)
+
+
+def test_uptime_format():
+ assert handlers.fmt_uptime(3 * 86400 + 5 * 3600) == "3д 5ч"
+ assert handlers.fmt_uptime(7200) == "2ч"
+ assert handlers.fmt_uptime(-5) == "0ч"
+
+
+def test_servers_report_marks_problems(prom):
+ import time
+
+ now = time.time()
+ prom.update({
+ "up{": vec(spb=1, ams=0),
+ "node_cpu_seconds_total": vec(spb=12, ams=3),
+ "MemAvailable": vec(spb=95, ams=40),
+ "node_filesystem_avail_bytes": vec(spb=50, ams=91),
+ "node_load1": vec(spb=0.5, ams=0.1),
+ "node_boot_time_seconds": vec(spb=now - 2 * 86400, ams=now - 3600),
+ "SwapTotal": vec(spb=0, ams=0),
+ "hwmon": vec(spb=40),
+ })
+ text = run(handlers.render_servers())
+ assert "SPB · ⚠ память" in text
+ assert "AMS · НЕ ОТВЕЧАЕТ" in text
+ assert "DORM — нет данных" in text
+ assert "аптайм 2д 0ч" in text
+
+
+def test_services_report(prom):
+ prom["up"] = [
+ {"metric": {"job": "node_spb", "instance": "spb:9100"}, "value": [0, "1"]},
+ {"metric": {"job": "blackbox_http", "instance": "https://.example"}, "value": [0, "0"]},
+ ]
+ text = run(handlers.render_services())
+ assert "1/2 в норме" in text
+ assert "<bad>" in text
+
+
+def test_services_all_ok(prom):
+ prom["up"] = [{"metric": {"job": "node_spb"}, "value": [0, "1"]}]
+ assert "Все 1 в норме" in run(handlers.render_services())
+
+
+def test_certs_report_sorted_with_warning(prom):
+ prom["probe_ssl_earliest_cert_expiry"] = [
+ {"metric": {"instance": "https://deev.space/"}, "value": [0, "60.2"]},
+ {"metric": {"instance": "https://tablo.deev.su"}, "value": [0, "5.1"]},
+ ]
+ text = run(handlers.render_certs())
+ assert "скоро истекают: tablo.deev.su — 5 дн" in text
+ rows = text.split("")[1].split("")[0].splitlines()
+ assert rows[0].endswith("tablo.deev.su") and rows[1].endswith("deev.space")
+
+
+def test_prometheus_error_is_reported(monkeypatch):
+ async def boom(session, query):
+ raise RuntimeError("connection refused")
+
+ monkeypatch.setattr(handlers, "_promq", boom)
+ assert "Не удалось получить метрики" in run(handlers.render_servers())