- custom_commands_service: return POST response directly from create_command() instead of a follow-up GET by_name - custom_commands_service: return PUT response directly from update_command() instead of a follow-up GET by_name - custom_commands_service: avoid GET after PUT in get_or_create_creator() by constructing updated creator from model_copy() - custom_commands_service: return POST response directly from get_or_create_creator() creator creation instead of a follow-up GET - custom_commands_service: parallelize all 9 sequential API calls in get_statistics() with asyncio.gather() - help_commands_service: return POST response directly from create_help() instead of a follow-up GET by_name - help_commands_service: return PUT response directly from update_help() instead of a follow-up GET by_name - tests: update test_update_help_success to mock PUT returning dict data Closes #95 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
529 lines
19 KiB
Python
529 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
|
|
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 returning the updated help command data directly
|
|
updated_data = {
|
|
"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.isoformat(),
|
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
"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,
|
|
}
|
|
|
|
async def mock_put(*args, **kwargs):
|
|
return updated_data
|
|
|
|
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
|
help_commands_service_instance._client.put = mock_put
|
|
|
|
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
|