This commit addresses the overly restrictive .gitignore pattern that was excluding all test files (test_*.py), including legitimate pytest and unittest test suites essential for code quality and CI/CD. Changes to .gitignore: - Added negation patterns !**/tests/test_*.py and !**/test_*.py to allow proper test files while still blocking temporary scripts - This enables pytest test suites to be tracked by git Added test files (17 files): Telegram Bot Tests (15 files): - reports-app/telegram-bot/tests/test_auth.py Tests for authentication and account linking flow - reports-app/telegram-bot/tests/test_callbacks.py Tests for callback query handlers - reports-app/telegram-bot/tests/test_formatters.py Tests for message formatting utilities - reports-app/telegram-bot/tests/test_formatters_extended.py Extended formatter tests - reports-app/telegram-bot/tests/test_handlers_menu.py Tests for menu handlers - reports-app/telegram-bot/tests/test_helpers.py Tests for helper functions - reports-app/telegram-bot/tests/test_helpers_extended.py Extended helper tests - reports-app/telegram-bot/tests/test_helpers_real.py Real integration tests for helpers - reports-app/telegram-bot/tests/test_helpers_real_simple.py Simplified integration tests - reports-app/telegram-bot/tests/test_login_flow.py Complete login flow integration tests - reports-app/telegram-bot/tests/test_menus.py Menu system tests - reports-app/telegram-bot/tests/test_session_company.py Session and company management tests - reports-app/telegram-bot/test_claude_integration.py Manual integration test (Claude AI) - reports-app/telegram-bot/test_claude_response.py Response formatting test - reports-app/telegram-bot/test_db.py Database operations manual test Shared Module Tests (2 files): - shared/auth/test_auth.py Authentication system tests - shared/database/test_pool.py Oracle connection pool tests Security verification: ✅ All test files use mock objects, fixtures, and environment variables ✅ No hardcoded credentials or secrets found ✅ Safe for version control 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
476 lines
19 KiB
Python
476 lines
19 KiB
Python
"""
|
|
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 <code> 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()
|