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