major-domo-v2/tests/test_commands_profile_images.py
Cal Corum aa7aab3901 CLAUDE: Implement player image management system
Add /set-image command for updating player fancy cards and headshots.

Features:
- Single command with fancy-card/headshot choice parameter
- Comprehensive URL validation (format + accessibility testing)
- Permission system (users can edit org players, admins can edit all)
- Preview embed with confirmation dialog before database update
- Player name autocomplete prioritizing user's team
- HTTP HEAD request to verify URL accessibility and content-type

Implementation:
- New commands/profile/ package with ImageCommands cog
- Two-stage URL validation (format check + accessibility test)
- Permission checking via Team.is_same_organization()
- Interactive confirmation view with 180s timeout
- Updates player.vanity_card or player.headshot field

Testing:
- 23 comprehensive tests covering validation and permissions
- Uses aioresponses for HTTP mocking (project standard)
- Test coverage for admin/user permissions and organization checks

Documentation:
- Comprehensive README.md with usage guide and troubleshooting
- Updated PRE_LAUNCH_ROADMAP.md to mark feature complete

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

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

322 lines
11 KiB
Python

"""
Tests for player image management commands.
Covers URL validation, permission checking, and command execution.
"""
import pytest
import asyncio
from unittest.mock import MagicMock, patch
import aiohttp
from aioresponses import aioresponses
from commands.profile.images import (
validate_url_format,
test_url_accessibility,
can_edit_player_image,
ImageCommands
)
from models.player import Player
from models.team import Team
from tests.factories import PlayerFactory, TeamFactory
class TestURLValidation:
"""Test URL format validation."""
def test_valid_jpg_url(self):
"""Test valid JPG URL passes validation."""
url = "https://example.com/image.jpg"
is_valid, error = validate_url_format(url)
assert is_valid is True
assert error == ""
def test_valid_png_url(self):
"""Test valid PNG URL passes validation."""
url = "https://example.com/image.png"
is_valid, error = validate_url_format(url)
assert is_valid is True
assert error == ""
def test_valid_webp_url(self):
"""Test valid WebP URL passes validation."""
url = "https://example.com/image.webp"
is_valid, error = validate_url_format(url)
assert is_valid is True
assert error == ""
def test_url_with_query_params(self):
"""Test URL with query parameters passes validation."""
url = "https://example.com/image.jpg?size=large&format=original"
is_valid, error = validate_url_format(url)
assert is_valid is True
assert error == ""
def test_invalid_no_protocol(self):
"""Test URL without protocol fails validation."""
url = "example.com/image.jpg"
is_valid, error = validate_url_format(url)
assert is_valid is False
assert "must start with http" in error.lower()
def test_invalid_ftp_protocol(self):
"""Test FTP protocol fails validation."""
url = "ftp://example.com/image.jpg"
is_valid, error = validate_url_format(url)
assert is_valid is False
assert "must start with http" in error.lower()
def test_invalid_extension(self):
"""Test invalid file extension fails validation."""
url = "https://example.com/document.pdf"
is_valid, error = validate_url_format(url)
assert is_valid is False
assert "extension" in error.lower()
def test_invalid_no_extension(self):
"""Test URL without extension fails validation."""
url = "https://example.com/image"
is_valid, error = validate_url_format(url)
assert is_valid is False
assert "extension" in error.lower()
def test_url_too_long(self):
"""Test URL exceeding max length fails validation."""
url = "https://example.com/" + "a" * 500 + ".jpg"
is_valid, error = validate_url_format(url)
assert is_valid is False
assert "too long" in error.lower()
@pytest.mark.asyncio
class TestURLAccessibility:
"""Test URL accessibility checking."""
async def test_accessible_url_success(self):
"""Test accessible URL with image content-type."""
url = "https://example.com/image.jpg"
with aioresponses() as m:
m.head(url, status=200, headers={'content-type': 'image/jpeg'})
is_accessible, error = await test_url_accessibility(url)
assert is_accessible is True
assert error == ""
async def test_url_not_found(self):
"""Test URL returning 404."""
url = "https://example.com/missing.jpg"
with aioresponses() as m:
m.head(url, status=404)
is_accessible, error = await test_url_accessibility(url)
assert is_accessible is False
assert "404" in error
async def test_url_wrong_content_type(self):
"""Test URL returning non-image content."""
url = "https://example.com/page.html"
with aioresponses() as m:
m.head(url, status=200, headers={'content-type': 'text/html'})
is_accessible, error = await test_url_accessibility(url)
assert is_accessible is False
assert "not return an image" in error
async def test_url_timeout(self):
"""Test URL request timeout."""
url = "https://example.com/slow.jpg"
with aioresponses() as m:
m.head(url, exception=asyncio.TimeoutError())
is_accessible, error = await test_url_accessibility(url)
assert is_accessible is False
assert "timed out" in error.lower()
async def test_url_connection_error(self):
"""Test URL connection error."""
url = "https://unreachable.example.com/image.jpg"
with aioresponses() as m:
m.head(url, exception=aiohttp.ClientError("Connection failed"))
is_accessible, error = await test_url_accessibility(url)
assert is_accessible is False
assert "could not access" in error.lower()
@pytest.mark.asyncio
class TestPermissionChecking:
"""Test permission checking logic."""
async def test_admin_can_edit_any_player(self):
"""Test administrator can edit any player's images."""
mock_interaction = MagicMock()
mock_interaction.user.id = 12345
mock_interaction.user.guild_permissions.administrator = True
player = PlayerFactory.create(id=1, name="Test Player")
player.team = TeamFactory.create(id=1, abbrev="NYY")
mock_logger = MagicMock()
has_permission, error = await can_edit_player_image(
mock_interaction, player, 12, mock_logger
)
assert has_permission is True
assert error == ""
async def test_user_can_edit_own_team_player(self):
"""Test user can edit players on their own team."""
mock_interaction = MagicMock()
mock_interaction.user.id = 12345
mock_interaction.user.guild_permissions.administrator = False
player_team = TeamFactory.create(id=1, abbrev="NYY", season=12)
player = PlayerFactory.create(id=1, name="Test Player")
player.team = player_team
user_team = TeamFactory.create(id=1, abbrev="NYY", season=12)
mock_logger = MagicMock()
with patch('commands.profile.images.team_service.get_teams_by_owner') as mock_get_teams:
mock_get_teams.return_value = [user_team]
has_permission, error = await can_edit_player_image(
mock_interaction, player, 12, mock_logger
)
assert has_permission is True
assert error == ""
async def test_user_can_edit_mil_player(self):
"""Test user can edit players on their minor league team."""
mock_interaction = MagicMock()
mock_interaction.user.id = 12345
mock_interaction.user.guild_permissions.administrator = False
player_team = TeamFactory.create(id=2, abbrev="NYYMIL", season=12)
player = PlayerFactory.create(id=1, name="Minor Player")
player.team = player_team
# User owns the major league team
user_team = TeamFactory.create(id=1, abbrev="NYY", season=12)
mock_logger = MagicMock()
with patch('commands.profile.images.team_service.get_teams_by_owner') as mock_get_teams:
mock_get_teams.return_value = [user_team]
has_permission, error = await can_edit_player_image(
mock_interaction, player, 12, mock_logger
)
assert has_permission is True
assert error == ""
async def test_user_cannot_edit_other_org_player(self):
"""Test user cannot edit players from other organizations."""
mock_interaction = MagicMock()
mock_interaction.user.id = 12345
mock_interaction.user.guild_permissions.administrator = False
player_team = TeamFactory.create(id=2, abbrev="BOS", season=12)
player = PlayerFactory.create(id=1, name="Other Player")
player.team = player_team
# User owns a different team
user_team = TeamFactory.create(id=1, abbrev="NYY", season=12)
mock_logger = MagicMock()
with patch('commands.profile.images.team_service.get_teams_by_owner') as mock_get_teams:
mock_get_teams.return_value = [user_team]
has_permission, error = await can_edit_player_image(
mock_interaction, player, 12, mock_logger
)
assert has_permission is False
assert "don't own" in error.lower()
async def test_user_with_no_teams_cannot_edit(self):
"""Test user without teams cannot edit any player."""
mock_interaction = MagicMock()
mock_interaction.user.id = 12345
mock_interaction.user.guild_permissions.administrator = False
player_team = TeamFactory.create(id=1, abbrev="NYY", season=12)
player = PlayerFactory.create(id=1, name="Test Player")
player.team = player_team
mock_logger = MagicMock()
with patch('commands.profile.images.team_service.get_teams_by_owner') as mock_get_teams:
mock_get_teams.return_value = []
has_permission, error = await can_edit_player_image(
mock_interaction, player, 12, mock_logger
)
assert has_permission is False
assert "don't own any teams" in error.lower()
async def test_player_without_team_fails(self):
"""Test player without team assignment fails permission check."""
mock_interaction = MagicMock()
mock_interaction.user.id = 12345
mock_interaction.user.guild_permissions.administrator = False
player = PlayerFactory.create(id=1, name="Free Agent")
player.team = None
mock_logger = MagicMock()
has_permission, error = await can_edit_player_image(
mock_interaction, player, 12, mock_logger
)
assert has_permission is False
assert "cannot determine" in error.lower()
@pytest.mark.asyncio
class TestImageCommandsIntegration:
"""Integration tests for ImageCommands cog."""
@pytest.fixture
def commands_cog(self):
"""Create ImageCommands cog for testing."""
mock_bot = MagicMock()
return ImageCommands(mock_bot)
async def test_set_image_command_structure(self, commands_cog):
"""Test that set_image command is properly configured."""
assert hasattr(commands_cog, 'set_image')
assert commands_cog.set_image.name == "set-image"
async def test_fancy_card_updates_vanity_card_field(self, commands_cog):
"""Test fancy-card choice updates vanity_card field."""
# This tests the field mapping logic
img_type = "fancy-card"
field_name = "vanity_card" if img_type == "fancy-card" else "headshot"
assert field_name == "vanity_card"
async def test_headshot_updates_headshot_field(self, commands_cog):
"""Test headshot choice updates headshot field."""
# This tests the field mapping logic
img_type = "headshot"
field_name = "vanity_card" if img_type == "fancy-card" else "headshot"
assert field_name == "headshot"