"""Тесты для utils/morning_runner.py — Scheduler и run_morning.""" from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest from utils.morning_runner import Scheduler, run_morning class TestSchedulerInit: """Тесты инициализации Scheduler.""" def test_init_sets_morning_time(self) -> None: """Инициализация должна устанавливать время.""" bot = AsyncMock() with patch.object(Scheduler, "_start_scheduler"): scheduler = Scheduler(bot, "08:30") assert scheduler.morning_time == "08:30" def test_init_default_morning_time(self) -> None: """Инициализация с дефолтным временем.""" bot = AsyncMock() with patch.object(Scheduler, "_start_scheduler"): scheduler = Scheduler(bot) assert scheduler.morning_time == "07:00" def test_init_does_not_start_scheduler(self) -> None: """Инициализация не должна запускать планировщик (start() вызывается отдельно).""" bot = AsyncMock() with patch.object(Scheduler, "_start_scheduler") as mock_start: Scheduler(bot) mock_start.assert_not_called() class TestSchedulerCalculateNextRun: """Тесты расчёта следующего запуска.""" def test_next_run_today_before_time(self) -> None: """Если сейчас раньше времени — вернуть сегодня.""" bot = AsyncMock() with patch.object(Scheduler, "_start_scheduler"): scheduler = Scheduler(bot, "14:00") now = datetime(2026, 5, 29, 10, 0, 0) next_run = scheduler._calculate_next_run(now) assert next_run == datetime(2026, 5, 29, 14, 0, 0) def test_next_run_tomorrow_after_time(self) -> None: """Если сейчас позже времени — вернуть завтра.""" bot = AsyncMock() with patch.object(Scheduler, "_start_scheduler"): scheduler = Scheduler(bot, "14:00") now = datetime(2026, 5, 29, 15, 0, 0) next_run = scheduler._calculate_next_run(now) assert next_run == datetime(2026, 5, 30, 14, 0, 0) def test_next_run_exact_time(self) -> None: """Если сейчас ровно время — вернуть завтра.""" bot = AsyncMock() with patch.object(Scheduler, "_start_scheduler"): scheduler = Scheduler(bot, "14:00") now = datetime(2026, 5, 29, 14, 0, 0) next_run = scheduler._calculate_next_run(now) assert next_run == datetime(2026, 5, 30, 14, 0, 0) class TestSchedulerStartStop: """Тесты запуска/остановки планировщика.""" @pytest.mark.asyncio async def test_start_starts_task(self) -> None: """start() должен вызывать _start_scheduler один раз.""" bot = AsyncMock() with patch.object(Scheduler, "_start_scheduler") as mock_start: scheduler = Scheduler(bot) await scheduler.start() mock_start.assert_called_once() def test_stop_stops_task(self) -> None: """stop() должен остановить task.""" bot = AsyncMock() with patch.object(Scheduler, "_start_scheduler"): scheduler = Scheduler(bot) scheduler._running = True mock_task = MagicMock() mock_task.done.return_value = False scheduler._task = mock_task scheduler.stop() assert scheduler._running is False @pytest.mark.asyncio async def test_double_start_no_duplicate(self) -> None: """Повторный start() не должен дублировать task (благодаря флагам).""" bot = AsyncMock() with patch.object(Scheduler, "_start_scheduler") as mock_start: scheduler = Scheduler(bot) await scheduler.start() await scheduler.start() # повторный вызов # _start_scheduler вызывается 2 раза, но реальный task один (флаг _running защищает) assert mock_start.call_count == 2 class TestRunMorning: """Тесты run_morning.""" @pytest.mark.asyncio async def test_run_morning_sends_plain_message(self) -> None: """run_morning должен отправлять plain text в канал.""" bot = AsyncMock() channel = AsyncMock() channel.name = "test-channel" channel.guild.me = MagicMock() channel.permissions_for.return_value.send_messages = True weather_data = { "current_condition": [ { "temp_C": "20", "FeelsLikeC": "22", "weatherDesc": [{"value": "Clear"}], "humidity": "50", "windspeedKmph": "10", "pressure": "1013", } ] } articles = [ { "title": "Test", "link": "http://test.com", "pub_date": "Mon, 01 Jan 2026 00:00:00 GMT", "creator": "", "tags": [], } ] posts = [ { "title": "Test", "link": "http://test.com", "pub_date": "Mon, 01 Jan 2026 00:00:00 GMT", "creator": "", "tags": [], } ] with ( patch( "utils.morning_runner.fetch_weather", new=AsyncMock(return_value=weather_data), ), patch( "utils.morning_runner.fetch_rss", new=AsyncMock(side_effect=[articles, posts]), ), patch( "utils.morning_runner.fetch_cat", new=AsyncMock(return_value="http://cat.jpg"), ), ): await run_morning(bot, channel) # Два сообщения: кот + дайджест assert channel.send.call_count == 2 # Первое сообщение — URL кота first_call = channel.send.call_args_list[0] assert first_call[0][0] == "http://cat.jpg" # Второе сообщение — текстовый дайджест second_call = channel.send.call_args_list[1] message_text = second_call[0][0] assert "Утренний дайджест" in message_text assert "Погода: Магнитогорск" in message_text class TestRunMorningWithFallback: """Тесты fallback в plain text.""" @pytest.mark.asyncio async def test_run_morning_empty_fallback(self) -> None: """run_morning должен отправлять fallback сообщение при пустых данных.""" bot = AsyncMock() channel = AsyncMock() channel.name = "test-channel" channel.guild.me = MagicMock() channel.permissions_for.return_value.send_messages = True # Все API возвращают None/пусто with ( patch( "utils.morning_runner.fetch_weather", new=AsyncMock(return_value=None) ), patch("utils.morning_runner.fetch_rss", new=AsyncMock(return_value=None)), patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)), ): await run_morning(bot, channel) # Только одно сообщение (без кота) с fallback текстом channel.send.assert_called_once() call_args = channel.send.call_args message_text = call_args[0][0] assert "Не удалось получить данные из внешних источников" in message_text @pytest.mark.asyncio async def test_run_morning_only_weather_data(self) -> None: """run_morning должен корректно обрабатывать только погоду без новостей.""" bot = AsyncMock() channel = AsyncMock() channel.name = "test-channel" channel.guild.me = MagicMock() channel.permissions_for.return_value.send_messages = True weather_data = { "current_condition": [ { "temp_C": "20", "FeelsLikeC": "22", "weatherDesc": [{"value": "Clear"}], "humidity": "50", "windspeedKmph": "10", "pressure": "1013", } ] } with ( patch( "utils.morning_runner.fetch_weather", new=AsyncMock(return_value=weather_data), ), patch( "utils.morning_runner.fetch_rss", new=AsyncMock(side_effect=[None, None]), ), patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)), ): await run_morning(bot, channel) # Одно сообщение (погода + отсутствие новостей, без кота) channel.send.assert_called_once() call_args = channel.send.call_args message_text = call_args[0][0] # Проверяем, что в тексте есть погода и нет fallback сообщения assert "Погода: Магнитогорск" in message_text assert "Не удалось получить данные из внешних источников" not in message_text