"""Tests for Telegram model picker thread fallback.""" import sys from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest def _ensure_telegram_mock(): if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): return mod = MagicMock() mod.ext.ContextTypes.DEFAULT_TYPE = type(None) mod.constants.ParseMode.MARKDOWN = "Markdown" mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" mod.constants.ParseMode.HTML = "HTML" mod.constants.ChatType.PRIVATE = "private" mod.constants.ChatType.GROUP = "group" mod.constants.ChatType.SUPERGROUP = "supergroup" mod.constants.ChatType.CHANNEL = "channel" mod.error.NetworkError = type("NetworkError", (OSError,), {}) mod.error.TimedOut = type("TimedOut", (OSError,), {}) mod.error.BadRequest = type("BadRequest", (Exception,), {}) for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): sys.modules.setdefault(name, mod) sys.modules.setdefault("telegram.error", mod.error) _ensure_telegram_mock() from gateway.config import PlatformConfig from gateway.platforms.telegram import TelegramAdapter def _make_adapter(): adapter = TelegramAdapter(PlatformConfig(enabled=True, token="test-token")) adapter._bot = AsyncMock() adapter._app = MagicMock() return adapter class TestTelegramModelPicker: @pytest.mark.asyncio async def test_send_model_picker_escapes_dynamic_provider_label(self): adapter = _make_adapter() sent = {} async def mock_send_message(**kwargs): sent.update(kwargs) return SimpleNamespace(message_id=101) adapter._bot.send_message = AsyncMock(side_effect=mock_send_message) result = await adapter.send_model_picker( chat_id="12345", providers=[ {"slug": "provider_one", "name": "Provider One", "total_models": 1, "is_current": True} ], current_model="model_1", current_provider="provider_one", session_key="s", on_model_selected=AsyncMock(), metadata={"thread_id": "99999"}, ) assert result.success is True assert "MARKDOWN_V2" in repr(sent["parse_mode"]) assert "provider\\_one" in sent["text"] assert "`model_1`" in sent["text"] @pytest.mark.asyncio async def test_back_button_escapes_dynamic_provider_label(self): adapter = _make_adapter() adapter._model_picker_state["12345"] = { "providers": [{"slug": "provider_one", "name": "Provider One", "total_models": 1, "is_current": True}], "current_model": "model_1", "current_provider": "provider_one", "session_key": "s", "on_model_selected": AsyncMock(), "msg_id": 42, } query = AsyncMock() query.data = "mb" query.message = MagicMock() query.message.chat_id = 12345 query.from_user = MagicMock() query.answer = AsyncMock() query.edit_message_text = AsyncMock() update = MagicMock() update.callback_query = query context = MagicMock() await adapter._handle_model_picker_callback(query, "mb", "12345") edit_kwargs = query.edit_message_text.call_args[1] assert "MARKDOWN_V2" in repr(edit_kwargs["parse_mode"]) assert "provider\\_one" in edit_kwargs["text"] assert "`model_1`" in edit_kwargs["text"] @pytest.mark.asyncio async def test_model_selected_edits_message_on_success(self): """Regression: the mm: (model selected → switch) success path must edit the picker message to show the confirmation and remove the buttons. An earlier revision of this PR over-indented the edit_message_text block so it lived inside the except branch and only fired when the callback raised.""" adapter = _make_adapter() callback = AsyncMock(return_value="Switched to `gpt-5`") adapter._model_picker_state["12345"] = { "providers": [ {"slug": "openai", "name": "OpenAI", "total_models": 1, "is_current": True} ], "current_model": "model_1", "current_provider": "openai", "session_key": "s", "on_model_selected": callback, "selected_provider": "openai", "model_list": ["gpt-5"], "msg_id": 42, } query = AsyncMock() query.data = "mm:0" query.message = MagicMock() query.message.chat_id = 12345 query.answer = AsyncMock() query.edit_message_text = AsyncMock() await adapter._handle_model_picker_callback(query, "mm:0", "12345") # The callback was invoked with the selected model callback.assert_awaited_once() # edit_message_text MUST be called on the success path (this is the # regression we're guarding). query.edit_message_text.assert_awaited() edit_kwargs = query.edit_message_text.call_args[1] assert "MARKDOWN_V2" in repr(edit_kwargs["parse_mode"]) # The dynamic result text was routed through format_message # (backtick code blocks survive escaping). assert "`gpt-5`" in edit_kwargs["text"] # State is cleaned up after a successful switch. assert "12345" not in adapter._model_picker_state @pytest.mark.asyncio async def test_retries_without_thread_when_thread_not_found(self): adapter = _make_adapter() providers = [{"slug": "openai", "name": "OpenAI", "total_models": 2, "is_current": True}] call_log = [] class FakeBadRequest(Exception): pass async def mock_send_message(**kwargs): call_log.append(dict(kwargs)) if kwargs.get("message_thread_id") is not None: raise FakeBadRequest("Message thread not found") return SimpleNamespace(message_id=99) adapter._bot.send_message = AsyncMock(side_effect=mock_send_message) result = await adapter.send_model_picker( chat_id="12345", providers=providers, current_model="gpt-5", current_provider="openai", session_key="s", on_model_selected=AsyncMock(), metadata={"thread_id": "99999"}, ) assert result.success is True assert len(call_log) == 2 assert call_log[0]["message_thread_id"] == 99999 assert "message_thread_id" not in call_log[1] or call_log[1]["message_thread_id"] is None