""" Comprehensive tests for Draft Services Tests cover: - DraftService: Draft configuration and state management - DraftPickService: Draft pick CRUD operations - DraftListService: Auto-draft queue management API Specification Reference: - GET /api/v3/draftdata - Returns draft configuration - PATCH /api/v3/draftdata/{id} - Updates draft config (query params) - GET /api/v3/draftpicks - Query draft picks with filters - GET /api/v3/draftpicks/{id} - Get single pick - PATCH /api/v3/draftpicks/{id} - Update pick (full model body required) - GET /api/v3/draftlist - Get team draft lists - POST /api/v3/draftlist - Bulk replace team draft list - DELETE /api/v3/draftlist/team/{id} - Clear team draft list """ import pytest from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch from services.draft_service import DraftService, draft_service from services.draft_pick_service import DraftPickService, draft_pick_service from services.draft_list_service import DraftListService, draft_list_service from models.draft_data import DraftData from models.draft_pick import DraftPick from models.draft_list import DraftList from models.team import Team from models.player import Player from exceptions import APIException # ============================================================================= # Test Data Helpers # ============================================================================= def create_draft_data(**overrides) -> dict: """ Create complete draft data matching API response format. API returns: id, currentpick, timer, paused, pick_deadline, result_channel, ping_channel, pick_minutes """ base_data = { 'id': 1, 'currentpick': 25, 'timer': True, 'paused': False, # New field for draft pause feature 'pick_deadline': (datetime.now() + timedelta(minutes=10)).isoformat(), 'result_channel': '123456789012345678', # API returns as string 'ping_channel': '987654321098765432', # API returns as string 'pick_minutes': 2 } base_data.update(overrides) return base_data def create_team_data(team_id: int, abbrev: str = "TST", **overrides) -> dict: """Create complete team data for nested objects (matches Team model requirements).""" base_data = { 'id': team_id, 'abbrev': abbrev, 'sname': f'{abbrev}', # Required: short name 'lname': f'{abbrev} Team', # Required: long name 'season': 12, 'division_id': 1, 'gmid': 100 + team_id, 'thumbnail': f'https://example.com/team{team_id}.png' } base_data.update(overrides) return base_data def create_player_data(player_id: int, name: str = "Test Player", **overrides) -> dict: """Create complete player data for nested objects.""" base_data = { 'id': player_id, 'name': name, 'wara': 2.5, 'season': 12, 'team_id': 1, 'image': f'https://example.com/player{player_id}.jpg', 'pos_1': 'SS' } base_data.update(overrides) return base_data def create_draft_pick_data( pick_id: int, season: int = 12, overall: int = 1, round_num: int = 1, player_id: int = None, include_nested: bool = True, **overrides ) -> dict: """ Create complete draft pick data matching API response format. API returns nested team and player objects when short_output=False. """ base_data = { 'id': pick_id, 'season': season, 'overall': overall, 'round': round_num, 'origowner_id': 1, 'owner_id': 1, 'player_id': player_id } if include_nested: base_data['origowner'] = create_team_data(1, 'WV') base_data['owner'] = create_team_data(1, 'WV') if player_id: base_data['player'] = create_player_data(player_id, f'Player {player_id}') base_data.update(overrides) return base_data def create_draft_list_data( entry_id: int, season: int = 12, team_id: int = 1, player_id: int = 100, rank: int = 1, **overrides ) -> dict: """ Create complete draft list entry matching API response format. API returns nested team and player objects. """ base_data = { 'id': entry_id, 'season': season, 'rank': rank, 'team': create_team_data(team_id, 'WV'), 'player': create_player_data(player_id, f'Target Player {player_id}') } base_data.update(overrides) return base_data # ============================================================================= # DraftService Tests # ============================================================================= class TestDraftService: """Tests for DraftService - draft configuration and state management.""" @pytest.fixture def mock_client(self): """Create mock API client.""" return AsyncMock() @pytest.fixture def service(self, mock_client): """Create DraftService with mocked client.""" svc = DraftService() svc._client = mock_client return svc # ------------------------------------------------------------------------- # get_draft_data() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_draft_data_success(self, service, mock_client): """ Test successful retrieval of draft data. Verifies: - GET /draftdata endpoint is called - Response is parsed into DraftData model - All fields are correctly populated """ mock_data = create_draft_data(currentpick=42, timer=True, pick_minutes=5) mock_client.get.return_value = {'count': 1, 'draftdata': [mock_data]} result = await service.get_draft_data() assert result is not None assert isinstance(result, DraftData) assert result.currentpick == 42 assert result.timer is True assert result.pick_minutes == 5 mock_client.get.assert_called_once_with('draftdata', params=None) @pytest.mark.asyncio async def test_get_draft_data_not_found(self, service, mock_client): """ Test handling when no draft data exists. Verifies graceful handling when API returns empty list. """ mock_client.get.return_value = {'count': 0, 'draftdata': []} result = await service.get_draft_data() assert result is None @pytest.mark.asyncio async def test_get_draft_data_api_error(self, service, mock_client): """ Test error handling when API call fails. Verifies service returns None on exception rather than crashing. """ mock_client.get.side_effect = APIException("API unavailable") result = await service.get_draft_data() assert result is None @pytest.mark.asyncio async def test_get_draft_data_channel_id_conversion(self, service, mock_client): """ Test that channel IDs are converted from string to int. Database stores channel IDs as strings, but we need integers for Discord. """ mock_data = create_draft_data( result_channel='123456789012345678', ping_channel='987654321098765432' ) mock_client.get.return_value = {'count': 1, 'draftdata': [mock_data]} result = await service.get_draft_data() assert result.result_channel == 123456789012345678 assert result.ping_channel == 987654321098765432 # ------------------------------------------------------------------------- # update_draft_data() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_update_draft_data_success(self, service, mock_client): """ Test successful draft data update. Verifies: - PATCH is called with query parameters (not JSON body) - Updated data is returned """ updated_data = create_draft_data(currentpick=50, timer=False) mock_client.patch.return_value = updated_data result = await service.update_draft_data( draft_id=1, updates={'currentpick': 50, 'timer': False} ) assert result is not None assert result.currentpick == 50 assert result.timer is False mock_client.patch.assert_called_once_with( 'draftdata', {'currentpick': 50, 'timer': False}, 1, use_query_params=True ) @pytest.mark.asyncio async def test_update_draft_data_failure(self, service, mock_client): """ Test handling of failed update. Verifies service returns None when PATCH fails. """ mock_client.patch.return_value = None result = await service.update_draft_data(draft_id=1, updates={'timer': True}) assert result is None # ------------------------------------------------------------------------- # set_timer() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_set_timer_enable(self, service, mock_client): """ Test enabling the draft timer. Verifies: - Timer is set to True - Pick deadline is calculated based on pick_minutes """ # First call gets current draft data for pick_minutes current_data = create_draft_data(pick_minutes=3, timer=False) mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]} # Second call updates the draft data updated_data = create_draft_data(timer=True) mock_client.patch.return_value = updated_data result = await service.set_timer(draft_id=1, active=True) assert result is not None assert result.timer is True # Verify patch was called with timer=True and a pick_deadline patch_call = mock_client.patch.call_args assert patch_call[0][1]['timer'] is True assert 'pick_deadline' in patch_call[0][1] @pytest.mark.asyncio async def test_set_timer_disable(self, service, mock_client): """ Test disabling the draft timer. Verifies: - Timer is set to False - Pick deadline is set far in the future """ updated_data = create_draft_data(timer=False) mock_client.patch.return_value = updated_data result = await service.set_timer(draft_id=1, active=False) assert result is not None # Verify pick_deadline is set far in future (690 days) patch_call = mock_client.patch.call_args deadline = patch_call[0][1]['pick_deadline'] assert deadline > datetime.now() + timedelta(days=600) @pytest.mark.asyncio async def test_set_timer_with_custom_minutes(self, service, mock_client): """ Test setting timer with custom pick_minutes. Verifies custom pick_minutes is passed to update. """ updated_data = create_draft_data(timer=True, pick_minutes=10) mock_client.patch.return_value = updated_data result = await service.set_timer(draft_id=1, active=True, pick_minutes=10) patch_call = mock_client.patch.call_args assert patch_call[0][1]['pick_minutes'] == 10 # ------------------------------------------------------------------------- # advance_pick() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_advance_pick_to_next(self, service, mock_client): """ Test advancing to the next unfilled pick. Verifies: - Service finds next pick without a player - Draft data is updated with new currentpick """ # Mock config at the correct import location (inside the method) with patch('config.get_config') as mock_config: config = MagicMock() config.sba_season = 12 config.draft_total_picks = 512 mock_config.return_value = config # Mock draft_pick_service at the module level with patch('services.draft_pick_service.draft_pick_service') as mock_pick_service: unfilled_pick = DraftPick(**create_draft_pick_data( pick_id=26, overall=26, player_id=None, include_nested=False )) mock_pick_service.get_pick = AsyncMock(return_value=unfilled_pick) # Current draft data has timer active current_data = create_draft_data(currentpick=25, timer=True, pick_minutes=2) mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]} # Update returns new state updated_data = create_draft_data(currentpick=26) mock_client.patch.return_value = updated_data result = await service.advance_pick(draft_id=1, current_pick=25) assert result is not None assert result.currentpick == 26 @pytest.mark.asyncio async def test_advance_pick_skips_filled_picks(self, service, mock_client): """ Test that advance_pick skips over already-filled picks. Verifies picks with player_id are skipped until an empty pick is found. """ with patch('config.get_config') as mock_config: config = MagicMock() config.sba_season = 12 config.draft_total_picks = 512 mock_config.return_value = config with patch('services.draft_pick_service.draft_pick_service') as mock_pick_service: # Picks 26-28 are filled, 29 is empty async def get_pick_side_effect(season, overall): if overall <= 28: return DraftPick(**create_draft_pick_data( pick_id=overall, overall=overall, player_id=overall * 10, include_nested=False )) else: return DraftPick(**create_draft_pick_data( pick_id=overall, overall=overall, player_id=None, include_nested=False )) mock_pick_service.get_pick = AsyncMock(side_effect=get_pick_side_effect) current_data = create_draft_data(currentpick=25, timer=True) mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]} updated_data = create_draft_data(currentpick=29) mock_client.patch.return_value = updated_data result = await service.advance_pick(draft_id=1, current_pick=25) # Should have jumped to pick 29 (skipping 26, 27, 28) patch_call = mock_client.patch.call_args assert patch_call[0][1]['currentpick'] == 29 # ------------------------------------------------------------------------- # update_channels() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_update_channels(self, service, mock_client): """ Test updating draft Discord channel configuration. Verifies both ping_channel and result_channel can be updated. """ updated_data = create_draft_data() mock_client.patch.return_value = updated_data result = await service.update_channels( draft_id=1, ping_channel_id=111111111111111111, result_channel_id=222222222222222222 ) assert result is not None patch_call = mock_client.patch.call_args assert patch_call[0][1]['ping_channel'] == 111111111111111111 assert patch_call[0][1]['result_channel'] == 222222222222222222 # ------------------------------------------------------------------------- # pause_draft() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_pause_draft_success(self, service, mock_client): """ Test successfully pausing the draft. Verifies: - PATCH is called with paused=True, timer=False, and far-future deadline - Updated draft data with paused=True is returned - Timer is stopped when draft is paused (prevents deadline expiry during pause) """ updated_data = create_draft_data(paused=True, timer=False) mock_client.patch.return_value = updated_data result = await service.pause_draft(draft_id=1) assert result is not None assert result.paused is True assert result.timer is False # Verify PATCH was called with all pause-related updates patch_call = mock_client.patch.call_args patch_data = patch_call[0][1] assert patch_data['paused'] is True assert patch_data['timer'] is False assert 'pick_deadline' in patch_data # Far-future deadline set @pytest.mark.asyncio async def test_pause_draft_failure(self, service, mock_client): """ Test handling of failed pause operation. Verifies service returns None when PATCH fails. """ mock_client.patch.return_value = None result = await service.pause_draft(draft_id=1) assert result is None @pytest.mark.asyncio async def test_pause_draft_api_error(self, service, mock_client): """ Test error handling when pause API call fails. Verifies service returns None on exception rather than crashing. """ mock_client.patch.side_effect = Exception("API unavailable") result = await service.pause_draft(draft_id=1) assert result is None # ------------------------------------------------------------------------- # resume_draft() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_resume_draft_success(self, service, mock_client): """ Test successfully resuming the draft. Verifies: - Current draft data is fetched to get pick_minutes - PATCH is called with paused=False, timer=True, and fresh deadline - Timer is restarted when draft is resumed """ # First call: get_draft_data to fetch pick_minutes current_data = create_draft_data(paused=True, timer=False, pick_minutes=5) mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]} # Second call: patch returns updated data updated_data = create_draft_data(paused=False, timer=True, pick_minutes=5) mock_client.patch.return_value = updated_data result = await service.resume_draft(draft_id=1) assert result is not None assert result.paused is False assert result.timer is True # Verify PATCH was called with all resume-related updates patch_call = mock_client.patch.call_args patch_data = patch_call[0][1] assert patch_data['paused'] is False assert patch_data['timer'] is True assert 'pick_deadline' in patch_data # Fresh deadline set @pytest.mark.asyncio async def test_resume_draft_failure(self, service, mock_client): """ Test handling of failed resume operation. Verifies service returns None when PATCH fails. """ # First call: get_draft_data succeeds current_data = create_draft_data(paused=True, timer=False) mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]} # PATCH fails mock_client.patch.return_value = None result = await service.resume_draft(draft_id=1) assert result is None @pytest.mark.asyncio async def test_resume_draft_api_error(self, service, mock_client): """ Test error handling when resume API call fails. Verifies service returns None on exception rather than crashing. """ # First call: get_draft_data succeeds current_data = create_draft_data(paused=True, timer=False) mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]} # PATCH fails with exception mock_client.patch.side_effect = Exception("API unavailable") result = await service.resume_draft(draft_id=1) assert result is None @pytest.mark.asyncio async def test_pause_resume_roundtrip(self, service, mock_client): """ Test pausing and then resuming the draft. Verifies the complete pause/resume workflow: 1. Pause stops the timer 2. Resume restarts the timer with fresh deadline """ # First pause - timer should be stopped paused_data = create_draft_data(paused=True, timer=False) mock_client.patch.return_value = paused_data pause_result = await service.pause_draft(draft_id=1) assert pause_result.paused is True assert pause_result.timer is False # Then resume - timer should be restarted # resume_draft first fetches current data to get pick_minutes mock_client.get.return_value = {'count': 1, 'draftdata': [paused_data]} resumed_data = create_draft_data(paused=False, timer=True) mock_client.patch.return_value = resumed_data resume_result = await service.resume_draft(draft_id=1) assert resume_result.paused is False assert resume_result.timer is True # ============================================================================= # DraftPickService Tests # ============================================================================= class TestDraftPickService: """Tests for DraftPickService - draft pick CRUD operations.""" @pytest.fixture def mock_client(self): """Create mock API client.""" return AsyncMock() @pytest.fixture def service(self, mock_client): """Create DraftPickService with mocked client.""" svc = DraftPickService() svc._client = mock_client return svc # ------------------------------------------------------------------------- # get_pick() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_pick_success(self, service, mock_client): """ Test successful retrieval of a specific pick. Verifies: - Correct query params are sent (season, overall) - Pick is parsed into DraftPick model """ # Use include_nested=False to avoid Team validation complexity pick_data = create_draft_pick_data(pick_id=42, overall=42, round_num=3, include_nested=False) # API returns data under 'draftpicks' key (matches endpoint name) mock_client.get.return_value = {'count': 1, 'draftpicks': [pick_data]} result = await service.get_pick(season=12, overall=42) assert result is not None assert isinstance(result, DraftPick) assert result.overall == 42 assert result.round == 3 mock_client.get.assert_called_once() # BaseService calls get(endpoint, params=params) call_kwargs = mock_client.get.call_args[1] assert 'params' in call_kwargs call_params = call_kwargs['params'] assert ('season', '12') in call_params assert ('overall', '42') in call_params @pytest.mark.asyncio async def test_get_pick_not_found(self, service, mock_client): """ Test handling when pick doesn't exist. Verifies service returns None for non-existent picks. """ mock_client.get.return_value = {'count': 0, 'draftpicks': []} result = await service.get_pick(season=12, overall=999) assert result is None # ------------------------------------------------------------------------- # get_picks_by_team() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_picks_by_team(self, service, mock_client): """ Test retrieving picks owned by a specific team. Verifies: - Correct filter params: owner_team_id, pick_round_start, pick_round_end - Multiple picks are returned as list """ picks_data = [ create_draft_pick_data(pick_id=i, overall=i, round_num=1, include_nested=False) for i in range(1, 4) ] mock_client.get.return_value = {'count': 3, 'draftpicks': picks_data} result = await service.get_picks_by_team( season=12, team_id=1, round_start=1, round_end=5 ) assert len(result) == 3 assert all(isinstance(p, DraftPick) for p in result) call_params = mock_client.get.call_args[1]['params'] assert ('owner_team_id', '1') in call_params assert ('pick_round_start', '1') in call_params assert ('pick_round_end', '5') in call_params assert ('sort', 'order-asc') in call_params # ------------------------------------------------------------------------- # get_picks_by_round() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_picks_by_round(self, service, mock_client): """ Test retrieving all picks in a specific round. Verifies pick_round_start and pick_round_end are both set to same value. """ picks_data = [ create_draft_pick_data(pick_id=i, overall=i, round_num=3, include_nested=False) for i in range(33, 49) # Round 3 picks ] mock_client.get.return_value = {'count': 16, 'draftpicks': picks_data} result = await service.get_picks_by_round(season=12, round_num=3) assert len(result) == 16 call_params = mock_client.get.call_args[1]['params'] assert ('pick_round_start', '3') in call_params assert ('pick_round_end', '3') in call_params @pytest.mark.asyncio async def test_get_picks_by_round_exclude_taken(self, service, mock_client): """ Test filtering out already-taken picks. Verifies player_taken=false filter is applied. """ mock_client.get.return_value = {'count': 0, 'draftpicks': []} await service.get_picks_by_round(season=12, round_num=3, include_taken=False) call_params = mock_client.get.call_args[1]['params'] assert ('player_taken', 'false') in call_params # ------------------------------------------------------------------------- # get_available_picks() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_available_picks(self, service, mock_client): """ Test retrieving picks that haven't been selected yet. Verifies player_taken=false filter is always applied. """ picks_data = [ create_draft_pick_data(pick_id=i, overall=i, player_id=None, include_nested=False) for i in range(50, 55) ] mock_client.get.return_value = {'count': 5, 'draftpicks': picks_data} result = await service.get_available_picks(season=12) assert len(result) == 5 assert all(p.player_id is None for p in result) call_params = mock_client.get.call_args[1]['params'] assert ('player_taken', 'false') in call_params @pytest.mark.asyncio async def test_get_available_picks_with_range(self, service, mock_client): """ Test filtering available picks by overall range. Verifies overall_start and overall_end params are passed. """ mock_client.get.return_value = {'count': 0, 'draftpicks': []} await service.get_available_picks( season=12, overall_start=100, overall_end=150 ) call_params = mock_client.get.call_args[1]['params'] assert ('overall_start', '100') in call_params assert ('overall_end', '150') in call_params # ------------------------------------------------------------------------- # get_recent_picks() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_recent_picks(self, service, mock_client): """ Test retrieving recently made picks. Verifies: - overall_end is set to current-1 (exclude current pick) - player_taken=true (only filled picks) - sort=order-desc (most recent first) - limit is applied """ picks_data = [ create_draft_pick_data(pick_id=i, overall=i, player_id=i*10, include_nested=False) for i in range(45, 50) ] mock_client.get.return_value = {'count': 5, 'draftpicks': picks_data} result = await service.get_recent_picks(season=12, overall_end=50, limit=5) assert len(result) == 5 call_params = mock_client.get.call_args[1]['params'] assert ('overall_end', '49') in call_params # 50 - 1 assert ('player_taken', 'true') in call_params assert ('sort', 'order-desc') in call_params assert ('limit', '5') in call_params # ------------------------------------------------------------------------- # get_upcoming_picks() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_upcoming_picks(self, service, mock_client): """ Test retrieving upcoming picks after current. Verifies: - overall_start is set to current+1 (exclude current pick) - sort=order-asc (chronological) - limit is applied """ picks_data = [ create_draft_pick_data(pick_id=i, overall=i, player_id=None, include_nested=False) for i in range(51, 56) ] mock_client.get.return_value = {'count': 5, 'draftpicks': picks_data} result = await service.get_upcoming_picks(season=12, overall_start=50, limit=5) assert len(result) == 5 call_params = mock_client.get.call_args[1]['params'] assert ('overall_start', '51') in call_params # 50 + 1 assert ('sort', 'order-asc') in call_params assert ('limit', '5') in call_params # ------------------------------------------------------------------------- # update_pick_selection() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_update_pick_selection_success(self, service, mock_client): """ Test successfully selecting a player for a pick. CRITICAL: API requires full DraftPickModel body, not partial update. Service must first GET the pick, then send complete model. """ # First call: get_by_id to retrieve current pick (no nested objects) current_pick_data = create_draft_pick_data( pick_id=42, overall=42, round_num=3, player_id=None, include_nested=False ) # Second call: patch with full model returns updated pick updated_pick_data = create_draft_pick_data( pick_id=42, overall=42, round_num=3, player_id=999, include_nested=False ) mock_client.get.return_value = current_pick_data mock_client.patch.return_value = updated_pick_data result = await service.update_pick_selection(pick_id=42, player_id=999) assert result is not None assert result.player_id == 999 # Verify PATCH was called with full model (not just player_id) patch_call = mock_client.patch.call_args patch_data = patch_call[0][1] assert patch_data['player_id'] == 999 assert patch_data['overall'] == 42 assert patch_data['round'] == 3 assert patch_data['season'] == 12 assert patch_data['origowner_id'] == 1 assert patch_data['owner_id'] == 1 @pytest.mark.asyncio async def test_update_pick_selection_pick_not_found(self, service, mock_client): """ Test handling when pick doesn't exist. Verifies service returns None and doesn't attempt PATCH. """ mock_client.get.return_value = None result = await service.update_pick_selection(pick_id=999, player_id=100) assert result is None mock_client.patch.assert_not_called() # ------------------------------------------------------------------------- # clear_pick_selection() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_clear_pick_selection_success(self, service, mock_client): """ Test clearing a player selection from a pick. Used for admin wipe operations. Must send full model with player_id=None. """ current_pick_data = create_draft_pick_data( pick_id=42, overall=42, round_num=3, player_id=999, include_nested=False ) cleared_pick_data = create_draft_pick_data( pick_id=42, overall=42, round_num=3, player_id=None, include_nested=False ) mock_client.get.return_value = current_pick_data mock_client.patch.return_value = cleared_pick_data result = await service.clear_pick_selection(pick_id=42) assert result is not None assert result.player_id is None # Verify full model sent with player_id=None patch_call = mock_client.patch.call_args patch_data = patch_call[0][1] assert patch_data['player_id'] is None assert 'overall' in patch_data # Full model required @pytest.mark.asyncio async def test_get_skipped_picks_for_team_success(self, service, mock_client): """ Test retrieving skipped picks for a team. Skipped picks are picks before the current overall that have no player selected. Returns picks ordered by overall (ascending) so earliest skipped pick is first. """ # Team 5 has two skipped picks (overall 10 and 15) before current pick 25 skipped_pick_1 = create_draft_pick_data( pick_id=10, overall=10, round_num=1, player_id=None, owner_team_id=5, include_nested=False ) skipped_pick_2 = create_draft_pick_data( pick_id=15, overall=15, round_num=1, player_id=None, owner_team_id=5, include_nested=False ) mock_client.get.return_value = { 'count': 2, 'picks': [skipped_pick_1, skipped_pick_2] } result = await service.get_skipped_picks_for_team( season=12, team_id=5, current_overall=25 ) # Verify results assert len(result) == 2 assert result[0].overall == 10 # Earliest skipped pick first assert result[1].overall == 15 assert result[0].player_id is None assert result[1].player_id is None # Verify API call mock_client.get.assert_called_once() call_args = mock_client.get.call_args params = call_args[1]['params'] # Should request picks before current (overall_end=24), owned by team, with no player assert ('overall_end', '24') in params assert ('owner_team_id', '5') in params assert ('player_taken', 'false') in params @pytest.mark.asyncio async def test_get_skipped_picks_for_team_none_found(self, service, mock_client): """ Test when team has no skipped picks. Returns empty list when all prior picks have been made. """ mock_client.get.return_value = { 'count': 0, 'picks': [] } result = await service.get_skipped_picks_for_team( season=12, team_id=5, current_overall=25 ) assert result == [] @pytest.mark.asyncio async def test_get_skipped_picks_for_team_api_error(self, service, mock_client): """ Test graceful handling of API errors. Returns empty list on error rather than raising exception. """ mock_client.get.side_effect = Exception("API Error") result = await service.get_skipped_picks_for_team( season=12, team_id=5, current_overall=25 ) # Should return empty list on error, not raise assert result == [] # ============================================================================= # DraftListService Tests # ============================================================================= class TestDraftListService: """Tests for DraftListService - auto-draft queue management.""" @pytest.fixture def mock_client(self): """Create mock API client.""" return AsyncMock() @pytest.fixture def service(self, mock_client): """Create DraftListService with mocked client.""" svc = DraftListService() svc._client = mock_client # Also mock get_client to return the same mock for POST/DELETE operations svc.get_client = AsyncMock(return_value=mock_client) return svc # ------------------------------------------------------------------------- # get_team_list() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_team_list_success(self, service, mock_client): """ Test retrieving a team's draft list. Verifies: - Correct query params (season, team_id) - Results are sorted by rank (client-side since API doesn't support sort) """ list_data = [ create_draft_list_data(entry_id=3, rank=3, player_id=103), create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), ] mock_client.get.return_value = {'count': 3, 'picks': list_data} result = await service.get_team_list(season=12, team_id=1) assert len(result) == 3 # Verify sorted by rank (client-side sorting) assert result[0].rank == 1 assert result[1].rank == 2 assert result[2].rank == 3 call_params = mock_client.get.call_args[1]['params'] assert ('season', '12') in call_params assert ('team_id', '1') in call_params # sort param should NOT be sent (API doesn't support it) assert not any(p[0] == 'sort' for p in call_params) @pytest.mark.asyncio async def test_get_team_list_empty(self, service, mock_client): """ Test handling when team has no draft list. Verifies empty list is returned, not None. """ mock_client.get.return_value = {'count': 0, 'picks': []} result = await service.get_team_list(season=12, team_id=1) assert result == [] # ------------------------------------------------------------------------- # add_to_list() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_add_to_list_at_end(self, service, mock_client): """ Test adding a player to end of draft list. Verifies: - Existing list is fetched first - New entry is added with rank = len(current) + 1 - Full list is POSTed (bulk replacement pattern) """ # Existing list has 2 entries existing_list = [ create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), ] mock_client.get.return_value = {'count': 2, 'picks': existing_list} # After POST, return updated list with 3 entries updated_list = existing_list + [ create_draft_list_data(entry_id=3, rank=3, player_id=103) ] # First get returns existing, second get returns updated (for verification) mock_client.get.side_effect = [ {'count': 2, 'picks': existing_list}, {'count': 3, 'picks': updated_list} ] mock_client.post.return_value = "Inserted 3 list values" result = await service.add_to_list( season=12, team_id=1, player_id=103, rank=None # rank=None means add to end ) assert result is not None assert len(result) == 3 # Verify POST payload structure post_call = mock_client.post.call_args payload = post_call[0][1] assert 'draft_list' in payload assert payload['count'] == 3 # New entry should have rank 3 new_entry = [e for e in payload['draft_list'] if e['player_id'] == 103][0] assert new_entry['rank'] == 3 @pytest.mark.asyncio async def test_add_to_list_at_position(self, service, mock_client): """ Test adding a player at a specific position. Verifies existing entries at/after that position are shifted down. """ existing_list = [ create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), ] updated_list = [ create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=3, rank=2, player_id=103), # Inserted create_draft_list_data(entry_id=2, rank=3, player_id=102), # Shifted ] mock_client.get.side_effect = [ {'count': 2, 'picks': existing_list}, {'count': 3, 'picks': updated_list} ] mock_client.post.return_value = "Inserted 3 list values" result = await service.add_to_list( season=12, team_id=1, player_id=103, rank=2 # Insert at position 2 ) assert result is not None # Verify ranks were adjusted post_call = mock_client.post.call_args payload = post_call[0][1] entries_by_player = {e['player_id']: e for e in payload['draft_list']} assert entries_by_player[101]['rank'] == 1 # Unchanged assert entries_by_player[103]['rank'] == 2 # Inserted assert entries_by_player[102]['rank'] == 3 # Shifted from 2 to 3 # ------------------------------------------------------------------------- # remove_player_from_list() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_remove_player_from_list_success(self, service, mock_client): """ Test removing a player from draft list. Verifies: - Player is removed - Remaining entries have ranks re-normalized (1, 2, 3...) """ existing_list = [ create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), create_draft_list_data(entry_id=3, rank=3, player_id=103), ] mock_client.get.return_value = {'count': 3, 'picks': existing_list} mock_client.post.return_value = "Inserted 2 list values" result = await service.remove_player_from_list( season=12, team_id=1, player_id=102 # Remove middle player ) assert result is True # Verify POST payload has player removed and ranks adjusted post_call = mock_client.post.call_args payload = post_call[0][1] assert payload['count'] == 2 player_ids = [e['player_id'] for e in payload['draft_list']] assert 102 not in player_ids # Verify ranks are re-normalized entries = sorted(payload['draft_list'], key=lambda e: e['rank']) assert entries[0]['player_id'] == 101 assert entries[0]['rank'] == 1 assert entries[1]['player_id'] == 103 assert entries[1]['rank'] == 2 # Was 3, now 2 @pytest.mark.asyncio async def test_remove_player_not_found(self, service, mock_client): """ Test removing a player who isn't in the list. Verifies False is returned and no POST is made. """ existing_list = [ create_draft_list_data(entry_id=1, rank=1, player_id=101), ] mock_client.get.return_value = {'count': 1, 'picks': existing_list} result = await service.remove_player_from_list( season=12, team_id=1, player_id=999 # Not in list ) assert result is False mock_client.post.assert_not_called() # ------------------------------------------------------------------------- # clear_list() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_clear_list_success(self, service, mock_client): """ Test clearing entire draft list for a team. Verifies DELETE /draftlist/team/{team_id} is called. """ existing_list = [ create_draft_list_data(entry_id=i, rank=i, player_id=100+i) for i in range(1, 6) ] mock_client.get.return_value = {'count': 5, 'picks': existing_list} mock_client.delete.return_value = "Deleted 5 list values" result = await service.clear_list(season=12, team_id=1) assert result is True mock_client.delete.assert_called_once_with('draftlist/team/1') @pytest.mark.asyncio async def test_clear_list_already_empty(self, service, mock_client): """ Test clearing an already-empty draft list. Verifies DELETE is not called when list is already empty. """ mock_client.get.return_value = {'count': 0, 'picks': []} result = await service.clear_list(season=12, team_id=1) assert result is True mock_client.delete.assert_not_called() # ------------------------------------------------------------------------- # reorder_list() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_reorder_list_success(self, service, mock_client): """ Test reordering draft list to a new order. Verifies entries are POSTed with new ranks matching specified order. """ existing_list = [ create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), create_draft_list_data(entry_id=3, rank=3, player_id=103), ] mock_client.get.return_value = {'count': 3, 'picks': existing_list} mock_client.post.return_value = "Inserted 3 list values" # Reverse the order new_order = [103, 102, 101] result = await service.reorder_list(season=12, team_id=1, new_order=new_order) assert result is True post_call = mock_client.post.call_args payload = post_call[0][1] entries_by_player = {e['player_id']: e for e in payload['draft_list']} assert entries_by_player[103]['rank'] == 1 # Was 3 assert entries_by_player[102]['rank'] == 2 # Unchanged assert entries_by_player[101]['rank'] == 3 # Was 1 # ------------------------------------------------------------------------- # move_entry_up() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_move_entry_up_success(self, service, mock_client): """ Test moving a player up one position (higher priority). Verifies the two affected entries swap ranks. """ existing_list = [ create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), create_draft_list_data(entry_id=3, rank=3, player_id=103), ] mock_client.get.return_value = {'count': 3, 'picks': existing_list} mock_client.post.return_value = "Inserted 3 list values" result = await service.move_entry_up(season=12, team_id=1, player_id=102) assert result is True post_call = mock_client.post.call_args payload = post_call[0][1] entries_by_player = {e['player_id']: e for e in payload['draft_list']} assert entries_by_player[102]['rank'] == 1 # Moved up from 2 assert entries_by_player[101]['rank'] == 2 # Moved down from 1 assert entries_by_player[103]['rank'] == 3 # Unchanged @pytest.mark.asyncio async def test_move_entry_up_already_at_top(self, service, mock_client): """ Test moving a player who is already at rank 1. Verifies False is returned and no POST is made. """ existing_list = [ create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), ] mock_client.get.return_value = {'count': 2, 'picks': existing_list} result = await service.move_entry_up(season=12, team_id=1, player_id=101) assert result is False mock_client.post.assert_not_called() # ------------------------------------------------------------------------- # move_entry_down() tests # ------------------------------------------------------------------------- @pytest.mark.asyncio async def test_move_entry_down_success(self, service, mock_client): """ Test moving a player down one position (lower priority). Verifies the two affected entries swap ranks. """ existing_list = [ create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), create_draft_list_data(entry_id=3, rank=3, player_id=103), ] mock_client.get.return_value = {'count': 3, 'picks': existing_list} mock_client.post.return_value = "Inserted 3 list values" result = await service.move_entry_down(season=12, team_id=1, player_id=102) assert result is True post_call = mock_client.post.call_args payload = post_call[0][1] entries_by_player = {e['player_id']: e for e in payload['draft_list']} assert entries_by_player[102]['rank'] == 3 # Moved down from 2 assert entries_by_player[103]['rank'] == 2 # Moved up from 3 assert entries_by_player[101]['rank'] == 1 # Unchanged @pytest.mark.asyncio async def test_move_entry_down_already_at_bottom(self, service, mock_client): """ Test moving a player who is already at the bottom. Verifies False is returned and no POST is made. """ existing_list = [ create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), ] mock_client.get.return_value = {'count': 2, 'picks': existing_list} result = await service.move_entry_down(season=12, team_id=1, player_id=102) assert result is False mock_client.post.assert_not_called() # ============================================================================= # DraftList Response Parsing Tests # ============================================================================= class TestDraftListResponseParsing: """ Tests for DraftListService response parsing quirks. The draftlist GET endpoint returns items under 'picks' key (not 'draftlist'), which requires custom parsing logic. """ @pytest.fixture def mock_client(self): """Create mock API client.""" return AsyncMock() @pytest.fixture def service(self, mock_client): """Create DraftListService with mocked client.""" svc = DraftListService() svc._client = mock_client return svc @pytest.mark.asyncio async def test_response_uses_picks_key(self, service, mock_client): """ Test that response with 'picks' key is correctly parsed. API quirk: GET /draftlist returns items under 'picks', not 'draftlist'. """ # Response uses 'picks' key response_data = { 'count': 2, 'picks': [ create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), ] } mock_client.get.return_value = response_data result = await service.get_team_list(season=12, team_id=1) assert len(result) == 2 assert all(isinstance(entry, DraftList) for entry in result) # ============================================================================= # Global Service Instance Tests # ============================================================================= class TestGlobalServiceInstances: """Tests for global service singleton instances.""" def test_draft_service_instance_exists(self): """Verify global draft_service instance is available.""" assert draft_service is not None assert isinstance(draft_service, DraftService) assert draft_service.endpoint == 'draftdata' def test_draft_pick_service_instance_exists(self): """Verify global draft_pick_service instance is available.""" assert draft_pick_service is not None assert isinstance(draft_pick_service, DraftPickService) assert draft_pick_service.endpoint == 'draftpicks' def test_draft_list_service_instance_exists(self): """Verify global draft_list_service instance is available.""" assert draft_list_service is not None assert isinstance(draft_list_service, DraftListService) assert draft_list_service.endpoint == 'draftlist' # ============================================================================= # Draft Model Tests # ============================================================================= class TestDraftDataModel: """Tests for DraftData Pydantic model.""" def test_create_draft_data(self): """Test basic DraftData model creation.""" data = DraftData( id=1, currentpick=25, timer=True, pick_minutes=2 ) assert data.currentpick == 25 assert data.timer is True assert data.pick_minutes == 2 def test_channel_id_string_to_int_conversion(self): """ Test that channel IDs are converted from string to int. Database stores channel IDs as strings, model converts to int. """ data = DraftData( id=1, currentpick=1, timer=False, pick_minutes=2, result_channel='123456789012345678', ping_channel='987654321098765432' ) assert data.result_channel == 123456789012345678 assert data.ping_channel == 987654321098765432 def test_is_draft_active_property(self): """Test is_draft_active property.""" active = DraftData(id=1, currentpick=1, timer=True, pick_minutes=2) inactive = DraftData(id=1, currentpick=1, timer=False, pick_minutes=2) assert active.is_draft_active is True assert inactive.is_draft_active is False def test_is_draft_active_when_paused(self): """ Test that is_draft_active returns False when draft is paused. Even if timer is True, is_draft_active should be False when paused because no picks should be processed. """ paused_with_timer = DraftData( id=1, currentpick=1, timer=True, paused=True, pick_minutes=2 ) paused_no_timer = DraftData( id=1, currentpick=1, timer=False, paused=True, pick_minutes=2 ) active_not_paused = DraftData( id=1, currentpick=1, timer=True, paused=False, pick_minutes=2 ) assert paused_with_timer.is_draft_active is False assert paused_no_timer.is_draft_active is False assert active_not_paused.is_draft_active is True def test_can_make_picks_property(self): """ Test can_make_picks property correctly reflects pause state. can_make_picks should be True only when not paused, regardless of timer state. """ # Not paused - can make picks not_paused = DraftData( id=1, currentpick=1, timer=True, paused=False, pick_minutes=2 ) assert not_paused.can_make_picks is True # Paused - cannot make picks paused = DraftData( id=1, currentpick=1, timer=True, paused=True, pick_minutes=2 ) assert paused.can_make_picks is False # Not paused, timer off - can still make picks (manual draft) manual_draft = DraftData( id=1, currentpick=1, timer=False, paused=False, pick_minutes=2 ) assert manual_draft.can_make_picks is True def test_draft_data_str_shows_paused_status(self): """ Test that __str__ displays paused status when draft is paused. Users should clearly see when the draft is paused. """ paused = DraftData( id=1, currentpick=25, timer=True, paused=True, pick_minutes=2 ) active = DraftData( id=1, currentpick=25, timer=True, paused=False, pick_minutes=2 ) inactive = DraftData( id=1, currentpick=25, timer=False, paused=False, pick_minutes=2 ) assert "PAUSED" in str(paused) assert "Active" in str(active) assert "Inactive" in str(inactive) def test_is_pick_expired_property(self): """Test is_pick_expired property.""" # Expired deadline expired = DraftData( id=1, currentpick=1, timer=True, pick_minutes=2, pick_deadline=datetime.now() - timedelta(minutes=5) ) assert expired.is_pick_expired is True # Future deadline not_expired = DraftData( id=1, currentpick=1, timer=True, pick_minutes=2, pick_deadline=datetime.now() + timedelta(minutes=5) ) assert not_expired.is_pick_expired is False # No deadline no_deadline = DraftData(id=1, currentpick=1, timer=False, pick_minutes=2) assert no_deadline.is_pick_expired is False class TestDraftPickModel: """Tests for DraftPick Pydantic model.""" def test_create_draft_pick_minimal(self): """Test DraftPick with minimal required fields.""" pick = DraftPick( id=1, season=12, overall=42, round=3, origowner_id=1 ) assert pick.overall == 42 assert pick.round == 3 assert pick.player_id is None def test_create_draft_pick_with_player(self): """Test DraftPick with player selected.""" pick = DraftPick( id=1, season=12, overall=42, round=3, origowner_id=1, owner_id=1, player_id=999 ) assert pick.player_id == 999 assert pick.is_selected is True def test_is_traded_property(self): """Test is_traded property.""" traded = DraftPick( id=1, season=12, overall=1, round=1, origowner_id=1, owner_id=2 # Different owners ) not_traded = DraftPick( id=2, season=12, overall=2, round=1, origowner_id=1, owner_id=1 # Same owner ) assert traded.is_traded is True assert not_traded.is_traded is False def test_is_selected_property(self): """Test is_selected property.""" selected = DraftPick( id=1, season=12, overall=1, round=1, origowner_id=1, player_id=100 ) not_selected = DraftPick( id=2, season=12, overall=2, round=1, origowner_id=1, player_id=None ) assert selected.is_selected is True assert not_selected.is_selected is False class TestDraftListModel: """Tests for DraftList Pydantic model.""" def test_create_draft_list_entry(self): """Test DraftList model creation with nested objects.""" team = Team(**create_team_data(1, 'WV')) player = Player(**create_player_data(100, 'Target Player')) entry = DraftList( id=1, season=12, rank=1, team=team, player=player ) assert entry.rank == 1 assert entry.team_id == 1 assert entry.player_id == 100 def test_team_id_property(self): """Test team_id property extracts ID from nested team.""" team = Team(**create_team_data(42, 'TST')) player = Player(**create_player_data(100)) entry = DraftList(id=1, season=12, rank=1, team=team, player=player) assert entry.team_id == 42 def test_player_id_property(self): """Test player_id property extracts ID from nested player.""" team = Team(**create_team_data(1)) player = Player(**create_player_data(999, 'Star Player')) entry = DraftList(id=1, season=12, rank=1, team=team, player=player) assert entry.player_id == 999 def test_is_top_ranked_property(self): """Test is_top_ranked property.""" team = Team(**create_team_data(1)) player = Player(**create_player_data(100)) top = DraftList(id=1, season=12, rank=1, team=team, player=player) not_top = DraftList(id=2, season=12, rank=5, team=team, player=player) assert top.is_top_ranked is True assert not_top.is_top_ranked is False