Implemented comprehensive team branding management system allowing team owners to update colors and logos for major league, minor league, and dice rolls. Features: - Modal-based interactive form input with validation - Hex color validation with normalization (6 chars, optional # prefix) - Image URL accessibility testing with aiohttp (5 second timeout) - Preview + confirmation workflow with ConfirmationView - Support for both major league and minor league affiliate updates - Dice color customization for game rolls - Discord role color sync (non-blocking with graceful fallback) - Comprehensive error handling and user feedback Technical Implementation: - BrandingModal class with 5 optional fields - Concurrent URL validation using asyncio.gather - Fixed team_service.update_team() to use PATCH with query parameters - Enhanced TeamService documentation with correct method signatures - 33 comprehensive tests (100% passing) Bug Fixes: - Fixed modal send timing (immediate response vs deferred) - Fixed interaction handling for cancel button - Fixed database API communication (PATCH query params vs PUT JSON) Files: - commands/teams/branding.py (NEW - ~500 lines) - commands/teams/__init__.py (added BrandingCommands registration) - commands/teams/CLAUDE.md (added comprehensive documentation) - tests/test_commands_teams_branding.py (NEW - 33 tests) - services/team_service.py (fixed update_team to use query params) - VERSION (2.19.2 → 2.20.0) Docker: manticorum67/major-domo-discord-app-v2:2.20.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
483 lines
16 KiB
Python
483 lines
16 KiB
Python
"""
|
|
Tests for team branding management commands.
|
|
|
|
Covers validation functions, permission checking, and command execution.
|
|
"""
|
|
import pytest
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from aioresponses import aioresponses
|
|
|
|
from commands.teams.branding import (
|
|
validate_hex_color,
|
|
validate_image_url,
|
|
BrandingCommands
|
|
)
|
|
from models.team import Team
|
|
from tests.factories import TeamFactory
|
|
|
|
|
|
class TestHexColorValidation:
|
|
"""Test hex color format validation."""
|
|
|
|
def test_valid_hex_no_prefix(self):
|
|
"""Test that valid hex without # prefix passes validation."""
|
|
is_valid, normalized, error = validate_hex_color("FF5733")
|
|
assert is_valid is True
|
|
assert normalized == "FF5733"
|
|
assert error == ""
|
|
|
|
def test_valid_hex_with_prefix(self):
|
|
"""Test that valid hex with # prefix passes validation."""
|
|
is_valid, normalized, error = validate_hex_color("#FF5733")
|
|
assert is_valid is True
|
|
assert normalized == "FF5733" # Prefix stripped
|
|
assert error == ""
|
|
|
|
def test_valid_hex_lowercase(self):
|
|
"""Test that lowercase hex is normalized to uppercase."""
|
|
is_valid, normalized, error = validate_hex_color("ff5733")
|
|
assert is_valid is True
|
|
assert normalized == "FF5733"
|
|
assert error == ""
|
|
|
|
def test_valid_hex_with_prefix_and_lowercase(self):
|
|
"""Test that lowercase hex with # prefix is normalized."""
|
|
is_valid, normalized, error = validate_hex_color("#ff5733")
|
|
assert is_valid is True
|
|
assert normalized == "FF5733"
|
|
assert error == ""
|
|
|
|
def test_empty_string_valid(self):
|
|
"""Test that empty string is valid (means keep current value)."""
|
|
is_valid, normalized, error = validate_hex_color("")
|
|
assert is_valid is True
|
|
assert normalized == ""
|
|
assert error == ""
|
|
|
|
def test_invalid_length_too_short(self):
|
|
"""Test that hex color with wrong length fails validation."""
|
|
is_valid, normalized, error = validate_hex_color("FF57")
|
|
assert is_valid is False
|
|
assert "6 characters" in error
|
|
|
|
def test_invalid_length_too_long(self):
|
|
"""Test that hex color with wrong length fails validation."""
|
|
is_valid, normalized, error = validate_hex_color("FF57331")
|
|
assert is_valid is False
|
|
assert "6 characters" in error
|
|
|
|
def test_invalid_characters(self):
|
|
"""Test that non-hex characters fail validation."""
|
|
is_valid, normalized, error = validate_hex_color("GGGGGG")
|
|
assert is_valid is False
|
|
assert "hex digits" in error
|
|
|
|
def test_invalid_special_characters(self):
|
|
"""Test that special characters fail validation."""
|
|
is_valid, normalized, error = validate_hex_color("FF57@3")
|
|
assert is_valid is False
|
|
assert "hex digits" in error
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestImageURLValidation:
|
|
"""Test image URL format and accessibility validation."""
|
|
|
|
async def test_valid_png_url(self):
|
|
"""Test that valid PNG URL passes format validation and accessibility check."""
|
|
url = "https://example.com/logo.png"
|
|
|
|
with aioresponses() as m:
|
|
m.head(url, status=200, headers={'Content-Type': 'image/png'})
|
|
|
|
is_valid, error = await validate_image_url(url)
|
|
assert is_valid is True
|
|
assert error == ""
|
|
|
|
async def test_valid_jpg_url(self):
|
|
"""Test that valid JPG URL passes validation."""
|
|
url = "https://example.com/logo.jpg"
|
|
|
|
with aioresponses() as m:
|
|
m.head(url, status=200, headers={'Content-Type': 'image/jpeg'})
|
|
|
|
is_valid, error = await validate_image_url(url)
|
|
assert is_valid is True
|
|
assert error == ""
|
|
|
|
async def test_valid_webp_url(self):
|
|
"""Test that valid WebP URL passes validation."""
|
|
url = "https://example.com/logo.webp"
|
|
|
|
with aioresponses() as m:
|
|
m.head(url, status=200, headers={'Content-Type': 'image/webp'})
|
|
|
|
is_valid, error = await validate_image_url(url)
|
|
assert is_valid is True
|
|
assert error == ""
|
|
|
|
async def test_url_with_query_params(self):
|
|
"""Test that URL with query parameters passes validation."""
|
|
url = "https://example.com/logo.png?size=large"
|
|
|
|
with aioresponses() as m:
|
|
m.head(url, status=200, headers={'Content-Type': 'image/png'})
|
|
|
|
is_valid, error = await validate_image_url(url)
|
|
assert is_valid is True
|
|
assert error == ""
|
|
|
|
async def test_empty_url_valid(self):
|
|
"""Test that empty URL is valid (means keep current value)."""
|
|
is_valid, error = await validate_image_url("")
|
|
assert is_valid is True
|
|
assert error == ""
|
|
|
|
async def test_invalid_protocol_ftp(self):
|
|
"""Test that FTP protocol fails validation."""
|
|
url = "ftp://example.com/logo.png"
|
|
is_valid, error = await validate_image_url(url)
|
|
assert is_valid is False
|
|
assert "http" in error.lower()
|
|
|
|
async def test_invalid_no_protocol(self):
|
|
"""Test that URL without protocol fails validation."""
|
|
url = "example.com/logo.png"
|
|
is_valid, error = await validate_image_url(url)
|
|
assert is_valid is False
|
|
assert "http" in error.lower()
|
|
|
|
async def test_invalid_extension(self):
|
|
"""Test that invalid extension fails validation."""
|
|
url = "https://example.com/document.pdf"
|
|
is_valid, error = await validate_image_url(url)
|
|
assert is_valid is False
|
|
assert any(ext in error for ext in ['.png', '.jpg', '.jpeg', '.gif', '.webp'])
|
|
|
|
async def test_url_not_accessible_404(self):
|
|
"""Test that inaccessible URL (404) fails validation."""
|
|
url = "https://example.com/logo.png"
|
|
|
|
with aioresponses() as m:
|
|
m.head(url, status=404)
|
|
|
|
is_valid, error = await validate_image_url(url)
|
|
assert is_valid is False
|
|
assert "404" in error
|
|
|
|
async def test_url_wrong_content_type(self):
|
|
"""Test that URL with wrong content-type fails validation."""
|
|
url = "https://example.com/logo.png"
|
|
|
|
with aioresponses() as m:
|
|
m.head(url, status=200, headers={'Content-Type': 'text/html'})
|
|
|
|
is_valid, error = await validate_image_url(url)
|
|
assert is_valid is False
|
|
assert "image" in error.lower()
|
|
|
|
async def test_url_timeout(self):
|
|
"""Test that timeout fails validation gracefully."""
|
|
url = "https://example.com/logo.png"
|
|
|
|
with aioresponses() as m:
|
|
m.head(url, exception=asyncio.TimeoutError())
|
|
|
|
is_valid, error = await validate_image_url(url)
|
|
assert is_valid is False
|
|
assert "timed out" in error.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestBrandingCommand:
|
|
"""Test branding command workflows."""
|
|
|
|
@pytest.fixture
|
|
def mock_bot(self):
|
|
"""Create mock bot instance."""
|
|
bot = MagicMock()
|
|
return bot
|
|
|
|
@pytest.fixture
|
|
def branding_cog(self, mock_bot):
|
|
"""Create BrandingCommands cog instance."""
|
|
return BrandingCommands(mock_bot)
|
|
|
|
@pytest.fixture
|
|
def mock_interaction(self):
|
|
"""Create mock Discord interaction."""
|
|
interaction = AsyncMock()
|
|
interaction.user = MagicMock()
|
|
interaction.user.id = 123456789
|
|
interaction.guild = MagicMock()
|
|
interaction.guild.roles = []
|
|
interaction.response = AsyncMock()
|
|
interaction.followup = AsyncMock()
|
|
return interaction
|
|
|
|
@pytest.fixture
|
|
def sample_team(self):
|
|
"""Create sample team data."""
|
|
return Team(
|
|
id=1,
|
|
abbrev="NYY",
|
|
sname="Yankees",
|
|
lname="New York Yankees",
|
|
color="003087",
|
|
dice_color="E4002B",
|
|
thumbnail="https://example.com/yankees.png",
|
|
stadium="Yankee Stadium",
|
|
season=12,
|
|
roster_type="majors",
|
|
owner_id=123456789
|
|
)
|
|
|
|
async def test_validate_all_inputs_major_color_only(self, branding_cog):
|
|
"""Test validating major league color only."""
|
|
modal_data = {
|
|
'major_color': 'FF5733',
|
|
'major_logo': '',
|
|
'minor_color': '',
|
|
'minor_logo': '',
|
|
'dice_color': '',
|
|
}
|
|
|
|
updates, errors = await branding_cog._validate_all_inputs(modal_data)
|
|
|
|
assert len(errors) == 0
|
|
assert updates['major']['color'] == 'FF5733'
|
|
assert 'thumbnail' not in updates['major']
|
|
assert len(updates['minor']) == 0
|
|
|
|
async def test_validate_all_inputs_dice_color(self, branding_cog):
|
|
"""Test validating dice color update."""
|
|
modal_data = {
|
|
'major_color': '',
|
|
'major_logo': '',
|
|
'minor_color': '',
|
|
'minor_logo': '',
|
|
'dice_color': '#A6CE39',
|
|
}
|
|
|
|
updates, errors = await branding_cog._validate_all_inputs(modal_data)
|
|
|
|
assert len(errors) == 0
|
|
assert updates['major']['dice_color'] == 'A6CE39'
|
|
|
|
async def test_validate_all_inputs_invalid_color(self, branding_cog):
|
|
"""Test that invalid color produces error."""
|
|
modal_data = {
|
|
'major_color': 'GGGGGG', # Invalid hex
|
|
'major_logo': '',
|
|
'minor_color': '',
|
|
'minor_logo': '',
|
|
'dice_color': '',
|
|
}
|
|
|
|
updates, errors = await branding_cog._validate_all_inputs(modal_data)
|
|
|
|
assert len(errors) == 1
|
|
assert "Major Team Color" in errors[0]
|
|
assert "hex digits" in errors[0]
|
|
|
|
async def test_validate_all_inputs_multiple_errors(self, branding_cog):
|
|
"""Test that multiple validation errors are collected."""
|
|
modal_data = {
|
|
'major_color': 'GGG', # Invalid
|
|
'major_logo': 'not-a-url', # Invalid
|
|
'minor_color': '',
|
|
'minor_logo': '',
|
|
'dice_color': 'ZZZ', # Invalid
|
|
}
|
|
|
|
updates, errors = await branding_cog._validate_all_inputs(modal_data)
|
|
|
|
assert len(errors) >= 2 # At least color errors
|
|
assert any("Major Team Color" in e for e in errors)
|
|
assert any("Dice" in e for e in errors)
|
|
|
|
async def test_validate_all_inputs_valid_url(self, branding_cog):
|
|
"""Test that valid URLs are added to updates."""
|
|
url = "https://example.com/logo.png"
|
|
|
|
with aioresponses() as m:
|
|
m.head(url, status=200, headers={'Content-Type': 'image/png'})
|
|
|
|
modal_data = {
|
|
'major_color': '',
|
|
'major_logo': url,
|
|
'minor_color': '',
|
|
'minor_logo': '',
|
|
'dice_color': '',
|
|
}
|
|
|
|
updates, errors = await branding_cog._validate_all_inputs(modal_data)
|
|
|
|
assert len(errors) == 0
|
|
assert updates['major']['thumbnail'] == url
|
|
|
|
async def test_create_preview_embeds_major_only(self, branding_cog, sample_team):
|
|
"""Test creating preview embeds for major league team only."""
|
|
updates = {
|
|
'major': {'color': 'FF5733', 'thumbnail': 'https://example.com/new.png'},
|
|
'minor': {}
|
|
}
|
|
|
|
embeds = await branding_cog._create_preview_embeds(sample_team, None, updates)
|
|
|
|
assert len(embeds) >= 1
|
|
assert "New York Yankees" in embeds[0].title
|
|
assert embeds[0].color.value == int('FF5733', 16)
|
|
|
|
async def test_create_preview_embeds_with_dice_color(self, branding_cog, sample_team):
|
|
"""Test creating preview embeds including dice color."""
|
|
updates = {
|
|
'major': {'dice_color': 'A6CE39'},
|
|
'minor': {}
|
|
}
|
|
|
|
embeds = await branding_cog._create_preview_embeds(sample_team, None, updates)
|
|
|
|
# Should have at least 1 embed (major) and possibly dice embed
|
|
assert len(embeds) >= 1
|
|
|
|
async def test_format_success_message_major_updates(self, branding_cog):
|
|
"""Test formatting success message for major league updates."""
|
|
updates = {
|
|
'major': {'color': 'FF5733', 'thumbnail': 'https://example.com/new.png'},
|
|
'minor': {}
|
|
}
|
|
|
|
message = branding_cog._format_success_message(updates, True, None)
|
|
|
|
assert "Major League" in message
|
|
assert "FF5733" in message
|
|
assert "Logo" in message
|
|
assert "✅" in message
|
|
|
|
async def test_format_success_message_with_role_error(self, branding_cog):
|
|
"""Test formatting success message when role update fails."""
|
|
updates = {
|
|
'major': {'color': 'FF5733'},
|
|
'minor': {}
|
|
}
|
|
|
|
message = branding_cog._format_success_message(updates, False, "Missing permissions")
|
|
|
|
assert "Major League" in message
|
|
assert "FF5733" in message
|
|
assert "Missing permissions" in message or "⚠️" in message
|
|
|
|
async def test_format_success_message_minor_updates(self, branding_cog):
|
|
"""Test formatting success message for minor league updates."""
|
|
updates = {
|
|
'major': {},
|
|
'minor': {'color': '33C3FF', 'thumbnail': 'https://example.com/mil.png'}
|
|
}
|
|
|
|
message = branding_cog._format_success_message(updates, False, None)
|
|
|
|
assert "Minor League" in message
|
|
assert "33C3FF" in message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestDiscordRoleUpdate:
|
|
"""Test Discord role color update functionality."""
|
|
|
|
@pytest.fixture
|
|
def mock_bot(self):
|
|
"""Create mock bot instance."""
|
|
return MagicMock()
|
|
|
|
@pytest.fixture
|
|
def branding_cog(self, mock_bot):
|
|
"""Create BrandingCommands cog instance."""
|
|
return BrandingCommands(mock_bot)
|
|
|
|
@pytest.fixture
|
|
def mock_interaction(self):
|
|
"""Create mock Discord interaction with guild and roles."""
|
|
interaction = AsyncMock()
|
|
interaction.user = MagicMock()
|
|
interaction.user.id = 123456789
|
|
interaction.guild = MagicMock()
|
|
interaction.response = AsyncMock()
|
|
interaction.followup = AsyncMock()
|
|
return interaction
|
|
|
|
@pytest.fixture
|
|
def sample_team(self):
|
|
"""Create sample team data."""
|
|
return Team(
|
|
id=1,
|
|
abbrev="NYY",
|
|
sname="Yankees",
|
|
lname="New York Yankees",
|
|
color="003087",
|
|
dice_color="E4002B",
|
|
thumbnail="https://example.com/yankees.png",
|
|
stadium="Yankee Stadium",
|
|
season=12,
|
|
roster_type="majors",
|
|
owner_id=123456789
|
|
)
|
|
|
|
async def test_update_discord_role_success(self, branding_cog, mock_interaction, sample_team):
|
|
"""Test successful Discord role color update."""
|
|
# Create mock role
|
|
mock_role = AsyncMock()
|
|
mock_role.name = "New York Yankees"
|
|
mock_role.edit = AsyncMock()
|
|
mock_interaction.guild.roles = [mock_role]
|
|
|
|
# Patch discord.utils.get to return our mock role
|
|
with patch('commands.teams.branding.discord.utils.get', return_value=mock_role):
|
|
success, error = await branding_cog._update_discord_role_color(
|
|
mock_interaction,
|
|
sample_team,
|
|
"FF5733"
|
|
)
|
|
|
|
assert success is True
|
|
assert error is None
|
|
mock_role.edit.assert_called_once()
|
|
|
|
async def test_update_discord_role_not_found(self, branding_cog, mock_interaction, sample_team):
|
|
"""Test Discord role update when role is not found."""
|
|
mock_interaction.guild.roles = []
|
|
|
|
# Patch discord.utils.get to return None
|
|
with patch('commands.teams.branding.discord.utils.get', return_value=None):
|
|
success, error = await branding_cog._update_discord_role_color(
|
|
mock_interaction,
|
|
sample_team,
|
|
"FF5733"
|
|
)
|
|
|
|
assert success is False
|
|
assert "not found" in error.lower()
|
|
|
|
async def test_update_discord_role_forbidden(self, branding_cog, mock_interaction, sample_team):
|
|
"""Test Discord role update when missing permissions."""
|
|
import discord as discord_module
|
|
|
|
# Create mock role that raises Forbidden
|
|
mock_role = AsyncMock()
|
|
mock_role.name = "New York Yankees"
|
|
mock_role.edit = AsyncMock(side_effect=discord_module.Forbidden(
|
|
MagicMock(status=403), "Missing Permissions"
|
|
))
|
|
mock_interaction.guild.roles = [mock_role]
|
|
|
|
with patch('commands.teams.branding.discord.utils.get', return_value=mock_role):
|
|
success, error = await branding_cog._update_discord_role_color(
|
|
mock_interaction,
|
|
sample_team,
|
|
"FF5733"
|
|
)
|
|
|
|
assert success is False
|
|
assert "permission" in error.lower()
|