Prevents users from managing injuries for players not on their team. Admins bypass the check; org affiliates (MiL/IL) are recognized. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
246 lines
9.5 KiB
Python
246 lines
9.5 KiB
Python
"""Tests for injury command team ownership verification (issue #18).
|
|
|
|
Ensures /injury set-new and /injury clear only allow users to manage
|
|
injuries for players on their own team (or organizational affiliates).
|
|
Admins bypass the check.
|
|
"""
|
|
|
|
import discord
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from commands.injuries.management import InjuryGroup
|
|
from models.player import Player
|
|
from models.team import Team
|
|
|
|
|
|
def _make_team(team_id: int, abbrev: str, sname: str | None = None) -> Team:
|
|
"""Create a Team via model_construct to skip validation.
|
|
|
|
For MiL teams (e.g. PORMIL), pass sname explicitly to avoid the IL
|
|
disambiguation logic in _get_base_abbrev treating them as IL teams.
|
|
"""
|
|
return Team.model_construct(
|
|
id=team_id,
|
|
abbrev=abbrev,
|
|
sname=sname or abbrev,
|
|
lname=f"Team {abbrev}",
|
|
season=13,
|
|
)
|
|
|
|
|
|
def _make_player(player_id: int, name: str, team: Team) -> Player:
|
|
"""Create a Player via model_construct to skip validation."""
|
|
return Player.model_construct(
|
|
id=player_id,
|
|
name=name,
|
|
wara=2.0,
|
|
season=13,
|
|
team_id=team.id,
|
|
team=team,
|
|
)
|
|
|
|
|
|
def _make_interaction(is_admin: bool = False) -> MagicMock:
|
|
"""Create a mock Discord interaction with configurable admin status."""
|
|
interaction = MagicMock()
|
|
interaction.user = MagicMock()
|
|
interaction.user.id = 12345
|
|
interaction.user.guild_permissions = MagicMock()
|
|
interaction.user.guild_permissions.administrator = is_admin
|
|
|
|
# Make isinstance(interaction.user, discord.Member) return True
|
|
interaction.user.__class__ = discord.Member
|
|
|
|
interaction.followup = MagicMock()
|
|
interaction.followup.send = AsyncMock()
|
|
return interaction
|
|
|
|
|
|
@pytest.fixture
|
|
def injury_group():
|
|
return InjuryGroup()
|
|
|
|
|
|
class TestVerifyTeamOwnership:
|
|
"""Tests for InjuryGroup._verify_team_ownership (issue #18)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_bypasses_check(self, injury_group):
|
|
"""Admins should always pass the ownership check."""
|
|
interaction = _make_interaction(is_admin=True)
|
|
por_team = _make_team(1, "POR")
|
|
player = _make_player(100, "Mike Trout", por_team)
|
|
|
|
result = await injury_group._verify_team_ownership(interaction, player)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_owner_passes_check(self, injury_group):
|
|
"""User who owns the player's team should pass."""
|
|
interaction = _make_interaction(is_admin=False)
|
|
por_team = _make_team(1, "POR")
|
|
player = _make_player(100, "Mike Trout", por_team)
|
|
|
|
with patch("services.team_service.team_service") as mock_ts, patch(
|
|
"commands.injuries.management.get_config"
|
|
) as mock_config:
|
|
mock_config.return_value.sba_season = 13
|
|
mock_ts.get_team_by_owner = AsyncMock(return_value=por_team)
|
|
result = await injury_group._verify_team_ownership(interaction, player)
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_org_affiliate_passes_check(self, injury_group):
|
|
"""User who owns the ML team should pass for MiL/IL affiliate players."""
|
|
interaction = _make_interaction(is_admin=False)
|
|
por_ml = _make_team(1, "POR")
|
|
por_mil = _make_team(2, "PORMIL", sname="POR MiL")
|
|
player = _make_player(100, "Minor Leaguer", por_mil)
|
|
|
|
with patch("services.team_service.team_service") as mock_ts, patch(
|
|
"commands.injuries.management.get_config"
|
|
) as mock_config:
|
|
mock_config.return_value.sba_season = 13
|
|
mock_ts.get_team_by_owner = AsyncMock(return_value=por_ml)
|
|
mock_ts.get_team = AsyncMock(return_value=por_mil)
|
|
result = await injury_group._verify_team_ownership(interaction, player)
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_different_team_fails(self, injury_group):
|
|
"""User who owns a different team should be denied."""
|
|
interaction = _make_interaction(is_admin=False)
|
|
por_team = _make_team(1, "POR")
|
|
nyy_team = _make_team(2, "NYY")
|
|
player = _make_player(100, "Mike Trout", nyy_team)
|
|
|
|
with patch("services.team_service.team_service") as mock_ts, patch(
|
|
"commands.injuries.management.get_config"
|
|
) as mock_config:
|
|
mock_config.return_value.sba_season = 13
|
|
mock_ts.get_team_by_owner = AsyncMock(return_value=por_team)
|
|
mock_ts.get_team = AsyncMock(return_value=nyy_team)
|
|
result = await injury_group._verify_team_ownership(interaction, player)
|
|
|
|
assert result is False
|
|
interaction.followup.send.assert_called_once()
|
|
call_kwargs = interaction.followup.send.call_args
|
|
embed = call_kwargs.kwargs.get("embed") or call_kwargs.args[0]
|
|
assert "Not Your Player" in embed.title
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_team_owned_fails(self, injury_group):
|
|
"""User who owns no team should be denied."""
|
|
interaction = _make_interaction(is_admin=False)
|
|
nyy_team = _make_team(2, "NYY")
|
|
player = _make_player(100, "Mike Trout", nyy_team)
|
|
|
|
with patch("services.team_service.team_service") as mock_ts, patch(
|
|
"commands.injuries.management.get_config"
|
|
) as mock_config:
|
|
mock_config.return_value.sba_season = 13
|
|
mock_ts.get_team_by_owner = AsyncMock(return_value=None)
|
|
result = await injury_group._verify_team_ownership(interaction, player)
|
|
|
|
assert result is False
|
|
interaction.followup.send.assert_called_once()
|
|
call_kwargs = interaction.followup.send.call_args
|
|
embed = call_kwargs.kwargs.get("embed") or call_kwargs.args[0]
|
|
assert "No Team Found" in embed.title
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_il_affiliate_passes_check(self, injury_group):
|
|
"""User who owns the ML team should pass for IL (injured list) players."""
|
|
interaction = _make_interaction(is_admin=False)
|
|
por_ml = _make_team(1, "POR")
|
|
por_il = _make_team(3, "PORIL", sname="POR IL")
|
|
player = _make_player(100, "IL Stash", por_il)
|
|
|
|
with patch("services.team_service.team_service") as mock_ts, patch(
|
|
"commands.injuries.management.get_config"
|
|
) as mock_config:
|
|
mock_config.return_value.sba_season = 13
|
|
mock_ts.get_team_by_owner = AsyncMock(return_value=por_ml)
|
|
result = await injury_group._verify_team_ownership(interaction, player)
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_player_team_not_populated_fails(self, injury_group):
|
|
"""Player with team_id but unpopulated team object should be denied.
|
|
|
|
Callers are expected to populate player.team before calling
|
|
_verify_team_ownership. If they don't, the method treats the missing
|
|
team as a failed check rather than silently allowing access.
|
|
"""
|
|
interaction = _make_interaction(is_admin=False)
|
|
por_team = _make_team(1, "POR")
|
|
player = Player.model_construct(
|
|
id=100,
|
|
name="Orphan Player",
|
|
wara=2.0,
|
|
season=13,
|
|
team_id=99,
|
|
team=None,
|
|
)
|
|
|
|
with patch("services.team_service.team_service") as mock_ts, patch(
|
|
"commands.injuries.management.get_config"
|
|
) as mock_config:
|
|
mock_config.return_value.sba_season = 13
|
|
mock_ts.get_team_by_owner = AsyncMock(return_value=por_team)
|
|
result = await injury_group._verify_team_ownership(interaction, player)
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_embeds_are_ephemeral(self, injury_group):
|
|
"""Error responses should be ephemeral so only the invoking user sees them."""
|
|
interaction = _make_interaction(is_admin=False)
|
|
nyy_team = _make_team(2, "NYY")
|
|
player = _make_player(100, "Mike Trout", nyy_team)
|
|
|
|
with patch("services.team_service.team_service") as mock_ts, patch(
|
|
"commands.injuries.management.get_config"
|
|
) as mock_config:
|
|
mock_config.return_value.sba_season = 13
|
|
# Test "No Team Found" path
|
|
mock_ts.get_team_by_owner = AsyncMock(return_value=None)
|
|
await injury_group._verify_team_ownership(interaction, player)
|
|
|
|
call_kwargs = interaction.followup.send.call_args
|
|
assert call_kwargs.kwargs.get("ephemeral") is True
|
|
|
|
# Reset and test "Not Your Player" path
|
|
interaction = _make_interaction(is_admin=False)
|
|
por_team = _make_team(1, "POR")
|
|
|
|
with patch("services.team_service.team_service") as mock_ts, patch(
|
|
"commands.injuries.management.get_config"
|
|
) as mock_config:
|
|
mock_config.return_value.sba_season = 13
|
|
mock_ts.get_team_by_owner = AsyncMock(return_value=por_team)
|
|
await injury_group._verify_team_ownership(interaction, player)
|
|
|
|
call_kwargs = interaction.followup.send.call_args
|
|
assert call_kwargs.kwargs.get("ephemeral") is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_player_without_team_id_passes(self, injury_group):
|
|
"""Players with no team_id should pass (can't verify, allow through)."""
|
|
interaction = _make_interaction(is_admin=False)
|
|
player = Player.model_construct(
|
|
id=100,
|
|
name="Free Agent",
|
|
wara=0.0,
|
|
season=13,
|
|
team_id=None,
|
|
team=None,
|
|
)
|
|
|
|
result = await injury_group._verify_team_ownership(interaction, player)
|
|
assert result is True
|