"""Тесты уведомлений в чат логов и ежечасного пинга «бот живой».""" 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 FakeWriter: """Заглушка записи аналитики в Postgres (см. тесты аналитики).""" def __init__(self) -> None: self.events: list[list[Any]] = [] async def __call__(self, events: list[list[Any]], messages: dict[Any, Any]) -> None: self.events.extend(events) @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 = FakeWriter() await init_analytics(settings, writer=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.events] == [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 = FakeWriter() await init_analytics(settings, writer=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.events] == [EventType.BOT_ALIVE]