diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index ae0b102..23dd1c2 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -18,6 +18,8 @@ jobs: python: - '**.py' - 'tox.ini' + - 'pyproject.toml' + - 'tests/**' - name: Setup Python uses: actions/setup-python@v5 @@ -58,6 +60,8 @@ jobs: python: - '**.py' - 'tox.ini' + - 'pyproject.toml' + - 'tests/**' - name: Set up Python uses: actions/setup-python@v5 @@ -85,6 +89,15 @@ jobs: ./.coverage ./coverage.xml + - name: Upload Coverage to Codecov + if: steps.filter.outputs.python == 'true' && matrix.toxenv == 'py313-unittest' + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + fail_ci_if_error: false + verbose: true + continue-on-error: true + coverage: runs-on: ubuntu-latest name: Coverage @@ -102,6 +115,8 @@ jobs: python: - '**.py' - 'tox.ini' + - 'pyproject.toml' + - 'tests/**' - name: Setup Python uses: actions/setup-python@v5 @@ -124,7 +139,19 @@ jobs: - name: Overall Coverage if: steps.filter.outputs.python == 'true' run: | - coverage report + # Use coverage.xml since .coverage has absolute paths from the test runner + python -c " + import xml.etree.ElementTree as ET + tree = ET.parse('coverage.xml') + root = tree.getroot() + line_rate = float(root.get('line-rate', 0)) + coverage_pct = line_rate * 100 + print(f'Total Coverage: {coverage_pct:.1f}%') + if coverage_pct < 90: + print(f'ERROR: Coverage {coverage_pct:.1f}% is below 90% threshold') + exit(1) + print('Coverage threshold met!') + " - name: Coverage Diff if: steps.filter.outputs.python == 'true' diff --git a/pyproject.toml b/pyproject.toml index c1f4e10..6bdfa58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools ~=65.5"] +requires = ["setuptools>=70.0"] [project] name = "am_bot" @@ -23,7 +23,9 @@ dev = [ "diff-cover", "isort", "pre-commit", - "pytest>=2.7.3", + "pytest>=7.0.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=4.0.0", "ruff", ] test = [ @@ -66,8 +68,30 @@ select = [ "W", ] +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = "-v --tb=short" + [tool.coverage.run] relative_files = true +source = ["am_bot"] +omit = ["tests/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if __name__ == .__main__.:", +] +show_missing = true + +[tool.setuptools] +packages = ["am_bot", "am_bot.cogs"] [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3c825bd --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for am_bot.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..45748be --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,252 @@ +"""Shared pytest fixtures and mock utilities for Discord bot testing.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +# Configure pytest-asyncio +pytest_plugins = ("pytest_asyncio",) + + +def make_mock_user( + user_id: int = 123456789, + name: str = "TestUser", + discriminator: str = "0001", + bot: bool = False, + display_name: str | None = None, +) -> MagicMock: + """Create a mock Discord User.""" + user = MagicMock() + user.id = user_id + user.name = name + user.discriminator = discriminator + user.bot = bot + user.display_name = display_name or name + user.mention = f"<@{user_id}>" + user.__str__ = lambda self: f"{name}#{discriminator}" + return user + + +def make_mock_member( + user_id: int = 123456789, + name: str = "TestUser", + discriminator: str = "0001", + bot: bool = False, + display_name: str | None = None, + guild: MagicMock | None = None, + roles: list | None = None, +) -> MagicMock: + """Create a mock Discord Member (extends User with guild-related attrs).""" + member = make_mock_user(user_id, name, discriminator, bot, display_name) + # Don't auto-create guild to avoid circular reference + member.guild = guild + member.roles = roles or [] + member.add_roles = AsyncMock() + member.remove_roles = AsyncMock() + member.avatar_url_as = MagicMock( + return_value=f"https://cdn.discordapp.com/avatars/{user_id}/test.png?size=128" + ) + return member + + +def make_mock_role( + role_id: int = 111111111, + name: str = "TestRole", + members: list | None = None, +) -> MagicMock: + """Create a mock Discord Role.""" + role = MagicMock() + role.id = role_id + role.name = name + role.members = members or [] + return role + + +def make_mock_channel( + channel_id: int = 999999999, + name: str = "test-channel", + guild: MagicMock | None = None, + channel_type: str = "text", +) -> MagicMock: + """Create a mock Discord TextChannel.""" + channel = MagicMock() + channel.id = channel_id + channel.name = name + channel.guild = guild + channel.type = channel_type + channel.send = AsyncMock() + channel.fetch_message = AsyncMock() + channel.purge = AsyncMock(return_value=[]) + channel.history = MagicMock() + channel.set_permissions = AsyncMock() + channel.edit = AsyncMock() + + # Create permissions mock + permissions = MagicMock() + permissions.read_messages = True + permissions.manage_messages = True + channel.permissions_for = MagicMock(return_value=permissions) + + return channel + + +def make_mock_guild( + guild_id: int = 153690873186484224, + name: str = "Test Guild", + members: list | None = None, + text_channels: list | None = None, + premium_subscription_count: int = 5, +) -> MagicMock: + """Create a mock Discord Guild.""" + guild = MagicMock() + guild.id = guild_id + guild.name = name + guild.members = members or [] + guild.text_channels = text_channels or [] + guild.premium_subscription_count = premium_subscription_count + guild.get_role = MagicMock() + guild.get_channel = MagicMock() + guild.fetch_member = AsyncMock() + guild.system_channel = make_mock_channel(name="system-channel") + # Create a simple bot member without recursive guild reference + bot_member = make_mock_user(user_id=999999, name="BotUser", bot=True) + bot_member.add_roles = AsyncMock() + bot_member.remove_roles = AsyncMock() + guild.me = bot_member + return guild + + +def make_mock_message( + message_id: int = 888888888, + content: str = "Test message", + author: MagicMock | None = None, + channel: MagicMock | None = None, + guild: MagicMock | None = None, + embeds: list | None = None, + reference: MagicMock | None = None, + reactions: list | None = None, +) -> MagicMock: + """Create a mock Discord Message.""" + message = MagicMock() + message.id = message_id + message.content = content + message.clean_content = content + message.author = author or make_mock_member() + message.channel = channel or make_mock_channel() + message.guild = guild or make_mock_guild() + message.embeds = embeds or [] + message.reference = reference + message.reactions = reactions or [] + message.delete = AsyncMock() + message.add_reaction = AsyncMock() + message.clear_reactions = AsyncMock() + message.jump_url = f"https://discord.com/channels/{message.guild.id}/{message.channel.id}/{message_id}" + return message + + +def make_mock_embed( + title: str = "Test Embed", + description: str = "Test description", + fields: list | None = None, + color: int = 0x00FF00, +) -> MagicMock: + """Create a mock Discord Embed.""" + embed = MagicMock() + embed.title = title + embed.description = description + embed.color = color + + # Create field mocks + mock_fields = [] + if fields: + for field_data in fields: + field = MagicMock() + field.name = field_data.get("name", "") + field.value = field_data.get("value", "") + field.inline = field_data.get("inline", False) + mock_fields.append(field) + embed.fields = mock_fields + + return embed + + +def make_mock_bot( + user_id: int = 999999, + name: str = "TestBot", +) -> MagicMock: + """Create a mock Discord Bot.""" + bot = MagicMock() + bot.user = make_mock_user(user_id=user_id, name=name, bot=True) + bot.get_channel = MagicMock() + bot.get_guild = MagicMock() + bot.get_cog = MagicMock() + bot.get_emoji = MagicMock() + bot.fetch_channel = AsyncMock() + bot.fetch_guild = AsyncMock() + bot.add_cog = AsyncMock() + bot.process_commands = AsyncMock() + bot.loop = asyncio.new_event_loop() + return bot + + +def make_mock_reaction_payload( + user_id: int = 123456789, + message_id: int = 888888888, + channel_id: int = 999999999, + guild_id: int = 153690873186484224, + emoji_name: str = "⭐", + emoji_id: int | None = None, + member: MagicMock | None = None, +) -> MagicMock: + """Create a mock RawReactionActionEvent payload.""" + payload = MagicMock() + payload.user_id = user_id + payload.message_id = message_id + payload.channel_id = channel_id + payload.guild_id = guild_id + payload.emoji = MagicMock() + payload.emoji.name = emoji_name + payload.emoji.id = emoji_id + payload.member = member or make_mock_member(user_id=user_id) + return payload + + +def make_mock_voice_state( + channel: MagicMock | None = None, +) -> MagicMock: + """Create a mock VoiceState.""" + state = MagicMock() + state.channel = channel + return state + + +@pytest.fixture +def mock_bot(): + """Fixture that provides a mock Discord bot.""" + return make_mock_bot() + + +@pytest.fixture +def mock_guild(): + """Fixture that provides a mock Discord guild.""" + return make_mock_guild() + + +@pytest.fixture +def mock_channel(): + """Fixture that provides a mock Discord text channel.""" + return make_mock_channel() + + +@pytest.fixture +def mock_member(): + """Fixture that provides a mock Discord member.""" + return make_mock_member() + + +@pytest.fixture +def mock_message(): + """Fixture that provides a mock Discord message.""" + return make_mock_message() diff --git a/tests/test_bot.py b/tests/test_bot.py new file mode 100644 index 0000000..35c597c --- /dev/null +++ b/tests/test_bot.py @@ -0,0 +1,153 @@ +"""Tests for the main bot module.""" + +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +import pytest + +from tests.conftest import make_mock_message, make_mock_user + + +class TestARKBot: + """Tests for the ARKBot class.""" + + @pytest.fixture + def bot(self): + """Create an ARKBot instance.""" + with patch("am_bot.bot.discord.Intents") as mock_intents: + mock_intents.default.return_value = MagicMock() + from am_bot.bot import ARKBot + + return ARKBot(command_prefix="!") + + def test_init(self, bot): + """Test ARKBot initialization.""" + assert bot.command_prefix == "!" + + @pytest.mark.asyncio + async def test_on_ready(self): + """Test on_ready event.""" + # Create a fully mocked bot for on_ready test + from am_bot.bot import ARKBot + + with patch.object( + ARKBot, "user", new_callable=PropertyMock + ) as mock_user: + mock_user.return_value = make_mock_user(name="TestBot", bot=True) + + with patch("am_bot.bot.discord.Intents") as mock_intents: + mock_intents.default.return_value = MagicMock() + bot = ARKBot(command_prefix="!") + + with patch.object( + bot, "add_cogs", new_callable=AsyncMock + ) as mock_add_cogs: + await bot.on_ready() + mock_add_cogs.assert_called_once() + + @pytest.mark.asyncio + async def test_on_message_ignores_self(self): + """Test that bot ignores its own messages.""" + from am_bot.bot import ARKBot + + mock_user = make_mock_user(user_id=999999, name="TestBot", bot=True) + + with patch.object( + ARKBot, "user", new_callable=PropertyMock + ) as mock_user_prop: + mock_user_prop.return_value = mock_user + + with patch("am_bot.bot.discord.Intents") as mock_intents: + mock_intents.default.return_value = MagicMock() + bot = ARKBot(command_prefix="!") + + message = make_mock_message() + message.author.id = 999999 # Same as bot + + with patch.object( + bot, "process_commands", new_callable=AsyncMock + ) as mock_process: + await bot.on_message(message) + mock_process.assert_not_called() + + @pytest.mark.asyncio + async def test_on_message_processes_other_messages(self): + """Test that bot processes other users' messages.""" + from am_bot.bot import ARKBot + + mock_user = make_mock_user(user_id=999999, name="TestBot", bot=True) + + with patch.object( + ARKBot, "user", new_callable=PropertyMock + ) as mock_user_prop: + mock_user_prop.return_value = mock_user + + with patch("am_bot.bot.discord.Intents") as mock_intents: + mock_intents.default.return_value = MagicMock() + bot = ARKBot(command_prefix="!") + + message = make_mock_message() + message.author.id = 12345 # Different from bot + + with patch.object( + bot, "process_commands", new_callable=AsyncMock + ) as mock_process: + await bot.on_message(message) + mock_process.assert_called_once_with(message) + + @pytest.mark.asyncio + async def test_add_cogs(self, bot): + """Test that add_cogs adds all expected cogs.""" + with patch.object( + bot, "add_cog", new_callable=AsyncMock + ) as mock_add_cog: + await bot.add_cogs() + + # Should add 7 cogs + assert mock_add_cog.call_count == 7 + + # Get the cog classes that were added + cog_types = [ + call[0][0].__class__.__name__ + for call in mock_add_cog.call_args_list + ] + + assert "GreetingsCog" in cog_types + assert "InviteResponseCog" in cog_types + assert "QuarantineCog" in cog_types + assert "ResponsesCog" in cog_types + assert "RoleAssignmentCog" in cog_types + assert "ServerStatsCog" in cog_types + assert "WorkshopCog" in cog_types + + +class TestBotIntents: + """Tests for bot intents configuration.""" + + def test_intents_include_members(self): + """Test that members intent is enabled.""" + from am_bot.bot import intents + + assert intents.members is True + + def test_intents_include_message_content(self): + """Test that message_content intent is enabled.""" + from am_bot.bot import intents + + assert intents.message_content is True + + +class TestBotImports: + """Tests for bot module imports and structure.""" + + def test_arkbot_is_exported_from_package(self): + """Test that ARKBot is exported from the am_bot package.""" + from am_bot import ARKBot + + assert ARKBot is not None + + def test_package_version_exists(self): + """Test that package has a version.""" + from am_bot import __version__ + + assert __version__ is not None + assert isinstance(__version__, str) diff --git a/tests/test_fake.py b/tests/test_fake.py deleted file mode 100644 index 625c413..0000000 --- a/tests/test_fake.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_fake(): - assert True diff --git a/tests/test_greetings.py b/tests/test_greetings.py new file mode 100644 index 0000000..9ef23d6 --- /dev/null +++ b/tests/test_greetings.py @@ -0,0 +1,135 @@ +"""Tests for the GreetingsCog module.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from am_bot.cogs.greetings import GreetingsCog +from tests.conftest import make_mock_channel, make_mock_guild, make_mock_member + + +class TestGreetingsCog: + """Tests for the GreetingsCog class.""" + + @pytest.fixture + def cog(self): + """Create a GreetingsCog instance.""" + return GreetingsCog() + + def test_init(self, cog): + """Test GreetingsCog initialization.""" + assert cog._last_member is None + + @pytest.mark.asyncio + async def test_on_member_join_with_system_channel(self, cog): + """Test welcome message is sent when member joins.""" + member = make_mock_member(user_id=12345, name="NewUser") + guild = make_mock_guild() + member.guild = guild + channel = make_mock_channel() + guild.system_channel = channel + + await cog.on_member_join(member) + + channel.send.assert_called_once() + call_args = channel.send.call_args[0][0] + assert member.mention in call_args + assert "Welcome" in call_args + + @pytest.mark.asyncio + async def test_on_member_join_no_system_channel(self, cog): + """Test no error when guild has no system channel.""" + member = make_mock_member() + guild = make_mock_guild() + guild.system_channel = None + member.guild = guild + + # Should not raise + await cog.on_member_join(member) + + @pytest.mark.asyncio + async def test_hello_command_first_time(self, cog): + """Test hello command with no previous interaction.""" + ctx = MagicMock() + ctx.send = AsyncMock() + ctx.author = make_mock_member(name="TestUser") + + await cog.hello(cog, ctx) + + ctx.send.assert_called_once() + call_args = ctx.send.call_args[0][0] + assert "Hello TestUser." in call_args + assert "familiar" not in call_args.lower() + + @pytest.mark.asyncio + async def test_hello_command_same_member_twice(self, cog): + """Test hello command when same member says hello twice.""" + ctx = MagicMock() + ctx.send = AsyncMock() + member = make_mock_member(user_id=12345, name="TestUser") + ctx.author = member + + # First hello + await cog.hello(cog, ctx) + + # Reset mock for second call + ctx.send.reset_mock() + + # Second hello from same member + await cog.hello(cog, ctx) + + ctx.send.assert_called_once() + call_args = ctx.send.call_args[0][0] + assert "familiar" in call_args.lower() + + @pytest.mark.asyncio + async def test_hello_command_different_member(self, cog): + """Test hello command with different members.""" + ctx = MagicMock() + ctx.send = AsyncMock() + + # First member + member1 = make_mock_member(user_id=12345, name="User1") + ctx.author = member1 + await cog.hello(cog, ctx) + + ctx.send.reset_mock() + + # Different member + member2 = make_mock_member(user_id=67890, name="User2") + ctx.author = member2 + await cog.hello(cog, ctx) + + ctx.send.assert_called_once() + call_args = ctx.send.call_args[0][0] + assert "Hello User2." in call_args + assert "familiar" not in call_args.lower() + + @pytest.mark.asyncio + async def test_hello_command_with_specific_member(self, cog): + """Test hello command targeting a specific member.""" + ctx = MagicMock() + ctx.send = AsyncMock() + ctx.author = make_mock_member(user_id=12345, name="Sender") + + target_member = make_mock_member(user_id=67890, name="TargetUser") + + await cog.hello(cog, ctx, member=target_member) + + ctx.send.assert_called_once() + call_args = ctx.send.call_args[0][0] + assert "Hello TargetUser." in call_args + + @pytest.mark.asyncio + async def test_hello_tracks_last_member(self, cog): + """Test that _last_member is updated after hello command.""" + ctx = MagicMock() + ctx.send = AsyncMock() + member = make_mock_member(user_id=12345, name="TrackedUser") + ctx.author = member + + assert cog._last_member is None + + await cog.hello(cog, ctx) + + assert cog._last_member is member diff --git a/tests/test_invite_response.py b/tests/test_invite_response.py new file mode 100644 index 0000000..f69430b --- /dev/null +++ b/tests/test_invite_response.py @@ -0,0 +1,265 @@ +"""Tests for the InviteResponseCog module.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from am_bot.cogs.invite_response import InviteResponseCog +from tests.conftest import ( + make_mock_bot, + make_mock_channel, + make_mock_embed, + make_mock_message, +) + + +class TestInviteResponseCog: + """Tests for the InviteResponseCog class.""" + + @pytest.fixture + def cog(self): + """Create an InviteResponseCog instance with mocked bot.""" + bot = make_mock_bot() + return InviteResponseCog(bot) + + def test_init(self, cog): + """Test InviteResponseCog initialization.""" + assert cog.bot is not None + + def test_parse_embed_email_with_backticks(self, cog): + """Test parsing email from embed with backticks.""" + embed = make_mock_embed( + fields=[{"name": "Email", "value": "`test@example.com`"}] + ) + + result = cog._parse_embed_email(embed) + assert result == "test@example.com" + + def test_parse_embed_email_without_backticks(self, cog): + """Test parsing email from embed without backticks.""" + embed = make_mock_embed( + fields=[{"name": "Email", "value": "test@example.com"}] + ) + + result = cog._parse_embed_email(embed) + assert result == "test@example.com" + + def test_parse_embed_email_no_email_field(self, cog): + """Test parsing when no Email field exists.""" + embed = make_mock_embed( + fields=[{"name": "Other", "value": "Some value"}] + ) + + result = cog._parse_embed_email(embed) + assert result is None + + def test_parse_embed_email_empty_fields(self, cog): + """Test parsing when embed has no fields.""" + embed = make_mock_embed(fields=[]) + + result = cog._parse_embed_email(embed) + assert result is None + + @pytest.mark.asyncio + async def test_on_message_ignores_self(self, cog): + """Test that bot ignores its own messages.""" + message = make_mock_message() + message.author.id = cog.bot.user.id + + with patch("am_bot.cogs.invite_response.send_email") as mock_send: + await cog.on_message(message) + mock_send.assert_not_called() + + @pytest.mark.asyncio + async def test_on_message_ignores_wrong_channel(self, cog): + """Test that messages in wrong channel are ignored.""" + message = make_mock_message() + message.author.id = 12345 + message.channel.id = 99999 # Not the invite help channel + + with patch( + "am_bot.cogs.invite_response.INVITE_HELP_TEXT_CHANNEL_ID", 88888 + ): + with patch("am_bot.cogs.invite_response.send_email") as mock_send: + await cog.on_message(message) + mock_send.assert_not_called() + + @pytest.mark.asyncio + async def test_on_message_ignores_no_reference(self, cog): + """Test that non-reply messages are ignored.""" + message = make_mock_message() + message.author.id = 12345 + message.reference = None + + with patch( + "am_bot.cogs.invite_response.INVITE_HELP_TEXT_CHANNEL_ID", + message.channel.id, + ): + with patch("am_bot.cogs.invite_response.send_email") as mock_send: + await cog.on_message(message) + mock_send.assert_not_called() + + @pytest.mark.asyncio + async def test_on_message_ignores_empty_content(self, cog): + """Test that messages with no content are ignored.""" + message = make_mock_message(content="") + message.author.id = 12345 + message.reference = MagicMock() + message.reference.channel_id = message.channel.id + message.reference.resolved = None + message.reference.message_id = 123 + + with patch( + "am_bot.cogs.invite_response.INVITE_HELP_TEXT_CHANNEL_ID", + message.channel.id, + ): + with patch("am_bot.cogs.invite_response.send_email") as mock_send: + await cog.on_message(message) + mock_send.assert_not_called() + + @pytest.mark.asyncio + async def test_on_message_sends_email_from_embed(self, cog): + """Test that email is sent when replying to embed message.""" + channel = make_mock_channel() + + # Create referenced message with embed + referenced_embed = make_mock_embed( + description="User's help request here", + fields=[{"name": "Email", "value": "`user@example.com`"}], + ) + referenced_message = make_mock_message(embeds=[referenced_embed]) + + # Create reply message + message = make_mock_message( + content="Here is my response", + channel=channel, + ) + message.author.id = 12345 + message.author.display_name = "StaffMember" + message.reference = MagicMock() + message.reference.channel_id = channel.id + message.reference.resolved = referenced_message + message.reference.message_id = referenced_message.id + + with patch( + "am_bot.cogs.invite_response.INVITE_HELP_TEXT_CHANNEL_ID", + channel.id, + ): + with patch("am_bot.cogs.invite_response.send_email") as mock_send: + await cog.on_message(message) + + mock_send.assert_called_once() + call_args = mock_send.call_args + assert call_args[0][0] == "user@example.com" + assert ( + "ARK Modding Discord Staff Response" + in call_args[1]["subject"] + ) + assert "Here is my response" in call_args[1]["body_txt"] + assert "StaffMember" in call_args[1]["body_txt"] + + @pytest.mark.asyncio + async def test_on_message_fetches_unresolved_reference(self, cog): + """Test that unresolved references are fetched.""" + channel = make_mock_channel() + + # Create referenced message with embed + referenced_embed = make_mock_embed( + description="User's help request", + fields=[{"name": "Email", "value": "`fetched@example.com`"}], + ) + referenced_message = make_mock_message(embeds=[referenced_embed]) + channel.fetch_message.return_value = referenced_message + + # Create reply message with unresolved reference + message = make_mock_message( + content="Staff response", + channel=channel, + ) + message.author.id = 12345 + message.author.display_name = "Staff" + message.reference = MagicMock() + message.reference.channel_id = channel.id + message.reference.resolved = None # Not resolved + message.reference.message_id = 777777 + + with patch( + "am_bot.cogs.invite_response.INVITE_HELP_TEXT_CHANNEL_ID", + channel.id, + ): + with patch("am_bot.cogs.invite_response.send_email") as mock_send: + await cog.on_message(message) + + channel.fetch_message.assert_called_once_with(777777) + mock_send.assert_called_once() + assert mock_send.call_args[0][0] == "fetched@example.com" + + @pytest.mark.asyncio + async def test_on_message_handles_legacy_plain_text(self, cog): + """Test fallback to legacy plain text format.""" + channel = make_mock_channel() + + # Create referenced message with plain text (legacy format) + # The regex pattern expects "Email: " at the start of the line + legacy_content = """Email: legacy@example.com +Line 2 +Line 3 +Line 4 +Line 5 +Line 6 +Line 7 +Help request content +More content here""" + referenced_message = make_mock_message( + content=legacy_content, embeds=[] + ) + + # Create reply message + message = make_mock_message( + content="Response to legacy", + channel=channel, + ) + message.author.id = 12345 + message.author.display_name = "Staff" + message.reference = MagicMock() + message.reference.channel_id = channel.id + message.reference.resolved = referenced_message + message.reference.message_id = referenced_message.id + + with patch( + "am_bot.cogs.invite_response.INVITE_HELP_TEXT_CHANNEL_ID", + channel.id, + ): + with patch("am_bot.cogs.invite_response.send_email") as mock_send: + await cog.on_message(message) + + mock_send.assert_called_once() + assert mock_send.call_args[0][0] == "legacy@example.com" + + @pytest.mark.asyncio + async def test_on_message_no_email_found(self, cog): + """Test that no email is sent when email not found.""" + channel = make_mock_channel() + + # Referenced message with no email + referenced_message = make_mock_message( + content="No email here", embeds=[] + ) + + message = make_mock_message( + content="Response", + channel=channel, + ) + message.author.id = 12345 + message.reference = MagicMock() + message.reference.channel_id = channel.id + message.reference.resolved = referenced_message + message.reference.message_id = referenced_message.id + + with patch( + "am_bot.cogs.invite_response.INVITE_HELP_TEXT_CHANNEL_ID", + channel.id, + ): + with patch("am_bot.cogs.invite_response.send_email") as mock_send: + await cog.on_message(message) + mock_send.assert_not_called() diff --git a/tests/test_quarantine.py b/tests/test_quarantine.py new file mode 100644 index 0000000..3fd1c57 --- /dev/null +++ b/tests/test_quarantine.py @@ -0,0 +1,565 @@ +"""Tests for the QuarantineCog module.""" + +import os +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from tests.conftest import ( + make_mock_bot, + make_mock_channel, + make_mock_guild, + make_mock_member, + make_mock_message, +) + + +# Set environment variables before importing the module +@pytest.fixture(autouse=True) +def setup_env(): + """Set up environment variables for tests.""" + with patch.dict( + os.environ, + { + "QUARANTINE_HONEYPOT_CHANNEL_ID": "123456789", + "QUARANTINE_ROLE_ID": "987654321", + "SPAM_SIMILARITY_THRESHOLD": "0.85", + "SPAM_CHANNEL_THRESHOLD": "3", + "MESSAGE_HISTORY_SECONDS": "3600", + "SPAM_MIN_MESSAGE_LENGTH": "20", + }, + ): + yield + + +class TestMessageRecord: + """Tests for the MessageRecord dataclass.""" + + def test_message_record_creation(self): + """Test creating a MessageRecord.""" + from am_bot.cogs.quarantine import MessageRecord + + now = datetime.now(timezone.utc) + record = MessageRecord( + content="test message content", + channel_id=123456789, + timestamp=now, + ) + assert record.content == "test message content" + assert record.channel_id == 123456789 + assert record.timestamp == now + + +class TestQuarantineCog: + """Tests for the QuarantineCog class.""" + + @pytest.fixture + def cog(self): + """Create a QuarantineCog instance with mocked bot.""" + from am_bot.cogs.quarantine import QuarantineCog + + bot = make_mock_bot() + return QuarantineCog(bot) + + def test_init(self, cog): + """Test QuarantineCog initialization.""" + assert cog.bot is not None + assert cog.message_history is not None + assert len(cog.message_history) == 0 + assert cog._cleanup_task is None + + def test_get_similarity_identical(self, cog): + """Test similarity calculation for identical strings.""" + similarity = cog._get_similarity("hello world", "hello world") + assert similarity == 1.0 + + def test_get_similarity_different(self, cog): + """Test similarity calculation for different strings.""" + similarity = cog._get_similarity("hello", "goodbye") + assert similarity < 0.5 + + def test_get_similarity_similar(self, cog): + """Test similarity calculation for similar strings.""" + similarity = cog._get_similarity( + "this is a test message about spam", + "this is a test message about spam detection", + ) + assert similarity > 0.7 + + def test_record_message(self, cog): + """Test recording a message in history.""" + message = make_mock_message(content="This is a test message") + message.author.id = 12345 + + cog._record_message(message) + + assert 12345 in cog.message_history + assert len(cog.message_history[12345]) == 1 + assert ( + cog.message_history[12345][0].content == "this is a test message" + ) + + def test_record_message_truncates_long_content(self, cog): + """Test that long messages are truncated.""" + long_content = "x" * 500 + message = make_mock_message(content=long_content) + message.author.id = 12345 + + cog._record_message(message) + + # Content should be truncated to 200 characters + assert len(cog.message_history[12345][0].content) == 200 + + def test_record_message_enforces_max_limit(self, cog): + """Test that message history enforces max limit per user.""" + from am_bot.cogs.quarantine import _MAX_MESSAGES_PER_USER + + message = make_mock_message(content="Test message") + message.author.id = 12345 + + # Record more messages than the limit + for i in range(_MAX_MESSAGES_PER_USER + 10): + message.content = f"Message number {i}" + cog._record_message(message) + + assert len(cog.message_history[12345]) == _MAX_MESSAGES_PER_USER + + def test_detect_cross_channel_spam_empty_content(self, cog): + """Test spam detection returns False for empty content.""" + result = cog._detect_cross_channel_spam(12345, "", 99999) + assert result is False + + def test_detect_cross_channel_spam_short_message(self, cog): + """Test spam detection returns False for short messages.""" + result = cog._detect_cross_channel_spam(12345, "hi", 99999) + assert result is False + + def test_detect_cross_channel_spam_no_history(self, cog): + """Test spam detection returns False when no history exists.""" + result = cog._detect_cross_channel_spam( + 12345, "This is a longer test message for spam detection", 99999 + ) + assert result is False + + def test_detect_cross_channel_spam_detects_spam(self, cog): + """Test spam detection detects cross-channel spam.""" + from am_bot.cogs.quarantine import MessageRecord + + user_id = 12345 + spam_message = ( + "This is definitely spam that should be detected across channels" + ) + + # Add similar messages to history from different channels + now = datetime.now(timezone.utc) + cog.message_history[user_id] = [ + MessageRecord(spam_message.lower(), 100, now), + MessageRecord(spam_message.lower(), 200, now), + MessageRecord(spam_message.lower(), 300, now), + ] + + # Check a new message in a different channel + result = cog._detect_cross_channel_spam( + user_id, spam_message, 999 # Different channel + ) + assert result is True + + def test_detect_cross_channel_spam_same_channel_not_spam(self, cog): + """Test that same-channel messages don't trigger spam detection.""" + from am_bot.cogs.quarantine import MessageRecord + + user_id = 12345 + spam_message = ( + "This is definitely spam that should be detected across channels" + ) + + # Add messages to history from the SAME channel + now = datetime.now(timezone.utc) + cog.message_history[user_id] = [ + MessageRecord(spam_message.lower(), 100, now), + MessageRecord(spam_message.lower(), 100, now), + MessageRecord(spam_message.lower(), 100, now), + ] + + # Check a new message in the same channel - should NOT be spam + result = cog._detect_cross_channel_spam(user_id, spam_message, 100) + assert result is False + + def test_detect_cross_channel_spam_different_lengths(self, cog): + """Test spam detection skips messages with very different lengths.""" + from am_bot.cogs.quarantine import MessageRecord + + user_id = 12345 + now = datetime.now(timezone.utc) + + # Add a short message to history + cog.message_history[user_id] = [ + MessageRecord("short", 100, now), + ] + + # Check a much longer message - should not match due to length ratio + long_message = "This is a very long message that should not match" + result = cog._detect_cross_channel_spam(user_id, long_message, 200) + assert result is False + + def test_cleanup_old_messages(self, cog): + """Test cleanup of old messages.""" + from am_bot.cogs.quarantine import ( + MESSAGE_HISTORY_SECONDS, + MessageRecord, + ) + + user_id = 12345 + now = datetime.now(timezone.utc) + old_time = now - timedelta(seconds=MESSAGE_HISTORY_SECONDS + 100) + + # Add old and new messages + cog.message_history[user_id] = [ + MessageRecord("old message", 100, old_time), + MessageRecord("new message", 200, now), + ] + + cog._cleanup_old_messages() + + # Only new message should remain + assert len(cog.message_history[user_id]) == 1 + assert cog.message_history[user_id][0].content == "new message" + + def test_cleanup_removes_empty_users(self, cog): + """Test that cleanup removes users with no messages.""" + from am_bot.cogs.quarantine import ( + MESSAGE_HISTORY_SECONDS, + MessageRecord, + ) + + user_id = 12345 + old_time = datetime.now(timezone.utc) - timedelta( + seconds=MESSAGE_HISTORY_SECONDS + 100 + ) + + # Add only old messages + cog.message_history[user_id] = [ + MessageRecord("old message", 100, old_time), + ] + + cog._cleanup_old_messages() + + # User should be removed entirely + assert user_id not in cog.message_history + + @pytest.mark.asyncio + async def test_assign_quarantine_role_no_role_configured(self, cog): + """Test that quarantine role assignment fails when not configured.""" + with patch("am_bot.cogs.quarantine.QUARANTINE_ROLE_ID", 0): + member = make_mock_member() + guild = make_mock_guild() + result = await cog._assign_quarantine_role( + member, guild, "test reason" + ) + assert result is False + + @pytest.mark.asyncio + async def test_assign_quarantine_role_role_not_found(self, cog): + """Test quarantine role assignment when role doesn't exist.""" + with patch("am_bot.cogs.quarantine.QUARANTINE_ROLE_ID", 12345): + member = make_mock_member() + guild = make_mock_guild() + guild.get_role.return_value = None + + result = await cog._assign_quarantine_role( + member, guild, "test reason" + ) + assert result is False + + @pytest.mark.asyncio + async def test_assign_quarantine_role_success(self, cog): + """Test successful quarantine role assignment.""" + with patch("am_bot.cogs.quarantine.QUARANTINE_ROLE_ID", 12345): + member = make_mock_member() + guild = make_mock_guild() + mock_role = MagicMock() + guild.get_role.return_value = mock_role + + result = await cog._assign_quarantine_role( + member, guild, "test reason" + ) + + assert result is True + member.add_roles.assert_called_once_with( + mock_role, reason="test reason" + ) + + @pytest.mark.asyncio + async def test_assign_quarantine_role_forbidden(self, cog): + """Test quarantine role assignment when bot lacks permissions.""" + import discord + + with patch("am_bot.cogs.quarantine.QUARANTINE_ROLE_ID", 12345): + member = make_mock_member() + member.add_roles.side_effect = discord.errors.Forbidden( + MagicMock(), "Missing permissions" + ) + guild = make_mock_guild() + mock_role = MagicMock() + guild.get_role.return_value = mock_role + + result = await cog._assign_quarantine_role( + member, guild, "test reason" + ) + assert result is False + + @pytest.mark.asyncio + async def test_purge_channel_success(self, cog): + """Test successful channel purge.""" + # Create guild with proper me attribute + guild = make_mock_guild() + channel = make_mock_channel(guild=guild) + channel.guild = guild + + member = make_mock_member() + after = datetime.now(timezone.utc) - timedelta(hours=1) + + # Mock purge returning deleted messages + deleted_messages = [MagicMock() for _ in range(5)] + channel.purge.return_value = deleted_messages + + result = await cog._purge_channel(channel, member, after) + assert result == 5 + + @pytest.mark.asyncio + async def test_purge_channel_no_permissions(self, cog): + """Test channel purge when bot lacks permissions.""" + # Create guild with proper me attribute + guild = make_mock_guild() + channel = make_mock_channel(guild=guild) + channel.guild = guild + + permissions = MagicMock() + permissions.read_messages = False + permissions.manage_messages = False + channel.permissions_for.return_value = permissions + + member = make_mock_member() + after = datetime.now(timezone.utc) - timedelta(hours=1) + + result = await cog._purge_channel(channel, member, after) + assert result == 0 + + @pytest.mark.asyncio + async def test_on_message_ignores_bot_messages(self, cog): + """Test that bot messages are ignored.""" + message = make_mock_message() + message.author.bot = True + + # Should not raise and should return early + await cog.on_message(message) + # No quarantine action should be taken + message.delete.assert_not_called() + + @pytest.mark.asyncio + async def test_on_message_ignores_dms(self, cog): + """Test that DMs are ignored.""" + message = make_mock_message() + message.guild = None + + await cog.on_message(message) + message.delete.assert_not_called() + + @pytest.mark.asyncio + async def test_on_message_honeypot_trigger(self, cog): + """Test honeypot channel triggers quarantine.""" + with ( + patch( + "am_bot.cogs.quarantine.QUARANTINE_HONEYPOT_CHANNEL_ID", 12345 + ), + patch("am_bot.cogs.quarantine.QUARANTINE_ROLE_ID", 98765), + ): + message = make_mock_message() + message.author.bot = False + message.channel.id = 12345 # Honeypot channel + + guild = make_mock_guild() + message.guild = guild + mock_role = MagicMock() + guild.get_role.return_value = mock_role + + await cog.on_message(message) + + # Message should be deleted + message.delete.assert_called_once() + + @pytest.mark.asyncio + async def test_on_message_records_normal_message(self, cog): + """Test that normal messages are recorded in history.""" + with patch("am_bot.cogs.quarantine.QUARANTINE_HONEYPOT_CHANNEL_ID", 0): + message = make_mock_message( + content="This is a normal message to test" + ) + message.author.bot = False + message.author.id = 12345 + message.channel.id = 99999 + + await cog.on_message(message) + + # Message should be recorded + assert 12345 in cog.message_history + assert len(cog.message_history[12345]) == 1 + + def test_cog_unload_cancels_cleanup_task(self, cog): + """Test that cog_unload cancels the cleanup task.""" + mock_task = MagicMock() + cog._cleanup_task = mock_task + + cog.cog_unload() + + mock_task.cancel.assert_called_once() + + def test_cog_unload_handles_no_task(self, cog): + """Test that cog_unload handles case when no task exists.""" + cog._cleanup_task = None + + # Should not raise + cog.cog_unload() + + def test_cog_load_creates_cleanup_task(self, cog): + """Test that cog_load creates the cleanup task.""" + mock_task = MagicMock() + cog.bot.loop.create_task = MagicMock(return_value=mock_task) + + cog.cog_load() + + cog.bot.loop.create_task.assert_called_once() + assert cog._cleanup_task == mock_task + + @pytest.mark.asyncio + async def test_handle_quarantine_deletes_message(self, cog): + """Test that handle_quarantine deletes the triggering message.""" + with patch("am_bot.cogs.quarantine.QUARANTINE_ROLE_ID", 0): + message = make_mock_message() + message.author = make_mock_member(user_id=12345) + message.guild = make_mock_guild() + + await cog._handle_quarantine(message, "test reason") + + message.delete.assert_called_once() + + @pytest.mark.asyncio + async def test_handle_quarantine_clears_user_history(self, cog): + """Test that quarantine clears the user's message history.""" + with patch("am_bot.cogs.quarantine.QUARANTINE_ROLE_ID", 98765): + from datetime import datetime, timezone + + from am_bot.cogs.quarantine import MessageRecord + + member = make_mock_member(user_id=12345) + guild = make_mock_guild() + mock_role = MagicMock() + guild.get_role.return_value = mock_role + guild.text_channels = [] # No channels to purge + + message = make_mock_message() + message.author = member + message.guild = guild + + # Add some message history + cog.message_history[12345] = [ + MessageRecord("test", 100, datetime.now(timezone.utc)) + ] + + await cog._handle_quarantine(message, "test reason") + + # User history should be cleared + assert 12345 not in cog.message_history + + @pytest.mark.asyncio + async def test_purge_member_messages(self, cog): + """Test purging all messages from a member.""" + guild = make_mock_guild() + channel1 = make_mock_channel(channel_id=111) + channel1.guild = guild + channel1.purge.return_value = [MagicMock(), MagicMock()] + + channel2 = make_mock_channel(channel_id=222) + channel2.guild = guild + channel2.purge.return_value = [MagicMock()] + + guild.text_channels = [channel1, channel2] + member = make_mock_member() + + result = await cog._purge_member_messages(member, guild) + + assert result == 3 # 2 from channel1 + 1 from channel2 + + @pytest.mark.asyncio + async def test_purge_channel_http_exception(self, cog): + """Test channel purge handles HTTP exceptions.""" + import discord + + guild = make_mock_guild() + channel = make_mock_channel(guild=guild) + channel.guild = guild + channel.purge.side_effect = discord.errors.HTTPException( + MagicMock(status=500), "Internal Server Error" + ) + + member = make_mock_member() + after = datetime.now(timezone.utc) - timedelta(hours=1) + + result = await cog._purge_channel(channel, member, after) + assert result == 0 + + @pytest.mark.asyncio + async def test_purge_channel_forbidden(self, cog): + """Test channel purge handles Forbidden exception.""" + import discord + + guild = make_mock_guild() + channel = make_mock_channel(guild=guild) + channel.guild = guild + channel.purge.side_effect = discord.errors.Forbidden( + MagicMock(), "Missing access" + ) + + member = make_mock_member() + after = datetime.now(timezone.utc) - timedelta(hours=1) + + result = await cog._purge_channel(channel, member, after) + assert result == 0 + + @pytest.mark.asyncio + async def test_on_message_spam_detection_triggers_quarantine(self, cog): + """Test that spam detection triggers quarantine.""" + from am_bot.cogs.quarantine import MessageRecord + + with ( + patch("am_bot.cogs.quarantine.QUARANTINE_HONEYPOT_CHANNEL_ID", 0), + patch("am_bot.cogs.quarantine.QUARANTINE_ROLE_ID", 98765), + ): + user_id = 12345 + spam_message = "This is definitely spam across channels" + + # Pre-populate history with similar messages + now = datetime.now(timezone.utc) + cog.message_history[user_id] = [ + MessageRecord(spam_message.lower(), 100, now), + MessageRecord(spam_message.lower(), 200, now), + MessageRecord(spam_message.lower(), 300, now), + ] + + guild = make_mock_guild() + mock_role = MagicMock() + guild.get_role.return_value = mock_role + guild.text_channels = [] + + member = make_mock_member(user_id=user_id) + message = make_mock_message(content=spam_message) + message.author = member + message.author.bot = False + message.guild = guild + message.channel.id = 999 # Different channel + + await cog.on_message(message) + + # Message should be deleted (quarantine triggered) + message.delete.assert_called_once() diff --git a/tests/test_responses.py b/tests/test_responses.py new file mode 100644 index 0000000..278b580 --- /dev/null +++ b/tests/test_responses.py @@ -0,0 +1,161 @@ +"""Tests for the ResponsesCog module.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from tests.conftest import make_mock_bot, make_mock_message + + +class TestResponsesCog: + """Tests for the ResponsesCog class.""" + + @pytest.fixture + def mock_commands(self): + """Mock the COMMANDS dictionary.""" + return { + "?": { + "test": { + "content": "This is a test response", + }, + "embed_test": { + "embed": { + "title": "Test Embed", + "description": "Test description", + "color": 123456, + }, + }, + "original": { + "content": "Original content", + }, + "duplicate": { + "duplicate": "original", + }, + }, + "!": { + "cmd": { + "content": "Exclamation command", + }, + }, + } + + @pytest.fixture + def cog(self, mock_commands): + """Create a ResponsesCog instance with mocked bot and commands.""" + with patch("am_bot.cogs.responses.COMMANDS", mock_commands): + from am_bot.cogs.responses import ResponsesCog + + bot = make_mock_bot() + return ResponsesCog(bot) + + @pytest.mark.asyncio + async def test_on_message_ignores_self(self, cog): + """Test that bot ignores its own messages.""" + message = make_mock_message(content="?test") + message.author.id = cog.bot.user.id + + await cog.on_message(message) + + message.channel.send.assert_not_called() + + @pytest.mark.asyncio + async def test_on_message_ignores_empty_content(self, cog): + """Test that bot ignores messages with no content.""" + message = make_mock_message(content="") + message.author.id = 12345 + + await cog.on_message(message) + + message.channel.send.assert_not_called() + + @pytest.mark.asyncio + async def test_on_message_content_response(self, cog, mock_commands): + """Test that content-based commands send the correct response.""" + with patch("am_bot.cogs.responses.COMMANDS", mock_commands): + message = make_mock_message(content="?test") + message.author.id = 12345 + + await cog.on_message(message) + + message.channel.send.assert_called_once() + call_kwargs = message.channel.send.call_args[1] + assert call_kwargs["content"] == "This is a test response" + + @pytest.mark.asyncio + async def test_on_message_embed_response(self, cog, mock_commands): + """Test that embed-based commands send the correct embed.""" + with patch("am_bot.cogs.responses.COMMANDS", mock_commands): + import discord + + with patch.object(discord.Embed, "from_dict") as mock_from_dict: + mock_embed = MagicMock() + mock_from_dict.return_value = mock_embed + + message = make_mock_message(content="?embed_test") + message.author.id = 12345 + + await cog.on_message(message) + + message.channel.send.assert_called_once() + call_kwargs = message.channel.send.call_args[1] + assert call_kwargs["embed"] == mock_embed + + @pytest.mark.asyncio + async def test_on_message_duplicate_command(self, cog, mock_commands): + """Test that duplicate commands reference the original.""" + with patch("am_bot.cogs.responses.COMMANDS", mock_commands): + message = make_mock_message(content="?duplicate") + message.author.id = 12345 + + await cog.on_message(message) + + message.channel.send.assert_called_once() + call_kwargs = message.channel.send.call_args[1] + assert call_kwargs["content"] == "Original content" + + @pytest.mark.asyncio + async def test_on_message_different_prefix(self, cog, mock_commands): + """Test commands with different prefixes.""" + with patch("am_bot.cogs.responses.COMMANDS", mock_commands): + message = make_mock_message(content="!cmd") + message.author.id = 12345 + + await cog.on_message(message) + + message.channel.send.assert_called_once() + call_kwargs = message.channel.send.call_args[1] + assert call_kwargs["content"] == "Exclamation command" + + @pytest.mark.asyncio + async def test_on_message_unknown_prefix(self, cog, mock_commands): + """Test that unknown prefixes are ignored.""" + with patch("am_bot.cogs.responses.COMMANDS", mock_commands): + message = make_mock_message(content="#unknown") + message.author.id = 12345 + + await cog.on_message(message) + + message.channel.send.assert_not_called() + + @pytest.mark.asyncio + async def test_on_message_unknown_command(self, cog, mock_commands): + """Test that unknown commands are ignored.""" + with patch("am_bot.cogs.responses.COMMANDS", mock_commands): + message = make_mock_message(content="?unknown") + message.author.id = 12345 + + await cog.on_message(message) + + message.channel.send.assert_not_called() + + @pytest.mark.asyncio + async def test_on_message_single_character(self, cog, mock_commands): + """Test that single character messages are handled gracefully.""" + with patch("am_bot.cogs.responses.COMMANDS", mock_commands): + message = make_mock_message(content="?") + message.author.id = 12345 + + await cog.on_message(message) + + # Should not crash, just not respond + message.channel.send.assert_not_called() diff --git a/tests/test_role_assignment.py b/tests/test_role_assignment.py new file mode 100644 index 0000000..828c491 --- /dev/null +++ b/tests/test_role_assignment.py @@ -0,0 +1,293 @@ +"""Tests for the RoleAssignmentCog module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from tests.conftest import ( + make_mock_bot, + make_mock_guild, + make_mock_member, + make_mock_reaction_payload, + make_mock_role, +) + + +class TestRoleAssignmentCog: + """Tests for the RoleAssignmentCog class.""" + + @pytest.fixture + def mock_roles(self): + """Mock the ASSIGNABLE_ROLES dictionary.""" + return { + "📋": { + "name": "Jobs Board", + "channel_id": 123456, + "role_id": 111111, + "message_id": 999999, + }, + "1️⃣": { + "name": "Modder", + "channel_id": 234567, + "role_id": 222222, + "message_id": 888888, + }, + "2️⃣": { + "name": "Mapper", + "channel_id": 234567, + "role_id": 333333, + "message_id": 888888, + }, + "cpp": { + "name": "C++", + "emoji_id": 444444, + "channel_id": 123456, + "role_id": 555555, + "message_id": 999999, + }, + } + + @pytest.fixture + def cog(self, mock_roles): + """Create a RoleAssignmentCog instance with mocked bot and roles.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + from am_bot.cogs.role_assignment import RoleAssignmentCog + + bot = make_mock_bot() + return RoleAssignmentCog(bot) + + def test_init(self, cog): + """Test RoleAssignmentCog initialization.""" + assert cog.bot is not None + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_ignores_self(self, cog, mock_roles): + """Test that bot ignores its own reactions.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + payload = make_mock_reaction_payload(emoji_name="📋") + payload.member.id = cog.bot.user.id + + await cog.on_raw_reaction_add(payload) + + payload.member.add_roles.assert_not_called() + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_ignores_unknown_emoji( + self, cog, mock_roles + ): + """Test that unknown emojis are ignored.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + payload = make_mock_reaction_payload(emoji_name="🎉") + + await cog.on_raw_reaction_add(payload) + + payload.member.add_roles.assert_not_called() + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_ignores_wrong_message( + self, cog, mock_roles + ): + """Test that reactions on wrong messages are ignored.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + payload = make_mock_reaction_payload( + emoji_name="📋", message_id=777777 # Wrong message ID + ) + + await cog.on_raw_reaction_add(payload) + + payload.member.add_roles.assert_not_called() + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_adds_role(self, cog, mock_roles): + """Test that correct role is added on reaction.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + mock_role = make_mock_role(role_id=111111, name="Jobs Board") + guild = make_mock_guild() + guild.get_role.return_value = mock_role + + payload = make_mock_reaction_payload( + emoji_name="📋", message_id=999999 + ) + payload.member.guild = guild + + await cog.on_raw_reaction_add(payload) + + guild.get_role.assert_called_once_with(111111) + payload.member.add_roles.assert_called_once_with(mock_role) + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_modder_triggers_stats_update( + self, cog, mock_roles + ): + """Test that Modder role triggers stats update.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + mock_role = make_mock_role(role_id=222222, name="Modder") + guild = make_mock_guild() + guild.get_role.return_value = mock_role + + mock_stats_cog = MagicMock() + mock_stats_cog.update_role_counts = AsyncMock() + cog.bot.get_cog.return_value = mock_stats_cog + + payload = make_mock_reaction_payload( + emoji_name="1️⃣", message_id=888888 + ) + payload.member.guild = guild + + await cog.on_raw_reaction_add(payload) + + cog.bot.get_cog.assert_called_with("ServerStatsCog") + mock_stats_cog.update_role_counts.assert_called_once() + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_mapper_triggers_stats_update( + self, cog, mock_roles + ): + """Test that Mapper role triggers stats update.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + mock_role = make_mock_role(role_id=333333, name="Mapper") + guild = make_mock_guild() + guild.get_role.return_value = mock_role + + mock_stats_cog = MagicMock() + mock_stats_cog.update_role_counts = AsyncMock() + cog.bot.get_cog.return_value = mock_stats_cog + + payload = make_mock_reaction_payload( + emoji_name="2️⃣", message_id=888888 + ) + payload.member.guild = guild + + await cog.on_raw_reaction_add(payload) + + mock_stats_cog.update_role_counts.assert_called_once() + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_no_stats_cog(self, cog, mock_roles): + """Test handling when ServerStatsCog is not loaded.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + mock_role = make_mock_role(role_id=222222, name="Modder") + guild = make_mock_guild() + guild.get_role.return_value = mock_role + + cog.bot.get_cog.return_value = None + + payload = make_mock_reaction_payload( + emoji_name="1️⃣", message_id=888888 + ) + payload.member.guild = guild + + # Should not raise + await cog.on_raw_reaction_add(payload) + payload.member.add_roles.assert_called_once() + + @pytest.mark.asyncio + async def test_on_raw_reaction_remove_ignores_unknown_emoji( + self, cog, mock_roles + ): + """Test that unknown emojis are ignored on removal.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + payload = make_mock_reaction_payload(emoji_name="🎉") + + await cog.on_raw_reaction_remove(payload) + + cog.bot.fetch_guild.assert_not_called() + + @pytest.mark.asyncio + async def test_on_raw_reaction_remove_ignores_wrong_message( + self, cog, mock_roles + ): + """Test that reactions on wrong messages are ignored on removal.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + payload = make_mock_reaction_payload( + emoji_name="📋", message_id=777777 + ) + + await cog.on_raw_reaction_remove(payload) + + cog.bot.fetch_guild.assert_not_called() + + @pytest.mark.asyncio + async def test_on_raw_reaction_remove_removes_role(self, cog, mock_roles): + """Test that correct role is removed on reaction removal.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + mock_role = make_mock_role(role_id=111111, name="Jobs Board") + guild = make_mock_guild() + guild.get_role.return_value = mock_role + member = make_mock_member() + guild.fetch_member.return_value = member + cog.bot.fetch_guild.return_value = guild + + payload = make_mock_reaction_payload( + emoji_name="📋", + message_id=999999, + user_id=12345, + guild_id=guild.id, + ) + + await cog.on_raw_reaction_remove(payload) + + cog.bot.fetch_guild.assert_called_once_with(guild.id) + guild.fetch_member.assert_called_once_with(12345) + guild.get_role.assert_called_once_with(111111) + member.remove_roles.assert_called_once_with(mock_role) + + @pytest.mark.asyncio + async def test_on_raw_reaction_remove_modder_triggers_stats( + self, cog, mock_roles + ): + """Test that Modder role removal triggers stats update.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + mock_role = make_mock_role(role_id=222222, name="Modder") + guild = make_mock_guild() + guild.get_role.return_value = mock_role + member = make_mock_member() + guild.fetch_member.return_value = member + cog.bot.fetch_guild.return_value = guild + + mock_stats_cog = MagicMock() + mock_stats_cog.update_role_counts = AsyncMock() + cog.bot.get_cog.return_value = mock_stats_cog + + payload = make_mock_reaction_payload( + emoji_name="1️⃣", + message_id=888888, + guild_id=guild.id, + ) + + await cog.on_raw_reaction_remove(payload) + + mock_stats_cog.update_role_counts.assert_called_once() + + def test_cog_load_creates_reset_reactions_task(self, cog, mock_roles): + """Test that cog_load creates the reset_reactions task.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + mock_task = MagicMock() + cog.bot.loop.create_task = MagicMock(return_value=mock_task) + + cog.cog_load() + + cog.bot.loop.create_task.assert_called_once() + + @pytest.mark.asyncio + async def test_on_raw_reaction_remove_no_stats_cog(self, cog, mock_roles): + """Test removal handling when ServerStatsCog is not loaded.""" + with patch("am_bot.cogs.role_assignment.ASSIGNABLE_ROLES", mock_roles): + mock_role = make_mock_role(role_id=222222, name="Modder") + guild = make_mock_guild() + guild.get_role.return_value = mock_role + member = make_mock_member() + guild.fetch_member.return_value = member + cog.bot.fetch_guild.return_value = guild + + cog.bot.get_cog.return_value = None + + payload = make_mock_reaction_payload( + emoji_name="1️⃣", + message_id=888888, + guild_id=guild.id, + ) + + # Should not raise + await cog.on_raw_reaction_remove(payload) + member.remove_roles.assert_called_once() diff --git a/tests/test_server_stats.py b/tests/test_server_stats.py new file mode 100644 index 0000000..e88bb26 --- /dev/null +++ b/tests/test_server_stats.py @@ -0,0 +1,192 @@ +"""Tests for the ServerStatsCog module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from am_bot.cogs.server_stats import ServerStatsCog +from tests.conftest import ( + make_mock_bot, + make_mock_channel, + make_mock_guild, + make_mock_member, + make_mock_role, +) + + +class TestServerStatsCog: + """Tests for the ServerStatsCog class.""" + + @pytest.fixture + def cog(self): + """Create a ServerStatsCog instance with mocked bot.""" + bot = make_mock_bot() + return ServerStatsCog(bot) + + def test_init(self, cog): + """Test ServerStatsCog initialization.""" + assert cog.bot is not None + assert cog.guild is None + + @pytest.mark.asyncio + async def test_on_member_join(self, cog): + """Test that member join triggers member count update.""" + with patch.object( + cog, "update_member_count", new_callable=AsyncMock + ) as mock: + await cog.on_member_join(MagicMock()) + mock.assert_called_once() + + @pytest.mark.asyncio + async def test_on_member_remove(self, cog): + """Test that member removal triggers member count update.""" + with patch.object( + cog, "update_member_count", new_callable=AsyncMock + ) as mock: + await cog.on_member_remove(MagicMock()) + mock.assert_called_once() + + @pytest.mark.asyncio + async def test_update_member_count(self, cog): + """Test updating member count channel.""" + # Create mock guild with members + members = [make_mock_member() for _ in range(100)] + guild = make_mock_guild(members=members) + cog.bot.get_guild.return_value = guild + + # Create mock channel + channel = make_mock_channel() + cog.bot.fetch_channel.return_value = channel + + await cog.update_member_count() + + cog.bot.fetch_channel.assert_called_once() + channel.edit.assert_called_once() + call_kwargs = channel.edit.call_args[1] + assert "100" in call_kwargs["name"] + assert "members" in call_kwargs["name"] + + @pytest.mark.asyncio + async def test_update_member_count_caches_guild(self, cog): + """Test that guild is cached after first call.""" + guild = make_mock_guild() + cog.bot.get_guild.return_value = guild + + channel = make_mock_channel() + cog.bot.fetch_channel.return_value = channel + + # First call + await cog.update_member_count() + assert cog.guild == guild + + # Second call should use cached guild + cog.bot.get_guild.reset_mock() + await cog.update_member_count() + + # get_guild should not be called again (uses cache) + # Note: `or` short-circuit means get_guild won't be called if set + + @pytest.mark.asyncio + async def test_update_boost_count(self, cog): + """Test updating boost count channel.""" + guild = make_mock_guild(premium_subscription_count=10) + cog.bot.get_guild.return_value = guild + + channel = make_mock_channel() + cog.bot.fetch_channel.return_value = channel + + await cog.update_boost_count() + + channel.edit.assert_called_once() + call_kwargs = channel.edit.call_args[1] + assert "10" in call_kwargs["name"] + assert "boosts" in call_kwargs["name"] + + @pytest.mark.asyncio + async def test_update_role_counts(self, cog): + """Test updating role count channels.""" + # Create roles with members + modder_role = make_mock_role(name="Modder") + modder_role.members = [make_mock_member() for _ in range(50)] + + mapper_role = make_mock_role(name="Mapper") + mapper_role.members = [make_mock_member() for _ in range(30)] + + guild = make_mock_guild() + guild.get_role.side_effect = lambda rid: { + 190385081523765248: modder_role, # MODDER_ROLE_ID + 190385107297632257: mapper_role, # MAPPER_ROLE_ID + }.get(rid) + cog.bot.get_guild.return_value = guild + + # Create mock channels + modder_channel = make_mock_channel(name="modder-stats") + mapper_channel = make_mock_channel(name="mapper-stats") + + async def fetch_channel_side_effect(channel_id): + if channel_id == 877564476315029525: # MODDER_STATS_CHANNEL_ID + return modder_channel + elif channel_id == 877566216359772211: # MAPPER_STATS_CHANNEL_ID + return mapper_channel + return make_mock_channel() + + cog.bot.fetch_channel.side_effect = fetch_channel_side_effect + + await cog.update_role_counts() + + # Both channels should be updated + modder_channel.edit.assert_called_once() + mapper_channel.edit.assert_called_once() + + # Check modder channel name + modder_call_kwargs = modder_channel.edit.call_args[1] + assert "50" in modder_call_kwargs["name"] + assert "modders" in modder_call_kwargs["name"] + + # Check mapper channel name + mapper_call_kwargs = mapper_channel.edit.call_args[1] + assert "30" in mapper_call_kwargs["name"] + assert "mappers" in mapper_call_kwargs["name"] + + @pytest.mark.asyncio + async def test_update_role_counts_with_empty_roles(self, cog): + """Test updating role counts when roles have no members.""" + modder_role = make_mock_role(name="Modder") + modder_role.members = [] + + mapper_role = make_mock_role(name="Mapper") + mapper_role.members = [] + + guild = make_mock_guild() + guild.get_role.side_effect = lambda rid: { + 190385081523765248: modder_role, + 190385107297632257: mapper_role, + }.get(rid) + cog.bot.get_guild.return_value = guild + + modder_channel = make_mock_channel() + mapper_channel = make_mock_channel() + + async def fetch_channel_side_effect(channel_id): + if channel_id == 877564476315029525: + return modder_channel + elif channel_id == 877566216359772211: + return mapper_channel + return make_mock_channel() + + cog.bot.fetch_channel.side_effect = fetch_channel_side_effect + + await cog.update_role_counts() + + # Should still update with 0 count + modder_call_kwargs = modder_channel.edit.call_args[1] + assert "0" in modder_call_kwargs["name"] + + def test_cog_load_creates_update_task(self, cog): + """Test that cog_load creates the update_server_stats task.""" + mock_task = MagicMock() + cog.bot.loop.create_task = MagicMock(return_value=mock_task) + + cog.cog_load() + + cog.bot.loop.create_task.assert_called_once() diff --git a/tests/test_ses.py b/tests/test_ses.py new file mode 100644 index 0000000..df14576 --- /dev/null +++ b/tests/test_ses.py @@ -0,0 +1,136 @@ +"""Tests for the ses (Simple Email Service) module.""" + +from unittest.mock import MagicMock, patch + +from am_bot.ses import AWS_REGION, SENDER, send_email + + +class TestSendEmail: + """Tests for the send_email function.""" + + def test_sender_constant(self): + """Test SENDER constant is set correctly.""" + assert SENDER == "no-reply@arkmodding.net" + + def test_aws_region_constant(self): + """Test AWS_REGION constant is set correctly.""" + assert AWS_REGION == "us-west-1" + + @patch("am_bot.ses.boto3") + def test_send_email_success(self, mock_boto3): + """Test successful email sending.""" + mock_client = MagicMock() + mock_boto3.client.return_value = mock_client + mock_client.send_email.return_value = {"MessageId": "test-message-id"} + + send_email( + to="user@example.com", + subject="Test Subject", + body_txt="Plain text body", + body_html="
HTML body", + ) + + mock_boto3.client.assert_called_once_with( + "ses", region_name="us-west-1" + ) + mock_client.send_email.assert_called_once_with( + Destination={"ToAddresses": ["user@example.com"]}, + Message={ + "Body": { + "Html": { + "Charset": "utf-8", + "Data": "HTML body", + }, + "Text": {"Charset": "utf-8", "Data": "Plain text body"}, + }, + "Subject": {"Charset": "utf-8", "Data": "Test Subject"}, + }, + Source="no-reply@arkmodding.net", + ) + + @patch("am_bot.ses.boto3") + def test_send_email_client_error(self, mock_boto3): + """Test email sending handles ClientError.""" + from botocore.exceptions import ClientError + + mock_client = MagicMock() + mock_boto3.client.return_value = mock_client + + error_response = { + "Error": {"Message": "Email address is not verified"} + } + mock_client.send_email.side_effect = ClientError( + error_response, "SendEmail" + ) + + # Should not raise, just log warning + send_email( + to="unverified@example.com", + subject="Test", + body_txt="Text", + body_html="HTML
", + ) + + mock_client.send_email.assert_called_once() + + @patch("am_bot.ses.boto3") + def test_send_email_with_special_characters(self, mock_boto3): + """Test email sending with special characters in content.""" + mock_client = MagicMock() + mock_boto3.client.return_value = mock_client + mock_client.send_email.return_value = {"MessageId": "test-id"} + + subject = "Test with émojis 🎉 and spëcial chars" + body_txt = "Line 1\nLine 2\n\tTabbed content" + body_html = "HTML with tags & entities
" + + send_email( + to="user@example.com", + subject=subject, + body_txt=body_txt, + body_html=body_html, + ) + + call_kwargs = mock_client.send_email.call_args[1] + assert call_kwargs["Message"]["Subject"]["Data"] == subject + assert call_kwargs["Message"]["Body"]["Text"]["Data"] == body_txt + assert call_kwargs["Message"]["Body"]["Html"]["Data"] == body_html + + @patch("am_bot.ses.boto3") + def test_send_email_uses_utf8_charset(self, mock_boto3): + """Test that all content uses UTF-8 charset.""" + mock_client = MagicMock() + mock_boto3.client.return_value = mock_client + mock_client.send_email.return_value = {"MessageId": "test-id"} + + send_email( + to="user@example.com", + subject="Test", + body_txt="Text", + body_html="HTML
", + ) + + call_kwargs = mock_client.send_email.call_args[1] + message = call_kwargs["Message"] + + assert message["Subject"]["Charset"] == "utf-8" + assert message["Body"]["Text"]["Charset"] == "utf-8" + assert message["Body"]["Html"]["Charset"] == "utf-8" + + @patch("am_bot.ses.boto3") + def test_send_email_empty_bodies(self, mock_boto3): + """Test sending email with empty bodies.""" + mock_client = MagicMock() + mock_boto3.client.return_value = mock_client + mock_client.send_email.return_value = {"MessageId": "test-id"} + + send_email( + to="user@example.com", + subject="Empty content test", + body_txt="", + body_html="", + ) + + call_kwargs = mock_client.send_email.call_args[1] + assert call_kwargs["Message"]["Body"]["Text"]["Data"] == "" + assert call_kwargs["Message"]["Body"]["Html"]["Data"] == "" diff --git a/tests/test_starboard.py b/tests/test_starboard.py new file mode 100644 index 0000000..5850958 --- /dev/null +++ b/tests/test_starboard.py @@ -0,0 +1,293 @@ +"""Tests for the StarboardCog module.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from am_bot.cogs.starboard import StarboardCog +from tests.conftest import ( + make_mock_bot, + make_mock_channel, + make_mock_embed, + make_mock_message, + make_mock_reaction_payload, +) + + +class TestStarboardCog: + """Tests for the StarboardCog class.""" + + @pytest.fixture + def cog(self): + """Create a StarboardCog instance with mocked bot.""" + bot = make_mock_bot() + return StarboardCog(bot) + + def test_init(self, cog): + """Test StarboardCog initialization.""" + assert cog.bot is not None + assert cog._starred_message_ids == set() + assert cog._last_message is None + + @pytest.mark.asyncio + async def test_on_ready_loads_existing_starboard(self, cog): + """Test that on_ready loads existing starboard messages.""" + starboard_channel_id = 863887933089906718 + guild_id = 153690873186484224 + + # Create mock starboard messages with embeds + embed1 = make_mock_embed( + fields=[ + { + "name": "\u200b", + "value": f"**[Click to jump to message!](https://discord.com/channels/{guild_id}/12345/111111)**", + } + ] + ) + msg1 = make_mock_message(message_id=1, embeds=[embed1]) + + embed2 = make_mock_embed( + fields=[ + { + "name": "\u200b", + "value": f"**[Click to jump to message!](https://discord.com/channels/{guild_id}/12345/222222)**", + } + ] + ) + msg2 = make_mock_message(message_id=2, embeds=[embed2]) + + # Create async generator for channel.history + async def mock_history(*args, **kwargs): + for msg in [msg1, msg2]: + yield msg + + channel = make_mock_channel(channel_id=starboard_channel_id) + channel.history.return_value = mock_history() + cog.bot.get_channel.return_value = channel + + await cog.on_ready() + + # Should have loaded both message IDs + assert 111111 in cog._starred_message_ids + assert 222222 in cog._starred_message_ids + assert cog._last_message == msg1 + + @pytest.mark.asyncio + async def test_on_ready_handles_missing_embeds(self, cog): + """Test that on_ready handles messages without embeds.""" + starboard_channel_id = 863887933089906718 + + # Message without embeds + msg_no_embed = make_mock_message(message_id=1, embeds=[]) + + async def mock_history(*args, **kwargs): + yield msg_no_embed + + channel = make_mock_channel(channel_id=starboard_channel_id) + channel.history.return_value = mock_history() + cog.bot.get_channel.return_value = channel + + # Should not raise + await cog.on_ready() + assert len(cog._starred_message_ids) == 0 + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_ignores_starboard_channel(self, cog): + """Test that reactions in starboard channel are ignored.""" + starboard_channel_id = 863887933089906718 + + payload = make_mock_reaction_payload( + emoji_name="⭐", channel_id=starboard_channel_id + ) + + await cog.on_raw_reaction_add(payload) + + cog.bot.get_channel.assert_not_called() + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_ignores_self(self, cog): + """Test that bot's own reactions are ignored.""" + payload = make_mock_reaction_payload(emoji_name="⭐") + payload.member.id = cog.bot.user.id + + await cog.on_raw_reaction_add(payload) + + cog.bot.get_channel.assert_not_called() + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_ignores_already_starred(self, cog): + """Test that already starred messages are ignored.""" + message_id = 888888 + cog._starred_message_ids.add(message_id) + + payload = make_mock_reaction_payload( + emoji_name="⭐", message_id=message_id + ) + + await cog.on_raw_reaction_add(payload) + + cog.bot.get_channel.assert_not_called() + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_ignores_non_star(self, cog): + """Test that non-star reactions are ignored.""" + payload = make_mock_reaction_payload(emoji_name="🎉") + + await cog.on_raw_reaction_add(payload) + + cog.bot.get_channel.assert_not_called() + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_not_enough_stars(self, cog): + """Test that messages with insufficient stars are not added.""" + # Create message with only 2 star reactions (below threshold of 5) + star_reaction = MagicMock() + star_reaction.emoji = "⭐" + star_reaction.count = 2 + + message = make_mock_message(reactions=[star_reaction, star_reaction]) + channel = make_mock_channel() + channel.fetch_message.return_value = message + cog.bot.get_channel.return_value = channel + + payload = make_mock_reaction_payload( + emoji_name="⭐", channel_id=12345, message_id=888888 + ) + + await cog.on_raw_reaction_add(payload) + + # Should not post to starboard + assert 888888 not in cog._starred_message_ids + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_enough_stars(self, cog): + """Test that messages with enough stars are added to starboard.""" + starboard_channel_id = 863887933089906718 + + # Create star reactions (5 or more) + star_reactions = [MagicMock(emoji="⭐") for _ in range(5)] + + message = make_mock_message( + message_id=888888, + content="Great message!", + reactions=star_reactions, + ) + + # Source channel + source_channel = make_mock_channel(channel_id=12345) + source_channel.fetch_message.return_value = message + + # Starboard channel + starboard_channel = make_mock_channel(channel_id=starboard_channel_id) + starboard_msg = make_mock_message() + starboard_msg.embeds = [make_mock_embed(color=16769024)] + starboard_channel.send.return_value = starboard_msg + + # Set up last message for color alternation + last_msg = make_mock_message() + last_msg.embeds = [make_mock_embed(color=16769024)] + cog._last_message = last_msg + + def get_channel_side_effect(channel_id): + if channel_id == 12345: + return source_channel + elif channel_id == starboard_channel_id: + return starboard_channel + return None + + cog.bot.get_channel.side_effect = get_channel_side_effect + + payload = make_mock_reaction_payload( + emoji_name="⭐", + channel_id=12345, + message_id=888888, + ) + payload.member.avatar_url_as = MagicMock( + return_value="https://cdn.discordapp.com/avatars/123/test.png?size=128" + ) + + await cog.on_raw_reaction_add(payload) + + # Should add to starboard + assert 888888 in cog._starred_message_ids + starboard_channel.send.assert_called_once() + starboard_msg.add_reaction.assert_called_once_with("⭐") + + @pytest.mark.asyncio + async def test_on_raw_reaction_add_alternates_colors(self, cog): + """Test that starboard embed colors alternate.""" + starboard_channel_id = 863887933089906718 + + star_reactions = [MagicMock(emoji="⭐") for _ in range(5)] + message = make_mock_message( + message_id=888888, reactions=star_reactions + ) + + source_channel = make_mock_channel(channel_id=12345) + source_channel.fetch_message.return_value = message + + starboard_channel = make_mock_channel(channel_id=starboard_channel_id) + starboard_msg = make_mock_message() + starboard_channel.send.return_value = starboard_msg + + # Set last message with one color + last_msg = make_mock_message() + last_embed = make_mock_embed(color=16769024) # Gold color + last_msg.embeds = [last_embed] + cog._last_message = last_msg + + def get_channel_side_effect(channel_id): + if channel_id == 12345: + return source_channel + elif channel_id == starboard_channel_id: + return starboard_channel + return None + + cog.bot.get_channel.side_effect = get_channel_side_effect + + payload = make_mock_reaction_payload( + emoji_name="⭐", + channel_id=12345, + message_id=888888, + ) + payload.member.avatar_url_as = MagicMock( + return_value="https://test.com/avatar.png?size=128" + ) + + with patch("discord.Embed") as MockEmbed: + mock_embed_instance = MagicMock() + MockEmbed.from_dict.return_value = mock_embed_instance + + await cog.on_raw_reaction_add(payload) + + # Verify embed was created + starboard_channel.send.assert_called_once() + + +class TestStarboardConstants: + """Tests for starboard module constants.""" + + def test_reaction_limit_is_five(self): + """Test that REACTION_LIMIT is set to 5.""" + from am_bot.cogs.starboard import REACTION_LIMIT + + assert REACTION_LIMIT == 5 + + def test_channel_id_pattern_matches_correctly(self): + """Test that channel_id_pattern regex works.""" + from am_bot.cogs.starboard import channel_id_pattern + + url = "https://discord.com/channels/153690873186484224/12345/67890" + match = channel_id_pattern.findall(url) + + assert len(match) == 1 + assert match[0] == "67890" + + def test_channel_id_pattern_no_match_wrong_guild(self): + """Test pattern doesn't match wrong guild.""" + from am_bot.cogs.starboard import channel_id_pattern + + url = "https://discord.com/channels/999999999999/12345/67890" + match = channel_id_pattern.findall(url) + + assert len(match) == 0 diff --git a/tests/test_workshop.py b/tests/test_workshop.py new file mode 100644 index 0000000..b648f66 --- /dev/null +++ b/tests/test_workshop.py @@ -0,0 +1,212 @@ +"""Tests for the WorkshopCog module.""" + +from unittest.mock import MagicMock + +import pytest + +from am_bot.cogs.workshop import WorkshopCog +from tests.conftest import ( + make_mock_bot, + make_mock_channel, + make_mock_guild, + make_mock_member, + make_mock_role, + make_mock_voice_state, +) + + +class TestWorkshopCog: + """Tests for the WorkshopCog class.""" + + @pytest.fixture + def cog(self): + """Create a WorkshopCog instance with mocked bot.""" + bot = make_mock_bot() + return WorkshopCog(bot) + + def test_init(self, cog): + """Test WorkshopCog initialization.""" + assert cog.bot is not None + + @pytest.mark.asyncio + async def test_on_voice_state_update_join_workshop(self, cog): + """Test joining workshop voice channel adds role and permissions.""" + workshop_voice_id = 770198004053311490 + workshop_text_id = 770198077943971880 + workshop_role_id = 770207045357797378 + + # Create mock role + mock_role = make_mock_role( + role_id=workshop_role_id, name="AMC Workshop" + ) + + # Create mock guild + guild = make_mock_guild() + guild.get_role.return_value = mock_role + + # Create mock text channel + text_channel = make_mock_channel(channel_id=workshop_text_id) + guild.get_channel.return_value = text_channel + + # Create member + member = make_mock_member() + member.guild = guild + + # Create voice states + before = make_mock_voice_state(channel=None) # Not in workshop before + + after_channel = make_mock_channel(channel_id=workshop_voice_id) + after = make_mock_voice_state(channel=after_channel) + + await cog.on_voice_state_update(member, before, after) + + # Should add workshop role + guild.get_role.assert_called_once_with(workshop_role_id) + member.add_roles.assert_called_once_with(mock_role) + + # Should set channel permissions + guild.get_channel.assert_called_once_with(channel_id=workshop_text_id) + text_channel.set_permissions.assert_called_once_with( + member, view_channel=True + ) + + @pytest.mark.asyncio + async def test_on_voice_state_update_leave_workshop(self, cog): + """Test leaving workshop voice channel removes permissions.""" + workshop_voice_id = 770198004053311490 + workshop_text_id = 770198077943971880 + + # Create mock guild + guild = make_mock_guild() + + # Create mock text channel + text_channel = make_mock_channel(channel_id=workshop_text_id) + guild.get_channel.return_value = text_channel + + # Create member + member = make_mock_member() + member.guild = guild + + # Create voice states - was in workshop, now not + before_channel = make_mock_channel(channel_id=workshop_voice_id) + before = make_mock_voice_state(channel=before_channel) + + after = make_mock_voice_state(channel=None) + + await cog.on_voice_state_update(member, before, after) + + # Should remove channel permissions + guild.get_channel.assert_called_once_with(channel_id=workshop_text_id) + text_channel.set_permissions.assert_called_once_with( + member, overwrite=None + ) + + @pytest.mark.asyncio + async def test_on_voice_state_update_switch_to_other_channel(self, cog): + """Test switching from workshop to another channel removes perms.""" + workshop_voice_id = 770198004053311490 + workshop_text_id = 770198077943971880 + + guild = make_mock_guild() + text_channel = make_mock_channel(channel_id=workshop_text_id) + guild.get_channel.return_value = text_channel + + member = make_mock_member() + member.guild = guild + + # Was in workshop + before_channel = make_mock_channel(channel_id=workshop_voice_id) + before = make_mock_voice_state(channel=before_channel) + + # Now in different channel + after_channel = make_mock_channel(channel_id=99999) + after = make_mock_voice_state(channel=after_channel) + + await cog.on_voice_state_update(member, before, after) + + # Should remove permissions + text_channel.set_permissions.assert_called_once_with( + member, overwrite=None + ) + + @pytest.mark.asyncio + async def test_on_voice_state_update_switch_from_other_to_workshop( + self, cog + ): + """Test switching from another channel to workshop adds permissions.""" + workshop_voice_id = 770198004053311490 + workshop_text_id = 770198077943971880 + workshop_role_id = 770207045357797378 + + mock_role = make_mock_role(role_id=workshop_role_id) + guild = make_mock_guild() + guild.get_role.return_value = mock_role + text_channel = make_mock_channel(channel_id=workshop_text_id) + guild.get_channel.return_value = text_channel + + member = make_mock_member() + member.guild = guild + + # Was in different channel + before_channel = make_mock_channel(channel_id=99999) + before = make_mock_voice_state(channel=before_channel) + + # Now in workshop + after_channel = make_mock_channel(channel_id=workshop_voice_id) + after = make_mock_voice_state(channel=after_channel) + + await cog.on_voice_state_update(member, before, after) + + # Should add role and permissions + member.add_roles.assert_called_once() + text_channel.set_permissions.assert_called_once_with( + member, view_channel=True + ) + + @pytest.mark.asyncio + async def test_on_voice_state_update_unrelated_channel(self, cog): + """Test that unrelated channel changes do nothing.""" + guild = make_mock_guild() + member = make_mock_member() + member.guild = guild + + # Switching between two unrelated channels + before_channel = make_mock_channel(channel_id=11111) + before = make_mock_voice_state(channel=before_channel) + + after_channel = make_mock_channel(channel_id=22222) + after = make_mock_voice_state(channel=after_channel) + + await cog.on_voice_state_update(member, before, after) + + # Nothing should happen + member.add_roles.assert_not_called() + guild.get_channel.assert_not_called() + + @pytest.mark.asyncio + async def test_on_voice_state_update_same_channel(self, cog): + """Test that staying in the same channel does nothing.""" + workshop_voice_id = 770198004053311490 + + guild = make_mock_guild() + member = make_mock_member() + member.guild = guild + + # Same channel before and after (e.g., mute/unmute) + channel = make_mock_channel(channel_id=workshop_voice_id) + before = make_mock_voice_state(channel=channel) + after = make_mock_voice_state(channel=channel) + + await cog.on_voice_state_update(member, before, after) + + # Nothing should happen (already has permissions) + member.add_roles.assert_not_called() + + def test_cog_load_creates_cleanup_task(self, cog): + """Test that cog_load creates the text_cleanup_task.""" + mock_task = MagicMock() + cog.bot.loop.create_task = MagicMock(return_value=mock_task) + + cog.cog_load() + + cog.bot.loop.create_task.assert_called_once() diff --git a/tox.ini b/tox.ini index 8336ddb..e73ab8e 100644 --- a/tox.ini +++ b/tox.ini @@ -68,13 +68,15 @@ commands= # To run unit tests. deps= -r requirements.txt - pytest + pytest>=7.0.0 + pytest-asyncio>=0.23.0 + pytest-cov>=4.0.0 coverage commands= coverage erase - coverage run -m pytest {posargs} + coverage run -m pytest tests/ {posargs} coverage xml -o {toxinidir}/coverage.xml - coverage report + coverage report --fail-under=90 [testenv:lint] @@ -100,11 +102,13 @@ commands={[unittest-config]commands} # requires a coverage file and should only be run after unittest deps= -r requirements.txt - pytest + pytest>=7.0.0 + pytest-asyncio>=0.23.0 + pytest-cov>=4.0.0 coverage diff-cover commands= - coverage report + coverage report --fail-under=90 coverage xml # diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 diff-cover coverage.xml --compare-branch=origin/main