DispelEvilBot/tests/test_alerts.py

180 lines
6.5 KiB
Python

"""Тесты уведомлений в чат логов и ежечасного пинга «бот живой»."""
from __future__ import annotations
import logging
from collections.abc import Callable
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
import pytest
from bot.services import alerts, heartbeat
from common.analytics import EventType, close_analytics, init_analytics
from common.config import Settings
from common.models import PurificationResult
class FakeBot:
"""Заглушка aiogram.Bot: записывает отправленные и пересланные сообщения."""
def __init__(self, *, fail: bool = False) -> None:
self.messages: list[tuple[int, str]] = []
self.forwarded: list[tuple[int, int, int]] = []
self._fail = fail
async def send_message(self, chat_id: int, text: str) -> None:
if self._fail:
raise RuntimeError("Telegram недоступен")
self.messages.append((chat_id, text))
async def forward_message(self, chat_id: int, from_chat_id: int, message_id: int) -> None:
if self._fail:
raise RuntimeError("Telegram недоступен")
self.forwarded.append((chat_id, from_chat_id, message_id))
class FakeClickHouse:
"""Заглушка клиента ClickHouse (см. тесты аналитики)."""
def __init__(self) -> None:
self.rows: list[list[Any]] = []
async def insert(self, table: str, data: list[list[Any]], column_names: list[str]) -> None:
self.rows.extend(data)
async def close(self) -> None:
pass
@pytest.fixture(autouse=True)
async def _clean_singleton():
await close_analytics()
yield
await close_analytics()
@pytest.fixture(autouse=True)
def _reset_ping_hour(monkeypatch: pytest.MonkeyPatch) -> None:
"""Пинг не должен «протекать» между тестами."""
monkeypatch.setattr(heartbeat, "_last_ping_hour", None)
def _patch_settings(monkeypatch: pytest.MonkeyPatch, settings: Settings) -> Settings:
"""Подменить настройки во всех модулях, которые их читают."""
monkeypatch.setattr("bot.services.alerts.get_settings", lambda: settings)
monkeypatch.setattr("bot.services.heartbeat.get_settings", lambda: settings)
return settings
# --- отправка в чат логов ---
async def test_notify_alert_skips_when_chat_disabled(
settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_settings(monkeypatch, settings_factory(alert_chat_id=0))
bot = FakeBot()
assert await alerts.notify_alert(bot, "текст") is False
assert bot.messages == []
async def test_notify_alert_sends_to_alert_chat(
settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_settings(monkeypatch, settings_factory(alert_chat_id=-100))
bot = FakeBot()
assert await alerts.notify_alert(bot, "текст") is True
assert bot.messages == [(-100, "текст")]
async def test_notify_alert_failure_is_logged(
settings_factory: Callable[..., Settings],
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
_patch_settings(monkeypatch, settings_factory(alert_chat_id=-100))
with caplog.at_level(logging.ERROR):
assert await alerts.notify_alert(FakeBot(fail=True), "текст") is False
assert any("не удалось отправить уведомление" in message.lower() for message in caplog.messages)
async def test_notify_alert_forwards_message(
settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_settings(monkeypatch, settings_factory(alert_chat_id=-100))
bot = FakeBot()
message = SimpleNamespace(chat=SimpleNamespace(id=555), message_id=42)
assert await alerts.notify_alert(bot, "текст", forward=message) is True
assert bot.forwarded == [(-100, 555, 42)]
async def test_notify_join_and_leave_text(
settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_settings(monkeypatch, settings_factory(alert_chat_id=-100))
bot = FakeBot()
await alerts.notify_join(bot, username="vasya", user_id=7, chat_title="Клан")
await alerts.notify_leave(bot, username=None, user_id=7, chat_title="Клан")
assert bot.messages[0] == (-100, "[вход] @vasya вошёл в чат «Клан»")
assert bot.messages[1] == (-100, "[выход] 7 покинул чат «Клан»")
async def test_notify_purification_text(
settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_settings(monkeypatch, settings_factory(alert_chat_id=-100))
bot = FakeBot()
result = PurificationResult(success=True, exp_add=3, money_add=41)
await alerts.notify_purification(bot, username="vasya", user_id=7, result=result)
assert bot.messages == [(-100, "[очищение] @vasya — опыт +3, монеты +41")]
# --- ежечасный пинг «бот живой» ---
async def test_ping_hourly_sends_once_per_hour(
settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch
) -> None:
settings = _patch_settings(monkeypatch, settings_factory(alert_chat_id=-100, app_env="prod"))
fake = FakeClickHouse()
await init_analytics(settings, client=fake)
bot = FakeBot()
assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 10, 0, tzinfo=UTC)) is True
assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 10, 30, tzinfo=UTC)) is False
assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 11, 5, tzinfo=UTC)) is True
await close_analytics()
assert len(bot.messages) == 2
assert "бот живой" in bot.messages[0][1]
assert [row[1] for row in fake.rows] == [EventType.BOT_ALIVE, EventType.BOT_ALIVE]
async def test_ping_hourly_records_event_without_alert_chat(
settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch
) -> None:
settings = _patch_settings(monkeypatch, settings_factory(alert_chat_id=0))
fake = FakeClickHouse()
await init_analytics(settings, client=fake)
bot = FakeBot()
assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 10, 0, tzinfo=UTC)) is False
assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 10, 45, tzinfo=UTC)) is False
await close_analytics()
assert bot.messages == []
assert [row[1] for row in fake.rows] == [EventType.BOT_ALIVE]