All checks were successful
Build Docker Image / build (pull_request) Successful in 1m22s
The rewards API requires a week field. The scout claim callback was posting without it, causing a 422 validation error when a user selected a card from a scout opportunity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1018 lines
36 KiB
Python
1018 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, they should get an ephemeral rejection."""
|
|
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.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()
|
|
call_kwargs = interaction.response.send_message.call_args[1]
|
|
assert call_kwargs["ephemeral"] is True
|
|
assert (
|
|
"already being processed"
|
|
in interaction.response.send_message.call_args[0][0].lower()
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_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, cards, rewards
|
|
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 cards POST (card copy — created before token consumption)
|
|
card_call = mock_db_post.call_args_list[1]
|
|
assert card_call[0][0] == "cards"
|
|
|
|
# Verify rewards POST (token consumption — after card is safely created)
|
|
reward_call = mock_db_post.call_args_list[2]
|
|
assert reward_call[0][0] == "rewards"
|
|
assert reward_call[1]["payload"]["name"] == "Scout Token"
|
|
|
|
# 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.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_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_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.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_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_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)
|
|
]
|
|
# Set up claims so total_scouts property returns 5
|
|
pid = sample_cards[0]["player"]["player_id"]
|
|
view.claims[pid] = ["Team A", "Team B", "Team C", "Team D", "Team E"]
|
|
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.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_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_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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rewards use PD_SEASON constant
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRewardsSeason:
|
|
"""Tests that reward records always use the PD_SEASON constant."""
|
|
|
|
@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.get_scout_tokens_used", new_callable=AsyncMock)
|
|
@patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock)
|
|
async def test_rewards_use_pd_season(
|
|
self,
|
|
mock_get_team,
|
|
mock_get_tokens,
|
|
mock_db_post,
|
|
mock_card_embeds,
|
|
sample_cards,
|
|
opener_team,
|
|
scouter_team,
|
|
mock_bot,
|
|
):
|
|
"""Reward records should always use the PD_SEASON constant for season."""
|
|
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_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 uses PD_SEASON
|
|
# Order: scout_claims (0), cards (1), rewards (2)
|
|
from helpers.constants import PD_SEASON
|
|
|
|
reward_call = mock_db_post.call_args_list[2]
|
|
assert reward_call[0][0] == "rewards"
|
|
assert reward_call[1]["payload"]["season"] == PD_SEASON
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shiny scout notification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestShinyScoutNotification:
|
|
"""Tests for the rare-card notification path (rarity >= 5)."""
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("discord_ui.scout_view.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.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_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_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("discord_ui.scout_view.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.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_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_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("discord_ui.scout_view.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.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_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_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()
|