paper-dynasty-discord/tests/scouting/test_scouting_cog.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

270 lines
9.9 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 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)