CLAUDE: Enhance voice channel validation for Major League teams

Update voice channel commands to require Major League team ownership for both public and private channels:

## Key Changes
- **Major League Team Validation**: Added `_get_user_major_league_team()` method to filter teams by `RosterType.MAJOR_LEAGUE`
- **Enhanced Requirements**: Both `/voice-channel public` and `/voice-channel private` now require Major League team ownership
- **Improved Error Messages**: Updated error messages to clearly indicate Major League team requirement
- **Schedule Integration**: Private channels now properly validate Major League teams for weekly game schedules

## Technical Implementation
- **Team Filtering**: Uses `team.roster_type() == RosterType.MAJOR_LEAGUE` to identify 3-character abbreviation teams
- **Service Integration**: Leverages existing team service and roster type logic from team model
- **Backward Compatibility**: Maintains existing `_get_user_team()` method for potential future use

## Team Type Validation
- **Major League**: 3-character abbreviations (NYY, BOS, LAD) - **Required for voice channels**
- **Minor League**: 4+ characters ending in "MIL" (NYYMIL, BOSMIL) - **Not eligible**
- **Injured List**: Ending in "IL" (NYYIL, BOSIL) - **Not eligible**

## Updated Tests
- **Mock Team Updates**: Added `roster_type()` method mocking to test team objects
- **Async Service Mocking**: Fixed team service mocks to return proper async results
- **Error Message Assertions**: Updated test assertions for new error messages
- **Type Safety**: Enhanced mock objects with proper Discord voice channel specifications

## Documentation Updates
- **README.md**: Added comprehensive team validation logic explanation
- **Architecture Documentation**: Detailed team type requirements and rationale
- **Code Examples**: Included implementation snippets for team filtering logic

**Rationale**: Voice channels are for scheduled gameplay between Major League teams. Minor League and Injured List teams don't participate in weekly games, so restricting access ensures proper schedule integration.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-09-24 23:30:05 -05:00
parent 8515caaf21
commit e20677ec72
3 changed files with 66 additions and 18 deletions

View File

