major-domo-v2/tests/test_dropadd_integration.py
Cal Corum f29cccd3ab
Some checks failed
Build Docker Image / build (pull_request) Failing after 15s
fix: roster validation now includes pending trades and fixes sWAR field name
RosterValidation used total_wara instead of total_sWAR, causing /legal
to silently fail. Transaction embed and submit validation now pass
next_week to validate_transaction() so pending trades are included in
roster count projections. Moved lazy imports to top-level in
transaction_embed.py. Fixed dropadd integration test fixtures that
exceeded sWAR cap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 22:57:06 -06:00

607 lines
24 KiB
Python

"""
Integration tests for /dropadd functionality
Tests complete workflows from command invocation through transaction submission.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime
from commands.transactions.dropadd import DropAddCommands
from services.transaction_builder import (
TransactionBuilder,
TransactionMove,
get_transaction_builder,
clear_transaction_builder,
)
from models.team import RosterType
from views.transaction_embed import TransactionEmbedView, SubmitConfirmationModal
from models.team import Team
from models.player import Player
from models.roster import TeamRoster
from models.transaction import Transaction
from models.current import Current
from tests.factories import PlayerFactory, TeamFactory
class TestDropAddIntegration:
"""Integration tests for complete /dropadd workflows."""
@pytest.fixture
def mock_bot(self):
"""Create mock Discord bot."""
return MagicMock()
@pytest.fixture
def commands_cog(self, mock_bot):
"""Create DropAddCommands cog instance."""
return DropAddCommands(mock_bot)
@pytest.fixture
def mock_interaction(self):
"""Create mock Discord interaction."""
interaction = AsyncMock()
interaction.user = MagicMock()
interaction.user.id = 258104532423147520
interaction.response = AsyncMock()
interaction.followup = AsyncMock()
interaction.client = MagicMock()
interaction.client.user = MagicMock()
interaction.channel = MagicMock()
# Guild mock required for @league_only decorator
interaction.guild = MagicMock()
interaction.guild.id = 669356687294988350 # SBA league server ID from config
# Mock message history for embed updates
mock_message = MagicMock()
mock_message.author = interaction.client.user
mock_message.embeds = [MagicMock()]
mock_message.embeds[0].title = "📋 Transaction Builder"
mock_message.edit = AsyncMock()
interaction.channel.history.return_value.__aiter__ = AsyncMock(
return_value=iter([mock_message])
)
return interaction
@pytest.fixture
def mock_team(self):
"""Create mock team."""
return TeamFactory.west_virginia()
@pytest.fixture
def mock_players(self):
"""Create mock players."""
return [
PlayerFactory.mike_trout(),
PlayerFactory.ronald_acuna(),
PlayerFactory.mookie_betts(),
]
@pytest.fixture
def mock_roster(self):
"""Create mock team roster.
Creates a legal roster: 24 ML players (under 26 limit), 4 MiL players (under 6 limit).
This allows adding players without hitting limits.
"""
# Create 24 ML players (under limit)
ml_players = []
for i in range(24):
ml_players.append(
Player(
id=1000 + i,
name=f"ML Player {i}",
wara=1.0,
season=13,
team_id=499,
team=None,
image=None,
image2=None,
vanity_card=None,
headshot=None,
pos_1="OF",
pitcher_injury=None,
injury_rating=None,
il_return=None,
demotion_week=None,
last_game=None,
last_game2=None,
strat_code=None,
bbref_id=None,
sbaplayer=None,
)
)
# Create 4 MiL players (under 6 limit to allow adding)
mil_players = []
for i in range(4):
mil_players.append(
Player(
id=2000 + i,
name=f"MiL Player {i}",
wara=1.0 + i * 0.1,
season=13,
team_id=499,
team=None,
image=None,
image2=None,
vanity_card=None,
headshot=None,
pos_1="OF",
pitcher_injury=None,
injury_rating=None,
il_return=None,
demotion_week=None,
last_game=None,
last_game2=None,
strat_code=None,
bbref_id=None,
sbaplayer=None,
)
)
return TeamRoster(
team_id=499,
team_abbrev="TST",
week=10,
season=13,
active_players=ml_players,
minor_league_players=mil_players,
)
@pytest.fixture
def mock_current_state(self):
"""Create mock current league state."""
return Current(week=10, season=13, freeze=False)
@pytest.mark.asyncio
async def test_complete_single_move_workflow(
self, commands_cog, mock_interaction, mock_team, mock_players, mock_roster
):
"""Test complete workflow for single move transaction.
Verifies that when a player and destination are provided to /dropadd,
the command:
1. Validates user has a team via validate_user_has_team
2. Creates a transaction builder
3. Searches for the player
4. Adds the move to the builder
5. Returns an interactive embed
"""
# Clear any existing builders
clear_transaction_builder(mock_interaction.user.id)
with patch(
"commands.transactions.dropadd.validate_user_has_team"
) as mock_validate:
with patch(
"commands.transactions.dropadd.player_service"
) as mock_player_service:
with patch(
"services.transaction_builder.roster_service"
) as mock_roster_service:
with patch(
"services.transaction_builder.transaction_service"
) as mock_tx_service:
# Setup mocks
mock_validate.return_value = (
mock_team # validate_user_has_team returns team
)
mock_player_service.search_players = AsyncMock(
return_value=[mock_players[0]]
) # Mike Trout
mock_roster_service.get_current_roster = AsyncMock(
return_value=mock_roster
)
mock_tx_service.get_team_transactions = AsyncMock(
return_value=[]
)
# Mock the new pending transaction check
mock_tx_service.is_player_in_pending_transaction = AsyncMock(
return_value=(False, None)
)
# Execute /dropadd command with quick move
await commands_cog.dropadd.callback(
commands_cog,
mock_interaction,
player="Mike Trout",
destination="ml",
)
# Verify command execution
mock_interaction.response.defer.assert_called_once()
mock_interaction.followup.send.assert_called_once()
# Get the builder that was created
builder = get_transaction_builder(
mock_interaction.user.id, mock_team
)
# Verify the move was added
assert builder.move_count == 1
move = builder.moves[0]
assert move.player.name == "Mike Trout"
# Note: TransactionMove no longer has 'action' field
assert move.to_roster == RosterType.MAJOR_LEAGUE
# Verify roster validation
validation = await builder.validate_transaction()
assert validation.is_legal is True
assert validation.major_league_count == 25 # 24 + 1
@pytest.mark.asyncio
async def test_complete_multi_move_workflow(
self, commands_cog, mock_interaction, mock_team, mock_players, mock_roster
):
"""Test complete workflow for multi-move transaction.
Verifies that manually adding multiple moves to the transaction builder
correctly tracks roster changes and validates legality.
"""
clear_transaction_builder(mock_interaction.user.id)
with patch(
"commands.transactions.dropadd.validate_user_has_team"
) as mock_validate:
with patch(
"services.transaction_builder.roster_service"
) as mock_roster_service:
with patch(
"services.transaction_builder.transaction_service"
) as mock_tx_service:
mock_validate.return_value = mock_team
mock_roster_service.get_current_roster = AsyncMock(
return_value=mock_roster
)
mock_tx_service.get_team_transactions = AsyncMock(return_value=[])
# Start with /dropadd command
await commands_cog.dropadd.callback(commands_cog, mock_interaction)
# Get the builder
builder = get_transaction_builder(
mock_interaction.user.id, mock_team
)
# Manually add multiple moves (simulating UI interactions)
add_move = TransactionMove(
player=mock_players[0], # Mike Trout
from_roster=RosterType.FREE_AGENCY,
to_roster=RosterType.MAJOR_LEAGUE,
to_team=mock_team,
)
drop_move = TransactionMove(
player=mock_players[1], # Ronald Acuna Jr.
from_roster=RosterType.MAJOR_LEAGUE,
to_roster=RosterType.FREE_AGENCY,
from_team=mock_team,
)
await builder.add_move(add_move, check_pending_transactions=False)
await builder.add_move(drop_move, check_pending_transactions=False)
# Verify multi-move transaction
assert builder.move_count == 2
validation = await builder.validate_transaction()
assert validation.is_legal is True
assert validation.major_league_count == 24 # 24 + 1 - 1 = 24
@pytest.mark.asyncio
async def test_complete_submission_workflow(
self,
commands_cog,
mock_interaction,
mock_team,
mock_players,
mock_roster,
mock_current_state,
):
"""Test complete transaction submission workflow.
Verifies that submitting a transaction via the builder creates
proper Transaction objects with correct attributes.
"""
clear_transaction_builder(mock_interaction.user.id)
with patch(
"services.transaction_builder.roster_service"
) as mock_roster_service:
with patch(
"services.transaction_builder.transaction_service"
) as mock_tx_service:
# Setup mocks
mock_roster_service.get_current_roster = AsyncMock(
return_value=mock_roster
)
mock_tx_service.get_team_transactions = AsyncMock(return_value=[])
# Create builder and add move
builder = get_transaction_builder(mock_interaction.user.id, mock_team)
move = TransactionMove(
player=mock_players[0],
from_roster=RosterType.FREE_AGENCY,
to_roster=RosterType.MAJOR_LEAGUE,
to_team=mock_team,
)
await builder.add_move(move, check_pending_transactions=False)
# Test submission
transactions = await builder.submit_transaction(week=11)
# Verify transaction creation
assert len(transactions) == 1
transaction = transactions[0]
assert isinstance(transaction, Transaction)
assert transaction.player.name == "Mike Trout"
assert transaction.week == 11
assert transaction.season == 13
assert "Season-013-Week-11-" in transaction.moveid
@pytest.mark.asyncio
async def test_submission_modal_workflow(
self, mock_interaction, mock_team, mock_players, mock_roster, mock_current_state
):
"""Test submission confirmation modal workflow.
Verifies that the SubmitConfirmationModal properly:
1. Validates the "CONFIRM" input
2. Fetches current league state
3. Submits transactions
4. Posts success message
Note: The modal uses services imported at the top of views/transaction_embed.py,
so we patch them where they're used (views.transaction_embed module).
Note: Discord.py's TextInput.value is a read-only property, so we
replace the entire confirmation attribute with a MagicMock.
"""
clear_transaction_builder(mock_interaction.user.id)
with patch(
"services.transaction_builder.roster_service"
) as mock_roster_service:
with patch(
"services.transaction_builder.transaction_service"
) as mock_tx_service:
with patch(
"views.transaction_embed.league_service"
) as mock_league_service:
with patch(
"views.transaction_embed.transaction_service"
) as mock_view_tx_service:
with patch(
"utils.transaction_logging.post_transaction_to_log"
) as mock_post_log:
mock_roster_service.get_current_roster = AsyncMock(
return_value=mock_roster
)
mock_tx_service.get_team_transactions = AsyncMock(
return_value=[]
)
mock_league_service.get_current_state = AsyncMock(
return_value=mock_current_state
)
mock_post_log.return_value = None
# Create builder with move
builder = get_transaction_builder(
mock_interaction.user.id, mock_team
)
move = TransactionMove(
player=mock_players[0],
from_roster=RosterType.FREE_AGENCY,
to_roster=RosterType.MAJOR_LEAGUE,
to_team=mock_team,
)
await builder.add_move(
move, check_pending_transactions=False
)
# Submit transactions first to get move IDs
transactions = await builder.submit_transaction(
week=mock_current_state.week + 1
)
mock_view_tx_service.create_transaction_batch = AsyncMock(
return_value=transactions
)
# Reset the builder and add move again for modal test
clear_transaction_builder(mock_interaction.user.id)
builder = get_transaction_builder(
mock_interaction.user.id, mock_team
)
await builder.add_move(
move, check_pending_transactions=False
)
# Create the modal
modal = SubmitConfirmationModal(builder)
# Replace the entire confirmation input with a mock that has .value
# Discord.py's TextInput.value is read-only, so we can't patch it
mock_confirmation = MagicMock()
mock_confirmation.value = "CONFIRM"
modal.confirmation = mock_confirmation
await modal.on_submit(mock_interaction)
# Verify submission process
mock_league_service.get_current_state.assert_called()
mock_interaction.response.defer.assert_called_once_with(
ephemeral=True
)
mock_interaction.followup.send.assert_called_once()
# Verify success message
call_args = mock_interaction.followup.send.call_args
success_msg = call_args[0][0]
assert "Transaction Submitted Successfully" in success_msg
assert "Move ID:" in success_msg
@pytest.mark.asyncio
async def test_error_handling_workflow(
self, commands_cog, mock_interaction, mock_team
):
"""Test error handling throughout the workflow.
Verifies that when validate_user_has_team raises an error,
the @logged_command decorator catches it and sends an error message.
Note: The @logged_command decorator catches exceptions, logs them,
and sends an error message to the user via followup.send().
The exception is then re-raised, so we catch it in the test.
"""
clear_transaction_builder(mock_interaction.user.id)
with patch(
"commands.transactions.dropadd.validate_user_has_team"
) as mock_validate:
# Test API error handling
mock_validate.side_effect = Exception("API Error")
# The decorator catches and re-raises the exception
# We wrap in try/except to verify the error handling
try:
await commands_cog.dropadd.callback(commands_cog, mock_interaction)
except Exception:
pass # Expected - decorator re-raises after logging
# Should still defer (called before error)
mock_interaction.response.defer.assert_called_once()
@pytest.mark.asyncio
async def test_roster_validation_workflow(
self, commands_cog, mock_interaction, mock_team, mock_players
):
"""Test roster validation throughout workflow.
Verifies that the transaction builder correctly validates roster limits
and provides appropriate error messages when adding players would exceed limits.
"""
clear_transaction_builder(mock_interaction.user.id)
# Create roster at limit (26 ML players for week 10)
ml_players = []
for i in range(26):
ml_players.append(
Player(
id=1000 + i,
name=f"ML Player {i}",
wara=1.0,
season=13,
team_id=499,
team=None,
image=None,
image2=None,
vanity_card=None,
headshot=None,
pos_1="OF",
pitcher_injury=None,
injury_rating=None,
il_return=None,
demotion_week=None,
last_game=None,
last_game2=None,
strat_code=None,
bbref_id=None,
sbaplayer=None,
)
)
full_roster = TeamRoster(
team_id=499,
team_abbrev="TST",
week=10,
season=13,
active_players=ml_players,
)
with patch(
"services.transaction_builder.roster_service"
) as mock_roster_service:
with patch(
"services.transaction_builder.transaction_service"
) as mock_tx_service:
mock_roster_service.get_current_roster = AsyncMock(
return_value=full_roster
)
mock_tx_service.get_team_transactions = AsyncMock(return_value=[])
# Create builder and try to add player (should exceed limit)
builder = get_transaction_builder(mock_interaction.user.id, mock_team)
move = TransactionMove(
player=mock_players[0],
from_roster=RosterType.FREE_AGENCY,
to_roster=RosterType.MAJOR_LEAGUE,
to_team=mock_team,
)
await builder.add_move(move, check_pending_transactions=False)
# Test validation
validation = await builder.validate_transaction()
assert validation.is_legal is False
assert validation.major_league_count == 27 # Over limit (26 + 1 added)
assert len(validation.errors) > 0
assert "27 players (limit: 26)" in validation.errors[0]
assert len(validation.suggestions) > 0
assert "Drop 1 ML player" in validation.suggestions[0]
@pytest.mark.asyncio
async def test_builder_persistence_workflow(
self, commands_cog, mock_interaction, mock_team, mock_players, mock_roster
):
"""Test that transaction builder persists across command calls.
Verifies that calling /dropadd multiple times uses the same
TransactionBuilder instance, preserving moves between calls.
"""
clear_transaction_builder(mock_interaction.user.id)
with patch(
"commands.transactions.dropadd.validate_user_has_team"
) as mock_validate:
with patch(
"services.transaction_builder.roster_service"
) as mock_roster_service:
with patch(
"services.transaction_builder.transaction_service"
) as mock_tx_service:
mock_validate.return_value = mock_team
mock_roster_service.get_current_roster = AsyncMock(
return_value=mock_roster
)
mock_tx_service.get_team_transactions = AsyncMock(return_value=[])
# First command call
await commands_cog.dropadd.callback(commands_cog, mock_interaction)
builder1 = get_transaction_builder(
mock_interaction.user.id, mock_team
)
# Add a move
move = TransactionMove(
player=mock_players[0],
from_roster=RosterType.FREE_AGENCY,
to_roster=RosterType.MAJOR_LEAGUE,
to_team=mock_team,
)
await builder1.add_move(move, check_pending_transactions=False)
assert builder1.move_count == 1
# Second command call should get same builder
await commands_cog.dropadd.callback(commands_cog, mock_interaction)
builder2 = get_transaction_builder(
mock_interaction.user.id, mock_team
)
# Should be same instance with same moves
assert builder1 is builder2
assert builder2.move_count == 1
assert builder2.moves[0].player.name == "Mike Trout"