Files
roa2web-service-auto/reports-app/telegram-bot/tests/test_helpers.py
Marius Mutu a7a1bef375 Add missing test files and update .gitignore to allow test files
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>
2025-10-25 15:09:43 +03:00

374 lines
14 KiB
Python

"""
Test Suite for Phase 2: Helper Functions
Tests the helper functions in app/bot/helpers.py:
- get_active_company_or_prompt()
- search_companies_by_name()
- create_company_selection_keyboard()
- format_company_context_footer()
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from telegram import Update, Message, Chat, User, InlineKeyboardMarkup
from app.bot.helpers import (
get_active_company_or_prompt,
search_companies_by_name,
create_company_selection_keyboard,
format_company_context_footer
)
from app.agent.session import ConversationSession, SessionManager
class TestGetActiveCompanyOrPrompt:
"""Test get_active_company_or_prompt function."""
@pytest.mark.asyncio
async def test_returns_company_when_set(self):
"""Test that function returns company dict when active company is set."""
# Mock Update
mock_update = MagicMock(spec=Update)
mock_update.message = MagicMock(spec=Message)
# Mock SessionManager with active company
mock_session_manager = MagicMock(spec=SessionManager)
mock_session = MagicMock(spec=ConversationSession)
mock_session.get_active_company.return_value = {
"id": 42,
"name": "ACME SRL",
"cui": "RO12345678"
}
mock_session_manager.get_or_create_session = AsyncMock(return_value=mock_session)
# Call function
result = await get_active_company_or_prompt(
update=mock_update,
session_manager=mock_session_manager,
telegram_user_id=123456
)
# Verify
assert result is not None
assert result["id"] == 42
assert result["name"] == "ACME SRL"
assert result["cui"] == "RO12345678"
# Verify no message was sent
mock_update.message.reply_text.assert_not_called()
@pytest.mark.asyncio
async def test_returns_none_and_sends_prompt_when_no_company(self):
"""Test that function returns None and sends prompt when no company set."""
# Mock Update with reply_text capability
mock_update = MagicMock(spec=Update)
mock_update.message = MagicMock(spec=Message)
mock_update.message.reply_text = AsyncMock()
# Mock SessionManager with NO active company
mock_session_manager = MagicMock(spec=SessionManager)
mock_session = MagicMock(spec=ConversationSession)
mock_session.get_active_company.return_value = None
mock_session_manager.get_or_create_session = AsyncMock(return_value=mock_session)
# Call function
result = await get_active_company_or_prompt(
update=mock_update,
session_manager=mock_session_manager,
telegram_user_id=123456
)
# Verify
assert result is None
# Verify prompt message was sent
mock_update.message.reply_text.assert_called_once()
call_args = mock_update.message.reply_text.call_args
message_text = call_args[0][0]
assert "Nu ai selectat o companie" in message_text
assert "/companies" in message_text
assert "/selectcompany" in message_text
assert call_args[1]["parse_mode"] == "Markdown"
class TestSearchCompaniesByName:
"""Test search_companies_by_name function."""
@pytest.mark.asyncio
async def test_case_insensitive_search(self):
"""Test that search is case-insensitive."""
mock_companies = [
{"id": 1, "nume_firma": "ACME Corporation SRL", "cui": "RO123"},
{"id": 2, "nume_firma": "Beta Industries", "cui": "RO456"},
{"id": 3, "nume_firma": "Gamma Solutions SRL", "cui": "RO789"}
]
with patch('app.bot.helpers.get_backend_client') as mock_get_client:
mock_client = MagicMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mock_client.get_user_companies = AsyncMock(return_value=mock_companies)
mock_get_client.return_value = mock_client
# Search with lowercase
results = await search_companies_by_name("acme", "fake-token")
assert len(results) == 1
assert results[0]["nume_firma"] == "ACME Corporation SRL"
@pytest.mark.asyncio
async def test_partial_match_search(self):
"""Test that search finds partial matches."""
mock_companies = [
{"id": 1, "nume_firma": "ACME Corporation SRL", "cui": "RO123"},
{"id": 2, "nume_firma": "Beta ACME Industries", "cui": "RO456"},
{"id": 3, "nume_firma": "Gamma Solutions SRL", "cui": "RO789"}
]
with patch('app.bot.helpers.get_backend_client') as mock_get_client:
mock_client = MagicMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mock_client.get_user_companies = AsyncMock(return_value=mock_companies)
mock_get_client.return_value = mock_client
# Search for partial string
results = await search_companies_by_name("acme", "fake-token")
assert len(results) == 2
assert any("ACME Corporation" in c["nume_firma"] for c in results)
assert any("Beta ACME" in c["nume_firma"] for c in results)
@pytest.mark.asyncio
async def test_no_matches_returns_empty_list(self):
"""Test that no matches returns empty list."""
mock_companies = [
{"id": 1, "nume_firma": "ACME Corporation SRL", "cui": "RO123"},
{"id": 2, "nume_firma": "Beta Industries", "cui": "RO456"}
]
with patch('app.bot.helpers.get_backend_client') as mock_get_client:
mock_client = MagicMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mock_client.get_user_companies = AsyncMock(return_value=mock_companies)
mock_get_client.return_value = mock_client
# Search for non-existent company
results = await search_companies_by_name("xyz", "fake-token")
assert len(results) == 0
assert results == []
@pytest.mark.asyncio
async def test_search_with_special_characters(self):
"""Test search handles company names with special characters."""
mock_companies = [
{"id": 1, "nume_firma": "ACME & Partners SRL", "cui": "RO123"},
{"id": 2, "nume_firma": "Beta-Gamma Industries", "cui": "RO456"}
]
with patch('app.bot.helpers.get_backend_client') as mock_get_client:
mock_client = MagicMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mock_client.get_user_companies = AsyncMock(return_value=mock_companies)
mock_get_client.return_value = mock_client
# Search with special char
results = await search_companies_by_name("beta-gamma", "fake-token")
assert len(results) == 1
assert results[0]["nume_firma"] == "Beta-Gamma Industries"
class TestCreateCompanySelectionKeyboard:
"""Test create_company_selection_keyboard function."""
def test_creates_keyboard_with_all_companies(self):
"""Test keyboard creation with company list."""
companies = [
{"id": 1, "nume_firma": "ACME SRL", "cui": "RO123"},
{"id": 2, "nume_firma": "Beta Industries", "cui": "RO456"}
]
keyboard = create_company_selection_keyboard(companies)
assert isinstance(keyboard, InlineKeyboardMarkup)
assert len(keyboard.inline_keyboard) == 2
# Check first button
first_button = keyboard.inline_keyboard[0][0]
assert "ACME SRL" in first_button.text
assert "RO123" in first_button.text
assert first_button.callback_data == "select_company:1"
# Check second button
second_button = keyboard.inline_keyboard[1][0]
assert "Beta Industries" in second_button.text
assert "RO456" in second_button.text
assert second_button.callback_data == "select_company:2"
def test_handles_company_without_cui(self):
"""Test keyboard handles companies without CUI."""
companies = [
{"id": 1, "nume_firma": "ACME SRL", "cui": ""},
{"id": 2, "nume_firma": "Beta Industries"} # No cui field
]
keyboard = create_company_selection_keyboard(companies)
assert len(keyboard.inline_keyboard) == 2
# First company with empty CUI - should still appear
first_button = keyboard.inline_keyboard[0][0]
assert "ACME SRL" in first_button.text
# Second company without cui field
second_button = keyboard.inline_keyboard[1][0]
assert "Beta Industries" in second_button.text
def test_limits_to_max_buttons(self):
"""Test that keyboard respects max_buttons limit."""
companies = [
{"id": i, "nume_firma": f"Company {i}", "cui": f"RO{i}"}
for i in range(1, 16) # 15 companies
]
keyboard = create_company_selection_keyboard(companies, max_buttons=5)
# Should have 5 company buttons + 1 overflow indicator
assert len(keyboard.inline_keyboard) == 6
# Last button should be overflow indicator
overflow_button = keyboard.inline_keyboard[5][0]
assert "încă 10 companii" in overflow_button.text
assert overflow_button.callback_data == "noop"
def test_no_overflow_indicator_when_exactly_max(self):
"""Test no overflow indicator when company count equals max."""
companies = [
{"id": i, "nume_firma": f"Company {i}", "cui": f"RO{i}"}
for i in range(1, 6) # 5 companies
]
keyboard = create_company_selection_keyboard(companies, max_buttons=5)
# Should have exactly 5 buttons, no overflow
assert len(keyboard.inline_keyboard) == 5
# Last button should be a regular company button
last_button = keyboard.inline_keyboard[4][0]
assert last_button.callback_data.startswith("select_company:")
def test_empty_company_list(self):
"""Test keyboard with empty company list."""
companies = []
keyboard = create_company_selection_keyboard(companies)
assert isinstance(keyboard, InlineKeyboardMarkup)
assert len(keyboard.inline_keyboard) == 0
class TestFormatCompanyContextFooter:
"""Test format_company_context_footer function."""
def test_footer_format(self):
"""Test that footer has correct format."""
footer = format_company_context_footer("ACME SRL")
assert "\n\n━━━━━━━━━━━━━━\n" in footer
assert "📊" in footer
assert "ACME SRL" in footer
assert "/selectcompany" in footer
def test_footer_with_long_company_name(self):
"""Test footer with very long company name."""
long_name = "Very Long Company Name With Many Words SRL Romania"
footer = format_company_context_footer(long_name)
assert long_name in footer
assert "📊" in footer
assert "/selectcompany" in footer
def test_footer_with_special_characters(self):
"""Test footer handles special characters in company name."""
special_name = "ACME & Partners (Romania) SRL"
footer = format_company_context_footer(special_name)
assert special_name in footer
assert "📊" in footer
def test_footer_structure(self):
"""Test that footer has consistent structure."""
footer = format_company_context_footer("Test Company")
# Should start with double newline
assert footer.startswith("\n\n")
# Should contain separator line
assert "━━━━━━━━━━━━━━" in footer
# Should end with command
assert footer.endswith("/selectcompany")
def test_footer_is_discrete(self):
"""Test that footer is visually discrete and professional."""
footer = format_company_context_footer("ACME SRL")
# Should be relatively short
assert len(footer) < 100
# Should have visual separation
assert footer.count("\n") >= 2
# Should have emoji for visual appeal
assert "📊" in footer
class TestHelpersIntegration:
"""Integration tests combining multiple helper functions."""
@pytest.mark.asyncio
async def test_search_and_create_keyboard_workflow(self):
"""Test workflow of searching companies and creating keyboard."""
mock_companies = [
{"id": 1, "nume_firma": "ACME Corporation SRL", "cui": "RO123"},
{"id": 2, "nume_firma": "ACME Industries", "cui": "RO456"}
]
with patch('app.bot.helpers.get_backend_client') as mock_get_client:
mock_client = MagicMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mock_client.get_user_companies = AsyncMock(return_value=mock_companies)
mock_get_client.return_value = mock_client
# Search
results = await search_companies_by_name("acme", "fake-token")
# Create keyboard from results
keyboard = create_company_selection_keyboard(results)
assert len(keyboard.inline_keyboard) == 2
assert "ACME Corporation" in keyboard.inline_keyboard[0][0].text
assert "ACME Industries" in keyboard.inline_keyboard[1][0].text
def test_footer_appends_to_message(self):
"""Test that footer can be appended to a message."""
message = "📊 **Dashboard Financiar**\n\nSold Total: 10,000 RON"
footer = format_company_context_footer("ACME SRL")
full_message = message + footer
assert "Dashboard Financiar" in full_message
assert "10,000 RON" in full_message
assert "━━━━━━━━━━━━━━" in full_message
assert "📊 ACME SRL" in full_message
if __name__ == "__main__":
pytest.main([__file__, "-v"])