"""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 datetime import pytest from unittest.mock import AsyncMock, MagicMock, Mock, patch import discord from discord.ext import commands 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)