@ -45,7 +45,7 @@ This directory contains Discord slash commands for creating and managing voice c
### Public Voice Channels (`/voice-channel public`)
- **Permissions**: Everyone can connect and speak
- **Naming**: Random codename generation (e.g., "Gameplay Phoenix", "Gameplay Thunder")
- **Requirements**: User must be on a current team
- **Requirements**: User must own a Major League team (3-character abbreviations like NYY, BOS)
- **Auto-cleanup**: Configurable threshold (default: empty for configured minutes)
### Private Voice Channels (`/voice-channel private`)
@ -55,7 +55,7 @@ This directory contains Discord slash commands for creating and managing voice c
- **Naming**: Automatic "{Away} vs {Home}" format based on current week's schedule
- **Opponent Detection**: Uses current league week to find scheduled opponent
- **Requirements**:
- User must be on a current team
- User must own a Major League team (3-character abbreviations like NYY, BOS)
- Team must have upcoming games in current week
- **Role Integration**: Finds Discord roles matching team full names (`team.lname`)
@ -69,11 +69,32 @@ This directory contains Discord slash commands for creating and managing voice c
## Architecture
### Command Flow
1. **Team Verification**: Check user has current team using `team_service`
1. **Major League Team Verification**: Check user owns a Major League team using `team_service`
2. **Channel Creation**: Create voice channel with appropriate permissions
3. **Tracking Registration**: Add channel to cleanup service tracking
4. **User Feedback**: Send success embed with channel details
### Team Validation Logic
The voice channel system validates that users own **Major League teams** specifically:
```python
async def _get_user_major_league_team(self, user_id: int, season: Optional[int] = None):
"""Get the user's Major League team for schedule/game purposes."""
teams = await team_service.get_teams_by_owner(user_id, season)
# Filter to only Major League teams (3-character abbreviations)
major_league_teams = [team for team in teams if team.roster_type() == RosterType.MAJOR_LEAGUE]
return major_league_teams[0] if major_league_teams else None
```
**Team Types:**
- **Major League**: 3-character abbreviations (e.g., NYY, BOS, LAD) - **Required for voice channels**
- **Minor League**: 4+ characters ending in "MIL" (e.g., NYYMIL, BOSMIL) - **Not eligible**
- **Injured List**: Ending in "IL" (e.g., NYYIL, BOSIL) - **Not eligible**
**Rationale:** Only Major League teams participate in weekly scheduled games, so voice channel creation is restricted to active Major League team owners.
### Permission System
```python
# Public channels - everyone can speak

View File

@ -17,6 +17,7 @@ from utils.logging import get_contextual_logger
from utils.decorators import logged_command
from constants import SBA_CURRENT_SEASON
from views.embeds import EmbedTemplate
from models.team import RosterType
logger = logging.getLogger(f'{__name__}.VoiceChannelCommands')
@ -63,6 +64,25 @@ class VoiceChannelCommands(commands.Cog):
teams = await team_service.get_teams_by_owner(user_id, season)
return teams[0] if teams else None
async def _get_user_major_league_team(self, user_id: int, season: Optional[int] = None):
"""
Get the user's Major League team for schedule/game purposes.
Args:
user_id: Discord user ID
season: Season to check (defaults to current)
Returns:
Major League Team object or None if not found
"""
season = season or SBA_CURRENT_SEASON
teams = await team_service.get_teams_by_owner(user_id, season)
# Filter to only Major League teams (3-character abbreviations)
major_league_teams = [team for team in teams if team.roster_type() == RosterType.MAJOR_LEAGUE]
return major_league_teams[0] if major_league_teams else None
async def _create_tracked_channel(
self,
interaction: discord.Interaction,
@ -107,12 +127,12 @@ class VoiceChannelCommands(commands.Cog):
"""Create a public voice channel for gameplay."""
await interaction.response.defer()
# Verify user has a team
user_team = await self._get_user_team(interaction.user.id)
# Verify user has a Major League team
user_team = await self._get_user_major_league_team(interaction.user.id)
if not user_team:
embed = EmbedTemplate.error(
title="No Team Found",
description="❌ You must be on a team to create voice channels.\n\n"
title="No Major League Team Found",
description="❌ You must own a Major League team to create voice channels.\n\n"
"Contact a league administrator if you believe this is an error."
)
await interaction.followup.send(embed=embed, ephemeral=True)
@ -166,12 +186,13 @@ class VoiceChannelCommands(commands.Cog):
"""Create a private voice channel for team matchup."""
await interaction.response.defer()
# Verify user has a team
user_team = await self._get_user_team(interaction.user.id)
# Verify user has a Major League team
user_team = await self._get_user_major_league_team(interaction.user.id)
if not user_team:
embed = EmbedTemplate.error(
title="No Team Found",
description="❌ You must be on a team to create voice channels.\n\n"
title="No Major League Team Found",
description="❌ You must own a Major League team to create private voice channels.\n\n"
"Private channels are for scheduled games between Major League teams.\n"
"Contact a league administrator if you believe this is an error."
)
await interaction.followup.send(embed=embed, ephemeral=True)

View File

@ -264,7 +264,7 @@ class TestVoiceChannelCleanupService:
# Mock guild and channel
mock_guild = MagicMock()
mock_guild.id = 999
mock_channel = AsyncMock()
mock_channel = AsyncMock(spec=discord.VoiceChannel)
mock_channel.id = 123
mock_channel.members = [] # Empty channel
@ -357,6 +357,9 @@ class TestVoiceChannelCommands:
mock_team.id = 1
mock_team.abbrev = "NYY"
mock_team.lname = "New York Yankees"
# Mock roster_type method to return MAJOR_LEAGUE for NYY
from models.team import RosterType
mock_team.roster_type.return_value = RosterType.MAJOR_LEAGUE
# Mock voice category
mock_category = MagicMock()
@ -372,7 +375,7 @@ class TestVoiceChannelCommands:
with patch.object(mock_interaction.guild, 'create_voice_channel', return_value=mock_channel) as mock_create:
with patch('commands.voice.channels.random_codename', return_value="Phoenix"):
with patch('discord.utils.get', return_value=mock_category):
mock_team_service.get_teams_by_owner.return_value = [mock_team]
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_team])
await voice_cog.create_public_channel.callback(voice_cog, mock_interaction)
@ -390,13 +393,13 @@ class TestVoiceChannelCommands:
call_args = mock_interaction.followup.send.call_args
assert 'embed' in call_args.kwargs
embed = call_args.kwargs['embed']
assert "Created public voice channel" in embed.title
assert "Voice Channel Created" in embed.title
@pytest.mark.asyncio
async def test_create_public_channel_no_team(self, voice_cog, mock_interaction):
"""Test public channel creation with no team."""
with patch('commands.voice.channels.team_service') as mock_team_service:
mock_team_service.get_teams_by_owner.return_value = []
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[])
await voice_cog.create_public_channel.callback(voice_cog, mock_interaction)
@ -408,7 +411,7 @@ class TestVoiceChannelCommands:
call_args = mock_interaction.followup.send.call_args
assert call_args.kwargs['ephemeral'] is True
embed = call_args.kwargs['embed']
assert "No Team Found" in embed.title
assert "No Major League Team Found" in embed.title
@pytest.mark.asyncio
async def test_create_private_channel_success(self, voice_cog, mock_interaction):
@ -419,6 +422,9 @@ class TestVoiceChannelCommands:
mock_user_team.abbrev = "NYY"
mock_user_team.lname = "New York Yankees"
mock_user_team.sname = "Yankees"
# Mock roster_type method to return MAJOR_LEAGUE for NYY
from models.team import RosterType
mock_user_team.roster_type.return_value = RosterType.MAJOR_LEAGUE
# Mock opponent team
mock_opponent_team = MagicMock(spec=Team)
@ -456,8 +462,8 @@ class TestVoiceChannelCommands:
with patch.object(mock_interaction.guild, 'create_voice_channel', return_value=mock_channel) as mock_create:
with patch('discord.utils.get') as mock_utils_get:
mock_team_service.get_teams_by_owner.return_value = [mock_user_team]
mock_league_service.get_current.return_value = mock_current
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_user_team])
mock_league_service.get_current_state = AsyncMock(return_value=mock_current)
mock_schedule.return_value = [mock_game]
# Mock discord.utils.get calls