""" Tests for the interactive login flow with buttons. Tests the new login interface that provides buttons for: - Getting help on how to obtain a link code - Prompting user to enter their link code - Navigating back to welcome message """ import pytest from unittest.mock import AsyncMock, MagicMock, patch from telegram import Update, User, Message, CallbackQuery, Chat from telegram.ext import ContextTypes from app.bot.handlers import start_command, button_callback, handle_text_message @pytest.fixture def mock_update_unlinked(): """Create mock Update for unlinked user at /start""" update = MagicMock(spec=Update) update.effective_user = MagicMock(spec=User) update.effective_user.id = 99999 update.effective_user.username = "newuser" update.message = MagicMock(spec=Message) update.message.reply_text = AsyncMock() return update @pytest.fixture def mock_context(): """Create mock Context""" context = MagicMock(spec=ContextTypes.DEFAULT_TYPE) context.args = [] context.bot = MagicMock() context.bot.send_message = AsyncMock() return context @pytest.fixture def mock_callback_query(): """Create mock CallbackQuery for button clicks""" query = MagicMock(spec=CallbackQuery) query.answer = AsyncMock() query.edit_message_text = AsyncMock() query.data = "login_help" update = MagicMock(spec=Update) update.callback_query = query update.effective_user = MagicMock(spec=User) update.effective_user.id = 99999 return update class TestLoginFlowStart: """Test /start command for unlinked users""" @pytest.mark.asyncio async def test_start_unlinked_shows_buttons(self, mock_update_unlinked, mock_context): """Test that /start for unlinked user shows login buttons""" with patch('app.bot.handlers.check_user_linked', new_callable=AsyncMock) as mock_check: mock_check.return_value = False await start_command(mock_update_unlinked, mock_context) # Verify message was sent assert mock_update_unlinked.message.reply_text.called # Get the call kwargs call_kwargs = mock_update_unlinked.message.reply_text.call_args.kwargs # Verify reply_markup (buttons) was included assert 'reply_markup' in call_kwargs assert call_kwargs['reply_markup'] is not None # Verify message contains welcome text call_text = mock_update_unlinked.message.reply_text.call_args.args[0] assert "Bun venit" in call_text assert "linkezi" in call_text.lower() @pytest.mark.asyncio async def test_start_unlinked_has_two_buttons(self, mock_update_unlinked, mock_context): """Test that welcome message has exactly 2 buttons""" with patch('app.bot.handlers.check_user_linked', new_callable=AsyncMock) as mock_check: mock_check.return_value = False await start_command(mock_update_unlinked, mock_context) call_kwargs = mock_update_unlinked.message.reply_text.call_args.kwargs keyboard = call_kwargs['reply_markup'] # Should have 2 rows with 1 button each assert len(keyboard.inline_keyboard) == 2 assert len(keyboard.inline_keyboard[0]) == 1 # First row: "Cum obtin codul?" assert len(keyboard.inline_keyboard[1]) == 1 # Second row: "Am deja cod" @pytest.mark.asyncio async def test_start_button_callbacks_correct(self, mock_update_unlinked, mock_context): """Test that buttons have correct callback data""" with patch('app.bot.handlers.check_user_linked', new_callable=AsyncMock) as mock_check: mock_check.return_value = False await start_command(mock_update_unlinked, mock_context) call_kwargs = mock_update_unlinked.message.reply_text.call_args.kwargs keyboard = call_kwargs['reply_markup'] # Check callback data button1 = keyboard.inline_keyboard[0][0] button2 = keyboard.inline_keyboard[1][0] assert button1.callback_data == "login_help" assert button2.callback_data == "login_prompt" class TestLoginHelpCallback: """Test login_help callback handler""" @pytest.mark.asyncio async def test_login_help_shows_instructions(self, mock_callback_query, mock_context): """Test that login_help callback shows detailed instructions""" mock_callback_query.callback_query.data = "login_help" await button_callback(mock_callback_query, mock_context) # Verify query was answered assert mock_callback_query.callback_query.answer.called # Verify message was edited assert mock_callback_query.callback_query.edit_message_text.called # Get the edited message text call_args = mock_callback_query.callback_query.edit_message_text.call_args.args message_text = call_args[0] # Verify it contains help instructions assert "Cum obtin codul" in message_text assert "Pasul 1" in message_text assert "Pasul 2" in message_text assert "Pasul 3" in message_text assert "Pasul 4" in message_text assert "/start" in message_text @pytest.mark.asyncio async def test_login_help_has_action_buttons(self, mock_callback_query, mock_context): """Test that login_help shows action buttons""" mock_callback_query.callback_query.data = "login_help" await button_callback(mock_callback_query, mock_context) call_kwargs = mock_callback_query.callback_query.edit_message_text.call_args.kwargs # Verify reply_markup exists assert 'reply_markup' in call_kwargs keyboard = call_kwargs['reply_markup'] # Should have 2 buttons assert len(keyboard.inline_keyboard) == 2 assert keyboard.inline_keyboard[0][0].callback_data == "login_prompt" assert keyboard.inline_keyboard[1][0].callback_data == "login_back" class TestLoginPromptCallback: """Test login_prompt callback handler""" @pytest.mark.asyncio async def test_login_prompt_edits_message(self, mock_callback_query, mock_context): """Test that login_prompt edits message with instructions""" mock_callback_query.callback_query.data = "login_prompt" await button_callback(mock_callback_query, mock_context) # Verify message was edited assert mock_callback_query.callback_query.edit_message_text.called call_args = mock_callback_query.callback_query.edit_message_text.call_args.args message_text = call_args[0] # Verify it contains linking instructions assert "Linkuire" in message_text or "linking" in message_text.lower() assert "/start" in message_text assert "cod" in message_text.lower() @pytest.mark.asyncio async def test_login_prompt_sends_force_reply(self, mock_callback_query, mock_context): """Test that login_prompt sends ForceReply message""" mock_callback_query.callback_query.data = "login_prompt" await button_callback(mock_callback_query, mock_context) # Verify bot.send_message was called with ForceReply assert mock_context.bot.send_message.called call_kwargs = mock_context.bot.send_message.call_args.kwargs # Check ForceReply was used assert 'reply_markup' in call_kwargs reply_markup = call_kwargs['reply_markup'] # Verify it's a ForceReply from telegram import ForceReply assert isinstance(reply_markup, ForceReply) assert reply_markup.selective is True class TestLoginBackCallback: """Test login_back callback handler""" @pytest.mark.asyncio async def test_login_back_returns_to_welcome(self, mock_callback_query, mock_context): """Test that login_back returns to welcome message""" mock_callback_query.callback_query.data = "login_back" await button_callback(mock_callback_query, mock_context) # Verify message was edited assert mock_callback_query.callback_query.edit_message_text.called call_args = mock_callback_query.callback_query.edit_message_text.call_args.args message_text = call_args[0] # Should show welcome message again assert "Bun venit" in message_text @pytest.mark.asyncio async def test_login_back_shows_original_buttons(self, mock_callback_query, mock_context): """Test that login_back shows original welcome buttons""" mock_callback_query.callback_query.data = "login_back" await button_callback(mock_callback_query, mock_context) call_kwargs = mock_callback_query.callback_query.edit_message_text.call_args.kwargs keyboard = call_kwargs['reply_markup'] # Should have same 2 buttons as start assert len(keyboard.inline_keyboard) == 2 assert keyboard.inline_keyboard[0][0].callback_data == "login_help" assert keyboard.inline_keyboard[1][0].callback_data == "login_prompt" class TestLoginFlowIntegration: """Integration tests for complete login flow""" @pytest.mark.asyncio async def test_complete_flow_help_to_prompt(self, mock_callback_query, mock_context): """Test flow: start → help → prompt""" # Step 1: User clicks "Cum obtin codul?" mock_callback_query.callback_query.data = "login_help" await button_callback(mock_callback_query, mock_context) # Verify help was shown assert mock_callback_query.callback_query.edit_message_text.called # Step 2: User clicks "Am deja cod - Linkez acum" from help mock_callback_query.callback_query.data = "login_prompt" mock_callback_query.callback_query.edit_message_text.reset_mock() mock_context.bot.send_message.reset_mock() await button_callback(mock_callback_query, mock_context) # Verify prompt was shown assert mock_callback_query.callback_query.edit_message_text.called assert mock_context.bot.send_message.called @pytest.mark.asyncio async def test_complete_flow_help_to_back(self, mock_callback_query, mock_context): """Test flow: start → help → back to welcome""" # Step 1: User clicks help mock_callback_query.callback_query.data = "login_help" await button_callback(mock_callback_query, mock_context) # Step 2: User clicks back mock_callback_query.callback_query.data = "login_back" mock_callback_query.callback_query.edit_message_text.reset_mock() await button_callback(mock_callback_query, mock_context) # Verify welcome message was shown call_args = mock_callback_query.callback_query.edit_message_text.call_args.args assert "Bun venit" in call_args[0] class TestLoginFlowEdgeCases: """Test edge cases and error handling""" @pytest.mark.asyncio async def test_start_with_code_still_works(self, mock_update_unlinked, mock_context): """Test that /start still works (doesn't show buttons)""" # Set args to simulate /start ABC12345 mock_context.args = ["ABC12345"] with patch('app.bot.handlers.link_telegram_account', new_callable=AsyncMock) as mock_link: mock_link.return_value = { 'username': 'testuser', 'companies': ['1', '2'] } await start_command(mock_update_unlinked, mock_context) # Verify linking was attempted assert mock_link.called # Verify success message was sent (not buttons) call_args = mock_update_unlinked.message.reply_text.call_args.args assert "linked cu succes" in call_args[0].lower() @pytest.mark.asyncio async def test_callback_with_invalid_data(self, mock_callback_query, mock_context): """Test that invalid callback data doesn't crash""" mock_callback_query.callback_query.data = "invalid_callback_xyz" # Should not raise exception try: await button_callback(mock_callback_query, mock_context) # If it gets here, it handled the invalid callback gracefully assert True except Exception as e: # Should not get here pytest.fail(f"button_callback raised exception for invalid callback: {e}") class TestDirectCodeInput: """Test direct code input functionality (without /start command)""" @pytest.fixture def mock_text_update_unlinked(self): """Create mock Update for text message from unlinked user""" update = MagicMock(spec=Update) update.effective_user = MagicMock(spec=User) update.effective_user.id = 99999 update.effective_user.username = "newuser" update.effective_user.first_name = "Test" update.effective_user.last_name = "User" update.message = MagicMock(spec=Message) update.message.text = "ABC12XYZ" update.message.reply_text = AsyncMock() update.message.delete = AsyncMock() return update @pytest.mark.asyncio async def test_direct_code_input_valid_code(self, mock_text_update_unlinked, mock_context): """Test that valid 8-char code triggers linking""" mock_text_update_unlinked.message.text = "ABC12XYZ" with patch('app.bot.handlers.check_user_linked', new_callable=AsyncMock) as mock_check, \ patch('app.bot.handlers.link_telegram_account', new_callable=AsyncMock) as mock_link: mock_check.return_value = False # User not linked mock_link.return_value = { 'username': 'testuser', 'companies': ['1', '2', '3'] } await handle_text_message(mock_text_update_unlinked, mock_context) # Verify linking was attempted with uppercase code mock_link.assert_called_once() call_args = mock_link.call_args assert call_args[0][1] == "ABC12XYZ" # Verify success message was sent assert mock_text_update_unlinked.message.reply_text.call_count >= 1 last_call_text = mock_text_update_unlinked.message.reply_text.call_args_list[-1][0][0] assert "linked cu succes" in last_call_text.lower() assert "testuser" in last_call_text @pytest.mark.asyncio async def test_direct_code_input_lowercase_converted(self, mock_text_update_unlinked, mock_context): """Test that lowercase code is converted to uppercase""" mock_text_update_unlinked.message.text = "abc12xyz" with patch('app.bot.handlers.check_user_linked', new_callable=AsyncMock) as mock_check, \ patch('app.bot.handlers.link_telegram_account', new_callable=AsyncMock) as mock_link: mock_check.return_value = False mock_link.return_value = { 'username': 'testuser', 'companies': [] } await handle_text_message(mock_text_update_unlinked, mock_context) # Verify code was converted to uppercase call_args = mock_link.call_args assert call_args[0][1] == "ABC12XYZ" @pytest.mark.asyncio async def test_direct_code_input_invalid_code(self, mock_text_update_unlinked, mock_context): """Test that invalid code (8 chars but not in DB) shows error message""" mock_text_update_unlinked.message.text = "XXXXXXXX" # Valid format but not in DB with patch('app.bot.handlers.check_user_linked', new_callable=AsyncMock) as mock_check, \ patch('app.bot.handlers.link_telegram_account', new_callable=AsyncMock) as mock_link: mock_check.return_value = False mock_link.return_value = None # Failed linking (code not found in DB) await handle_text_message(mock_text_update_unlinked, mock_context) # Verify error message was sent # Should have at least 2 messages (loading + error) assert mock_text_update_unlinked.message.reply_text.call_count >= 2 last_call_text = mock_text_update_unlinked.message.reply_text.call_args_list[-1][0][0] assert "Cod invalid" in last_call_text or "expirat" in last_call_text.lower() @pytest.mark.asyncio async def test_direct_code_input_wrong_length(self, mock_text_update_unlinked, mock_context): """Test that code with wrong length shows help message""" mock_text_update_unlinked.message.text = "ABC123" # Only 6 chars with patch('app.bot.handlers.check_user_linked', new_callable=AsyncMock) as mock_check: mock_check.return_value = False await handle_text_message(mock_text_update_unlinked, mock_context) # Should show helpful message about code format call_text = mock_text_update_unlinked.message.reply_text.call_args[0][0] assert "8 caractere" in call_text.lower() or "linkezi" in call_text.lower() @pytest.mark.asyncio async def test_direct_code_input_non_alphanumeric(self, mock_text_update_unlinked, mock_context): """Test that non-alphanumeric text shows help message""" mock_text_update_unlinked.message.text = "ABC@12#Z" # Has special chars with patch('app.bot.handlers.check_user_linked', new_callable=AsyncMock) as mock_check: mock_check.return_value = False await handle_text_message(mock_text_update_unlinked, mock_context) # Should show helpful message call_text = mock_text_update_unlinked.message.reply_text.call_args[0][0] assert "linkezi" in call_text.lower() or "8 caractere" in call_text.lower() @pytest.mark.asyncio async def test_direct_code_input_already_linked(self, mock_text_update_unlinked, mock_context): """Test that linked users don't trigger code validation""" mock_text_update_unlinked.message.text = "ABC12XYZ" with patch('app.bot.handlers.check_user_linked', new_callable=AsyncMock) as mock_check, \ patch('app.bot.handlers.link_telegram_account', new_callable=AsyncMock) as mock_link: mock_check.return_value = True # User already linked await handle_text_message(mock_text_update_unlinked, mock_context) # Linking should NOT be attempted mock_link.assert_not_called() # No reply should be sent (or minimal reply for future NLP) # For now, the function just returns early assert True @pytest.mark.asyncio async def test_direct_code_input_shows_loading_message(self, mock_text_update_unlinked, mock_context): """Test that linking shows a loading message""" mock_text_update_unlinked.message.text = "ABC12XYZ" with patch('app.bot.handlers.check_user_linked', new_callable=AsyncMock) as mock_check, \ patch('app.bot.handlers.link_telegram_account', new_callable=AsyncMock) as mock_link: mock_check.return_value = False mock_link.return_value = { 'username': 'testuser', 'companies': [] } await handle_text_message(mock_text_update_unlinked, mock_context) # Verify at least 2 messages were sent (loading + result) assert mock_text_update_unlinked.message.reply_text.call_count >= 2 # First call should be the loading message first_call_text = mock_text_update_unlinked.message.reply_text.call_args_list[0][0][0] assert "linking" in first_call_text.lower() or "asteapta" in first_call_text.lower()