paper-dynasty-discord/tests/scouting/test_scout_view.py
Cal Corum 3c0fa133fd refactor: Consolidate scouting utilities, add test suite, use Discord timestamps
- Consolidate SCOUT_TOKENS_PER_DAY and get_scout_tokens_used() into
  helpers/scouting.py (was duplicated across 3 files)
- Add midnight_timestamp() utility to helpers/utils.py
- Remove _build_scouted_ids() wrapper, use self.claims directly
- Fix build_scout_embed return type annotation
- Use Discord <t:UNIX:R> relative timestamps for scout window countdown
- Add 66-test suite covering helpers, ScoutView, and cog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:22:58 +00:00

1030 lines
36 KiB
Python

"""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()