from __future__ import annotations import asyncio import unittest from datetime import datetime from typing import Any, cast from unittest.mock import AsyncMock, patch from aiogram.types import Chat, Message import bot class _StubState: def __init__(self, mode="lobby"): self.value = bot.MODE_STATES[mode].state async def get_state(self): return self.value async def set_state(self, state): self.value = state.state async def clear(self): self.value = None class DeployNoticeTests(unittest.IsolatedAsyncioTestCase): async def test_sends_once_per_new_version_only_in_development(self): fake_bot = AsyncMock() state = _StubState("generation") with ( patch.object(bot, "DEPLOY_ENV", "development"), patch.object(bot, "VERSION", "0.1.0+5"), patch.dict("os.environ", {"DEV_CHAT_ID": "2201809732"}, clear=False), patch.object(bot, "get_deploy_notice_version", return_value="0.1.0+4"), patch.object(bot, "set_deploy_notice_version"), patch.object(bot.asyncio, "to_thread", new=AsyncMock(side_effect=["0.1.0+4", None, "0.1.0+5"])), ): self.assertTrue(await bot.notify_development_deploy(fake_bot, cast(Any, state), 2201809732)) self.assertFalse(await bot.notify_development_deploy(fake_bot)) fake_bot.send_message.assert_awaited_once_with( chat_id=2201809732, text="🚀 Бот обновлён\nВерсия: 0.1.0+5", reply_markup=bot.main_menu_keyboard(), ) self.assertEqual(state.value, bot.BotMode.lobby.state) async def test_does_not_send_outside_development(self): fake_bot = AsyncMock() for env in ("production", "", "staging", "Development", " development"): with self.subTest(env=env), patch.object(bot, "DEPLOY_ENV", env): await bot.notify_development_deploy(fake_bot) fake_bot.send_message.assert_not_awaited() async def test_deploy_notice_does_not_block_if_test_api_hangs(self): fake_bot = AsyncMock() fake_bot.send_message.side_effect = asyncio.TimeoutError with ( patch.object(bot, "DEPLOY_ENV", "development"), patch.dict("os.environ", {"DEV_CHAT_ID": "2201809732"}, clear=False), patch.object(bot, "get_deploy_notice_version", return_value="old"), ): await bot.notify_development_deploy(fake_bot) fake_bot.send_message.assert_awaited_once() class UserFacingErrorTests(unittest.IsolatedAsyncioTestCase): async def test_failure_log_has_traceback_and_operation_id(self): message = AsyncMock() with bot.diagnostic_operation() as operation_id: with self.assertLogs(bot.logger, level="ERROR") as captured: try: raise ValueError("provider failure") except ValueError as exc: await bot.report_error(message, "генерация поста", exc) self.assertIn(f"operation_id={operation_id}", "\n".join(captured.output)) self.assertIn("Traceback (most recent call last)", "\n".join(captured.output)) self.assertIn("provider failure", "\n".join(captured.output)) async def test_production_error_is_generic_without_exception_details(self): message = AsyncMock() with patch.object(bot, "DEPLOY_ENV", "production"): await bot.report_error(message, "генерация поста", RuntimeError("internal detail")) message.answer.assert_awaited_once() self.assertNotIn("internal detail", message.answer.await_args.args[0]) async def test_development_error_details_are_a_separate_message(self): message = AsyncMock() with patch.object(bot, "DEPLOY_ENV", "development"): await bot.report_error(message, "генерация поста", RuntimeError("internal detail")) self.assertEqual(message.answer.await_count, 2) self.assertNotIn("internal detail", message.answer.await_args_list[0].args[0]) diagnostic = message.answer.await_args_list[1] self.assertIn("Dev-диагностика", diagnostic.args[0]) self.assertIn("internal detail", diagnostic.args[0]) async def test_unknown_environment_does_not_send_developer_diagnostics(self): message = AsyncMock() with patch.object(bot, "DEPLOY_ENV", "staging"): await bot.report_error(message, "генерация поста", RuntimeError("internal detail")) message.answer.assert_awaited_once() self.assertNotIn("internal detail", message.answer.await_args.args[0]) async def test_missing_environment_disables_developer_diagnostics(self): message = AsyncMock() with patch.object(bot, "DEPLOY_ENV", ""): await bot.report_error(message, "генерация поста", RuntimeError("internal detail")) message.answer.assert_awaited_once() self.assertNotIn("internal detail", message.answer.await_args.args[0]) class HelpRoutingTests(unittest.TestCase): def test_commands_are_routed_in_command_handler(self): self.assertEqual( bot.MODE_COMMANDS, { "/training": "training", "/generate": "generation", "/subscription": "subscription", "/help": "help", }, ) from pathlib import Path source = Path(bot.__file__).read_text(encoding="utf-8") self.assertIn("async def handle_command_text", source) self.assertIn('if command == "/start":', source) self.assertIn("elif command in MODE_COMMANDS:", source) self.assertIn('elif command == "/clear":', source) self.assertIn('elif command == "/idea":', source) self.assertNotIn("idea_voice", source) def test_help_sections_cover_usage_scenarios(self): from help_message import HELP_SECTIONS, help_plain_view, rich_to_plain text = "\n".join(rich_to_plain(body) for _title, body in HELP_SECTIONS.values()) self.assertIn("/clear", text) self.assertIn("/idea", text) self.assertNotIn("/idea_voice", text) for term in ("пост", "голосовое", "скриншот", "реплаем", "/help"): self.assertIn(term, text) menu = help_plain_view(None) for title, _body in HELP_SECTIONS.values(): self.assertIn(title, menu) class HelpNavigationTests(unittest.TestCase): def test_menu_contains_no_embedded_buttons(self): from help_message import help_menu_html self.assertNotIn("