diff --git a/cogs/dev_tools.py b/cogs/dev_tools.py new file mode 100644 index 0000000..18a3512 --- /dev/null +++ b/cogs/dev_tools.py @@ -0,0 +1,77 @@ +"""Dev-only tools for testing Paper Dynasty systems. + +This cog is only loaded when DATABASE != prod. It provides commands +for integration testing that create and clean up synthetic test data. +""" + +import logging + +import discord +from discord import app_commands +from discord.ext import commands + +from api_calls import db_delete + +logger = logging.getLogger(__name__) + + +class CleanupView(discord.ui.View): + """Post-test buttons to clean up or keep synthetic game data.""" + + def __init__( + self, owner_id: int, game_id: int, embed: discord.Embed, timeout: float = 300.0 + ): + super().__init__(timeout=timeout) + self.owner_id = owner_id + self.game_id = game_id + self.embed = embed + + @discord.ui.button( + label="Clean Up Test Data", style=discord.ButtonStyle.danger, emoji="๐Ÿงน" + ) + async def cleanup_btn( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + if interaction.user.id != self.owner_id: + return + + try: + await db_delete("decisions/game", self.game_id) + await db_delete("plays/game", self.game_id) + await db_delete("games", self.game_id) + self.embed.add_field( + name="", + value=f"๐Ÿงน Test data cleaned up (game #{self.game_id} removed)", + inline=False, + ) + except Exception as e: + self.embed.add_field( + name="", + value=f"โŒ Cleanup failed: {e}", + inline=False, + ) + + self.clear_items() + await interaction.response.edit_message(embed=self.embed, view=self) + self.stop() + + @discord.ui.button( + label="Keep Test Data", style=discord.ButtonStyle.secondary, emoji="๐Ÿ“Œ" + ) + async def keep_btn( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + if interaction.user.id != self.owner_id: + return + + self.embed.add_field( + name="", + value=f"๐Ÿ“Œ Test data kept (game #{self.game_id})", + inline=False, + ) + self.clear_items() + await interaction.response.edit_message(embed=self.embed, view=self) + self.stop() + + async def on_timeout(self): + self.clear_items() diff --git a/tests/test_dev_tools.py b/tests/test_dev_tools.py new file mode 100644 index 0000000..ec7bc4f --- /dev/null +++ b/tests/test_dev_tools.py @@ -0,0 +1,81 @@ +"""Tests for the DevToolsCog /dev refractor-test command.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import discord +import pytest + + +class TestCleanupView: + """Test the cleanup button view for the refractor integration test. + + The view presents two buttons after a test run: 'Clean Up Test Data' + deletes the synthetic game/plays/decisions; 'Keep Test Data' dismisses + the buttons. Only the command invoker can press them. + """ + + @pytest.fixture + def owner_interaction(self): + """Interaction from the user who ran the command.""" + interaction = AsyncMock(spec=discord.Interaction) + interaction.user = MagicMock() + interaction.user.id = 12345 + interaction.response = AsyncMock() + return interaction + + @pytest.fixture + def other_interaction(self): + """Interaction from a different user โ€” should be rejected.""" + interaction = AsyncMock(spec=discord.Interaction) + interaction.user = MagicMock() + interaction.user.id = 99999 + interaction.response = AsyncMock() + return interaction + + async def test_view_has_two_buttons(self): + """View should have exactly two buttons: cleanup and keep. + + Must be async because discord.ui.View.__init__ calls + asyncio.get_running_loop() internally and requires an event loop. + """ + from cogs.dev_tools import CleanupView + + view = CleanupView(owner_id=12345, game_id=1, embed=discord.Embed()) + buttons = [ + child for child in view.children if isinstance(child, discord.ui.Button) + ] + assert len(buttons) == 2 + + async def test_unauthorized_user_ignored(self, other_interaction): + """Non-owner clicks should be silently ignored.""" + from cogs.dev_tools import CleanupView + + view = CleanupView(owner_id=12345, game_id=1, embed=discord.Embed()) + with patch("cogs.dev_tools.db_delete", new_callable=AsyncMock) as mock_delete: + await view.cleanup_btn.callback(other_interaction) + mock_delete.assert_not_called() + other_interaction.response.edit_message.assert_not_called() + + async def test_cleanup_calls_delete_endpoints(self, owner_interaction): + """Cleanup button deletes decisions, plays, then game in order.""" + from cogs.dev_tools import CleanupView + + embed = discord.Embed(description="test") + view = CleanupView(owner_id=12345, game_id=42, embed=embed) + with patch("cogs.dev_tools.db_delete", new_callable=AsyncMock) as mock_delete: + await view.cleanup_btn.callback(owner_interaction) + assert mock_delete.call_count == 3 + # Verify correct endpoints and order + calls = mock_delete.call_args_list + assert "decisions/game" in str(calls[0]) + assert "plays/game" in str(calls[1]) + assert "games" in str(calls[2]) + + async def test_keep_removes_buttons(self, owner_interaction): + """Keep button removes buttons and updates embed.""" + from cogs.dev_tools import CleanupView + + embed = discord.Embed(description="test") + view = CleanupView(owner_id=12345, game_id=42, embed=embed) + await view.keep_btn.callback(owner_interaction) + owner_interaction.response.edit_message.assert_called_once()