from __future__ import annotations from datetime import datetime from types import SimpleNamespace from unittest.mock import AsyncMock, Mock from aiogram.fsm.context import FSMContext from aiogram.types import Chat, MessageOriginChat from adapter.telegram.converter import is_forum_message, resolve_forum_chat_id from adapter.telegram.handlers import chat as chat_handler from adapter.telegram.handlers import confirm as confirm_handler from adapter.telegram.handlers import forum as forum_handler from adapter.telegram.states import ChatState, ForumSetupState from core.protocol import OutgoingMessage def make_message(*, text: str = "hello", thread_id: int | None = None): message = SimpleNamespace() message.text = text message.caption = None message.photo = None message.document = None message.voice = None message.message_thread_id = thread_id message.chat = SimpleNamespace(id=-100123) message.from_user = SimpleNamespace(id=42, full_name="Alice", first_name="Alice") message.answer = AsyncMock() message.edit_text = AsyncMock() message.edit_reply_markup = AsyncMock() message.bot = SimpleNamespace( send_message=AsyncMock(), send_chat_action=AsyncMock(), create_forum_topic=AsyncMock(), get_me=AsyncMock(), get_chat_member=AsyncMock(), ) message.chat_shared = None return message class FakeTask: def cancel(self) -> None: self.cancelled = True def __await__(self): async def _done(): return None return _done().__await__() async def test_forum_helpers_detect_and_resolve(monkeypatch): message = make_message(thread_id=77) monkeypatch.setattr( chat_handler.db, "get_chat_by_thread", lambda tg_user_id, thread_id: {"chat_id": "chat-77"} if thread_id == 77 else None, ) assert is_forum_message(message) is True assert resolve_forum_chat_id(message, 42) == "chat-77" async def test_cmd_forum_enters_setup_state(): message = make_message(text="/forum") state = AsyncMock(spec=FSMContext) await forum_handler.cmd_forum(message, state) state.set_state.assert_awaited_once_with(ForumSetupState.waiting_for_group) message.answer.assert_awaited_once() assert message.answer.await_args.kwargs["reply_markup"] is not None async def test_handle_group_forward_registers_group_and_topics(monkeypatch): message = make_message(text="forwarded") message.forward_from_chat = SimpleNamespace(id=-100200, type="supergroup", title="Lambda") message.bot.get_me.return_value = SimpleNamespace(id=999) message.bot.get_chat_member.return_value = SimpleNamespace( status="administrator", can_manage_topics=True, ) message.bot.create_forum_topic.side_effect = [ SimpleNamespace(message_thread_id=11), SimpleNamespace(message_thread_id=22), ] state = AsyncMock(spec=FSMContext) monkeypatch.setattr( forum_handler.db, "get_user_chats", lambda tg_user_id: [ {"chat_id": "chat-1", "name": "One", "forum_thread_id": None}, {"chat_id": "chat-2", "name": "Two", "forum_thread_id": None}, ], ) set_forum_group = Mock() set_forum_thread = Mock() monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group) monkeypatch.setattr(forum_handler.db, "set_forum_thread", set_forum_thread) await forum_handler.handle_group_forward(message, state) set_forum_group.assert_called_once_with(42, -100200) assert message.bot.create_forum_topic.await_count == 2 set_forum_thread.assert_any_call("chat-1", 11) set_forum_thread.assert_any_call("chat-2", 22) state.set_state.assert_awaited_once_with(ChatState.idle) assert "Группа подключена" in message.answer.await_args.args[0] async def test_handle_group_forward_accepts_forward_origin_chat(monkeypatch): message = make_message(text="forwarded") message.forward_from_chat = None message.forward_origin = MessageOriginChat( date=datetime.now(), sender_chat=Chat(id=-100200, type="supergroup", title="Lambda", is_forum=True), ) message.bot.get_me.return_value = SimpleNamespace(id=999) message.bot.get_chat_member.return_value = SimpleNamespace( status="administrator", can_manage_topics=True, ) state = AsyncMock(spec=FSMContext) monkeypatch.setattr(forum_handler.db, "get_user_chats", lambda tg_user_id: []) set_forum_group = Mock() monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group) await forum_handler.handle_group_forward(message, state) set_forum_group.assert_called_once_with(42, -100200) state.set_state.assert_awaited_once_with(ChatState.idle) assert "Группа подключена" in message.answer.await_args.args[0] async def test_handle_group_forward_accepts_chat_shared(monkeypatch): message = make_message(text="selected") message.chat_shared = SimpleNamespace(request_id=1, chat_id=-100200, title="Lambda") message.bot.get_me.return_value = SimpleNamespace(id=999) message.bot.get_chat_member.return_value = SimpleNamespace( status="administrator", can_manage_topics=True, ) state = AsyncMock(spec=FSMContext) monkeypatch.setattr(forum_handler.db, "get_user_chats", lambda tg_user_id: []) set_forum_group = Mock() monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group) await forum_handler.handle_group_forward(message, state) set_forum_group.assert_called_once_with(42, -100200) state.set_state.assert_awaited_once_with(ChatState.idle) assert "Группа подключена" in message.answer.await_args.args[0] async def test_handle_group_forward_reports_missing_forward_metadata(): message = make_message(text="not forwarded") message.forward_from_chat = None message.forward_origin = None state = AsyncMock(spec=FSMContext) await forum_handler.handle_group_forward(message, state) message.answer.assert_awaited_once() assert "данных о группе" in message.answer.await_args.args[0] state.set_state.assert_not_awaited() async def test_handle_group_forward_reports_non_forum_supergroup(): message = make_message(text="forwarded") message.forward_from_chat = SimpleNamespace( id=-100200, type="supergroup", title="Lambda", is_forum=False, ) state = AsyncMock(spec=FSMContext) await forum_handler.handle_group_forward(message, state) message.answer.assert_awaited_once() assert "выключены Topics" in message.answer.await_args.args[0] state.set_state.assert_not_awaited() async def test_handle_message_routes_forum_thread(monkeypatch): message = make_message(thread_id=77) dispatcher = SimpleNamespace( dispatch=AsyncMock( return_value=[OutgoingMessage(chat_id="chat-77", text="ok")] ) ) state = AsyncMock(spec=FSMContext) state.get_data.return_value = {} monkeypatch.setattr( chat_handler.db, "get_or_create_tg_user", lambda tg_user_id, platform_user_id, display_name: { "platform_user_id": "usr-42", "display_name": display_name, }, ) monkeypatch.setattr( chat_handler.db, "get_chat_by_thread", lambda tg_user_id, thread_id: {"chat_id": "chat-77", "name": "Forum chat"}, ) monkeypatch.setattr( chat_handler.db, "get_chat_by_id", lambda chat_id: {"chat_id": chat_id, "name": "Forum chat"}, ) monkeypatch.setattr( chat_handler.asyncio, "create_task", lambda coro: (coro.close(), FakeTask())[1], ) await chat_handler.handle_message(message, state, dispatcher) incoming = dispatcher.dispatch.await_args.args[0] assert incoming.chat_id == "chat-77" assert incoming.user_id == "usr-42" assert state.update_data.await_args.kwargs == { "active_chat_id": "chat-77", "active_chat_name": "Forum chat", } message.bot.send_message.assert_awaited_once() assert message.bot.send_message.await_args.args[0] == -100123 assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 77 assert message.bot.send_message.await_args.args[1] == "ok" async def test_cmd_new_chat_creates_forum_topic_for_dm(monkeypatch): message = make_message(text="/new Analysis") state = AsyncMock(spec=FSMContext) message.bot.create_forum_topic.return_value = SimpleNamespace(message_thread_id=333) monkeypatch.setattr(chat_handler.db, "get_forum_group", lambda tg_user_id: -100200) monkeypatch.setattr(chat_handler.db, "count_chats", lambda tg_user_id: 2) create_chat = Mock(return_value="chat-3") set_forum_thread = Mock() monkeypatch.setattr(chat_handler.db, "create_chat", create_chat) monkeypatch.setattr(chat_handler.db, "set_forum_thread", set_forum_thread) await chat_handler.cmd_new_chat(message, state) create_chat.assert_called_once_with(42, "Analysis") message.bot.create_forum_topic.assert_awaited_once_with(chat_id=-100200, name="Analysis") set_forum_thread.assert_called_once_with("chat-3", 333) state.update_data.assert_awaited_once_with(active_chat_id="chat-3", active_chat_name="Analysis") message.answer.assert_awaited_once() assert "Форум-тема тоже создана" in message.answer.await_args.args[0] async def test_cmd_new_chat_registers_topic(monkeypatch): message = make_message(text="/new Research", thread_id=88) state = AsyncMock(spec=FSMContext) monkeypatch.setattr( chat_handler.db, "get_chat_by_thread", lambda tg_user_id, thread_id: None, ) monkeypatch.setattr(chat_handler.db, "count_chats", lambda tg_user_id: 4) create_chat = Mock(return_value="chat-5") set_forum_thread = Mock() monkeypatch.setattr(chat_handler.db, "create_chat", create_chat) monkeypatch.setattr(chat_handler.db, "set_forum_thread", set_forum_thread) await chat_handler.cmd_new_chat(message, state) create_chat.assert_called_once_with(42, "Research") set_forum_thread.assert_called_once_with("chat-5", 88) message.bot.send_message.assert_awaited_once() assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 88 state.update_data.assert_awaited_once_with(active_chat_id="chat-5", active_chat_name="Research") async def test_cmd_list_chats_rejected_in_forum_topic(): message = make_message(text="/chats", thread_id=88) state = AsyncMock(spec=FSMContext) await chat_handler.cmd_list_chats(message, state) message.bot.send_message.assert_awaited_once() assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 88 assert "отключено" in message.bot.send_message.await_args.args[1] async def test_switch_chat_rejected_in_forum_topic(): callback = SimpleNamespace( data="switch:chat-9:Other", from_user=SimpleNamespace(id=42, full_name="Alice"), message=make_message(thread_id=88), answer=AsyncMock(), ) state = AsyncMock(spec=FSMContext) await chat_handler.switch_chat(callback, state) state.update_data.assert_not_awaited() callback.answer.assert_awaited_once_with( "Переключение чатов доступно только в личке с ботом.", show_alert=True, ) async def test_new_chat_callback_rejected_in_forum_topic(monkeypatch): callback = SimpleNamespace( data="new_chat", from_user=SimpleNamespace(id=42, full_name="Alice"), message=make_message(thread_id=88), answer=AsyncMock(), ) state = AsyncMock(spec=FSMContext) create_chat = Mock() monkeypatch.setattr(chat_handler.db, "create_chat", create_chat) await chat_handler.cb_new_chat(callback, state) create_chat.assert_not_called() state.update_data.assert_not_awaited() callback.answer.assert_awaited_once_with( "Создание нового чата из списка доступно только в личке с ботом.", show_alert=True, ) async def test_confirm_callback_routes_back_to_forum_thread(monkeypatch): message = make_message(thread_id=77) callback = SimpleNamespace( data="confirm:yes:action-1", from_user=message.from_user, message=message, answer=AsyncMock(), ) dispatcher = SimpleNamespace( dispatch=AsyncMock( return_value=[OutgoingMessage(chat_id="chat-77", text="done")] ) ) state = AsyncMock(spec=FSMContext) state.get_data.return_value = {} monkeypatch.setattr( confirm_handler.db, "get_or_create_tg_user", lambda tg_user_id, platform_user_id, display_name: { "platform_user_id": "usr-42", "display_name": display_name, }, ) monkeypatch.setattr( confirm_handler.db, "get_chat_by_thread", lambda tg_user_id, thread_id: {"chat_id": "chat-77"}, ) monkeypatch.setattr( confirm_handler.db, "get_chat_by_id", lambda chat_id: {"chat_id": chat_id, "name": "Forum chat"}, ) await confirm_handler.handle_confirm(callback, state, dispatcher) assert dispatcher.dispatch.await_args.args[0].chat_id == "chat-77" assert callback.message.bot.send_message.await_count == 1 assert callback.message.bot.send_message.await_args.args[1] == "done" assert callback.message.bot.send_message.await_args.kwargs["message_thread_id"] == 77