Major fixes and improvements: Trade System Fixes: - Fix duplicate player moves in trade embed Player Exchanges section - Resolve "WVMiL not participating" error for Minor League destinations - Implement organizational authority model for ML/MiL/IL team relationships - Update Trade.cross_team_moves to deduplicate using moves_giving only Team Model Enhancements: - Rewrite roster_type() method using sname as definitive source per spec - Fix edge cases like "BHMIL" (Birmingham IL) vs "BHMMIL" - Update _get_base_abbrev() to use consistent sname-based logic - Add organizational lookup support in trade participation Autocomplete System: - Fix major_league_team_autocomplete invalid roster_type parameter - Implement client-side filtering using Team.roster_type() method - Add comprehensive test coverage for all autocomplete functions - Centralize autocomplete logic to shared utils functions Test Infrastructure: - Add 25 new tests for trade models and trade builder - Add 13 autocomplete function tests with error handling - Fix existing test failures with proper mocking patterns - Update dropadd tests to use shared autocomplete functions Documentation Updates: - Document trade model enhancements and deduplication fix - Add autocomplete function documentation with usage examples - Document organizational authority model and edge case handling - Update README files with recent fixes and implementation notes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
454 lines
16 KiB
Python
454 lines
16 KiB
Python
"""
|
|
Tests for trade builder service.
|
|
|
|
Tests the TradeBuilder service functionality including multi-team management,
|
|
move validation, and trade validation logic.
|
|
"""
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from services.trade_builder import (
|
|
TradeBuilder,
|
|
TradeValidationResult,
|
|
get_trade_builder,
|
|
clear_trade_builder,
|
|
_active_trade_builders
|
|
)
|
|
from models.trade import TradeStatus
|
|
from models.team import RosterType
|
|
from tests.factories import PlayerFactory, TeamFactory
|
|
|
|
|
|
class TestTradeBuilder:
|
|
"""Test TradeBuilder functionality."""
|
|
|
|
def setup_method(self):
|
|
"""Set up test fixtures."""
|
|
self.user_id = 12345
|
|
self.team1 = TeamFactory.west_virginia()
|
|
self.team2 = TeamFactory.new_york()
|
|
self.team3 = TeamFactory.create(id=3, abbrev="BOS", sname="Red Sox")
|
|
|
|
self.player1 = PlayerFactory.mike_trout()
|
|
self.player2 = PlayerFactory.mookie_betts()
|
|
|
|
# Clear any existing trade builders
|
|
_active_trade_builders.clear()
|
|
|
|
def test_trade_builder_initialization(self):
|
|
"""Test TradeBuilder initialization."""
|
|
builder = TradeBuilder(self.user_id, self.team1, season=12)
|
|
|
|
assert builder.trade.initiated_by == self.user_id
|
|
assert builder.trade.season == 12
|
|
assert builder.trade.status == TradeStatus.DRAFT
|
|
assert builder.team_count == 1 # Initiating team is added automatically
|
|
assert builder.is_empty # No moves yet
|
|
assert builder.move_count == 0
|
|
|
|
# Check that initiating team is in participants
|
|
initiating_participant = builder.trade.get_participant_by_team_id(self.team1.id)
|
|
assert initiating_participant is not None
|
|
assert initiating_participant.team == self.team1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_team(self):
|
|
"""Test adding teams to a trade."""
|
|
builder = TradeBuilder(self.user_id, self.team1, season=12)
|
|
|
|
# Add second team
|
|
success, error = await builder.add_team(self.team2)
|
|
assert success
|
|
assert error == ""
|
|
assert builder.team_count == 2
|
|
|
|
# Add third team
|
|
success, error = await builder.add_team(self.team3)
|
|
assert success
|
|
assert error == ""
|
|
assert builder.team_count == 3
|
|
assert builder.trade.is_multi_team_trade
|
|
|
|
# Try to add same team again
|
|
success, error = await builder.add_team(self.team2)
|
|
assert not success
|
|
assert "already participating" in error
|
|
assert builder.team_count == 3 # No change
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_team(self):
|
|
"""Test removing teams from a trade."""
|
|
builder = TradeBuilder(self.user_id, self.team1, season=12)
|
|
await builder.add_team(self.team2)
|
|
await builder.add_team(self.team3)
|
|
|
|
assert builder.team_count == 3
|
|
|
|
# Remove team3 (no moves)
|
|
success, error = await builder.remove_team(self.team3.id)
|
|
assert success
|
|
assert error == ""
|
|
assert builder.team_count == 2
|
|
|
|
# Try to remove non-existent team
|
|
success, error = await builder.remove_team(999)
|
|
assert not success
|
|
assert "not participating" in error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_player_move(self):
|
|
"""Test adding player moves to a trade."""
|
|
builder = TradeBuilder(self.user_id, self.team1, season=12)
|
|
await builder.add_team(self.team2)
|
|
|
|
# Add player move from team1 to team2
|
|
success, error = await builder.add_player_move(
|
|
player=self.player1,
|
|
from_team=self.team1,
|
|
to_team=self.team2,
|
|
from_roster=RosterType.MAJOR_LEAGUE,
|
|
to_roster=RosterType.MAJOR_LEAGUE
|
|
)
|
|
|
|
assert success
|
|
assert error == ""
|
|
assert not builder.is_empty
|
|
assert builder.move_count > 0
|
|
|
|
# Check that move appears in both teams' lists
|
|
team1_participant = builder.trade.get_participant_by_team_id(self.team1.id)
|
|
team2_participant = builder.trade.get_participant_by_team_id(self.team2.id)
|
|
|
|
assert len(team1_participant.moves_giving) == 1
|
|
assert len(team2_participant.moves_receiving) == 1
|
|
|
|
# Try to add same player again (should fail)
|
|
success, error = await builder.add_player_move(
|
|
player=self.player1,
|
|
from_team=self.team2,
|
|
to_team=self.team1,
|
|
from_roster=RosterType.MAJOR_LEAGUE,
|
|
to_roster=RosterType.MAJOR_LEAGUE
|
|
)
|
|
|
|
assert not success
|
|
assert "already involved" in error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_supplementary_move(self):
|
|
"""Test adding supplementary moves to a trade."""
|
|
builder = TradeBuilder(self.user_id, self.team1, season=12)
|
|
await builder.add_team(self.team2)
|
|
|
|
# Add supplementary move within team1
|
|
success, error = await builder.add_supplementary_move(
|
|
team=self.team1,
|
|
player=self.player1,
|
|
from_roster=RosterType.MINOR_LEAGUE,
|
|
to_roster=RosterType.MAJOR_LEAGUE
|
|
)
|
|
|
|
assert success
|
|
assert error == ""
|
|
|
|
# Check that move appears in team1's supplementary moves
|
|
team1_participant = builder.trade.get_participant_by_team_id(self.team1.id)
|
|
assert len(team1_participant.supplementary_moves) == 1
|
|
|
|
# Try to add supplementary move for team not in trade
|
|
success, error = await builder.add_supplementary_move(
|
|
team=self.team3,
|
|
player=self.player2,
|
|
from_roster=RosterType.MINOR_LEAGUE,
|
|
to_roster=RosterType.MAJOR_LEAGUE
|
|
)
|
|
|
|
assert not success
|
|
assert "not participating" in error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_move(self):
|
|
"""Test removing moves from a trade."""
|
|
builder = TradeBuilder(self.user_id, self.team1, season=12)
|
|
await builder.add_team(self.team2)
|
|
|
|
# Add a player move
|
|
await builder.add_player_move(
|
|
player=self.player1,
|
|
from_team=self.team1,
|
|
to_team=self.team2,
|
|
from_roster=RosterType.MAJOR_LEAGUE,
|
|
to_roster=RosterType.MAJOR_LEAGUE
|
|
)
|
|
|
|
assert not builder.is_empty
|
|
|
|
# Remove the move
|
|
success, error = await builder.remove_move(self.player1.id)
|
|
assert success
|
|
assert error == ""
|
|
|
|
# Check that move is removed from both teams
|
|
team1_participant = builder.trade.get_participant_by_team_id(self.team1.id)
|
|
team2_participant = builder.trade.get_participant_by_team_id(self.team2.id)
|
|
|
|
assert len(team1_participant.moves_giving) == 0
|
|
assert len(team2_participant.moves_receiving) == 0
|
|
|
|
# Try to remove non-existent move
|
|
success, error = await builder.remove_move(999)
|
|
assert not success
|
|
assert "No move found" in error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_trade_empty(self):
|
|
"""Test validation of empty trade."""
|
|
builder = TradeBuilder(self.user_id, self.team1, season=12)
|
|
await builder.add_team(self.team2)
|
|
|
|
# Mock the transaction builders
|
|
with patch.object(builder, '_get_or_create_builder') as mock_get_builder:
|
|
mock_builder1 = MagicMock()
|
|
mock_builder2 = MagicMock()
|
|
|
|
# Set up mock validation results
|
|
from services.transaction_builder import RosterValidationResult
|
|
|
|
valid_result = RosterValidationResult(
|
|
is_legal=True,
|
|
major_league_count=24,
|
|
minor_league_count=5,
|
|
warnings=[],
|
|
errors=[],
|
|
suggestions=[]
|
|
)
|
|
|
|
mock_builder1.validate_transaction = AsyncMock(return_value=valid_result)
|
|
mock_builder2.validate_transaction = AsyncMock(return_value=valid_result)
|
|
|
|
def get_builder_side_effect(team):
|
|
if team.id == self.team1.id:
|
|
return mock_builder1
|
|
elif team.id == self.team2.id:
|
|
return mock_builder2
|
|
return MagicMock()
|
|
|
|
mock_get_builder.side_effect = get_builder_side_effect
|
|
|
|
# Add the builders to the internal dict
|
|
builder._team_builders[self.team1.id] = mock_builder1
|
|
builder._team_builders[self.team2.id] = mock_builder2
|
|
|
|
# Validate empty trade (should have trade-level errors)
|
|
validation = await builder.validate_trade()
|
|
assert not validation.is_legal # Empty trade should be invalid
|
|
assert len(validation.trade_errors) > 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_trade_with_moves(self):
|
|
"""Test validation of trade with balanced moves."""
|
|
builder = TradeBuilder(self.user_id, self.team1, season=12)
|
|
await builder.add_team(self.team2)
|
|
|
|
# Mock the transaction builders
|
|
with patch.object(builder, '_get_or_create_builder') as mock_get_builder:
|
|
mock_builder1 = MagicMock()
|
|
mock_builder2 = MagicMock()
|
|
|
|
# Set up mock validation results
|
|
from services.transaction_builder import RosterValidationResult
|
|
|
|
valid_result = RosterValidationResult(
|
|
is_legal=True,
|
|
major_league_count=24,
|
|
minor_league_count=5,
|
|
warnings=[],
|
|
errors=[],
|
|
suggestions=[]
|
|
)
|
|
|
|
mock_builder1.validate_transaction = AsyncMock(return_value=valid_result)
|
|
mock_builder2.validate_transaction = AsyncMock(return_value=valid_result)
|
|
|
|
# Configure add_move methods to return expected tuple (success, error_message)
|
|
mock_builder1.add_move.return_value = (True, "")
|
|
mock_builder2.add_move.return_value = (True, "")
|
|
|
|
def get_builder_side_effect(team):
|
|
if team.id == self.team1.id:
|
|
return mock_builder1
|
|
elif team.id == self.team2.id:
|
|
return mock_builder2
|
|
return MagicMock()
|
|
|
|
mock_get_builder.side_effect = get_builder_side_effect
|
|
|
|
# Add the builders to the internal dict
|
|
builder._team_builders[self.team1.id] = mock_builder1
|
|
builder._team_builders[self.team2.id] = mock_builder2
|
|
|
|
# Add balanced moves
|
|
await builder.add_player_move(
|
|
player=self.player1,
|
|
from_team=self.team1,
|
|
to_team=self.team2,
|
|
from_roster=RosterType.MAJOR_LEAGUE,
|
|
to_roster=RosterType.MAJOR_LEAGUE
|
|
)
|
|
|
|
await builder.add_player_move(
|
|
player=self.player2,
|
|
from_team=self.team2,
|
|
to_team=self.team1,
|
|
from_roster=RosterType.MAJOR_LEAGUE,
|
|
to_roster=RosterType.MAJOR_LEAGUE
|
|
)
|
|
|
|
# Validate balanced trade
|
|
validation = await builder.validate_trade()
|
|
|
|
# Should be valid now (balanced trade with valid rosters)
|
|
assert validation.is_legal
|
|
assert len(validation.participant_validations) == 2
|
|
|
|
def test_clear_trade(self):
|
|
"""Test clearing a trade."""
|
|
builder = TradeBuilder(self.user_id, self.team1, season=12)
|
|
|
|
# Add some data
|
|
builder.trade.add_participant(self.team2)
|
|
team1_participant = builder.trade.get_participant_by_team_id(self.team1.id)
|
|
team1_participant.moves_giving.append(MagicMock())
|
|
|
|
assert not builder.is_empty
|
|
|
|
# Clear the trade
|
|
builder.clear_trade()
|
|
|
|
# Check that all moves are cleared
|
|
assert builder.is_empty
|
|
team1_participant = builder.trade.get_participant_by_team_id(self.team1.id)
|
|
assert len(team1_participant.moves_giving) == 0
|
|
|
|
def test_get_trade_summary(self):
|
|
"""Test trade summary generation."""
|
|
builder = TradeBuilder(self.user_id, self.team1, season=12)
|
|
|
|
# Initially just one team
|
|
summary = builder.get_trade_summary()
|
|
assert "WV" in summary
|
|
|
|
# Add second team
|
|
builder.trade.add_participant(self.team2)
|
|
summary = builder.get_trade_summary()
|
|
assert "WV" in summary and "NY" in summary
|
|
|
|
|
|
class TestTradeBuilderCache:
|
|
"""Test trade builder cache functionality."""
|
|
|
|
def setup_method(self):
|
|
"""Clear cache before each test."""
|
|
_active_trade_builders.clear()
|
|
|
|
def test_get_trade_builder(self):
|
|
"""Test getting trade builder from cache."""
|
|
user_id = 12345
|
|
team = TeamFactory.west_virginia()
|
|
|
|
# First call should create new builder
|
|
builder1 = get_trade_builder(user_id, team)
|
|
assert builder1 is not None
|
|
assert len(_active_trade_builders) == 1
|
|
|
|
# Second call should return same builder
|
|
builder2 = get_trade_builder(user_id, team)
|
|
assert builder2 is builder1
|
|
|
|
def test_clear_trade_builder(self):
|
|
"""Test clearing trade builder from cache."""
|
|
user_id = 12345
|
|
team = TeamFactory.west_virginia()
|
|
|
|
# Create builder
|
|
builder = get_trade_builder(user_id, team)
|
|
assert len(_active_trade_builders) == 1
|
|
|
|
# Clear builder
|
|
clear_trade_builder(user_id)
|
|
assert len(_active_trade_builders) == 0
|
|
|
|
# Next call should create new builder
|
|
new_builder = get_trade_builder(user_id, team)
|
|
assert new_builder is not builder
|
|
|
|
|
|
class TestTradeValidationResult:
|
|
"""Test TradeValidationResult functionality."""
|
|
|
|
def test_validation_result_aggregation(self):
|
|
"""Test aggregation of validation results."""
|
|
result = TradeValidationResult()
|
|
|
|
# Add trade-level errors
|
|
result.trade_errors = ["Trade error 1", "Trade error 2"]
|
|
result.trade_warnings = ["Trade warning 1"]
|
|
result.trade_suggestions = ["Trade suggestion 1"]
|
|
|
|
# Mock participant validations
|
|
from services.transaction_builder import RosterValidationResult
|
|
|
|
team1_validation = RosterValidationResult(
|
|
is_legal=False,
|
|
major_league_count=24,
|
|
minor_league_count=5,
|
|
warnings=["Team1 warning"],
|
|
errors=["Team1 error"],
|
|
suggestions=["Team1 suggestion"]
|
|
)
|
|
|
|
team2_validation = RosterValidationResult(
|
|
is_legal=True,
|
|
major_league_count=25,
|
|
minor_league_count=4,
|
|
warnings=[],
|
|
errors=[],
|
|
suggestions=[]
|
|
)
|
|
|
|
result.participant_validations[1] = team1_validation
|
|
result.participant_validations[2] = team2_validation
|
|
result.is_legal = False # One team has errors
|
|
|
|
# Test aggregated results
|
|
all_errors = result.all_errors
|
|
assert len(all_errors) == 3 # 2 trade + 1 team
|
|
assert "Trade error 1" in all_errors
|
|
assert "Team1 error" in all_errors
|
|
|
|
all_warnings = result.all_warnings
|
|
assert len(all_warnings) == 2 # 1 trade + 1 team
|
|
assert "Trade warning 1" in all_warnings
|
|
assert "Team1 warning" in all_warnings
|
|
|
|
all_suggestions = result.all_suggestions
|
|
assert len(all_suggestions) == 2 # 1 trade + 1 team
|
|
assert "Trade suggestion 1" in all_suggestions
|
|
assert "Team1 suggestion" in all_suggestions
|
|
|
|
# Test participant validation lookup
|
|
team1_val = result.get_participant_validation(1)
|
|
assert team1_val == team1_validation
|
|
|
|
non_existent = result.get_participant_validation(999)
|
|
assert non_existent is None
|
|
|
|
def test_validation_result_empty_state(self):
|
|
"""Test empty validation result."""
|
|
result = TradeValidationResult()
|
|
|
|
assert result.is_legal # Default is True
|
|
assert len(result.all_errors) == 0
|
|
assert len(result.all_warnings) == 0
|
|
assert len(result.all_suggestions) == 0
|
|
assert len(result.participant_validations) == 0 |