"""Tests for discord_ui/scout_view.py — ScoutView and ScoutButton behavior. Covers view initialization, button callbacks (guard rails, claim flow, token checks, multi-scout), embed updates, and timeout handling. Note: All tests that instantiate ScoutView must be async because discord.ui.View.__init__ requires a running event loop. """ import pytest from unittest.mock import AsyncMock, MagicMock, Mock, patch import discord from discord_ui.scout_view import ScoutView, ScoutButton, SCOUT_TOKENS_PER_DAY # --------------------------------------------------------------------------- # ScoutView initialization # --------------------------------------------------------------------------- class TestScoutViewInit: """Tests for ScoutView construction and initial state.""" @pytest.mark.asyncio async def test_creates_one_button_per_card( self, sample_cards, opener_team, mock_bot ): """Should add exactly one button per card in the pack.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) buttons = [c for c in view.children if isinstance(c, discord.ui.Button)] assert len(buttons) == len(sample_cards) @pytest.mark.asyncio async def test_buttons_labeled_sequentially( self, sample_cards, opener_team, mock_bot ): """Buttons should be labeled 'Card 1', 'Card 2', etc.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) labels = [c.label for c in view.children if isinstance(c, discord.ui.Button)] expected = [f"Card {i + 1}" for i in range(len(sample_cards))] assert labels == expected @pytest.mark.asyncio async def test_buttons_are_secondary_style( self, sample_cards, opener_team, mock_bot ): """All buttons should start with the gray/secondary style (face-down).""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) for btn in view.children: if isinstance(btn, discord.ui.Button): assert btn.style == discord.ButtonStyle.secondary @pytest.mark.asyncio async def test_initial_state_is_clean(self, sample_cards, opener_team, mock_bot): """Claims, scouted_users, and processing_users should all start empty.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) assert view.claims == {} assert view.scouted_users == set() assert view.processing_users == set() assert view.total_scouts == 0 @pytest.mark.asyncio async def test_timeout_is_30_minutes(self, sample_cards, opener_team, mock_bot): """The view timeout should be 1800 seconds (30 minutes).""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) assert view.timeout == 1800.0 # --------------------------------------------------------------------------- # ScoutButton callback — guard rails # --------------------------------------------------------------------------- class TestScoutButtonGuards: """Tests for the access control checks in ScoutButton.callback.""" def _make_view(self, sample_cards, opener_team, mock_bot): view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] return view @pytest.mark.asyncio async def test_opener_blocked(self, sample_cards, opener_team, mock_bot): """The pack opener should be rejected with an ephemeral message.""" view = self._make_view(sample_cards, opener_team, mock_bot) button = view.children[0] interaction = AsyncMock(spec=discord.Interaction) interaction.response = AsyncMock() interaction.response.send_message = AsyncMock() interaction.user = Mock() interaction.user.id = 99999 # same as opener await button.callback(interaction) interaction.response.send_message.assert_called_once() call_kwargs = interaction.response.send_message.call_args[1] assert call_kwargs["ephemeral"] is True assert "own pack" in interaction.response.send_message.call_args[0][0].lower() @pytest.mark.asyncio async def test_already_scouted_blocked(self, sample_cards, opener_team, mock_bot): """A user who already scouted this pack should be rejected.""" view = self._make_view(sample_cards, opener_team, mock_bot) view.scouted_users.add(12345) button = view.children[0] interaction = AsyncMock(spec=discord.Interaction) interaction.response = AsyncMock() interaction.response.send_message = AsyncMock() interaction.user = Mock() interaction.user.id = 12345 await button.callback(interaction) interaction.response.send_message.assert_called_once() assert ( "already scouted" in interaction.response.send_message.call_args[0][0].lower() ) @pytest.mark.asyncio async def test_double_click_silently_ignored( self, sample_cards, opener_team, mock_bot ): """If a user is already being processed, the click should be silently dropped.""" view = self._make_view(sample_cards, opener_team, mock_bot) view.processing_users.add(12345) button = view.children[0] interaction = AsyncMock(spec=discord.Interaction) interaction.user = Mock() interaction.user.id = 12345 await button.callback(interaction) # Should not have called defer or send_message interaction.response.defer.assert_not_called() # --------------------------------------------------------------------------- # ScoutButton callback — successful scout flow # --------------------------------------------------------------------------- class TestScoutButtonSuccess: """Tests for the happy-path scout claim flow.""" def _make_view_with_message(self, sample_cards, opener_team, mock_bot): view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] view.message = AsyncMock(spec=discord.Message) view.message.edit = AsyncMock() return view @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_successful_scout_creates_card_copy( self, mock_get_team, mock_get_tokens, mock_db_get, mock_db_post, mock_card_embeds, sample_cards, opener_team, scouter_team, mock_bot, ): """A valid scout should POST a scout_claim, consume a token, and create a card copy.""" view = self._make_view_with_message(sample_cards, opener_team, mock_bot) mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] interaction = AsyncMock(spec=discord.Interaction) interaction.response = AsyncMock() interaction.response.defer = AsyncMock() interaction.response.send_message = AsyncMock() interaction.followup = AsyncMock() interaction.followup.send = AsyncMock() interaction.user = Mock() interaction.user.id = 12345 button = view.children[0] await button.callback(interaction) # Should have deferred interaction.response.defer.assert_called_once_with(ephemeral=True) # db_post should be called 3 times: scout_claims, rewards, cards assert mock_db_post.call_count == 3 # Verify scout_claims POST claim_call = mock_db_post.call_args_list[0] assert claim_call[0][0] == "scout_claims" # Verify rewards POST (token consumption) reward_call = mock_db_post.call_args_list[1] assert reward_call[0][0] == "rewards" assert reward_call[1]["payload"]["name"] == "Scout Token" # Verify cards POST (card copy) card_call = mock_db_post.call_args_list[2] assert card_call[0][0] == "cards" # User should be marked as scouted assert 12345 in view.scouted_users assert view.total_scouts == 1 # Ephemeral follow-up with card details interaction.followup.send.assert_called() @pytest.mark.asyncio @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_no_team_rejects( self, mock_get_team, mock_db_post, sample_cards, opener_team, mock_bot, ): """A user without a PD team should be rejected with an ephemeral message.""" view = self._make_view_with_message(sample_cards, opener_team, mock_bot) mock_get_team.return_value = None interaction = AsyncMock(spec=discord.Interaction) interaction.response = AsyncMock() interaction.response.defer = AsyncMock() interaction.followup = AsyncMock() interaction.followup.send = AsyncMock() interaction.user = Mock() interaction.user.id = 12345 button = view.children[0] await button.callback(interaction) interaction.followup.send.assert_called_once() msg = interaction.followup.send.call_args[0][0] assert "team" in msg.lower() assert mock_db_post.call_count == 0 @pytest.mark.asyncio @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_out_of_tokens_rejects( self, mock_get_team, mock_get_tokens, mock_db_post, sample_cards, opener_team, scouter_team, mock_bot, ): """A user who has used all daily tokens should be rejected.""" view = self._make_view_with_message(sample_cards, opener_team, mock_bot) mock_get_team.return_value = scouter_team mock_get_tokens.return_value = SCOUT_TOKENS_PER_DAY # all used interaction = AsyncMock(spec=discord.Interaction) interaction.response = AsyncMock() interaction.response.defer = AsyncMock() interaction.followup = AsyncMock() interaction.followup.send = AsyncMock() interaction.user = Mock() interaction.user.id = 12345 button = view.children[0] await button.callback(interaction) interaction.followup.send.assert_called_once() msg = interaction.followup.send.call_args[0][0] assert "out of scout tokens" in msg.lower() assert mock_db_post.call_count == 0 # --------------------------------------------------------------------------- # Multi-scout behavior # --------------------------------------------------------------------------- class TestMultiScout: """Tests for the multi-scout-per-card design. Any card can be scouted by multiple different players, but each player can only scout one card per pack. """ def _make_view_with_message(self, sample_cards, opener_team, mock_bot): view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] view.message = AsyncMock(spec=discord.Message) view.message.edit = AsyncMock() return view @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_two_users_can_scout_same_card( self, mock_get_team, mock_get_tokens, mock_db_get, mock_db_post, mock_card_embeds, sample_cards, opener_team, scouter_team, scouter_team_2, mock_bot, ): """Two different users should both be able to scout the same card.""" view = self._make_view_with_message(sample_cards, opener_team, mock_bot) mock_get_tokens.return_value = 0 mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] button = view.children[0] # both pick the same card # First scouter mock_get_team.return_value = scouter_team interaction1 = AsyncMock(spec=discord.Interaction) interaction1.response = AsyncMock() interaction1.response.defer = AsyncMock() interaction1.followup = AsyncMock() interaction1.followup.send = AsyncMock() interaction1.user = Mock() interaction1.user.id = 11111 await button.callback(interaction1) assert 11111 in view.scouted_users assert view.total_scouts == 1 # Second scouter — same card mock_get_team.return_value = scouter_team_2 interaction2 = AsyncMock(spec=discord.Interaction) interaction2.response = AsyncMock() interaction2.response.defer = AsyncMock() interaction2.followup = AsyncMock() interaction2.followup.send = AsyncMock() interaction2.user = Mock() interaction2.user.id = 22222 await button.callback(interaction2) assert 22222 in view.scouted_users assert view.total_scouts == 2 # Claims should track both teams under the same player_id player_id = sample_cards[0]["player"]["player_id"] assert player_id in view.claims assert len(view.claims[player_id]) == 2 @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_same_user_cannot_scout_twice( self, mock_get_team, mock_get_tokens, mock_db_get, mock_db_post, mock_card_embeds, sample_cards, opener_team, scouter_team, mock_bot, ): """The same user should be blocked from scouting a second card.""" view = self._make_view_with_message(sample_cards, opener_team, mock_bot) mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] # First scout succeeds interaction1 = AsyncMock(spec=discord.Interaction) interaction1.response = AsyncMock() interaction1.response.defer = AsyncMock() interaction1.followup = AsyncMock() interaction1.followup.send = AsyncMock() interaction1.user = Mock() interaction1.user.id = 12345 await view.children[0].callback(interaction1) assert view.total_scouts == 1 # Second scout by same user is blocked interaction2 = AsyncMock(spec=discord.Interaction) interaction2.response = AsyncMock() interaction2.response.send_message = AsyncMock() interaction2.user = Mock() interaction2.user.id = 12345 await view.children[1].callback(interaction2) interaction2.response.send_message.assert_called_once() assert ( "already scouted" in interaction2.response.send_message.call_args[0][0].lower() ) assert view.total_scouts == 1 # unchanged @pytest.mark.asyncio async def test_buttons_never_disabled_after_scout( self, sample_cards, opener_team, mock_bot ): """All buttons should remain enabled regardless of how many scouts happen. This verifies the 'unlimited scouts per card' design — buttons only disable on timeout, not on individual claims. """ view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) # Simulate claims on every card for card in sample_cards: pid = card["player"]["player_id"] view.claims[pid] = ["Team A", "Team B"] for btn in view.children: if isinstance(btn, discord.ui.Button): assert not btn.disabled # --------------------------------------------------------------------------- # ScoutView.on_timeout # --------------------------------------------------------------------------- class TestScoutViewTimeout: """Tests for the timeout handler that closes the scout window.""" @pytest.mark.asyncio async def test_timeout_disables_all_buttons( self, sample_cards, opener_team, mock_bot ): """After timeout, every button should be disabled.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] view.message = AsyncMock(spec=discord.Message) view.message.edit = AsyncMock() await view.on_timeout() for btn in view.children: if isinstance(btn, discord.ui.Button): assert btn.disabled @pytest.mark.asyncio async def test_timeout_updates_embed_title( self, sample_cards, opener_team, mock_bot ): """The embed title should change to 'Scout Window Closed' on timeout.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] view.message = AsyncMock(spec=discord.Message) view.message.edit = AsyncMock() await view.on_timeout() view.message.edit.assert_called_once() call_kwargs = view.message.edit.call_args[1] embed = call_kwargs["embed"] assert "closed" in embed.title.lower() @pytest.mark.asyncio async def test_timeout_with_scouts_shows_count( self, sample_cards, opener_team, mock_bot ): """When there were scouts, the closed title should include the count.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] view.total_scouts = 5 view.message = AsyncMock(spec=discord.Message) view.message.edit = AsyncMock() await view.on_timeout() embed = view.message.edit.call_args[1]["embed"] assert "5" in embed.title @pytest.mark.asyncio async def test_timeout_without_message_is_safe( self, sample_cards, opener_team, mock_bot ): """Timeout should not crash if the message reference is None.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.message = None # Should not raise await view.on_timeout() # --------------------------------------------------------------------------- # Processing user cleanup # --------------------------------------------------------------------------- class TestProcessingUserCleanup: """Verify the processing_users set is cleaned up in all code paths.""" @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_processing_cleared_on_success( self, mock_get_team, mock_get_tokens, mock_db_get, mock_db_post, mock_card_embeds, sample_cards, opener_team, scouter_team, mock_bot, ): """After a successful scout, the user should be removed from processing_users.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] view.message = AsyncMock(spec=discord.Message) view.message.edit = AsyncMock() mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] interaction = AsyncMock(spec=discord.Interaction) interaction.response = AsyncMock() interaction.response.defer = AsyncMock() interaction.followup = AsyncMock() interaction.followup.send = AsyncMock() interaction.user = Mock() interaction.user.id = 12345 await view.children[0].callback(interaction) assert 12345 not in view.processing_users @pytest.mark.asyncio @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_processing_cleared_on_claim_db_failure( self, mock_get_team, mock_get_tokens, mock_db_post, sample_cards, opener_team, scouter_team, mock_bot, ): """If db_post('scout_claims') raises, processing_users should still be cleared.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 mock_db_post.side_effect = Exception("DB down") interaction = AsyncMock(spec=discord.Interaction) interaction.response = AsyncMock() interaction.response.defer = AsyncMock() interaction.followup = AsyncMock() interaction.followup.send = AsyncMock() interaction.user = Mock() interaction.user.id = 12345 await view.children[0].callback(interaction) assert 12345 not in view.processing_users # Scout should not have been recorded assert view.total_scouts == 0 @pytest.mark.asyncio @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_processing_cleared_on_no_team( self, mock_get_team, sample_cards, opener_team, mock_bot, ): """If the user has no team, they should still be removed from processing_users.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] mock_get_team.return_value = None interaction = AsyncMock(spec=discord.Interaction) interaction.response = AsyncMock() interaction.response.defer = AsyncMock() interaction.followup = AsyncMock() interaction.followup.send = AsyncMock() interaction.user = Mock() interaction.user.id = 12345 await view.children[0].callback(interaction) assert 12345 not in view.processing_users # --------------------------------------------------------------------------- # db_get("current") fallback # --------------------------------------------------------------------------- class TestCurrentSeasonFallback: """Tests for the fallback when db_get('current') returns None.""" @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_current_returns_none_uses_fallback( self, mock_get_team, mock_get_tokens, mock_db_get, mock_db_post, mock_card_embeds, sample_cards, opener_team, scouter_team, mock_bot, ): """When db_get('current') returns None, rewards should use PD_SEASON fallback.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] view.message = AsyncMock(spec=discord.Message) view.message.edit = AsyncMock() mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 mock_db_get.return_value = None # db_get("current") returns None mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] interaction = AsyncMock(spec=discord.Interaction) interaction.response = AsyncMock() interaction.response.defer = AsyncMock() interaction.followup = AsyncMock() interaction.followup.send = AsyncMock() interaction.user = Mock() interaction.user.id = 12345 await view.children[0].callback(interaction) # Should still complete successfully assert view.total_scouts == 1 assert 12345 in view.scouted_users # Verify the rewards POST used fallback values from helpers.constants import PD_SEASON reward_call = mock_db_post.call_args_list[1] assert reward_call[1]["payload"]["season"] == PD_SEASON assert reward_call[1]["payload"]["week"] == 1 # --------------------------------------------------------------------------- # Shiny scout notification # --------------------------------------------------------------------------- class TestShinyScoutNotification: """Tests for the rare-card notification path (rarity >= 5).""" @pytest.mark.asyncio @patch("helpers.discord_utils.send_to_channel", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_shiny_card_sends_notification( self, mock_get_team, mock_get_tokens, mock_db_get, mock_db_post, mock_card_embeds, mock_send_to_channel, sample_cards, opener_team, scouter_team, mock_bot, ): """Scouting a card with rarity >= 5 should post to #pd-network-news.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, # card 0 is MVP (rarity 5) opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] view.message = AsyncMock(spec=discord.Message) view.message.edit = AsyncMock() mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] interaction = AsyncMock(spec=discord.Interaction) interaction.response = AsyncMock() interaction.response.defer = AsyncMock() interaction.followup = AsyncMock() interaction.followup.send = AsyncMock() interaction.user = Mock() interaction.user.id = 12345 # Card 0 is MVP (rarity value 5) — should trigger notification await view.children[0].callback(interaction) mock_send_to_channel.assert_called_once() call_args = mock_send_to_channel.call_args assert call_args[0][1] == "pd-network-news" @pytest.mark.asyncio @patch("helpers.discord_utils.send_to_channel", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_non_shiny_card_no_notification( self, mock_get_team, mock_get_tokens, mock_db_get, mock_db_post, mock_card_embeds, mock_send_to_channel, sample_cards, opener_team, scouter_team, mock_bot, ): """Scouting a card with rarity < 5 should NOT post a notification.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, # card 2 is Starter (rarity 2) opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] view.message = AsyncMock(spec=discord.Message) view.message.edit = AsyncMock() mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] interaction = AsyncMock(spec=discord.Interaction) interaction.response = AsyncMock() interaction.response.defer = AsyncMock() interaction.followup = AsyncMock() interaction.followup.send = AsyncMock() interaction.user = Mock() interaction.user.id = 12345 # Card 2 is Starter (rarity value 2) — no notification await view.children[2].callback(interaction) mock_send_to_channel.assert_not_called() @pytest.mark.asyncio @patch("helpers.discord_utils.send_to_channel", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_shiny_notification_failure_does_not_crash( self, mock_get_team, mock_get_tokens, mock_db_get, mock_db_post, mock_card_embeds, mock_send_to_channel, sample_cards, opener_team, scouter_team, mock_bot, ): """If sending the shiny notification fails, the scout should still succeed.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] view.message = AsyncMock(spec=discord.Message) view.message.edit = AsyncMock() mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] mock_send_to_channel.side_effect = Exception("Channel not found") interaction = AsyncMock(spec=discord.Interaction) interaction.response = AsyncMock() interaction.response.defer = AsyncMock() interaction.followup = AsyncMock() interaction.followup.send = AsyncMock() interaction.user = Mock() interaction.user.id = 12345 # Should not raise even though notification fails await view.children[0].callback(interaction) # Scout should still complete assert view.total_scouts == 1 assert 12345 in view.scouted_users # --------------------------------------------------------------------------- # update_message edge cases # --------------------------------------------------------------------------- class TestUpdateMessage: """Tests for ScoutView.update_message edge cases.""" @pytest.mark.asyncio async def test_update_message_with_no_message_is_noop( self, sample_cards, opener_team, mock_bot ): """update_message should silently return if self.message is None.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] view.message = None # Should not raise await view.update_message() @pytest.mark.asyncio async def test_update_message_edit_failure_is_caught( self, sample_cards, opener_team, mock_bot ): """If message.edit raises, it should be caught and logged, not re-raised.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, opener_team=opener_team, opener_user_id=99999, bot=mock_bot, ) view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] view.message = AsyncMock(spec=discord.Message) view.message.edit = AsyncMock( side_effect=discord.HTTPException(Mock(status=500), "Server error") ) # Should not raise await view.update_message()