Add /branding command for team color/logo management (v2.20.0)
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>
This commit is contained in:
parent
570f2e5e5a
commit
5133dc3d0f
@ -26,6 +26,15 @@ This directory contains Discord slash commands for team information and roster m
|
|||||||
- `team_service.get_team_by_abbrev()`
|
- `team_service.get_team_by_abbrev()`
|
||||||
- `team_service.get_team_roster()`
|
- `team_service.get_team_roster()`
|
||||||
|
|
||||||
|
### `branding.py`
|
||||||
|
- **Command**: `/branding`
|
||||||
|
- **Description**: Update team colors and logos via interactive modal
|
||||||
|
- **Access**: Team owners only (verified via `team_service.get_team_by_owner()`)
|
||||||
|
- **Service Dependencies**:
|
||||||
|
- `team_service.get_team_by_owner()`
|
||||||
|
- `team_service.update_team()`
|
||||||
|
- `models.team.Team.minor_league_affiliate()`
|
||||||
|
|
||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
### Team Information Display (`info.py`)
|
### Team Information Display (`info.py`)
|
||||||
@ -67,6 +76,49 @@ This directory contains Discord slash commands for team information and roster m
|
|||||||
- **Player Organization**: Batters and pitchers grouped separately
|
- **Player Organization**: Batters and pitchers grouped separately
|
||||||
- **Chunked Display**: Long player lists split across multiple fields
|
- **Chunked Display**: Long player lists split across multiple fields
|
||||||
|
|
||||||
|
### Team Branding Management (`branding.py`)
|
||||||
|
- **Modal-Based Input**: Interactive form with 5 optional fields
|
||||||
|
- **Preview + Confirmation**: Visual preview before applying changes
|
||||||
|
- **Multi-Team Support**: Updates major league and minor league teams
|
||||||
|
- **Discord Integration**: Attempts to update Discord role colors (non-blocking)
|
||||||
|
|
||||||
|
#### Workflow
|
||||||
|
1. User runs `/branding`
|
||||||
|
2. Bot verifies user owns a team
|
||||||
|
3. Modal displays with 5 optional fields showing current values
|
||||||
|
4. User fills in desired changes, submits
|
||||||
|
5. Bot validates all inputs (hex colors, URL accessibility)
|
||||||
|
6. Bot shows preview embeds with confirmation buttons
|
||||||
|
7. User confirms or cancels
|
||||||
|
8. Bot applies changes to database
|
||||||
|
9. Bot attempts to update Discord role color (non-blocking)
|
||||||
|
10. Bot shows success message with details
|
||||||
|
|
||||||
|
#### Modal Fields
|
||||||
|
- **Major League Team Color** - 6-character hex code (e.g., FF5733 or #FF5733)
|
||||||
|
- **Major League Logo URL** - Public image URL (.png, .jpg, .jpeg, .gif, .webp)
|
||||||
|
- **Minor League Team Color** - 6-character hex code
|
||||||
|
- **Minor League Logo URL** - Public image URL
|
||||||
|
- **Dice Roll Color** - 6-character hex code for dice displays
|
||||||
|
|
||||||
|
#### Validation Rules
|
||||||
|
- **Hex Colors**: Must be exactly 6 characters, valid hex digits (0-9, A-F), # prefix optional
|
||||||
|
- **Image URLs**: Must start with http:// or https://, end with valid extension, be publicly accessible
|
||||||
|
- **All Fields Optional**: Leave blank to keep current value
|
||||||
|
- **URL Accessibility**: Tests URLs with HEAD request (5 second timeout)
|
||||||
|
- **Content Type Check**: Verifies URLs point to images
|
||||||
|
|
||||||
|
#### Database Updates
|
||||||
|
- Major league team: `color`, `thumbnail`, `dice_color` fields
|
||||||
|
- Minor league team: `color`, `thumbnail` fields
|
||||||
|
- Uses `team_service.update_team()` for all database operations
|
||||||
|
|
||||||
|
#### Discord Role Updates
|
||||||
|
- Bot attempts to update Discord role color to match team color
|
||||||
|
- Failures are non-blocking (show warning but database updates succeed)
|
||||||
|
- Common failure reasons: role not found, missing permissions
|
||||||
|
- Updates role by matching `team.lname` (long name)
|
||||||
|
|
||||||
## Architecture Notes
|
## Architecture Notes
|
||||||
|
|
||||||
### Embed Design
|
### Embed Design
|
||||||
@ -109,6 +161,13 @@ This directory contains Discord slash commands for team information and roster m
|
|||||||
- Verify standings data structure matches expected format
|
- Verify standings data structure matches expected format
|
||||||
- Ensure error handling for malformed standings data
|
- Ensure error handling for malformed standings data
|
||||||
|
|
||||||
|
5. **Branding command issues**:
|
||||||
|
- **"You don't own a team"**: User is not registered as team owner for current season
|
||||||
|
- **"URL not accessible"**: Image URL returned non-200 status or timed out (check URL is public)
|
||||||
|
- **"Color must be 6 characters"**: Hex color is wrong length or contains invalid characters
|
||||||
|
- **"Discord role update failed"**: Role color couldn't be updated (database still succeeded - not critical)
|
||||||
|
- **"No minor league affiliate"**: Team doesn't have MiL team (this is normal for some teams)
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
- `services.team_service`
|
- `services.team_service`
|
||||||
- `models.team.Team`
|
- `models.team.Team`
|
||||||
@ -118,7 +177,9 @@ This directory contains Discord slash commands for team information and roster m
|
|||||||
- `exceptions.BotException`
|
- `exceptions.BotException`
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
Run tests with: `python -m pytest tests/test_commands_teams.py -v`
|
Run tests with:
|
||||||
|
- All team commands: `python -m pytest tests/test_commands_teams.py -v`
|
||||||
|
- Branding command only: `python -m pytest tests/test_commands_teams_branding.py -v`
|
||||||
|
|
||||||
## Database Requirements
|
## Database Requirements
|
||||||
- Team records with abbreviations, names, colors, logos
|
- Team records with abbreviations, names, colors, logos
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from discord.ext import commands
|
|||||||
|
|
||||||
from .info import TeamInfoCommands
|
from .info import TeamInfoCommands
|
||||||
from .roster import TeamRosterCommands
|
from .roster import TeamRosterCommands
|
||||||
|
from .branding import BrandingCommands
|
||||||
|
|
||||||
logger = logging.getLogger(f'{__name__}.setup_teams')
|
logger = logging.getLogger(f'{__name__}.setup_teams')
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ async def setup_teams(bot: commands.Bot) -> Tuple[int, int, List[str]]:
|
|||||||
team_cogs: List[Tuple[str, Type[commands.Cog]]] = [
|
team_cogs: List[Tuple[str, Type[commands.Cog]]] = [
|
||||||
("TeamInfoCommands", TeamInfoCommands),
|
("TeamInfoCommands", TeamInfoCommands),
|
||||||
("TeamRosterCommands", TeamRosterCommands),
|
("TeamRosterCommands", TeamRosterCommands),
|
||||||
|
("BrandingCommands", BrandingCommands),
|
||||||
]
|
]
|
||||||
|
|
||||||
successful = 0
|
successful = 0
|
||||||
|
|||||||
677
commands/teams/branding.py
Normal file
677
commands/teams/branding.py
Normal file
@ -0,0 +1,677 @@
|
|||||||
|
"""
|
||||||
|
Team Branding Management Commands
|
||||||
|
|
||||||
|
Allows team owners to update their team's visual branding including colors and logos
|
||||||
|
for major league teams, minor league affiliates, and dice roll displays.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
from typing import Optional, Tuple, Dict, List
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from config import get_config
|
||||||
|
from services import team_service
|
||||||
|
from models.team import Team
|
||||||
|
from utils.logging import get_contextual_logger
|
||||||
|
from utils.decorators import logged_command
|
||||||
|
from views.embeds import EmbedTemplate, EmbedColors
|
||||||
|
from views.confirmations import ConfirmationView
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Validation Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def validate_hex_color(color: str) -> Tuple[bool, str, str]:
|
||||||
|
"""
|
||||||
|
Validate hex color format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
color: Hex color string (with or without # prefix)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, normalized_color, error_message)
|
||||||
|
- is_valid: True if validation passed
|
||||||
|
- normalized_color: Uppercase hex without # prefix
|
||||||
|
- error_message: Empty if valid, error description if invalid
|
||||||
|
"""
|
||||||
|
if not color:
|
||||||
|
return True, "", ""
|
||||||
|
|
||||||
|
# Strip # and whitespace
|
||||||
|
color = color.strip().lstrip('#')
|
||||||
|
|
||||||
|
# Check length
|
||||||
|
if len(color) != 6:
|
||||||
|
return False, "", "Color must be 6 characters (e.g., FF5733 or #FF5733)"
|
||||||
|
|
||||||
|
# Check characters are valid hex
|
||||||
|
if not all(c in '0123456789ABCDEFabcdef' for c in color):
|
||||||
|
return False, "", "Color must contain only hex digits (0-9, A-F)"
|
||||||
|
|
||||||
|
return True, color.upper(), ""
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_image_url(url: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate image URL format and accessibility.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Image URL to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
- is_valid: True if validation passed
|
||||||
|
- error_message: Empty if valid, error description if invalid
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
# Format validation
|
||||||
|
if not url.startswith(('http://', 'https://')):
|
||||||
|
return False, "URL must start with http:// or https://"
|
||||||
|
|
||||||
|
valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.webp')
|
||||||
|
# Check extension (handle query parameters)
|
||||||
|
url_path = url.split('?')[0] # Strip query params
|
||||||
|
if not any(url_path.lower().endswith(ext) for ext in valid_extensions):
|
||||||
|
return False, f"URL must end with {', '.join(valid_extensions)}"
|
||||||
|
|
||||||
|
# Accessibility test
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.head(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
return False, f"URL returned status {resp.status} (not accessible)"
|
||||||
|
|
||||||
|
content_type = resp.headers.get('Content-Type', '')
|
||||||
|
if not content_type.startswith('image/'):
|
||||||
|
return False, "URL does not point to an image"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return False, "URL request timed out (5 seconds)"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Unable to access URL: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Branding Modal
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class BrandingModal(discord.ui.Modal, title="Team Branding"):
|
||||||
|
"""Modal form for collecting team branding updates."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cog, # BrandingCommands instance
|
||||||
|
ml_team: Team,
|
||||||
|
mil_team: Optional[Team]
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize branding modal with current team values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cog: BrandingCommands cog instance (for access to service and logger)
|
||||||
|
ml_team: Major league team to update
|
||||||
|
mil_team: Minor league team (optional)
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.cog = cog
|
||||||
|
self.ml_team = ml_team
|
||||||
|
self.mil_team = mil_team
|
||||||
|
|
||||||
|
# Set placeholders to show current values
|
||||||
|
current_ml_color = ml_team.color or "Not set"
|
||||||
|
current_dice_color = ml_team.dice_color or "Not set"
|
||||||
|
current_mil_color = mil_team.color if mil_team else "No MiL team"
|
||||||
|
|
||||||
|
self.major_color.placeholder = f"Current: {current_ml_color}"
|
||||||
|
self.dice_color.placeholder = f"Current: {current_dice_color}"
|
||||||
|
self.minor_color.placeholder = f"Current: {current_mil_color}"
|
||||||
|
|
||||||
|
major_color = discord.ui.TextInput(
|
||||||
|
label="Major League Team Color",
|
||||||
|
placeholder="FF5733 or #FF5733",
|
||||||
|
max_length=7,
|
||||||
|
required=False,
|
||||||
|
style=discord.TextStyle.short
|
||||||
|
)
|
||||||
|
|
||||||
|
major_logo = discord.ui.TextInput(
|
||||||
|
label="Major League Logo URL",
|
||||||
|
placeholder="https://... (leave blank to keep current)",
|
||||||
|
max_length=500,
|
||||||
|
required=False,
|
||||||
|
style=discord.TextStyle.short
|
||||||
|
)
|
||||||
|
|
||||||
|
minor_color = discord.ui.TextInput(
|
||||||
|
label="Minor League Team Color",
|
||||||
|
placeholder="33C3FF or #33C3FF",
|
||||||
|
max_length=7,
|
||||||
|
required=False,
|
||||||
|
style=discord.TextStyle.short
|
||||||
|
)
|
||||||
|
|
||||||
|
minor_logo = discord.ui.TextInput(
|
||||||
|
label="Minor League Logo URL",
|
||||||
|
placeholder="https://... (leave blank to keep current)",
|
||||||
|
max_length=500,
|
||||||
|
required=False,
|
||||||
|
style=discord.TextStyle.short
|
||||||
|
)
|
||||||
|
|
||||||
|
dice_color = discord.ui.TextInput(
|
||||||
|
label="Dice Roll Color",
|
||||||
|
placeholder="A6CE39 or #A6CE39",
|
||||||
|
max_length=7,
|
||||||
|
required=False,
|
||||||
|
style=discord.TextStyle.short
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_submit(self, interaction: discord.Interaction):
|
||||||
|
"""Handle modal submission and validation."""
|
||||||
|
await interaction.response.defer()
|
||||||
|
|
||||||
|
# Collect modal data
|
||||||
|
modal_data = {
|
||||||
|
'major_color': self.major_color.value.strip(),
|
||||||
|
'major_logo': self.major_logo.value.strip(),
|
||||||
|
'minor_color': self.minor_color.value.strip(),
|
||||||
|
'minor_logo': self.minor_logo.value.strip(),
|
||||||
|
'dice_color': self.dice_color.value.strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if any fields were filled
|
||||||
|
if not any(modal_data.values()):
|
||||||
|
embed = EmbedTemplate.info(
|
||||||
|
"No Changes",
|
||||||
|
"No branding changes were specified. All fields were left blank."
|
||||||
|
)
|
||||||
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate and process
|
||||||
|
await self.cog._process_branding_update(
|
||||||
|
interaction,
|
||||||
|
self.ml_team,
|
||||||
|
self.mil_team,
|
||||||
|
modal_data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Branding Commands Cog
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class BrandingCommands(commands.Cog):
|
||||||
|
"""Team branding management command handlers."""
|
||||||
|
|
||||||
|
def __init__(self, bot: commands.Bot):
|
||||||
|
self.bot = bot
|
||||||
|
self.logger = get_contextual_logger(f'{__name__}.BrandingCommands')
|
||||||
|
self.logger.info("BrandingCommands cog initialized")
|
||||||
|
|
||||||
|
@app_commands.command(name="branding", description="Update your team's colors and logos")
|
||||||
|
@logged_command("/branding")
|
||||||
|
async def team_branding(self, interaction: discord.Interaction):
|
||||||
|
"""
|
||||||
|
Update team branding including colors and logos.
|
||||||
|
|
||||||
|
Team owners can update:
|
||||||
|
- Major league team color and logo
|
||||||
|
- Minor league team color and logo
|
||||||
|
- Dice roll color
|
||||||
|
"""
|
||||||
|
# Get current season
|
||||||
|
config = get_config()
|
||||||
|
season = config.sba_current_season
|
||||||
|
|
||||||
|
# Verify user owns a team (must do this BEFORE responding to interaction)
|
||||||
|
ml_team = await team_service.get_team_by_owner(interaction.user.id, season)
|
||||||
|
|
||||||
|
if not ml_team:
|
||||||
|
self.logger.info("User does not own a team", user_id=interaction.user.id)
|
||||||
|
embed = EmbedTemplate.error(
|
||||||
|
"Not a Team Owner",
|
||||||
|
"You don't own a team in the current season."
|
||||||
|
)
|
||||||
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get minor league affiliate (if exists)
|
||||||
|
mil_team = None
|
||||||
|
try:
|
||||||
|
mil_team = await ml_team.minor_league_affiliate()
|
||||||
|
self.logger.info("Found minor league affiliate", mil_team_id=mil_team.id)
|
||||||
|
except ValueError:
|
||||||
|
# No MiL affiliate - this is OK
|
||||||
|
self.logger.info("No minor league affiliate found for team", team_id=ml_team.id)
|
||||||
|
|
||||||
|
# Show branding modal as immediate response
|
||||||
|
modal = BrandingModal(
|
||||||
|
cog=self,
|
||||||
|
ml_team=ml_team,
|
||||||
|
mil_team=mil_team
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send modal as the interaction response
|
||||||
|
await interaction.response.send_modal(modal)
|
||||||
|
|
||||||
|
async def _process_branding_update(
|
||||||
|
self,
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
ml_team: Team,
|
||||||
|
mil_team: Optional[Team],
|
||||||
|
modal_data: Dict[str, str]
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Process and validate branding update from modal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interaction: Discord interaction
|
||||||
|
ml_team: Major league team to update
|
||||||
|
mil_team: Minor league team (optional)
|
||||||
|
modal_data: Dictionary of modal field values
|
||||||
|
"""
|
||||||
|
# Validate all inputs
|
||||||
|
updates, errors = await self._validate_all_inputs(modal_data)
|
||||||
|
|
||||||
|
# Show validation errors
|
||||||
|
if errors:
|
||||||
|
error_text = "\n".join(errors)
|
||||||
|
embed = EmbedTemplate.error(
|
||||||
|
"Validation Errors",
|
||||||
|
f"{error_text}\n\n💡 **Tip:** Leave fields blank to keep current values"
|
||||||
|
)
|
||||||
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if there are any actual updates
|
||||||
|
if not updates['major'] and not updates['minor']:
|
||||||
|
embed = EmbedTemplate.info(
|
||||||
|
"No Changes",
|
||||||
|
"No valid branding changes were specified."
|
||||||
|
)
|
||||||
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create preview embeds
|
||||||
|
embeds = await self._create_preview_embeds(ml_team, mil_team, updates)
|
||||||
|
|
||||||
|
# Show confirmation dialog
|
||||||
|
confirmation_view = ConfirmationView(
|
||||||
|
responders=[interaction.user],
|
||||||
|
timeout=60.0,
|
||||||
|
confirm_label="Apply Changes",
|
||||||
|
cancel_label="Cancel"
|
||||||
|
)
|
||||||
|
|
||||||
|
confirmation_message = await interaction.followup.send(
|
||||||
|
content="🎨 **Branding Preview** - Review and confirm changes:",
|
||||||
|
embeds=embeds,
|
||||||
|
view=confirmation_view,
|
||||||
|
wait=True
|
||||||
|
)
|
||||||
|
|
||||||
|
await confirmation_view.wait()
|
||||||
|
|
||||||
|
if not confirmation_view.confirmed:
|
||||||
|
embed = EmbedTemplate.info(
|
||||||
|
"Cancelled",
|
||||||
|
"Branding changes were not applied."
|
||||||
|
)
|
||||||
|
await confirmation_message.edit(content=None, embeds=[embed], view=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Apply updates
|
||||||
|
await self._apply_branding_updates(interaction, ml_team, mil_team, updates, confirmation_message)
|
||||||
|
|
||||||
|
async def _validate_all_inputs(
|
||||||
|
self,
|
||||||
|
modal_data: Dict[str, str]
|
||||||
|
) -> Tuple[Dict[str, Dict[str, str]], List[str]]:
|
||||||
|
"""
|
||||||
|
Validate all modal inputs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
modal_data: Dictionary of modal field values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (updates_dict, error_list)
|
||||||
|
- updates_dict: {'major': {...}, 'minor': {...}}
|
||||||
|
- error_list: List of error messages
|
||||||
|
"""
|
||||||
|
updates = {
|
||||||
|
'major': {},
|
||||||
|
'minor': {},
|
||||||
|
}
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate major league color
|
||||||
|
if modal_data['major_color']:
|
||||||
|
valid, normalized, error = validate_hex_color(modal_data['major_color'])
|
||||||
|
if not valid:
|
||||||
|
errors.append(f"**Major Team Color:** {error}")
|
||||||
|
else:
|
||||||
|
updates['major']['color'] = normalized
|
||||||
|
|
||||||
|
# Validate dice color
|
||||||
|
if modal_data['dice_color']:
|
||||||
|
valid, normalized, error = validate_hex_color(modal_data['dice_color'])
|
||||||
|
if not valid:
|
||||||
|
errors.append(f"**Dice Roll Color:** {error}")
|
||||||
|
else:
|
||||||
|
updates['major']['dice_color'] = normalized
|
||||||
|
|
||||||
|
# Validate minor league color
|
||||||
|
if modal_data['minor_color']:
|
||||||
|
valid, normalized, error = validate_hex_color(modal_data['minor_color'])
|
||||||
|
if not valid:
|
||||||
|
errors.append(f"**Minor Team Color:** {error}")
|
||||||
|
else:
|
||||||
|
updates['minor']['color'] = normalized
|
||||||
|
|
||||||
|
# Collect URLs for concurrent validation
|
||||||
|
url_validations = []
|
||||||
|
|
||||||
|
if modal_data['major_logo']:
|
||||||
|
url_validations.append(('major_logo', 'Major Team Logo', modal_data['major_logo']))
|
||||||
|
|
||||||
|
if modal_data['minor_logo']:
|
||||||
|
url_validations.append(('minor_logo', 'Minor Team Logo', modal_data['minor_logo']))
|
||||||
|
|
||||||
|
# Validate all URLs concurrently
|
||||||
|
if url_validations:
|
||||||
|
tasks = [validate_image_url(url) for _, _, url in url_validations]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
for (field, label, url), (valid, error) in zip(url_validations, results):
|
||||||
|
if not valid:
|
||||||
|
errors.append(f"**{label}:** {error}")
|
||||||
|
else:
|
||||||
|
# Add to appropriate updates dict
|
||||||
|
if field == 'major_logo':
|
||||||
|
updates['major']['thumbnail'] = url
|
||||||
|
elif field == 'minor_logo':
|
||||||
|
updates['minor']['thumbnail'] = url
|
||||||
|
|
||||||
|
return updates, errors
|
||||||
|
|
||||||
|
async def _create_preview_embeds(
|
||||||
|
self,
|
||||||
|
ml_team: Team,
|
||||||
|
mil_team: Optional[Team],
|
||||||
|
updates: Dict[str, Dict[str, str]]
|
||||||
|
) -> List[discord.Embed]:
|
||||||
|
"""
|
||||||
|
Create preview embeds showing branding changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ml_team: Major league team
|
||||||
|
mil_team: Minor league team (optional)
|
||||||
|
updates: Updates dictionary from validation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of 1-3 embeds showing previews
|
||||||
|
"""
|
||||||
|
embeds = []
|
||||||
|
|
||||||
|
# Major league preview
|
||||||
|
if updates['major']:
|
||||||
|
# Determine preview color (use new color if provided, else current)
|
||||||
|
if 'color' in updates['major']:
|
||||||
|
preview_color = int(updates['major']['color'], 16)
|
||||||
|
elif ml_team.color:
|
||||||
|
preview_color = int(ml_team.color, 16)
|
||||||
|
else:
|
||||||
|
preview_color = EmbedColors.PRIMARY
|
||||||
|
|
||||||
|
# Determine preview logo
|
||||||
|
preview_logo = updates['major'].get('thumbnail', ml_team.thumbnail)
|
||||||
|
|
||||||
|
embed = EmbedTemplate.create_base_embed(
|
||||||
|
title=f"{ml_team.lname} - Preview",
|
||||||
|
description="Major League Team Branding",
|
||||||
|
color=preview_color
|
||||||
|
)
|
||||||
|
|
||||||
|
if preview_logo:
|
||||||
|
embed.set_thumbnail(url=preview_logo)
|
||||||
|
|
||||||
|
# Add fields showing what's changing
|
||||||
|
changes = []
|
||||||
|
if 'color' in updates['major']:
|
||||||
|
changes.append(f"**Team Color:** #{updates['major']['color']}")
|
||||||
|
if 'thumbnail' in updates['major']:
|
||||||
|
changes.append(f"**Team Logo:** Updated ✓")
|
||||||
|
if 'dice_color' in updates['major']:
|
||||||
|
changes.append(f"**Dice Color:** #{updates['major']['dice_color']}")
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
embed.add_field(
|
||||||
|
name="Changes",
|
||||||
|
value="\n".join(changes),
|
||||||
|
inline=False
|
||||||
|
)
|
||||||
|
|
||||||
|
embeds.append(embed)
|
||||||
|
|
||||||
|
# Minor league preview (if applicable)
|
||||||
|
if mil_team and updates['minor']:
|
||||||
|
# Determine preview color
|
||||||
|
if 'color' in updates['minor']:
|
||||||
|
preview_color = int(updates['minor']['color'], 16)
|
||||||
|
elif mil_team.color:
|
||||||
|
preview_color = int(mil_team.color, 16)
|
||||||
|
else:
|
||||||
|
preview_color = EmbedColors.PRIMARY
|
||||||
|
|
||||||
|
# Determine preview logo
|
||||||
|
preview_logo = updates['minor'].get('thumbnail', mil_team.thumbnail)
|
||||||
|
|
||||||
|
embed = EmbedTemplate.create_base_embed(
|
||||||
|
title=f"{mil_team.lname} - Preview",
|
||||||
|
description="Minor League Team Branding",
|
||||||
|
color=preview_color
|
||||||
|
)
|
||||||
|
|
||||||
|
if preview_logo:
|
||||||
|
embed.set_thumbnail(url=preview_logo)
|
||||||
|
|
||||||
|
# Add fields showing what's changing
|
||||||
|
changes = []
|
||||||
|
if 'color' in updates['minor']:
|
||||||
|
changes.append(f"**Team Color:** #{updates['minor']['color']}")
|
||||||
|
if 'thumbnail' in updates['minor']:
|
||||||
|
changes.append(f"**Team Logo:** Updated ✓")
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
embed.add_field(
|
||||||
|
name="Changes",
|
||||||
|
value="\n".join(changes),
|
||||||
|
inline=False
|
||||||
|
)
|
||||||
|
|
||||||
|
embeds.append(embed)
|
||||||
|
|
||||||
|
# Dice color preview (if updated and different from team color)
|
||||||
|
if 'dice_color' in updates.get('major', {}):
|
||||||
|
dice_color_int = int(updates['major']['dice_color'], 16)
|
||||||
|
|
||||||
|
dice_embed = EmbedTemplate.create_base_embed(
|
||||||
|
title=f"{ml_team.lname} - Dice Color Preview",
|
||||||
|
description="This color will be used for dice rolls in gameplay",
|
||||||
|
color=dice_color_int
|
||||||
|
)
|
||||||
|
|
||||||
|
dice_embed.add_field(
|
||||||
|
name="Dice Color",
|
||||||
|
value=f"#{updates['major']['dice_color']}",
|
||||||
|
inline=False
|
||||||
|
)
|
||||||
|
|
||||||
|
embeds.append(dice_embed)
|
||||||
|
|
||||||
|
return embeds
|
||||||
|
|
||||||
|
async def _apply_branding_updates(
|
||||||
|
self,
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
ml_team: Team,
|
||||||
|
mil_team: Optional[Team],
|
||||||
|
updates: Dict[str, Dict[str, str]],
|
||||||
|
confirmation_message
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Apply branding updates to database and Discord.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interaction: Discord interaction
|
||||||
|
ml_team: Major league team to update
|
||||||
|
mil_team: Minor league team (optional)
|
||||||
|
updates: Updates dictionary from validation
|
||||||
|
confirmation_message: The message to update with results
|
||||||
|
"""
|
||||||
|
role_updated = False
|
||||||
|
role_error = None
|
||||||
|
|
||||||
|
# Update major league team
|
||||||
|
if updates['major']:
|
||||||
|
self.logger.info("Updating major league team", team_id=ml_team.id, updates=updates['major'])
|
||||||
|
updated_ml = await team_service.update_team(ml_team.id, updates['major'])
|
||||||
|
|
||||||
|
if not updated_ml:
|
||||||
|
self.logger.error("Failed to update major league team", team_id=ml_team.id)
|
||||||
|
embed = EmbedTemplate.error(
|
||||||
|
"Update Failed",
|
||||||
|
"Failed to update major league team branding. Please try again."
|
||||||
|
)
|
||||||
|
await confirmation_message.edit(content=None, embeds=[embed], view=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update Discord role color (non-blocking)
|
||||||
|
if 'color' in updates['major']:
|
||||||
|
role_updated, role_error = await self._update_discord_role_color(
|
||||||
|
interaction,
|
||||||
|
ml_team,
|
||||||
|
updates['major']['color']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update minor league team (if applicable)
|
||||||
|
if mil_team and updates['minor']:
|
||||||
|
self.logger.info("Updating minor league team", team_id=mil_team.id, updates=updates['minor'])
|
||||||
|
await team_service.update_team(mil_team.id, updates['minor'])
|
||||||
|
|
||||||
|
# Create success message
|
||||||
|
success_message = self._format_success_message(updates, role_updated, role_error)
|
||||||
|
|
||||||
|
embed = EmbedTemplate.success(
|
||||||
|
"Branding Updated",
|
||||||
|
success_message
|
||||||
|
)
|
||||||
|
|
||||||
|
await confirmation_message.edit(content=None, embeds=[embed], view=None)
|
||||||
|
|
||||||
|
async def _update_discord_role_color(
|
||||||
|
self,
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
team: Team,
|
||||||
|
hex_color: str
|
||||||
|
) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Update Discord role color for team (non-blocking).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interaction: Discord interaction
|
||||||
|
team: Team whose role to update
|
||||||
|
hex_color: New hex color (without # prefix)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Find role by team long name
|
||||||
|
role = discord.utils.get(
|
||||||
|
interaction.guild.roles,
|
||||||
|
name=team.lname
|
||||||
|
)
|
||||||
|
|
||||||
|
if not role:
|
||||||
|
self.logger.warning("Discord role not found", team_name=team.lname)
|
||||||
|
return False, "Discord role not found"
|
||||||
|
|
||||||
|
# Convert hex to int
|
||||||
|
color_int = int(hex_color, 16)
|
||||||
|
|
||||||
|
# Update role
|
||||||
|
await role.edit(colour=color_int)
|
||||||
|
|
||||||
|
self.logger.info("Discord role color updated", team_name=team.lname, color=hex_color)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except discord.Forbidden:
|
||||||
|
self.logger.warning("Missing permissions to edit role", team_name=team.lname)
|
||||||
|
return False, "Missing permissions to edit role"
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Failed to update Discord role color: {e}", team_name=team.lname)
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def _format_success_message(
|
||||||
|
self,
|
||||||
|
updates: Dict[str, Dict[str, str]],
|
||||||
|
role_updated: bool,
|
||||||
|
role_error: Optional[str]
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Format success message showing all applied changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
updates: Updates dictionary
|
||||||
|
role_updated: Whether Discord role was updated
|
||||||
|
role_error: Error message if role update failed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted success message string
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Major league updates
|
||||||
|
if updates['major']:
|
||||||
|
lines.append("**Major League:**")
|
||||||
|
if 'color' in updates['major']:
|
||||||
|
lines.append(f"• Color: #{updates['major']['color']} ✅")
|
||||||
|
if 'thumbnail' in updates['major']:
|
||||||
|
lines.append(f"• Logo: Updated ✅")
|
||||||
|
if 'dice_color' in updates['major']:
|
||||||
|
lines.append(f"• Dice Color: #{updates['major']['dice_color']} ✅")
|
||||||
|
|
||||||
|
# Discord role status
|
||||||
|
if 'color' in updates['major']:
|
||||||
|
if role_updated:
|
||||||
|
lines.append(f"• Discord role: Updated ✅")
|
||||||
|
else:
|
||||||
|
lines.append(f"• Discord role: Failed ({role_error}) ⚠️")
|
||||||
|
|
||||||
|
# Minor league updates
|
||||||
|
if updates['minor']:
|
||||||
|
lines.append("\n**Minor League:**")
|
||||||
|
if 'color' in updates['minor']:
|
||||||
|
lines.append(f"• Color: #{updates['minor']['color']} ✅")
|
||||||
|
if 'thumbnail' in updates['minor']:
|
||||||
|
lines.append(f"• Logo: Updated ✅")
|
||||||
|
|
||||||
|
# Warning about role update failure
|
||||||
|
if role_error:
|
||||||
|
lines.append("\n⚠️ **Note:** Discord role color update failed, but database was updated successfully.")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot: commands.Bot):
|
||||||
|
"""Load the branding commands cog."""
|
||||||
|
await bot.add_cog(BrandingCommands(bot))
|
||||||
@ -282,7 +282,8 @@ class TeamService(BaseService[Team]):
|
|||||||
Updated team instance or None
|
Updated team instance or None
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return await self.update(team_id, updates)
|
# Use PATCH with query parameters (database API expects this)
|
||||||
|
return await self.patch(team_id, updates, use_query_params=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update team {team_id}: {e}")
|
logger.error(f"Failed to update team {team_id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
482
tests/test_commands_teams_branding.py
Normal file
482
tests/test_commands_teams_branding.py
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
Loading…
Reference in New Issue
Block a user