major-domo-v2/tests/test_services_help_commands.py
Cal Corum bcd6a10aef CLAUDE: Implement custom help commands system
Add comprehensive admin-managed help system for league documentation,
resources, FAQs, and guides. Replaces planned /links command with a
more flexible and powerful solution.

Features:
- Full CRUD operations via Discord commands (/help, /help-create, /help-edit, /help-delete, /help-list)
- Permission-based access control (admins + Help Editor role)
- Markdown-formatted content with category organization
- View tracking and analytics
- Soft delete with restore capability
- Full audit trail (creator, editor, timestamps)
- Autocomplete for topic discovery
- Interactive modals and paginated list views

Implementation:
- New models/help_command.py with Pydantic validation
- New services/help_commands_service.py with full CRUD API integration
- New views/help_commands.py with interactive modals and views
- New commands/help/ package with command handlers
- Comprehensive README.md documentation in commands/help/
- Test coverage for models and services

Configuration:
- Added HELP_EDITOR_ROLE_NAME constant to constants.py
- Updated bot.py to load help commands
- Updated PRE_LAUNCH_ROADMAP.md to mark system as complete
- Updated CLAUDE.md documentation

Requires database migration for help_commands table.
See .claude/DATABASE_MIGRATION_HELP_COMMANDS.md for details.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 13:14:13 -05:00

519 lines
19 KiB
Python

