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