paper-dynasty-discord/tests/scouting/test_scouting_cog.py
Cal Corum ee80cd72ae fix: apply Black formatting and resolve ruff lint violations
Run Black formatter across 83 files and fix 1514 ruff violations:
- E722: bare except → typed exceptions (17 fixes)
- E711/E712/E721: comparison style fixes with noqa for SQLAlchemy (44 fixes)
- F841: unused variable assignments (70 fixes)
- F541/F401: f-string and import cleanup (1383 auto-fixes)

Remaining 925 errors are all F403/F405 (star imports) — structural,
requires converting to explicit imports in a separate effort.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:37:46 -05:00

268 lines
9.8 KiB
Python

"""Tests for cogs/economy_new/scouting.py — the Scouting cog.
Covers the /scout-tokens command and the cleanup_expired background task.
Note: Scouting.__init__ calls self.cleanup_expired.start() which requires
a running event loop. All tests that instantiate the cog must be async.
"""
import pytest
from unittest.mock import AsyncMock, Mock, patch
import discord
from cogs.economy_new.scouting import Scouting, SCOUT_TOKENS_PER_DAY
def _make_team():
return {
"id": 1,
"lname": "Test Team",
"color": "a6ce39",
"logo": "https://example.com/logo.png",
"season": 4,
}
# ---------------------------------------------------------------------------
# Cog setup
# ---------------------------------------------------------------------------
class TestScoutingCogSetup:
"""Tests for cog initialization and lifecycle."""
@pytest.mark.asyncio
async def test_cog_initializes(self, mock_bot):
"""The Scouting cog should initialize without errors."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
assert cog.bot is mock_bot
@pytest.mark.asyncio
async def test_cleanup_task_starts(self, mock_bot):
"""The cleanup_expired loop task should be started on init."""
cog = Scouting(mock_bot)
assert cog.cleanup_expired.is_running()
cog.cleanup_expired.cancel()
@pytest.mark.asyncio
async def test_cog_unload_calls_cancel(self, mock_bot):
"""Unloading the cog should call cancel on the cleanup task."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
# Verify cog_unload runs without error
await cog.cog_unload()
# ---------------------------------------------------------------------------
# /scout-tokens command
# ---------------------------------------------------------------------------
class TestScoutTokensCommand:
"""Tests for the /scout-tokens slash command."""
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
async def test_shows_remaining_tokens(
self, mock_get_team, mock_get_tokens, mock_bot
):
"""Should display the correct number of remaining tokens."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_get_team.return_value = _make_team()
mock_get_tokens.return_value = 1 # 1 used today
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 cog.scout_tokens_command.callback(cog, interaction)
interaction.response.defer.assert_called_once_with(ephemeral=True)
interaction.followup.send.assert_called_once()
call_kwargs = interaction.followup.send.call_args[1]
embed = call_kwargs["embed"]
remaining = SCOUT_TOKENS_PER_DAY - 1
assert str(remaining) in embed.description
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
async def test_no_team_rejects(self, mock_get_team, mock_get_tokens, mock_bot):
"""A user without a PD team should get a rejection message."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
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 cog.scout_tokens_command.callback(cog, interaction)
msg = interaction.followup.send.call_args[0][0]
assert "team" in msg.lower()
mock_get_tokens.assert_not_called()
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
async def test_all_tokens_used_shows_zero(
self, mock_get_team, mock_get_tokens, mock_bot
):
"""When all tokens are used, should show 0 remaining with extra message."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_get_team.return_value = _make_team()
mock_get_tokens.return_value = SCOUT_TOKENS_PER_DAY
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 cog.scout_tokens_command.callback(cog, interaction)
embed = interaction.followup.send.call_args[1]["embed"]
assert "0" in embed.description
assert (
"used all" in embed.description.lower()
or "tomorrow" in embed.description.lower()
)
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
async def test_no_tokens_used_shows_full(
self, mock_get_team, mock_get_tokens, mock_bot
):
"""When no tokens have been used, should show the full daily allowance."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_get_team.return_value = _make_team()
mock_get_tokens.return_value = 0
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 cog.scout_tokens_command.callback(cog, interaction)
embed = interaction.followup.send.call_args[1]["embed"]
assert str(SCOUT_TOKENS_PER_DAY) in embed.description
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
async def test_db_get_returns_none(self, mock_get_team, mock_get_tokens, mock_bot):
"""If get_scout_tokens_used returns 0 (API failure handled internally), should show full tokens."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_get_team.return_value = _make_team()
mock_get_tokens.return_value = (
0 # get_scout_tokens_used handles None internally
)
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 cog.scout_tokens_command.callback(cog, interaction)
embed = interaction.followup.send.call_args[1]["embed"]
assert str(SCOUT_TOKENS_PER_DAY) in embed.description
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
async def test_over_limit_tokens_shows_zero(
self, mock_get_team, mock_get_tokens, mock_bot
):
"""If somehow more tokens than the daily limit were used, should show 0 not negative."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_get_team.return_value = _make_team()
mock_get_tokens.return_value = 5 # more than SCOUT_TOKENS_PER_DAY
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 cog.scout_tokens_command.callback(cog, interaction)
embed = interaction.followup.send.call_args[1]["embed"]
# Should show "0" not "-3"
assert "0" in embed.description
assert "-" not in embed.description.split("remaining")[0]
# ---------------------------------------------------------------------------
# cleanup_expired task
# ---------------------------------------------------------------------------
class TestCleanupExpired:
"""Tests for the background cleanup task."""
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.db_get", new_callable=AsyncMock)
async def test_cleanup_logs_expired_opportunities(self, mock_db_get, mock_bot):
"""The cleanup task should query for expired unclaimed opportunities."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_db_get.return_value = {"count": 3}
# Call the coroutine directly (not via the loop)
await cog.cleanup_expired.coro(cog)
mock_db_get.assert_called_once()
call_args = mock_db_get.call_args
assert call_args[0][0] == "scout_opportunities"
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.db_get", new_callable=AsyncMock)
async def test_cleanup_handles_api_failure(self, mock_db_get, mock_bot):
"""Cleanup should not crash if the API is unavailable."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_db_get.side_effect = Exception("API not ready")
# Should not raise
await cog.cleanup_expired.coro(cog)