"""
Tests for Help Commands Service in Discord Bot v2.0
Comprehensive tests for help commands CRUD operations and business logic.
"""
import pytest
from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock
from services.help_commands_service import (
HelpCommandsService,
HelpCommandNotFoundError,
HelpCommandExistsError
)
from models.help_command import (
HelpCommand,
HelpCommandSearchFilters,
HelpCommandSearchResult,
HelpCommandStats
)
@pytest.fixture
def sample_help_command() -> HelpCommand:
"""Fixture providing a sample help command."""
now = datetime.now(timezone.utc)
return HelpCommand(
id=1,
name='trading-rules',
title='Trading Rules & Guidelines',
content='Complete trading rules for the league...',
category='rules',
created_by_discord_id=123456789,
created_at=now,
updated_at=None,
last_modified_by=None,
is_active=True,
view_count=100,
display_order=10
)
@pytest.fixture
def mock_client():
"""Mock API client."""
client = AsyncMock()
return client
@pytest.fixture
def help_commands_service_instance(mock_client):
"""Create HelpCommandsService instance with mocked client."""
service = HelpCommandsService()
service._client = mock_client
return service
class TestHelpCommandsServiceInit:
"""Test service initialization and basic functionality."""
def test_service_singleton_pattern(self):
"""Test that the service follows singleton pattern."""
from services.help_commands_service import help_commands_service
# Multiple imports should return the same instance
from services.help_commands_service import help_commands_service as service2
assert help_commands_service is service2
def test_service_has_required_methods(self):
"""Test that service has all required methods."""
from services.help_commands_service import help_commands_service
# Core CRUD operations
assert hasattr(help_commands_service, 'create_help')
assert hasattr(help_commands_service, 'get_help_by_name')
assert hasattr(help_commands_service, 'update_help')
assert hasattr(help_commands_service, 'delete_help')
assert hasattr(help_commands_service, 'restore_help')
# Search and listing
assert hasattr(help_commands_service, 'search_help_commands')
assert hasattr(help_commands_service, 'get_all_help_topics')
assert hasattr(help_commands_service, 'get_help_names_for_autocomplete')
# View tracking
assert hasattr(help_commands_service, 'increment_view_count')
# Statistics
assert hasattr(help_commands_service, 'get_statistics')
class TestHelpCommandsServiceCRUD:
"""Test CRUD operations of the help commands service."""
@pytest.mark.asyncio
async def test_create_help_success(self, help_commands_service_instance):
"""Test successful help command creation."""
created_help = None
async def mock_get_help_by_name(name, *args, **kwargs):
if created_help and name == "test-topic":
return created_help
# Command doesn't exist initially - raise exception
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
async def mock_create(data):
nonlocal created_help
# Create the help command model directly from the data
created_help = HelpCommand(
id=1,
name=data["name"],
title=data["title"],
content=data["content"],
category=data.get("category"),
created_by_discord_id=data["created_by_discord_id"],
created_at=datetime.now(timezone.utc),
updated_at=None,
last_modified_by=None,
is_active=True,
view_count=0,
display_order=data.get("display_order", 0)
)
return created_help
# Patch the service methods
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
help_commands_service_instance.create = mock_create
result = await help_commands_service_instance.create_help(
name="test-topic",
title="Test Topic",
content="This is test content for the help topic.",
creator_discord_id=123456789,
category="info"
)
assert isinstance(result, HelpCommand)
assert result.name == "test-topic"
assert result.title == "Test Topic"
assert result.category == "info"
assert result.view_count == 0
@pytest.mark.asyncio
async def test_create_help_already_exists(self, help_commands_service_instance, sample_help_command):
"""Test help command creation when topic already exists."""
# Mock topic already exists
async def mock_get_help_by_name(*args, **kwargs):
return sample_help_command
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
with pytest.raises(HelpCommandExistsError, match="Help topic 'trading-rules' already exists"):
await help_commands_service_instance.create_help(
name="trading-rules",
title="Trading Rules",
content="Rules content",
creator_discord_id=123456789
)
@pytest.mark.asyncio
async def test_get_help_by_name_success(self, help_commands_service_instance, sample_help_command):
"""Test successful help command retrieval."""
# Mock the API client to return proper data structure
help_data = {
'id': sample_help_command.id,
'name': sample_help_command.name,
'title': sample_help_command.title,
'content': sample_help_command.content,
'category': sample_help_command.category,
'created_by_discord_id': sample_help_command.created_by_discord_id,
'created_at': sample_help_command.created_at.isoformat(),
'updated_at': sample_help_command.updated_at.isoformat() if sample_help_command.updated_at else None,
'last_modified_by': sample_help_command.last_modified_by,
'is_active': sample_help_command.is_active,
'view_count': sample_help_command.view_count,
'display_order': sample_help_command.display_order
}
help_commands_service_instance._client.get.return_value = help_data
result = await help_commands_service_instance.get_help_by_name("trading-rules")
assert isinstance(result, HelpCommand)
assert result.name == "trading-rules"
assert result.title == "Trading Rules & Guidelines"
assert result.view_count == 100
@pytest.mark.asyncio
async def test_get_help_by_name_not_found(self, help_commands_service_instance):
"""Test help command retrieval when topic doesn't exist."""
# Mock the API client to return None (not found)
help_commands_service_instance._client.get.return_value = None
with pytest.raises(HelpCommandNotFoundError, match="Help topic 'nonexistent' not found"):
await help_commands_service_instance.get_help_by_name("nonexistent")
@pytest.mark.asyncio
async def test_update_help_success(self, help_commands_service_instance, sample_help_command):
"""Test successful help command update."""
# Mock getting the existing help command
async def mock_get_help_by_name(name, include_inactive=False):
if name == "trading-rules":
return sample_help_command
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
# Mock the API update call
async def mock_put(*args, **kwargs):
return True
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
help_commands_service_instance._client.put = mock_put
# Update should call get_help_by_name again at the end, so mock it to return updated version
updated_help = HelpCommand(
id=sample_help_command.id,
name=sample_help_command.name,
title="Updated Trading Rules",
content="Updated content",
category=sample_help_command.category,
created_by_discord_id=sample_help_command.created_by_discord_id,
created_at=sample_help_command.created_at,
updated_at=datetime.now(timezone.utc),
last_modified_by=987654321,
is_active=sample_help_command.is_active,
view_count=sample_help_command.view_count,
display_order=sample_help_command.display_order
)
call_count = 0
async def mock_get_with_counter(name, include_inactive=False):
nonlocal call_count
call_count += 1
if call_count == 1:
return sample_help_command
else:
return updated_help
help_commands_service_instance.get_help_by_name = mock_get_with_counter
result = await help_commands_service_instance.update_help(
name="trading-rules",
new_title="Updated Trading Rules",
new_content="Updated content",
updater_discord_id=987654321
)
assert isinstance(result, HelpCommand)
assert result.title == "Updated Trading Rules"
@pytest.mark.asyncio
async def test_delete_help_success(self, help_commands_service_instance, sample_help_command):
"""Test successful help command deletion (soft delete)."""
# Mock getting the help command
async def mock_get_help_by_name(name, include_inactive=False):
return sample_help_command
# Mock the API delete call
async def mock_delete(*args, **kwargs):
return None
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
help_commands_service_instance._client.delete = mock_delete
result = await help_commands_service_instance.delete_help("trading-rules")
assert result is True
@pytest.mark.asyncio
async def test_restore_help_success(self, help_commands_service_instance):
"""Test successful help command restoration."""
# Mock getting a deleted help command
deleted_help = HelpCommand(
id=1,
name='deleted-topic',
title='Deleted Topic',
content='Content',
created_by_discord_id=123456789,
created_at=datetime.now(timezone.utc),
is_active=False
)
async def mock_get_help_by_name(name, include_inactive=False):
return deleted_help
# Mock the API restore call
restored_data = {
'id': deleted_help.id,
'name': deleted_help.name,
'title': deleted_help.title,
'content': deleted_help.content,
'created_by_discord_id': deleted_help.created_by_discord_id,
'created_at': deleted_help.created_at.isoformat(),
'is_active': True,
'view_count': 0,
'display_order': 0
}
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
help_commands_service_instance._client.patch.return_value = restored_data
result = await help_commands_service_instance.restore_help("deleted-topic")
assert isinstance(result, HelpCommand)
assert result.is_active is True
class TestHelpCommandsServiceSearch:
"""Test search and listing operations."""
@pytest.mark.asyncio
async def test_search_help_commands(self, help_commands_service_instance):
"""Test searching for help commands with filters."""
filters = HelpCommandSearchFilters(
name_contains='trading',
category='rules',
page=1,
page_size=10
)
# Mock API response
api_response = {
'help_commands': [
{
'id': 1,
'name': 'trading-rules',
'title': 'Trading Rules',
'content': 'Content',
'category': 'rules',
'created_by_discord_id': 123,
'created_at': datetime.now(timezone.utc).isoformat(),
'is_active': True,
'view_count': 100,
'display_order': 0
}
],
'total_count': 1,
'page': 1,
'page_size': 10,
'total_pages': 1,
'has_more': False
}
help_commands_service_instance._client.get.return_value = api_response
result = await help_commands_service_instance.search_help_commands(filters)
assert isinstance(result, HelpCommandSearchResult)
assert len(result.help_commands) == 1
assert result.total_count == 1
assert result.help_commands[0].name == 'trading-rules'
@pytest.mark.asyncio
async def test_get_all_help_topics(self, help_commands_service_instance):
"""Test getting all help topics."""
# Mock API response
api_response = {
'help_commands': [
{
'id': i,
'name': f'topic-{i}',
'title': f'Topic {i}',
'content': f'Content {i}',
'category': 'rules' if i % 2 == 0 else 'guides',
'created_by_discord_id': 123,
'created_at': datetime.now(timezone.utc).isoformat(),
'is_active': True,
'view_count': i * 10,
'display_order': i
}
for i in range(1, 6)
],
'total_count': 5,
'page': 1,
'page_size': 100,
'total_pages': 1,
'has_more': False
}
help_commands_service_instance._client.get.return_value = api_response
result = await help_commands_service_instance.get_all_help_topics()
assert isinstance(result, list)
assert len(result) == 5
assert all(isinstance(cmd, HelpCommand) for cmd in result)
@pytest.mark.asyncio
async def test_get_help_names_for_autocomplete(self, help_commands_service_instance):
"""Test getting help names for autocomplete."""
# Mock API response
api_response = {
'results': [
{
'name': 'trading-rules',
'title': 'Trading Rules',
'category': 'rules'
},
{
'name': 'trading-deadline',
'title': 'Trading Deadline',
'category': 'info'
}
]
}
help_commands_service_instance._client.get.return_value = api_response
result = await help_commands_service_instance.get_help_names_for_autocomplete(
partial_name='trading',
limit=25
)
assert isinstance(result, list)
assert len(result) == 2
assert 'trading-rules' in result
assert 'trading-deadline' in result
class TestHelpCommandsServiceViewTracking:
"""Test view count tracking."""
@pytest.mark.asyncio
async def test_increment_view_count(self, help_commands_service_instance, sample_help_command):
"""Test incrementing view count."""
# Mock the API patch call
help_commands_service_instance._client.patch = AsyncMock()
# Mock getting the updated help command
updated_help = HelpCommand(
id=sample_help_command.id,
name=sample_help_command.name,
title=sample_help_command.title,
content=sample_help_command.content,
category=sample_help_command.category,
created_by_discord_id=sample_help_command.created_by_discord_id,
created_at=sample_help_command.created_at,
is_active=sample_help_command.is_active,
view_count=sample_help_command.view_count + 1,
display_order=sample_help_command.display_order
)
async def mock_get_help_by_name(name, include_inactive=False):
return updated_help
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
result = await help_commands_service_instance.increment_view_count("trading-rules")
assert isinstance(result, HelpCommand)
assert result.view_count == 101
class TestHelpCommandsServiceStatistics:
"""Test statistics gathering."""
@pytest.mark.asyncio
async def test_get_statistics(self, help_commands_service_instance):
"""Test getting help command statistics."""
# Mock API response
api_response = {
'total_commands': 50,
'active_commands': 45,
'total_views': 5000,
'most_viewed_command': {
'id': 1,
'name': 'popular-topic',
'title': 'Popular Topic',
'content': 'Content',
'created_by_discord_id': 123,
'created_at': datetime.now(timezone.utc).isoformat(),
'is_active': True,
'view_count': 500,
'display_order': 0
},
'recent_commands_count': 5
}
help_commands_service_instance._client.get.return_value = api_response
result = await help_commands_service_instance.get_statistics()
assert isinstance(result, HelpCommandStats)
assert result.total_commands == 50
assert result.active_commands == 45
assert result.total_views == 5000
assert result.most_viewed_command is not None
assert result.most_viewed_command.name == 'popular-topic'
assert result.recent_commands_count == 5
class TestHelpCommandsServiceErrorHandling:
"""Test error handling scenarios."""
@pytest.mark.asyncio
async def test_api_connection_error(self, help_commands_service_instance):
"""Test handling of API connection errors."""
from exceptions import APIException, BotException
# Mock the API client to raise an APIException
help_commands_service_instance._client.get.side_effect = APIException("Connection error")
with pytest.raises(BotException, match="Failed to retrieve help topic 'test'"):
await help_commands_service_instance.get_help_by_name("test")
@pytest.mark.asyncio
async def test_empty_statistics_on_error(self, help_commands_service_instance):
"""Test that get_statistics returns empty stats on error."""
# Mock the API client to raise an exception
help_commands_service_instance._client.get.side_effect = Exception("API Error")
result = await help_commands_service_instance.get_statistics()
# Should return empty stats instead of raising
assert isinstance(result, HelpCommandStats)
assert result.total_commands == 0
assert result.active_commands == 0
assert result.total_views == 0