From 1c2b384915f4a7ba070c95c86126746bae2f7279 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 14 Aug 2020 09:59:56 -0700 Subject: Rename "cogs" directory to "exts" The directory contains modules, which are extensions. It only indirectly contains cogs through the extensions. Therefore, a technically more accurate name is "extensions", or "exts" when abbreviated. Furthermore, "exts" is consistent with SeasonalBot. --- tests/bot/cogs/__init__.py | 0 tests/bot/cogs/backend/__init__.py | 0 tests/bot/cogs/backend/sync/__init__.py | 0 tests/bot/cogs/backend/sync/test_base.py | 404 ----------- tests/bot/cogs/backend/sync/test_cog.py | 416 ----------- tests/bot/cogs/backend/sync/test_roles.py | 157 ----- tests/bot/cogs/backend/sync/test_users.py | 158 ----- tests/bot/cogs/backend/test_logging.py | 32 - tests/bot/cogs/filters/__init__.py | 0 tests/bot/cogs/filters/test_antimalware.py | 165 ----- tests/bot/cogs/filters/test_antispam.py | 35 - tests/bot/cogs/filters/test_security.py | 54 -- tests/bot/cogs/filters/test_token_remover.py | 310 --------- tests/bot/cogs/info/__init__.py | 0 tests/bot/cogs/info/test_information.py | 584 ---------------- tests/bot/cogs/moderation/__init__.py | 0 tests/bot/cogs/moderation/infraction/__init__.py | 0 .../cogs/moderation/infraction/test_infractions.py | 55 -- tests/bot/cogs/moderation/test_incidents.py | 770 --------------------- tests/bot/cogs/moderation/test_modlog.py | 29 - tests/bot/cogs/moderation/test_silence.py | 261 ------- tests/bot/cogs/moderation/test_slowmode.py | 111 --- tests/bot/cogs/test_cogs.py | 80 --- tests/bot/cogs/test_duck_pond.py | 548 --------------- tests/bot/cogs/utils/__init__.py | 0 tests/bot/cogs/utils/test_jams.py | 173 ----- tests/bot/cogs/utils/test_snekbox.py | 409 ----------- tests/bot/exts/__init__.py | 0 tests/bot/exts/backend/__init__.py | 0 tests/bot/exts/backend/sync/__init__.py | 0 tests/bot/exts/backend/sync/test_base.py | 404 +++++++++++ tests/bot/exts/backend/sync/test_cog.py | 416 +++++++++++ tests/bot/exts/backend/sync/test_roles.py | 157 +++++ tests/bot/exts/backend/sync/test_users.py | 158 +++++ tests/bot/exts/backend/test_logging.py | 32 + tests/bot/exts/filters/__init__.py | 0 tests/bot/exts/filters/test_antimalware.py | 165 +++++ tests/bot/exts/filters/test_antispam.py | 35 + tests/bot/exts/filters/test_security.py | 54 ++ tests/bot/exts/filters/test_token_remover.py | 310 +++++++++ tests/bot/exts/info/__init__.py | 0 tests/bot/exts/info/test_information.py | 584 ++++++++++++++++ tests/bot/exts/moderation/__init__.py | 0 tests/bot/exts/moderation/infraction/__init__.py | 0 .../exts/moderation/infraction/test_infractions.py | 55 ++ tests/bot/exts/moderation/test_incidents.py | 770 +++++++++++++++++++++ tests/bot/exts/moderation/test_modlog.py | 29 + tests/bot/exts/moderation/test_silence.py | 261 +++++++ tests/bot/exts/moderation/test_slowmode.py | 111 +++ tests/bot/exts/test_cogs.py | 81 +++ tests/bot/exts/test_duck_pond.py | 548 +++++++++++++++ tests/bot/exts/utils/__init__.py | 0 tests/bot/exts/utils/test_jams.py | 173 +++++ tests/bot/exts/utils/test_snekbox.py | 409 +++++++++++ 54 files changed, 4752 insertions(+), 4751 deletions(-) delete mode 100644 tests/bot/cogs/__init__.py delete mode 100644 tests/bot/cogs/backend/__init__.py delete mode 100644 tests/bot/cogs/backend/sync/__init__.py delete mode 100644 tests/bot/cogs/backend/sync/test_base.py delete mode 100644 tests/bot/cogs/backend/sync/test_cog.py delete mode 100644 tests/bot/cogs/backend/sync/test_roles.py delete mode 100644 tests/bot/cogs/backend/sync/test_users.py delete mode 100644 tests/bot/cogs/backend/test_logging.py delete mode 100644 tests/bot/cogs/filters/__init__.py delete mode 100644 tests/bot/cogs/filters/test_antimalware.py delete mode 100644 tests/bot/cogs/filters/test_antispam.py delete mode 100644 tests/bot/cogs/filters/test_security.py delete mode 100644 tests/bot/cogs/filters/test_token_remover.py delete mode 100644 tests/bot/cogs/info/__init__.py delete mode 100644 tests/bot/cogs/info/test_information.py delete mode 100644 tests/bot/cogs/moderation/__init__.py delete mode 100644 tests/bot/cogs/moderation/infraction/__init__.py delete mode 100644 tests/bot/cogs/moderation/infraction/test_infractions.py delete mode 100644 tests/bot/cogs/moderation/test_incidents.py delete mode 100644 tests/bot/cogs/moderation/test_modlog.py delete mode 100644 tests/bot/cogs/moderation/test_silence.py delete mode 100644 tests/bot/cogs/moderation/test_slowmode.py delete mode 100644 tests/bot/cogs/test_cogs.py delete mode 100644 tests/bot/cogs/test_duck_pond.py delete mode 100644 tests/bot/cogs/utils/__init__.py delete mode 100644 tests/bot/cogs/utils/test_jams.py delete mode 100644 tests/bot/cogs/utils/test_snekbox.py create mode 100644 tests/bot/exts/__init__.py create mode 100644 tests/bot/exts/backend/__init__.py create mode 100644 tests/bot/exts/backend/sync/__init__.py create mode 100644 tests/bot/exts/backend/sync/test_base.py create mode 100644 tests/bot/exts/backend/sync/test_cog.py create mode 100644 tests/bot/exts/backend/sync/test_roles.py create mode 100644 tests/bot/exts/backend/sync/test_users.py create mode 100644 tests/bot/exts/backend/test_logging.py create mode 100644 tests/bot/exts/filters/__init__.py create mode 100644 tests/bot/exts/filters/test_antimalware.py create mode 100644 tests/bot/exts/filters/test_antispam.py create mode 100644 tests/bot/exts/filters/test_security.py create mode 100644 tests/bot/exts/filters/test_token_remover.py create mode 100644 tests/bot/exts/info/__init__.py create mode 100644 tests/bot/exts/info/test_information.py create mode 100644 tests/bot/exts/moderation/__init__.py create mode 100644 tests/bot/exts/moderation/infraction/__init__.py create mode 100644 tests/bot/exts/moderation/infraction/test_infractions.py create mode 100644 tests/bot/exts/moderation/test_incidents.py create mode 100644 tests/bot/exts/moderation/test_modlog.py create mode 100644 tests/bot/exts/moderation/test_silence.py create mode 100644 tests/bot/exts/moderation/test_slowmode.py create mode 100644 tests/bot/exts/test_cogs.py create mode 100644 tests/bot/exts/test_duck_pond.py create mode 100644 tests/bot/exts/utils/__init__.py create mode 100644 tests/bot/exts/utils/test_jams.py create mode 100644 tests/bot/exts/utils/test_snekbox.py (limited to 'tests') diff --git a/tests/bot/cogs/__init__.py b/tests/bot/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/backend/__init__.py b/tests/bot/cogs/backend/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/backend/sync/__init__.py b/tests/bot/cogs/backend/sync/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/backend/sync/test_base.py b/tests/bot/cogs/backend/sync/test_base.py deleted file mode 100644 index 3009aacb6..000000000 --- a/tests/bot/cogs/backend/sync/test_base.py +++ /dev/null @@ -1,404 +0,0 @@ -import asyncio -import unittest -from unittest import mock - -import discord - -from bot import constants -from bot.api import ResponseCodeError -from bot.cogs.backend.sync._syncers import Syncer, _Diff -from tests import helpers - - -class TestSyncer(Syncer): - """Syncer subclass with mocks for abstract methods for testing purposes.""" - - name = "test" - _get_diff = mock.AsyncMock() - _sync = mock.AsyncMock() - - -class SyncerBaseTests(unittest.TestCase): - """Tests for the syncer base class.""" - - def setUp(self): - self.bot = helpers.MockBot() - - def test_instantiation_fails_without_abstract_methods(self): - """The class must have abstract methods implemented.""" - with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): - Syncer(self.bot) - - -class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase): - """Tests for sending the sync confirmation prompt.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = TestSyncer(self.bot) - - def mock_get_channel(self): - """Fixture to return a mock channel and message for when `get_channel` is used.""" - self.bot.reset_mock() - - mock_channel = helpers.MockTextChannel() - mock_message = helpers.MockMessage() - - mock_channel.send.return_value = mock_message - self.bot.get_channel.return_value = mock_channel - - return mock_channel, mock_message - - def mock_fetch_channel(self): - """Fixture to return a mock channel and message for when `fetch_channel` is used.""" - self.bot.reset_mock() - - mock_channel = helpers.MockTextChannel() - mock_message = helpers.MockMessage() - - self.bot.get_channel.return_value = None - mock_channel.send.return_value = mock_message - self.bot.fetch_channel.return_value = mock_channel - - return mock_channel, mock_message - - async def test_send_prompt_edits_and_returns_message(self): - """The given message should be edited to display the prompt and then should be returned.""" - msg = helpers.MockMessage() - ret_val = await self.syncer._send_prompt(msg) - - msg.edit.assert_called_once() - self.assertIn("content", msg.edit.call_args[1]) - self.assertEqual(ret_val, msg) - - async def test_send_prompt_gets_dev_core_channel(self): - """The dev-core channel should be retrieved if an extant message isn't given.""" - subtests = ( - (self.bot.get_channel, self.mock_get_channel), - (self.bot.fetch_channel, self.mock_fetch_channel), - ) - - for method, mock_ in subtests: - with self.subTest(method=method, msg=mock_.__name__): - mock_() - await self.syncer._send_prompt() - - method.assert_called_once_with(constants.Channels.dev_core) - - async def test_send_prompt_returns_none_if_channel_fetch_fails(self): - """None should be returned if there's an HTTPException when fetching the channel.""" - self.bot.get_channel.return_value = None - self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!") - - ret_val = await self.syncer._send_prompt() - - self.assertIsNone(ret_val) - - async def test_send_prompt_sends_and_returns_new_message_if_not_given(self): - """A new message mentioning core devs should be sent and returned if message isn't given.""" - for mock_ in (self.mock_get_channel, self.mock_fetch_channel): - with self.subTest(msg=mock_.__name__): - mock_channel, mock_message = mock_() - ret_val = await self.syncer._send_prompt() - - mock_channel.send.assert_called_once() - self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) - self.assertEqual(ret_val, mock_message) - - async def test_send_prompt_adds_reactions(self): - """The message should have reactions for confirmation added.""" - extant_message = helpers.MockMessage() - subtests = ( - (extant_message, lambda: (None, extant_message)), - (None, self.mock_get_channel), - (None, self.mock_fetch_channel), - ) - - for message_arg, mock_ in subtests: - subtest_msg = "Extant message" if mock_.__name__ == "" else mock_.__name__ - - with self.subTest(msg=subtest_msg): - _, mock_message = mock_() - await self.syncer._send_prompt(message_arg) - - calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] - mock_message.add_reaction.assert_has_calls(calls) - - -class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): - """Tests for waiting for a sync confirmation reaction on the prompt.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = TestSyncer(self.bot) - self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) - - @staticmethod - def get_message_reaction(emoji): - """Fixture to return a mock message an reaction from the given `emoji`.""" - message = helpers.MockMessage() - reaction = helpers.MockReaction(emoji=emoji, message=message) - - return message, reaction - - def test_reaction_check_for_valid_emoji_and_authors(self): - """Should return True if authors are identical or are a bot and a core dev, respectively.""" - user_subtests = ( - ( - helpers.MockMember(id=77), - helpers.MockMember(id=77), - "identical users", - ), - ( - helpers.MockMember(id=77, bot=True), - helpers.MockMember(id=43, roles=[self.core_dev_role]), - "bot author and core-dev reactor", - ), - ) - - for emoji in self.syncer._REACTION_EMOJIS: - for author, user, msg in user_subtests: - with self.subTest(author=author, user=user, emoji=emoji, msg=msg): - message, reaction = self.get_message_reaction(emoji) - ret_val = self.syncer._reaction_check(author, message, reaction, user) - - self.assertTrue(ret_val) - - def test_reaction_check_for_invalid_reactions(self): - """Should return False for invalid reaction events.""" - valid_emoji = self.syncer._REACTION_EMOJIS[0] - subtests = ( - ( - helpers.MockMember(id=77), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=43, roles=[self.core_dev_role]), - "users are not identical", - ), - ( - helpers.MockMember(id=77, bot=True), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=43), - "reactor lacks the core-dev role", - ), - ( - helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), - "reactor is a bot", - ), - ( - helpers.MockMember(id=77), - helpers.MockMessage(id=95), - helpers.MockReaction(emoji=valid_emoji, message=helpers.MockMessage(id=26)), - helpers.MockMember(id=77), - "messages are not identical", - ), - ( - helpers.MockMember(id=77), - *self.get_message_reaction("InVaLiD"), - helpers.MockMember(id=77), - "emoji is invalid", - ), - ) - - for *args, msg in subtests: - kwargs = dict(zip(("author", "message", "reaction", "user"), args)) - with self.subTest(**kwargs, msg=msg): - ret_val = self.syncer._reaction_check(*args) - self.assertFalse(ret_val) - - async def test_wait_for_confirmation(self): - """The message should always be edited and only return True if the emoji is a check mark.""" - subtests = ( - (constants.Emojis.check_mark, True, None), - ("InVaLiD", False, None), - (None, False, asyncio.TimeoutError), - ) - - for emoji, ret_val, side_effect in subtests: - for bot in (True, False): - with self.subTest(emoji=emoji, ret_val=ret_val, side_effect=side_effect, bot=bot): - # Set up mocks - message = helpers.MockMessage() - member = helpers.MockMember(bot=bot) - - self.bot.wait_for.reset_mock() - self.bot.wait_for.return_value = (helpers.MockReaction(emoji=emoji), None) - self.bot.wait_for.side_effect = side_effect - - # Call the function - actual_return = await self.syncer._wait_for_confirmation(member, message) - - # Perform assertions - self.bot.wait_for.assert_called_once() - self.assertIn("reaction_add", self.bot.wait_for.call_args[0]) - - message.edit.assert_called_once() - kwargs = message.edit.call_args[1] - self.assertIn("content", kwargs) - - # Core devs should only be mentioned if the author is a bot. - if bot: - self.assertIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) - else: - self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) - - self.assertIs(actual_return, ret_val) - - -class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): - """Tests for main function orchestrating the sync.""" - - def setUp(self): - self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) - self.syncer = TestSyncer(self.bot) - - async def test_sync_respects_confirmation_result(self): - """The sync should abort if confirmation fails and continue if confirmed.""" - mock_message = helpers.MockMessage() - subtests = ( - (True, mock_message), - (False, None), - ) - - for confirmed, message in subtests: - with self.subTest(confirmed=confirmed): - self.syncer._sync.reset_mock() - self.syncer._get_diff.reset_mock() - - diff = _Diff({1, 2, 3}, {4, 5}, None) - self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = mock.AsyncMock( - return_value=(confirmed, message) - ) - - guild = helpers.MockGuild() - await self.syncer.sync(guild) - - self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._get_confirmation_result.assert_called_once() - - if confirmed: - self.syncer._sync.assert_called_once_with(diff) - else: - self.syncer._sync.assert_not_called() - - async def test_sync_diff_size(self): - """The diff size should be correctly calculated.""" - subtests = ( - (6, _Diff({1, 2}, {3, 4}, {5, 6})), - (5, _Diff({1, 2, 3}, None, {4, 5})), - (0, _Diff(None, None, None)), - (0, _Diff(set(), set(), set())), - ) - - for size, diff in subtests: - with self.subTest(size=size, diff=diff): - self.syncer._get_diff.reset_mock() - self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - - guild = helpers.MockGuild() - await self.syncer.sync(guild) - - self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._get_confirmation_result.assert_called_once() - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) - - async def test_sync_message_edited(self): - """The message should be edited if one was sent, even if the sync has an API error.""" - subtests = ( - (None, None, False), - (helpers.MockMessage(), None, True), - (helpers.MockMessage(), ResponseCodeError(mock.MagicMock()), True), - ) - - for message, side_effect, should_edit in subtests: - with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): - self.syncer._sync.side_effect = side_effect - self.syncer._get_confirmation_result = mock.AsyncMock( - return_value=(True, message) - ) - - guild = helpers.MockGuild() - await self.syncer.sync(guild) - - if should_edit: - message.edit.assert_called_once() - self.assertIn("content", message.edit.call_args[1]) - - async def test_sync_confirmation_context_redirect(self): - """If ctx is given, a new message should be sent and author should be ctx's author.""" - mock_member = helpers.MockMember() - subtests = ( - (None, self.bot.user, None), - (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()), - ) - - for ctx, author, message in subtests: - with self.subTest(ctx=ctx, author=author, message=message): - if ctx is not None: - ctx.send.return_value = message - - # Make sure `_get_diff` returns a MagicMock, not an AsyncMock - self.syncer._get_diff.return_value = mock.MagicMock() - - self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - - guild = helpers.MockGuild() - await self.syncer.sync(guild, ctx) - - if ctx is not None: - ctx.send.assert_called_once() - - self.syncer._get_confirmation_result.assert_called_once() - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) - - @mock.patch.object(constants.Sync, "max_diff", new=3) - async def test_confirmation_result_small_diff(self): - """Should always return True and the given message if the diff size is too small.""" - author = helpers.MockMember() - expected_message = helpers.MockMessage() - - for size in (3, 2): # pragma: no cover - with self.subTest(size=size): - self.syncer._send_prompt = mock.AsyncMock() - self.syncer._wait_for_confirmation = mock.AsyncMock() - - coro = self.syncer._get_confirmation_result(size, author, expected_message) - result, actual_message = await coro - - self.assertTrue(result) - self.assertEqual(actual_message, expected_message) - self.syncer._send_prompt.assert_not_called() - self.syncer._wait_for_confirmation.assert_not_called() - - @mock.patch.object(constants.Sync, "max_diff", new=3) - async def test_confirmation_result_large_diff(self): - """Should return True if confirmed and False if _send_prompt fails or aborted.""" - author = helpers.MockMember() - mock_message = helpers.MockMessage() - - subtests = ( - (True, mock_message, True, "confirmed"), - (False, None, False, "_send_prompt failed"), - (False, mock_message, False, "aborted"), - ) - - for expected_result, expected_message, confirmed, msg in subtests: # pragma: no cover - with self.subTest(msg=msg): - self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message) - self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed) - - coro = self.syncer._get_confirmation_result(4, author) - actual_result, actual_message = await coro - - self.syncer._send_prompt.assert_called_once_with(None) # message defaults to None - self.assertIs(actual_result, expected_result) - self.assertEqual(actual_message, expected_message) - - if expected_message: - self.syncer._wait_for_confirmation.assert_called_once_with( - author, expected_message - ) diff --git a/tests/bot/cogs/backend/sync/test_cog.py b/tests/bot/cogs/backend/sync/test_cog.py deleted file mode 100644 index e40552817..000000000 --- a/tests/bot/cogs/backend/sync/test_cog.py +++ /dev/null @@ -1,416 +0,0 @@ -import unittest -from unittest import mock - -import discord - -from bot import constants -from bot.api import ResponseCodeError -from bot.cogs.backend import sync -from bot.cogs.backend.sync._cog import Sync -from bot.cogs.backend.sync._syncers import Syncer -from tests import helpers -from tests.base import CommandTestCase - - -class SyncExtensionTests(unittest.IsolatedAsyncioTestCase): - """Tests for the sync extension.""" - - @staticmethod - def test_extension_setup(): - """The Sync cog should be added.""" - bot = helpers.MockBot() - sync.setup(bot) - bot.add_cog.assert_called_once() - - -class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): - """Base class for Sync cog tests. Sets up patches for syncers.""" - - def setUp(self): - self.bot = helpers.MockBot() - - self.role_syncer_patcher = mock.patch( - "bot.cogs.backend.sync._syncers.RoleSyncer", - autospec=Syncer, - spec_set=True - ) - self.user_syncer_patcher = mock.patch( - "bot.cogs.backend.sync._syncers.UserSyncer", - autospec=Syncer, - spec_set=True - ) - self.RoleSyncer = self.role_syncer_patcher.start() - self.UserSyncer = self.user_syncer_patcher.start() - - self.cog = Sync(self.bot) - - def tearDown(self): - self.role_syncer_patcher.stop() - self.user_syncer_patcher.stop() - - @staticmethod - def response_error(status: int) -> ResponseCodeError: - """Fixture to return a ResponseCodeError with the given status code.""" - response = mock.MagicMock() - response.status = status - - return ResponseCodeError(response) - - -class SyncCogTests(SyncCogTestCase): - """Tests for the Sync cog.""" - - @mock.patch.object(Sync, "sync_guild", new_callable=mock.MagicMock) - def test_sync_cog_init(self, sync_guild): - """Should instantiate syncers and run a sync for the guild.""" - # Reset because a Sync cog was already instantiated in setUp. - self.RoleSyncer.reset_mock() - self.UserSyncer.reset_mock() - self.bot.loop.create_task = mock.MagicMock() - - mock_sync_guild_coro = mock.MagicMock() - sync_guild.return_value = mock_sync_guild_coro - - Sync(self.bot) - - self.RoleSyncer.assert_called_once_with(self.bot) - self.UserSyncer.assert_called_once_with(self.bot) - sync_guild.assert_called_once_with() - self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) - - async def test_sync_cog_sync_guild(self): - """Roles and users should be synced only if a guild is successfully retrieved.""" - for guild in (helpers.MockGuild(), None): - with self.subTest(guild=guild): - self.bot.reset_mock() - self.cog.role_syncer.reset_mock() - self.cog.user_syncer.reset_mock() - - self.bot.get_guild = mock.MagicMock(return_value=guild) - - await self.cog.sync_guild() - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.get_guild.assert_called_once_with(constants.Guild.id) - - if guild is None: - self.cog.role_syncer.sync.assert_not_called() - self.cog.user_syncer.sync.assert_not_called() - else: - self.cog.role_syncer.sync.assert_called_once_with(guild) - self.cog.user_syncer.sync.assert_called_once_with(guild) - - async def patch_user_helper(self, side_effect: BaseException) -> None: - """Helper to set a side effect for bot.api_client.patch and then assert it is called.""" - self.bot.api_client.patch.reset_mock(side_effect=True) - self.bot.api_client.patch.side_effect = side_effect - - user_id, updated_information = 5, {"key": 123} - await self.cog.patch_user(user_id, updated_information) - - self.bot.api_client.patch.assert_called_once_with( - f"bot/users/{user_id}", - json=updated_information, - ) - - async def test_sync_cog_patch_user(self): - """A PATCH request should be sent and 404 errors ignored.""" - for side_effect in (None, self.response_error(404)): - with self.subTest(side_effect=side_effect): - await self.patch_user_helper(side_effect) - - async def test_sync_cog_patch_user_non_404(self): - """A PATCH request should be sent and the error raised if it's not a 404.""" - with self.assertRaises(ResponseCodeError): - await self.patch_user_helper(self.response_error(500)) - - -class SyncCogListenerTests(SyncCogTestCase): - """Tests for the listeners of the Sync cog.""" - - def setUp(self): - super().setUp() - self.cog.patch_user = mock.AsyncMock(spec_set=self.cog.patch_user) - - self.guild_id_patcher = mock.patch("bot.cogs.backend.sync._cog.constants.Guild.id", 5) - self.guild_id = self.guild_id_patcher.start() - - self.guild = helpers.MockGuild(id=self.guild_id) - self.other_guild = helpers.MockGuild(id=0) - - def tearDown(self): - self.guild_id_patcher.stop() - - async def test_sync_cog_on_guild_role_create(self): - """A POST request should be sent with the new role's data.""" - self.assertTrue(self.cog.on_guild_role_create.__cog_listener__) - - role_data = { - "colour": 49, - "id": 777, - "name": "rolename", - "permissions": 8, - "position": 23, - } - role = helpers.MockRole(**role_data, guild=self.guild) - await self.cog.on_guild_role_create(role) - - self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) - - async def test_sync_cog_on_guild_role_create_ignores_guilds(self): - """Events from other guilds should be ignored.""" - role = helpers.MockRole(guild=self.other_guild) - await self.cog.on_guild_role_create(role) - self.bot.api_client.post.assert_not_awaited() - - async def test_sync_cog_on_guild_role_delete(self): - """A DELETE request should be sent.""" - self.assertTrue(self.cog.on_guild_role_delete.__cog_listener__) - - role = helpers.MockRole(id=99, guild=self.guild) - await self.cog.on_guild_role_delete(role) - - self.bot.api_client.delete.assert_called_once_with("bot/roles/99") - - async def test_sync_cog_on_guild_role_delete_ignores_guilds(self): - """Events from other guilds should be ignored.""" - role = helpers.MockRole(guild=self.other_guild) - await self.cog.on_guild_role_delete(role) - self.bot.api_client.delete.assert_not_awaited() - - async def test_sync_cog_on_guild_role_update(self): - """A PUT request should be sent if the colour, name, permissions, or position changes.""" - self.assertTrue(self.cog.on_guild_role_update.__cog_listener__) - - role_data = { - "colour": 49, - "id": 777, - "name": "rolename", - "permissions": 8, - "position": 23, - } - subtests = ( - (True, ("colour", "name", "permissions", "position")), - (False, ("hoist", "mentionable")), - ) - - for should_put, attributes in subtests: - for attribute in attributes: - with self.subTest(should_put=should_put, changed_attribute=attribute): - self.bot.api_client.put.reset_mock() - - after_role_data = role_data.copy() - after_role_data[attribute] = 876 - - before_role = helpers.MockRole(**role_data, guild=self.guild) - after_role = helpers.MockRole(**after_role_data, guild=self.guild) - - await self.cog.on_guild_role_update(before_role, after_role) - - if should_put: - self.bot.api_client.put.assert_called_once_with( - f"bot/roles/{after_role.id}", - json=after_role_data - ) - else: - self.bot.api_client.put.assert_not_called() - - async def test_sync_cog_on_guild_role_update_ignores_guilds(self): - """Events from other guilds should be ignored.""" - role = helpers.MockRole(guild=self.other_guild) - await self.cog.on_guild_role_update(role, role) - self.bot.api_client.put.assert_not_awaited() - - async def test_sync_cog_on_member_remove(self): - """Member should be patched to set in_guild as False.""" - self.assertTrue(self.cog.on_member_remove.__cog_listener__) - - member = helpers.MockMember(guild=self.guild) - await self.cog.on_member_remove(member) - - self.cog.patch_user.assert_called_once_with( - member.id, - json={"in_guild": False} - ) - - async def test_sync_cog_on_member_remove_ignores_guilds(self): - """Events from other guilds should be ignored.""" - member = helpers.MockMember(guild=self.other_guild) - await self.cog.on_member_remove(member) - self.cog.patch_user.assert_not_awaited() - - async def test_sync_cog_on_member_update_roles(self): - """Members should be patched if their roles have changed.""" - self.assertTrue(self.cog.on_member_update.__cog_listener__) - - # Roles are intentionally unsorted. - before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30), helpers.MockRole(id=20)] - before_member = helpers.MockMember(roles=before_roles, guild=self.guild) - after_member = helpers.MockMember(roles=before_roles[1:], guild=self.guild) - - await self.cog.on_member_update(before_member, after_member) - - data = {"roles": sorted(role.id for role in after_member.roles)} - self.cog.patch_user.assert_called_once_with(after_member.id, json=data) - - async def test_sync_cog_on_member_update_other(self): - """Members should not be patched if other attributes have changed.""" - self.assertTrue(self.cog.on_member_update.__cog_listener__) - - subtests = ( - ("activities", discord.Game("Pong"), discord.Game("Frogger")), - ("nick", "old nick", "new nick"), - ("status", discord.Status.online, discord.Status.offline), - ) - - for attribute, old_value, new_value in subtests: - with self.subTest(attribute=attribute): - self.cog.patch_user.reset_mock() - - before_member = helpers.MockMember(**{attribute: old_value}, guild=self.guild) - after_member = helpers.MockMember(**{attribute: new_value}, guild=self.guild) - - await self.cog.on_member_update(before_member, after_member) - - self.cog.patch_user.assert_not_called() - - async def test_sync_cog_on_member_update_ignores_guilds(self): - """Events from other guilds should be ignored.""" - member = helpers.MockMember(guild=self.other_guild) - await self.cog.on_member_update(member, member) - self.cog.patch_user.assert_not_awaited() - - async def test_sync_cog_on_user_update(self): - """A user should be patched only if the name, discriminator, or avatar changes.""" - self.assertTrue(self.cog.on_user_update.__cog_listener__) - - before_data = { - "name": "old name", - "discriminator": "1234", - "bot": False, - } - - subtests = ( - (True, "name", "name", "new name", "new name"), - (True, "discriminator", "discriminator", "8765", 8765), - (False, "bot", "bot", True, True), - ) - - for should_patch, attribute, api_field, value, api_value in subtests: - with self.subTest(attribute=attribute): - self.cog.patch_user.reset_mock() - - after_data = before_data.copy() - after_data[attribute] = value - before_user = helpers.MockUser(**before_data) - after_user = helpers.MockUser(**after_data) - - await self.cog.on_user_update(before_user, after_user) - - if should_patch: - self.cog.patch_user.assert_called_once() - - # Don't care if *all* keys are present; only the changed one is required - call_args = self.cog.patch_user.call_args - self.assertEqual(call_args.args[0], after_user.id) - self.assertIn("json", call_args.kwargs) - - self.assertIn("ignore_404", call_args.kwargs) - self.assertTrue(call_args.kwargs["ignore_404"]) - - json = call_args.kwargs["json"] - self.assertIn(api_field, json) - self.assertEqual(json[api_field], api_value) - else: - self.cog.patch_user.assert_not_called() - - async def on_member_join_helper(self, side_effect: Exception) -> dict: - """ - Helper to set `side_effect` for on_member_join and assert a PUT request was sent. - - The request data for the mock member is returned. All exceptions will be re-raised. - """ - member = helpers.MockMember( - discriminator="1234", - roles=[helpers.MockRole(id=22), helpers.MockRole(id=12)], - guild=self.guild, - ) - - data = { - "discriminator": int(member.discriminator), - "id": member.id, - "in_guild": True, - "name": member.name, - "roles": sorted(role.id for role in member.roles) - } - - self.bot.api_client.put.reset_mock(side_effect=True) - self.bot.api_client.put.side_effect = side_effect - - try: - await self.cog.on_member_join(member) - except Exception: - raise - finally: - self.bot.api_client.put.assert_called_once_with( - f"bot/users/{member.id}", - json=data - ) - - return data - - async def test_sync_cog_on_member_join(self): - """Should PUT user's data or POST it if the user doesn't exist.""" - for side_effect in (None, self.response_error(404)): - with self.subTest(side_effect=side_effect): - self.bot.api_client.post.reset_mock() - data = await self.on_member_join_helper(side_effect) - - if side_effect: - self.bot.api_client.post.assert_called_once_with("bot/users", json=data) - else: - self.bot.api_client.post.assert_not_called() - - async def test_sync_cog_on_member_join_non_404(self): - """ResponseCodeError should be re-raised if status code isn't a 404.""" - with self.assertRaises(ResponseCodeError): - await self.on_member_join_helper(self.response_error(500)) - - self.bot.api_client.post.assert_not_called() - - async def test_sync_cog_on_member_join_ignores_guilds(self): - """Events from other guilds should be ignored.""" - member = helpers.MockMember(guild=self.other_guild) - await self.cog.on_member_join(member) - self.bot.api_client.post.assert_not_awaited() - self.bot.api_client.put.assert_not_awaited() - - -class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): - """Tests for the commands in the Sync cog.""" - - async def test_sync_roles_command(self): - """sync() should be called on the RoleSyncer.""" - ctx = helpers.MockContext() - await self.cog.sync_roles_command.callback(self.cog, ctx) - - self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) - - async def test_sync_users_command(self): - """sync() should be called on the UserSyncer.""" - ctx = helpers.MockContext() - await self.cog.sync_users_command.callback(self.cog, ctx) - - self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) - - async def test_commands_require_admin(self): - """The sync commands should only run if the author has the administrator permission.""" - cmds = ( - self.cog.sync_group, - self.cog.sync_roles_command, - self.cog.sync_users_command, - ) - - for cmd in cmds: - with self.subTest(cmd=cmd): - await self.assertHasPermissionsCheck(cmd, {"administrator": True}) diff --git a/tests/bot/cogs/backend/sync/test_roles.py b/tests/bot/cogs/backend/sync/test_roles.py deleted file mode 100644 index 99d682ede..000000000 --- a/tests/bot/cogs/backend/sync/test_roles.py +++ /dev/null @@ -1,157 +0,0 @@ -import unittest -from unittest import mock - -import discord - -from bot.cogs.backend.sync._syncers import RoleSyncer, _Diff, _Role -from tests import helpers - - -def fake_role(**kwargs): - """Fixture to return a dictionary representing a role with default values set.""" - kwargs.setdefault("id", 9) - kwargs.setdefault("name", "fake role") - kwargs.setdefault("colour", 7) - kwargs.setdefault("permissions", 0) - kwargs.setdefault("position", 55) - - return kwargs - - -class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): - """Tests for determining differences between roles in the DB and roles in the Guild cache.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = RoleSyncer(self.bot) - - @staticmethod - def get_guild(*roles): - """Fixture to return a guild object with the given roles.""" - guild = helpers.MockGuild() - guild.roles = [] - - for role in roles: - mock_role = helpers.MockRole(**role) - mock_role.colour = discord.Colour(role["colour"]) - mock_role.permissions = discord.Permissions(role["permissions"]) - guild.roles.append(mock_role) - - return guild - - async def test_empty_diff_for_identical_roles(self): - """No differences should be found if the roles in the guild and DB are identical.""" - self.bot.api_client.get.return_value = [fake_role()] - guild = self.get_guild(fake_role()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), set()) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_updated_roles(self): - """Only updated roles should be added to the 'updated' set of the diff.""" - updated_role = fake_role(id=41, name="new") - - self.bot.api_client.get.return_value = [fake_role(id=41, name="old"), fake_role()] - guild = self.get_guild(updated_role, fake_role()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_Role(**updated_role)}, set()) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_new_roles(self): - """Only new roles should be added to the 'created' set of the diff.""" - new_role = fake_role(id=41, name="new") - - self.bot.api_client.get.return_value = [fake_role()] - guild = self.get_guild(fake_role(), new_role) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_Role(**new_role)}, set(), set()) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_deleted_roles(self): - """Only deleted roles should be added to the 'deleted' set of the diff.""" - deleted_role = fake_role(id=61, name="deleted") - - self.bot.api_client.get.return_value = [fake_role(), deleted_role] - guild = self.get_guild(fake_role()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), {_Role(**deleted_role)}) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_new_updated_and_deleted_roles(self): - """When roles are added, updated, and removed, all of them are returned properly.""" - new = fake_role(id=41, name="new") - updated = fake_role(id=71, name="updated") - deleted = fake_role(id=61, name="deleted") - - self.bot.api_client.get.return_value = [ - fake_role(), - fake_role(id=71, name="updated name"), - deleted, - ] - guild = self.get_guild(fake_role(), new, updated) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) - - self.assertEqual(actual_diff, expected_diff) - - -class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase): - """Tests for the API requests that sync roles.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = RoleSyncer(self.bot) - - async def test_sync_created_roles(self): - """Only POST requests should be made with the correct payload.""" - roles = [fake_role(id=111), fake_role(id=222)] - - role_tuples = {_Role(**role) for role in roles} - diff = _Diff(role_tuples, set(), set()) - await self.syncer._sync(diff) - - calls = [mock.call("bot/roles", json=role) for role in roles] - self.bot.api_client.post.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.post.call_count, len(roles)) - - self.bot.api_client.put.assert_not_called() - self.bot.api_client.delete.assert_not_called() - - async def test_sync_updated_roles(self): - """Only PUT requests should be made with the correct payload.""" - roles = [fake_role(id=111), fake_role(id=222)] - - role_tuples = {_Role(**role) for role in roles} - diff = _Diff(set(), role_tuples, set()) - await self.syncer._sync(diff) - - calls = [mock.call(f"bot/roles/{role['id']}", json=role) for role in roles] - self.bot.api_client.put.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.put.call_count, len(roles)) - - self.bot.api_client.post.assert_not_called() - self.bot.api_client.delete.assert_not_called() - - async def test_sync_deleted_roles(self): - """Only DELETE requests should be made with the correct payload.""" - roles = [fake_role(id=111), fake_role(id=222)] - - role_tuples = {_Role(**role) for role in roles} - diff = _Diff(set(), set(), role_tuples) - await self.syncer._sync(diff) - - calls = [mock.call(f"bot/roles/{role['id']}") for role in roles] - self.bot.api_client.delete.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.delete.call_count, len(roles)) - - self.bot.api_client.post.assert_not_called() - self.bot.api_client.put.assert_not_called() diff --git a/tests/bot/cogs/backend/sync/test_users.py b/tests/bot/cogs/backend/sync/test_users.py deleted file mode 100644 index 51dcbe48a..000000000 --- a/tests/bot/cogs/backend/sync/test_users.py +++ /dev/null @@ -1,158 +0,0 @@ -import unittest -from unittest import mock - -from bot.cogs.backend.sync._syncers import UserSyncer, _Diff, _User -from tests import helpers - - -def fake_user(**kwargs): - """Fixture to return a dictionary representing a user with default values set.""" - kwargs.setdefault("id", 43) - kwargs.setdefault("name", "bob the test man") - kwargs.setdefault("discriminator", 1337) - kwargs.setdefault("roles", (666,)) - kwargs.setdefault("in_guild", True) - - return kwargs - - -class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): - """Tests for determining differences between users in the DB and users in the Guild cache.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = UserSyncer(self.bot) - - @staticmethod - def get_guild(*members): - """Fixture to return a guild object with the given members.""" - guild = helpers.MockGuild() - guild.members = [] - - for member in members: - member = member.copy() - del member["in_guild"] - - mock_member = helpers.MockMember(**member) - mock_member.roles = [helpers.MockRole(id=role_id) for role_id in member["roles"]] - - guild.members.append(mock_member) - - return guild - - async def test_empty_diff_for_no_users(self): - """When no users are given, an empty diff should be returned.""" - guild = self.get_guild() - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_empty_diff_for_identical_users(self): - """No differences should be found if the users in the guild and DB are identical.""" - self.bot.api_client.get.return_value = [fake_user()] - guild = self.get_guild(fake_user()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_updated_users(self): - """Only updated users should be added to the 'updated' set of the diff.""" - updated_user = fake_user(id=99, name="new") - - self.bot.api_client.get.return_value = [fake_user(id=99, name="old"), fake_user()] - guild = self.get_guild(updated_user, fake_user()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**updated_user)}, None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_new_users(self): - """Only new users should be added to the 'created' set of the diff.""" - new_user = fake_user(id=99, name="new") - - self.bot.api_client.get.return_value = [fake_user()] - guild = self.get_guild(fake_user(), new_user) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_User(**new_user)}, set(), None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_sets_in_guild_false_for_leaving_users(self): - """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" - leaving_user = fake_user(id=63, in_guild=False) - - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63)] - guild = self.get_guild(fake_user()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**leaving_user)}, None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_new_updated_and_leaving_users(self): - """When users are added, updated, and removed, all of them are returned properly.""" - new_user = fake_user(id=99, name="new") - updated_user = fake_user(id=55, name="updated") - leaving_user = fake_user(id=63, in_guild=False) - - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=55), fake_user(id=63)] - guild = self.get_guild(fake_user(), new_user, updated_user) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_empty_diff_for_db_users_not_in_guild(self): - """When the DB knows a user the guild doesn't, no difference is found.""" - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] - guild = self.get_guild(fake_user()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) - - self.assertEqual(actual_diff, expected_diff) - - -class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): - """Tests for the API requests that sync users.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = UserSyncer(self.bot) - - async def test_sync_created_users(self): - """Only POST requests should be made with the correct payload.""" - users = [fake_user(id=111), fake_user(id=222)] - - user_tuples = {_User(**user) for user in users} - diff = _Diff(user_tuples, set(), None) - await self.syncer._sync(diff) - - calls = [mock.call("bot/users", json=user) for user in users] - self.bot.api_client.post.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.post.call_count, len(users)) - - self.bot.api_client.put.assert_not_called() - self.bot.api_client.delete.assert_not_called() - - async def test_sync_updated_users(self): - """Only PUT requests should be made with the correct payload.""" - users = [fake_user(id=111), fake_user(id=222)] - - user_tuples = {_User(**user) for user in users} - diff = _Diff(set(), user_tuples, None) - await self.syncer._sync(diff) - - calls = [mock.call(f"bot/users/{user['id']}", json=user) for user in users] - self.bot.api_client.put.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.put.call_count, len(users)) - - self.bot.api_client.post.assert_not_called() - self.bot.api_client.delete.assert_not_called() diff --git a/tests/bot/cogs/backend/test_logging.py b/tests/bot/cogs/backend/test_logging.py deleted file mode 100644 index c867773e2..000000000 --- a/tests/bot/cogs/backend/test_logging.py +++ /dev/null @@ -1,32 +0,0 @@ -import unittest -from unittest.mock import patch - -from bot import constants -from bot.cogs.backend.logging import Logging -from tests.helpers import MockBot, MockTextChannel - - -class LoggingTests(unittest.IsolatedAsyncioTestCase): - """Test cases for connected login.""" - - def setUp(self): - self.bot = MockBot() - self.cog = Logging(self.bot) - self.dev_log = MockTextChannel(id=1234, name="dev-log") - - @patch("bot.cogs.backend.logging.DEBUG_MODE", False) - async def test_debug_mode_false(self): - """Should send connected message to dev-log.""" - self.bot.get_channel.return_value = self.dev_log - - await self.cog.startup_greeting() - self.bot.wait_until_guild_available.assert_awaited_once_with() - self.bot.get_channel.assert_called_once_with(constants.Channels.dev_log) - self.dev_log.send.assert_awaited_once() - - @patch("bot.cogs.backend.logging.DEBUG_MODE", True) - async def test_debug_mode_true(self): - """Should not send anything to dev-log.""" - await self.cog.startup_greeting() - self.bot.wait_until_guild_available.assert_awaited_once_with() - self.bot.get_channel.assert_not_called() diff --git a/tests/bot/cogs/filters/__init__.py b/tests/bot/cogs/filters/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/filters/test_antimalware.py b/tests/bot/cogs/filters/test_antimalware.py deleted file mode 100644 index b00211f47..000000000 --- a/tests/bot/cogs/filters/test_antimalware.py +++ /dev/null @@ -1,165 +0,0 @@ -import unittest -from unittest.mock import AsyncMock, Mock - -from discord import NotFound - -from bot.cogs.filters import antimalware -from bot.constants import Channels, STAFF_ROLES -from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole - - -class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): - """Test the AntiMalware cog.""" - - def setUp(self): - """Sets up fresh objects for each test.""" - self.bot = MockBot() - self.bot.filter_list_cache = { - "FILE_FORMAT.True": { - ".first": {}, - ".second": {}, - ".third": {}, - } - } - self.cog = antimalware.AntiMalware(self.bot) - self.message = MockMessage() - self.whitelist = [".first", ".second", ".third"] - - async def test_message_with_allowed_attachment(self): - """Messages with allowed extensions should not be deleted""" - attachment = MockAttachment(filename="python.first") - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - self.message.delete.assert_not_called() - - async def test_message_without_attachment(self): - """Messages without attachments should result in no action.""" - await self.cog.on_message(self.message) - self.message.delete.assert_not_called() - - async def test_direct_message_with_attachment(self): - """Direct messages should have no action taken.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - self.message.guild = None - - await self.cog.on_message(self.message) - - self.message.delete.assert_not_called() - - async def test_message_with_illegal_extension_gets_deleted(self): - """A message containing an illegal extension should send an embed.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - - self.message.delete.assert_called_once() - - async def test_message_send_by_staff(self): - """A message send by a member of staff should be ignored.""" - staff_role = MockRole(id=STAFF_ROLES[0]) - self.message.author.roles.append(staff_role) - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - - self.message.delete.assert_not_called() - - async def test_python_file_redirect_embed_description(self): - """A message containing a .py file should result in an embed redirecting the user to our paste site""" - attachment = MockAttachment(filename="python.py") - self.message.attachments = [attachment] - self.message.channel.send = AsyncMock() - - await self.cog.on_message(self.message) - self.message.channel.send.assert_called_once() - args, kwargs = self.message.channel.send.call_args - embed = kwargs.pop("embed") - - self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION) - - async def test_txt_file_redirect_embed_description(self): - """A message containing a .txt file should result in the correct embed.""" - attachment = MockAttachment(filename="python.txt") - self.message.attachments = [attachment] - self.message.channel.send = AsyncMock() - antimalware.TXT_EMBED_DESCRIPTION = Mock() - antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test" - - await self.cog.on_message(self.message) - self.message.channel.send.assert_called_once() - args, kwargs = self.message.channel.send.call_args - embed = kwargs.pop("embed") - cmd_channel = self.bot.get_channel(Channels.bot_commands) - - self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value) - antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention) - - async def test_other_disallowed_extension_embed_description(self): - """Test the description for a non .py/.txt disallowed extension.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - self.message.channel.send = AsyncMock() - antimalware.DISALLOWED_EMBED_DESCRIPTION = Mock() - antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value = "test" - - await self.cog.on_message(self.message) - self.message.channel.send.assert_called_once() - args, kwargs = self.message.channel.send.call_args - embed = kwargs.pop("embed") - meta_channel = self.bot.get_channel(Channels.meta) - - self.assertEqual(embed.description, antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value) - antimalware.DISALLOWED_EMBED_DESCRIPTION.format.assert_called_with( - joined_whitelist=", ".join(self.whitelist), - blocked_extensions_str=".disallowed", - meta_channel_mention=meta_channel.mention - ) - - async def test_removing_deleted_message_logs(self): - """Removing an already deleted message logs the correct message""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - self.message.delete = AsyncMock(side_effect=NotFound(response=Mock(status=""), message="")) - - with self.assertLogs(logger=antimalware.log, level="INFO"): - await self.cog.on_message(self.message) - self.message.delete.assert_called_once() - - async def test_message_with_illegal_attachment_logs(self): - """Deleting a message with an illegal attachment should result in a log.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - - with self.assertLogs(logger=antimalware.log, level="INFO"): - await self.cog.on_message(self.message) - - async def test_get_disallowed_extensions(self): - """The return value should include all non-whitelisted extensions.""" - test_values = ( - ([], []), - (self.whitelist, []), - ([".first"], []), - ([".first", ".disallowed"], [".disallowed"]), - ([".disallowed"], [".disallowed"]), - ([".disallowed", ".illegal"], [".disallowed", ".illegal"]), - ) - - for extensions, expected_disallowed_extensions in test_values: - with self.subTest(extensions=extensions, expected_disallowed_extensions=expected_disallowed_extensions): - self.message.attachments = [MockAttachment(filename=f"filename{extension}") for extension in extensions] - disallowed_extensions = self.cog._get_disallowed_extensions(self.message) - self.assertCountEqual(disallowed_extensions, expected_disallowed_extensions) - - -class AntiMalwareSetupTests(unittest.TestCase): - """Tests setup of the `AntiMalware` cog.""" - - def test_setup(self): - """Setup of the extension should call add_cog.""" - bot = MockBot() - antimalware.setup(bot) - bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/filters/test_antispam.py b/tests/bot/cogs/filters/test_antispam.py deleted file mode 100644 index 8a3d8d02e..000000000 --- a/tests/bot/cogs/filters/test_antispam.py +++ /dev/null @@ -1,35 +0,0 @@ -import unittest - -from bot.cogs.filters import antispam - - -class AntispamConfigurationValidationTests(unittest.TestCase): - """Tests validation of the antispam cog configuration.""" - - def test_default_antispam_config_is_valid(self): - """The default antispam configuration is valid.""" - validation_errors = antispam.validate_config() - self.assertEqual(validation_errors, {}) - - def test_unknown_rule_returns_error(self): - """Configuring an unknown rule returns an error.""" - self.assertEqual( - antispam.validate_config({'invalid-rule': {}}), - {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} - ) - - def test_missing_keys_returns_error(self): - """Not configuring required keys returns an error.""" - keys = (('interval', 'max'), ('max', 'interval')) - for configured_key, unconfigured_key in keys: - with self.subTest( - configured_key=configured_key, - unconfigured_key=unconfigured_key - ): - config = {'burst': {configured_key: 10}} - error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" - - self.assertEqual( - antispam.validate_config(config), - {'burst': error} - ) diff --git a/tests/bot/cogs/filters/test_security.py b/tests/bot/cogs/filters/test_security.py deleted file mode 100644 index 82679f69c..000000000 --- a/tests/bot/cogs/filters/test_security.py +++ /dev/null @@ -1,54 +0,0 @@ -import unittest -from unittest.mock import MagicMock - -from discord.ext.commands import NoPrivateMessage - -from bot.cogs.filters import security -from tests.helpers import MockBot, MockContext - - -class SecurityCogTests(unittest.TestCase): - """Tests the `Security` cog.""" - - def setUp(self): - """Attach an instance of the cog to the class for tests.""" - self.bot = MockBot() - self.cog = security.Security(self.bot) - self.ctx = MockContext() - - def test_check_additions(self): - """The cog should add its checks after initialization.""" - self.bot.check.assert_any_call(self.cog.check_on_guild) - self.bot.check.assert_any_call(self.cog.check_not_bot) - - def test_check_not_bot_returns_false_for_humans(self): - """The bot check should return `True` when invoked with human authors.""" - self.ctx.author.bot = False - self.assertTrue(self.cog.check_not_bot(self.ctx)) - - def test_check_not_bot_returns_true_for_robots(self): - """The bot check should return `False` when invoked with robotic authors.""" - self.ctx.author.bot = True - self.assertFalse(self.cog.check_not_bot(self.ctx)) - - def test_check_on_guild_raises_when_outside_of_guild(self): - """When invoked outside of a guild, `check_on_guild` should cause an error.""" - self.ctx.guild = None - - with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): - self.cog.check_on_guild(self.ctx) - - def test_check_on_guild_returns_true_inside_of_guild(self): - """When invoked inside of a guild, `check_on_guild` should return `True`.""" - self.ctx.guild = "lemon's lemonade stand" - self.assertTrue(self.cog.check_on_guild(self.ctx)) - - -class SecurityCogLoadTests(unittest.TestCase): - """Tests loading the `Security` cog.""" - - def test_security_cog_load(self): - """Setup of the extension should call add_cog.""" - bot = MagicMock() - security.setup(bot) - bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/filters/test_token_remover.py b/tests/bot/cogs/filters/test_token_remover.py deleted file mode 100644 index 55b284ef9..000000000 --- a/tests/bot/cogs/filters/test_token_remover.py +++ /dev/null @@ -1,310 +0,0 @@ -import unittest -from re import Match -from unittest import mock -from unittest.mock import MagicMock - -from discord import Colour, NotFound - -from bot import constants -from bot.cogs.filters import token_remover -from bot.cogs.filters.token_remover import Token, TokenRemover -from bot.cogs.moderation.modlog import ModLog -from tests.helpers import MockBot, MockMessage, autospec - - -class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): - """Tests the `TokenRemover` cog.""" - - def setUp(self): - """Adds the cog, a bot, and a message to the instance for usage in tests.""" - self.bot = MockBot() - self.cog = TokenRemover(bot=self.bot) - - self.msg = MockMessage(id=555, content="hello world") - self.msg.channel.mention = "#lemonade-stand" - self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) - self.msg.author.avatar_url_as.return_value = "picture-lemon.png" - - def test_is_valid_user_id_valid(self): - """Should consider user IDs valid if they decode entirely to ASCII digits.""" - ids = ( - "NDcyMjY1OTQzMDYyNDEzMzMy", - "NDc1MDczNjI5Mzk5NTQ3OTA0", - "NDY3MjIzMjMwNjUwNzc3NjQx", - ) - - for user_id in ids: - with self.subTest(user_id=user_id): - result = TokenRemover.is_valid_user_id(user_id) - self.assertTrue(result) - - def test_is_valid_user_id_invalid(self): - """Should consider non-digit and non-ASCII IDs invalid.""" - ids = ( - ("SGVsbG8gd29ybGQ", "non-digit ASCII"), - ("0J_RgNC40LLQtdGCINC80LjRgA", "cyrillic text"), - ("4pO14p6L4p6C4pG34p264pGl8J-EiOKSj-KCieKBsA", "Unicode digits"), - ("4oaA4oaB4oWh4oWi4Lyz4Lyq4Lyr4LG9", "Unicode numerals"), - ("8J2fjvCdn5nwnZ-k8J2fr_Cdn7rgravvvJngr6c", "Unicode decimals"), - ("{hello}[world]&(bye!)", "ASCII invalid Base64"), - ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), - ) - - for user_id, msg in ids: - with self.subTest(msg=msg): - result = TokenRemover.is_valid_user_id(user_id) - self.assertFalse(result) - - def test_is_valid_timestamp_valid(self): - """Should consider timestamps valid if they're greater than the Discord epoch.""" - timestamps = ( - "XsyRkw", - "Xrim9Q", - "XsyR-w", - "XsySD_", - "Dn9r_A", - ) - - for timestamp in timestamps: - with self.subTest(timestamp=timestamp): - result = TokenRemover.is_valid_timestamp(timestamp) - self.assertTrue(result) - - def test_is_valid_timestamp_invalid(self): - """Should consider timestamps invalid if they're before Discord epoch or can't be parsed.""" - timestamps = ( - ("B4Yffw", "DISCORD_EPOCH - TOKEN_EPOCH - 1"), - ("ew", "123"), - ("AoIKgA", "42076800"), - ("{hello}[world]&(bye!)", "ASCII invalid Base64"), - ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), - ) - - for timestamp, msg in timestamps: - with self.subTest(msg=msg): - result = TokenRemover.is_valid_timestamp(timestamp) - self.assertFalse(result) - - def test_mod_log_property(self): - """The `mod_log` property should ask the bot to return the `ModLog` cog.""" - self.bot.get_cog.return_value = 'lemon' - self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) - self.bot.get_cog.assert_called_once_with('ModLog') - - async def test_on_message_edit_uses_on_message(self): - """The edit listener should delegate handling of the message to the normal listener.""" - self.cog.on_message = mock.create_autospec(self.cog.on_message, spec_set=True) - - await self.cog.on_message_edit(MockMessage(), self.msg) - self.cog.on_message.assert_awaited_once_with(self.msg) - - @autospec(TokenRemover, "find_token_in_message", "take_action") - async def test_on_message_takes_action(self, find_token_in_message, take_action): - """Should take action if a valid token is found when a message is sent.""" - cog = TokenRemover(self.bot) - found_token = "foobar" - find_token_in_message.return_value = found_token - - await cog.on_message(self.msg) - - find_token_in_message.assert_called_once_with(self.msg) - take_action.assert_awaited_once_with(cog, self.msg, found_token) - - @autospec(TokenRemover, "find_token_in_message", "take_action") - async def test_on_message_skips_missing_token(self, find_token_in_message, take_action): - """Shouldn't take action if a valid token isn't found when a message is sent.""" - cog = TokenRemover(self.bot) - find_token_in_message.return_value = False - - await cog.on_message(self.msg) - - find_token_in_message.assert_called_once_with(self.msg) - take_action.assert_not_awaited() - - @autospec(TokenRemover, "find_token_in_message") - async def test_on_message_ignores_dms_bots(self, find_token_in_message): - """Shouldn't parse a message if it is a DM or authored by a bot.""" - cog = TokenRemover(self.bot) - dm_msg = MockMessage(guild=None) - bot_msg = MockMessage(author=MagicMock(bot=True)) - - for msg in (dm_msg, bot_msg): - await cog.on_message(msg) - find_token_in_message.assert_not_called() - - @autospec("bot.cogs.filters.token_remover", "TOKEN_RE") - def test_find_token_no_matches(self, token_re): - """None should be returned if the regex matches no tokens in a message.""" - token_re.finditer.return_value = () - - return_value = TokenRemover.find_token_in_message(self.msg) - - self.assertIsNone(return_value) - token_re.finditer.assert_called_once_with(self.msg.content) - - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") - @autospec("bot.cogs.filters.token_remover", "Token") - @autospec("bot.cogs.filters.token_remover", "TOKEN_RE") - def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp): - """The first match with a valid user ID and timestamp should be returned as a `Token`.""" - matches = [ - mock.create_autospec(Match, spec_set=True, instance=True), - mock.create_autospec(Match, spec_set=True, instance=True), - ] - tokens = [ - mock.create_autospec(Token, spec_set=True, instance=True), - mock.create_autospec(Token, spec_set=True, instance=True), - ] - - token_re.finditer.return_value = matches - token_cls.side_effect = tokens - is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid. - is_valid_timestamp.return_value = True - - return_value = TokenRemover.find_token_in_message(self.msg) - - self.assertEqual(tokens[1], return_value) - token_re.finditer.assert_called_once_with(self.msg.content) - - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") - @autospec("bot.cogs.filters.token_remover", "Token") - @autospec("bot.cogs.filters.token_remover", "TOKEN_RE") - def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp): - """None should be returned if no matches have valid user IDs or timestamps.""" - token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] - token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) - is_valid_id.return_value = False - is_valid_timestamp.return_value = False - - return_value = TokenRemover.find_token_in_message(self.msg) - - self.assertIsNone(return_value) - token_re.finditer.assert_called_once_with(self.msg.content) - - def test_regex_invalid_tokens(self): - """Messages without anything looking like a token are not matched.""" - tokens = ( - "", - "lemon wins", - "..", - "x.y", - "x.y.", - ".y.z", - ".y.", - "..z", - "x..z", - " . . ", - "\n.\n.\n", - "hellö.world.bye", - "base64.nötbåse64.morebase64", - "19jd3J.dfkm3d.€víł§tüff", - ) - - for token in tokens: - with self.subTest(token=token): - results = token_remover.TOKEN_RE.findall(token) - self.assertEqual(len(results), 0) - - def test_regex_valid_tokens(self): - """Messages that look like tokens should be matched.""" - # Don't worry, these tokens have been invalidated. - tokens = ( - "NDcyMjY1OTQzMDYy_DEzMz-y.XsyRkw.VXmErH7j511turNpfURmb0rVNm8", - "NDcyMjY1OTQzMDYyNDEzMzMy.Xrim9Q.Ysnu2wacjaKs7qnoo46S8Dm2us8", - "NDc1MDczNjI5Mzk5NTQ3OTA0.XsyR-w.sJf6omBPORBPju3WJEIAcwW9Zds", - "NDY3MjIzMjMwNjUwNzc3NjQx.XsySD_.s45jqDV_Iisn-symw0yDRrk_jf4", - ) - - for token in tokens: - with self.subTest(token=token): - results = token_remover.TOKEN_RE.fullmatch(token) - self.assertIsNotNone(results, f"{token} was not matched by the regex") - - def test_regex_matches_multiple_valid(self): - """Should support multiple matches in the middle of a string.""" - token_1 = "NDY3MjIzMjMwNjUwNzc3NjQx.XsyWGg.uFNEQPCc4ePwGh7egG8UicQssz8" - token_2 = "NDcyMjY1OTQzMDYyNDEzMzMy.XsyWMw.l8XPnDqb0lp-EiQ2g_0xVFT1pyc" - message = f"garbage {token_1} hello {token_2} world" - - results = token_remover.TOKEN_RE.finditer(message) - results = [match[0] for match in results] - self.assertCountEqual((token_1, token_2), results) - - @autospec("bot.cogs.filters.token_remover", "LOG_MESSAGE") - def test_format_log_message(self, log_message): - """Should correctly format the log message with info from the message and token.""" - token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") - log_message.format.return_value = "Howdy" - - return_value = TokenRemover.format_log_message(self.msg, token) - - self.assertEqual(return_value, log_message.format.return_value) - log_message.format.assert_called_once_with( - author=self.msg.author, - author_id=self.msg.author.id, - channel=self.msg.channel.mention, - user_id=token.user_id, - timestamp=token.timestamp, - hmac="x" * len(token.hmac), - ) - - @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) - @autospec("bot.cogs.filters.token_remover", "log") - @autospec(TokenRemover, "format_log_message") - async def test_take_action(self, format_log_message, logger, mod_log_property): - """Should delete the message and send a mod log.""" - cog = TokenRemover(self.bot) - mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True) - token = mock.create_autospec(Token, spec_set=True, instance=True) - log_msg = "testing123" - - mod_log_property.return_value = mod_log - format_log_message.return_value = log_msg - - await cog.take_action(self.msg, token) - - self.msg.delete.assert_called_once_with() - self.msg.channel.send.assert_called_once_with( - token_remover.DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) - ) - - format_log_message.assert_called_once_with(self.msg, token) - logger.debug.assert_called_with(log_msg) - self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") - - mod_log.ignore.assert_called_once_with(constants.Event.message_delete, self.msg.id) - mod_log.send_log_message.assert_called_once_with( - icon_url=constants.Icons.token_removed, - colour=Colour(constants.Colours.soft_red), - title="Token removed!", - text=log_msg, - thumbnail=self.msg.author.avatar_url_as.return_value, - channel_id=constants.Channels.mod_alerts - ) - - @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) - async def test_take_action_delete_failure(self, mod_log_property): - """Shouldn't send any messages if the token message can't be deleted.""" - cog = TokenRemover(self.bot) - mod_log_property.return_value = mock.create_autospec(ModLog, spec_set=True, instance=True) - self.msg.delete.side_effect = NotFound(MagicMock(), MagicMock()) - - token = mock.create_autospec(Token, spec_set=True, instance=True) - await cog.take_action(self.msg, token) - - self.msg.delete.assert_called_once_with() - self.msg.channel.send.assert_not_awaited() - - -class TokenRemoverExtensionTests(unittest.TestCase): - """Tests for the token_remover extension.""" - - @autospec("bot.cogs.filters.token_remover", "TokenRemover") - def test_extension_setup(self, cog): - """The TokenRemover cog should be added.""" - bot = MockBot() - token_remover.setup(bot) - - cog.assert_called_once_with(bot) - bot.add_cog.assert_called_once() - self.assertTrue(isinstance(bot.add_cog.call_args.args[0], TokenRemover)) diff --git a/tests/bot/cogs/info/__init__.py b/tests/bot/cogs/info/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/info/test_information.py b/tests/bot/cogs/info/test_information.py deleted file mode 100644 index 895a8328e..000000000 --- a/tests/bot/cogs/info/test_information.py +++ /dev/null @@ -1,584 +0,0 @@ -import asyncio -import textwrap -import unittest -import unittest.mock - -import discord - -from bot import constants -from bot.cogs.info import information -from bot.utils.checks import InWhitelistCheckFailure -from tests import helpers - -COG_PATH = "bot.cogs.info.information.Information" - - -class InformationCogTests(unittest.TestCase): - """Tests the Information cog.""" - - @classmethod - def setUpClass(cls): - cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderators) - - def setUp(self): - """Sets up fresh objects for each test.""" - self.bot = helpers.MockBot() - - self.cog = information.Information(self.bot) - - self.ctx = helpers.MockContext() - self.ctx.author.roles.append(self.moderator_role) - - def test_roles_command_command(self): - """Test if the `role_info` command correctly returns the `moderator_role`.""" - self.ctx.guild.roles.append(self.moderator_role) - - self.cog.roles_info.can_run = unittest.mock.AsyncMock() - self.cog.roles_info.can_run.return_value = True - - coroutine = self.cog.roles_info.callback(self.cog, self.ctx) - - self.assertIsNone(asyncio.run(coroutine)) - self.ctx.send.assert_called_once() - - _, kwargs = self.ctx.send.call_args - embed = kwargs.pop('embed') - - self.assertEqual(embed.title, "Role information (Total 1 role)") - self.assertEqual(embed.colour, discord.Colour.blurple()) - self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") - - def test_role_info_command(self): - """Tests the `role info` command.""" - dummy_role = helpers.MockRole( - name="Dummy", - id=112233445566778899, - colour=discord.Colour.blurple(), - position=10, - members=[self.ctx.author], - permissions=discord.Permissions(0) - ) - - admin_role = helpers.MockRole( - name="Admins", - id=998877665544332211, - colour=discord.Colour.red(), - position=3, - members=[self.ctx.author], - permissions=discord.Permissions(0), - ) - - self.ctx.guild.roles.append([dummy_role, admin_role]) - - self.cog.role_info.can_run = unittest.mock.AsyncMock() - self.cog.role_info.can_run.return_value = True - - coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) - - self.assertIsNone(asyncio.run(coroutine)) - - self.assertEqual(self.ctx.send.call_count, 2) - - (_, dummy_kwargs), (_, admin_kwargs) = self.ctx.send.call_args_list - - dummy_embed = dummy_kwargs["embed"] - admin_embed = admin_kwargs["embed"] - - self.assertEqual(dummy_embed.title, "Dummy info") - self.assertEqual(dummy_embed.colour, discord.Colour.blurple()) - - self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id)) - self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}") - self.assertEqual(dummy_embed.fields[2].value, "0.63 0.48 218") - self.assertEqual(dummy_embed.fields[3].value, "1") - self.assertEqual(dummy_embed.fields[4].value, "10") - self.assertEqual(dummy_embed.fields[5].value, "0") - - self.assertEqual(admin_embed.title, "Admins info") - self.assertEqual(admin_embed.colour, discord.Colour.red()) - - @unittest.mock.patch('bot.cogs.info.information.time_since') - def test_server_info_command(self, time_since_patch): - time_since_patch.return_value = '2 days ago' - - self.ctx.guild = helpers.MockGuild( - features=('lemons', 'apples'), - region="The Moon", - roles=[self.moderator_role], - channels=[ - discord.TextChannel( - state={}, - guild=self.ctx.guild, - data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} - ), - discord.CategoryChannel( - state={}, - guild=self.ctx.guild, - data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} - ), - discord.VoiceChannel( - state={}, - guild=self.ctx.guild, - data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} - ) - ], - members=[ - *(helpers.MockMember(status=discord.Status.online) for _ in range(2)), - *(helpers.MockMember(status=discord.Status.idle) for _ in range(1)), - *(helpers.MockMember(status=discord.Status.dnd) for _ in range(4)), - *(helpers.MockMember(status=discord.Status.offline) for _ in range(3)), - ], - member_count=1_234, - icon_url='a-lemon.jpg', - ) - - coroutine = self.cog.server_info.callback(self.cog, self.ctx) - self.assertIsNone(asyncio.run(coroutine)) - - time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') - _, kwargs = self.ctx.send.call_args - embed = kwargs.pop('embed') - self.assertEqual(embed.colour, discord.Colour.blurple()) - self.assertEqual( - embed.description, - textwrap.dedent( - f""" - **Server information** - Created: {time_since_patch.return_value} - Voice region: {self.ctx.guild.region} - Features: {', '.join(self.ctx.guild.features)} - - **Channel counts** - Category channels: 1 - Text channels: 1 - Voice channels: 1 - Staff channels: 0 - - **Member counts** - Members: {self.ctx.guild.member_count:,} - Staff members: 0 - Roles: {len(self.ctx.guild.roles)} - - **Member statuses** - {constants.Emojis.status_online} 2 - {constants.Emojis.status_idle} 1 - {constants.Emojis.status_dnd} 4 - {constants.Emojis.status_offline} 3 - """ - ) - ) - self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') - - -class UserInfractionHelperMethodTests(unittest.TestCase): - """Tests for the helper methods of the `!user` command.""" - - def setUp(self): - """Common set-up steps done before for each test.""" - self.bot = helpers.MockBot() - self.bot.api_client.get = unittest.mock.AsyncMock() - self.cog = information.Information(self.bot) - self.member = helpers.MockMember(id=1234) - - def test_user_command_helper_method_get_requests(self): - """The helper methods should form the correct get requests.""" - test_values = ( - { - "helper_method": self.cog.basic_user_infraction_counts, - "expected_args": ("bot/infractions", {'hidden': 'False', 'user__id': str(self.member.id)}), - }, - { - "helper_method": self.cog.expanded_user_infraction_counts, - "expected_args": ("bot/infractions", {'user__id': str(self.member.id)}), - }, - { - "helper_method": self.cog.user_nomination_counts, - "expected_args": ("bot/nominations", {'user__id': str(self.member.id)}), - }, - ) - - for test_value in test_values: - helper_method = test_value["helper_method"] - endpoint, params = test_value["expected_args"] - - with self.subTest(method=helper_method, endpoint=endpoint, params=params): - asyncio.run(helper_method(self.member)) - self.bot.api_client.get.assert_called_once_with(endpoint, params=params) - self.bot.api_client.get.reset_mock() - - def _method_subtests(self, method, test_values, default_header): - """Helper method that runs the subtests for the different helper methods.""" - for test_value in test_values: - api_response = test_value["api response"] - expected_lines = test_value["expected_lines"] - - with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines): - self.bot.api_client.get.return_value = api_response - - expected_output = "\n".join(default_header + expected_lines) - actual_output = asyncio.run(method(self.member)) - - self.assertEqual(expected_output, actual_output) - - def test_basic_user_infraction_counts_returns_correct_strings(self): - """The method should correctly list both the total and active number of non-hidden infractions.""" - test_values = ( - # No infractions means zero counts - { - "api response": [], - "expected_lines": ["Total: 0", "Active: 0"], - }, - # Simple, single-infraction dictionaries - { - "api response": [{"type": "ban", "active": True}], - "expected_lines": ["Total: 1", "Active: 1"], - }, - { - "api response": [{"type": "ban", "active": False}], - "expected_lines": ["Total: 1", "Active: 0"], - }, - # Multiple infractions with various `active` status - { - "api response": [ - {"type": "ban", "active": True}, - {"type": "kick", "active": False}, - {"type": "ban", "active": True}, - {"type": "ban", "active": False}, - ], - "expected_lines": ["Total: 4", "Active: 2"], - }, - ) - - header = ["**Infractions**"] - - self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) - - def test_expanded_user_infraction_counts_returns_correct_strings(self): - """The method should correctly list the total and active number of all infractions split by infraction type.""" - test_values = ( - { - "api response": [], - "expected_lines": ["This user has never received an infraction."], - }, - # Shows non-hidden inactive infraction as expected - { - "api response": [{"type": "kick", "active": False, "hidden": False}], - "expected_lines": ["Kicks: 1"], - }, - # Shows non-hidden active infraction as expected - { - "api response": [{"type": "mute", "active": True, "hidden": False}], - "expected_lines": ["Mutes: 1 (1 active)"], - }, - # Shows hidden inactive infraction as expected - { - "api response": [{"type": "superstar", "active": False, "hidden": True}], - "expected_lines": ["Superstars: 1"], - }, - # Shows hidden active infraction as expected - { - "api response": [{"type": "ban", "active": True, "hidden": True}], - "expected_lines": ["Bans: 1 (1 active)"], - }, - # Correctly displays tally of multiple infractions of mixed properties in alphabetical order - { - "api response": [ - {"type": "kick", "active": False, "hidden": True}, - {"type": "ban", "active": True, "hidden": True}, - {"type": "superstar", "active": True, "hidden": True}, - {"type": "mute", "active": True, "hidden": True}, - {"type": "ban", "active": False, "hidden": False}, - {"type": "note", "active": False, "hidden": True}, - {"type": "note", "active": False, "hidden": True}, - {"type": "warn", "active": False, "hidden": False}, - {"type": "note", "active": False, "hidden": True}, - ], - "expected_lines": [ - "Bans: 2 (1 active)", - "Kicks: 1", - "Mutes: 1 (1 active)", - "Notes: 3", - "Superstars: 1 (1 active)", - "Warns: 1", - ], - }, - ) - - header = ["**Infractions**"] - - self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) - - def test_user_nomination_counts_returns_correct_strings(self): - """The method should list the number of active and historical nominations for the user.""" - test_values = ( - { - "api response": [], - "expected_lines": ["This user has never been nominated."], - }, - { - "api response": [{'active': True}], - "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], - }, - { - "api response": [{'active': True}, {'active': False}], - "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], - }, - { - "api response": [{'active': False}], - "expected_lines": ["This user has 1 historical nomination, but is currently not nominated."], - }, - { - "api response": [{'active': False}, {'active': False}], - "expected_lines": ["This user has 2 historical nominations, but is currently not nominated."], - }, - - ) - - header = ["**Nominations**"] - - self._method_subtests(self.cog.user_nomination_counts, test_values, header) - - -@unittest.mock.patch("bot.cogs.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) -@unittest.mock.patch("bot.cogs.info.information.constants.MODERATION_CHANNELS", new=[50]) -class UserEmbedTests(unittest.TestCase): - """Tests for the creation of the `!user` embed.""" - - def setUp(self): - """Common set-up steps done before for each test.""" - self.bot = helpers.MockBot() - self.bot.api_client.get = unittest.mock.AsyncMock() - self.cog = information.Information(self.bot) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): - """The embed should use the string representation of the user if they don't have a nick.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) - user = helpers.MockMember() - user.nick = None - user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") - - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertEqual(embed.title, "Mr. Hemlock") - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_nick_in_title_if_available(self): - """The embed should use the nick if it's available.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) - user = helpers.MockMember() - user.nick = "Cat lover" - user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") - - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_ignores_everyone_role(self): - """Created `!user` embeds should not contain mention of the @everyone-role.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) - admins_role = helpers.MockRole(name='Admins') - admins_role.colour = 100 - - # A `MockMember` has the @Everyone role by default; we add the Admins to that. - user = helpers.MockMember(roles=[admins_role], top_role=admins_role) - - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertIn("&Admins", embed.description) - self.assertNotIn("&Everyone", embed.description) - - @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) - @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) - def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): - """The embed should contain expanded infractions and nomination info in mod channels.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) - - moderators_role = helpers.MockRole(name='Moderators') - moderators_role.colour = 100 - - infraction_counts.return_value = "expanded infractions info" - nomination_counts.return_value = "nomination info" - - user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - infraction_counts.assert_called_once_with(user) - nomination_counts.assert_called_once_with(user) - - self.assertEqual( - textwrap.dedent(f""" - **User Information** - Created: {"1 year ago"} - Profile: {user.mention} - ID: {user.id} - - **Member Information** - Joined: {"1 year ago"} - Roles: &Moderators - - expanded infractions info - - nomination info - """).strip(), - embed.description - ) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) - def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): - """The embed should contain only basic infraction data outside of mod channels.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) - - moderators_role = helpers.MockRole(name='Moderators') - moderators_role.colour = 100 - - infraction_counts.return_value = "basic infractions info" - - user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - infraction_counts.assert_called_once_with(user) - - self.assertEqual( - textwrap.dedent(f""" - **User Information** - Created: {"1 year ago"} - Profile: {user.mention} - ID: {user.id} - - **Member Information** - Joined: {"1 year ago"} - Roles: &Moderators - - basic infractions info - """).strip(), - embed.description - ) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): - """The embed should be created with the colour of the top role, if a top role is available.""" - ctx = helpers.MockContext() - - moderators_role = helpers.MockRole(name='Moderators') - moderators_role.colour = 100 - - user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): - """The embed should be created with a blurple colour if the user has no assigned roles.""" - ctx = helpers.MockContext() - - user = helpers.MockMember(id=217) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertEqual(embed.colour, discord.Colour.blurple()) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): - """The embed thumbnail should be set to the user's avatar in `png` format.""" - ctx = helpers.MockContext() - - user = helpers.MockMember(id=217) - user.avatar_url_as.return_value = "avatar url" - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - user.avatar_url_as.assert_called_once_with(static_format="png") - self.assertEqual(embed.thumbnail.url, "avatar url") - - -@unittest.mock.patch("bot.cogs.info.information.constants") -class UserCommandTests(unittest.TestCase): - """Tests for the `!user` command.""" - - def setUp(self): - """Set up steps executed before each test is run.""" - self.bot = helpers.MockBot() - self.cog = information.Information(self.bot) - - self.moderator_role = helpers.MockRole(name="Moderators", id=2, position=10) - self.flautist_role = helpers.MockRole(name="Flautists", id=3, position=2) - self.bassist_role = helpers.MockRole(name="Bassists", id=4, position=3) - - self.author = helpers.MockMember(id=1, name="syntaxaire") - self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role]) - self.target = helpers.MockMember(id=3, name="__fluzz__") - - def test_regular_member_cannot_target_another_member(self, constants): - """A regular user should not be able to use `!user` targeting another user.""" - constants.MODERATION_ROLES = [self.moderator_role.id] - - ctx = helpers.MockContext(author=self.author) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) - - ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") - - def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): - """A regular user should not be able to use this command outside of bot-commands.""" - constants.MODERATION_ROLES = [self.moderator_role.id] - constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) - - msg = "Sorry, but you may only use this command within <#50>." - with self.assertRaises(InWhitelistCheckFailure, msg=msg): - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) - - @unittest.mock.patch("bot.cogs.info.information.Information.create_user_embed") - def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): - """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" - constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) - - create_embed.assert_called_once_with(ctx, self.author) - ctx.send.assert_called_once() - - @unittest.mock.patch("bot.cogs.info.information.Information.create_user_embed") - def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): - """A user should target itself with `!user` when a `user` argument was not provided.""" - constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) - - create_embed.assert_called_once_with(ctx, self.author) - ctx.send.assert_called_once() - - @unittest.mock.patch("bot.cogs.info.information.Information.create_user_embed") - def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): - """Staff members should be able to bypass the bot-commands channel restriction.""" - constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) - - create_embed.assert_called_once_with(ctx, self.moderator) - ctx.send.assert_called_once() - - @unittest.mock.patch("bot.cogs.info.information.Information.create_user_embed") - def test_moderators_can_target_another_member(self, create_embed, constants): - """A moderator should be able to use `!user` targeting another user.""" - constants.MODERATION_ROLES = [self.moderator_role.id] - constants.STAFF_ROLES = [self.moderator_role.id] - - ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) - - create_embed.assert_called_once_with(ctx, self.target) - ctx.send.assert_called_once() diff --git a/tests/bot/cogs/moderation/__init__.py b/tests/bot/cogs/moderation/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/moderation/infraction/__init__.py b/tests/bot/cogs/moderation/infraction/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/moderation/infraction/test_infractions.py b/tests/bot/cogs/moderation/infraction/test_infractions.py deleted file mode 100644 index 2df61d431..000000000 --- a/tests/bot/cogs/moderation/infraction/test_infractions.py +++ /dev/null @@ -1,55 +0,0 @@ -import textwrap -import unittest -from unittest.mock import AsyncMock, Mock, patch - -from bot.cogs.moderation.infraction.infractions import Infractions -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole - - -class TruncationTests(unittest.IsolatedAsyncioTestCase): - """Tests for ban and kick command reason truncation.""" - - def setUp(self): - self.bot = MockBot() - self.cog = Infractions(self.bot) - self.user = MockMember(id=1234, top_role=MockRole(id=3577, position=10)) - self.target = MockMember(id=1265, top_role=MockRole(id=9876, position=0)) - self.guild = MockGuild(id=4567) - self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild) - - @patch("bot.cogs.moderation.infraction._utils.get_active_infraction") - @patch("bot.cogs.moderation.infraction._utils.post_infraction") - async def test_apply_ban_reason_truncation(self, post_infraction_mock, get_active_mock): - """Should truncate reason for `ctx.guild.ban`.""" - get_active_mock.return_value = None - post_infraction_mock.return_value = {"foo": "bar"} - - self.cog.apply_infraction = AsyncMock() - self.bot.get_cog.return_value = AsyncMock() - self.cog.mod_log.ignore = Mock() - self.ctx.guild.ban = Mock() - - await self.cog.apply_ban(self.ctx, self.target, "foo bar" * 3000) - self.ctx.guild.ban.assert_called_once_with( - self.target, - reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."), - delete_message_days=0 - ) - self.cog.apply_infraction.assert_awaited_once_with( - self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value - ) - - @patch("bot.cogs.moderation.infraction._utils.post_infraction") - async def test_apply_kick_reason_truncation(self, post_infraction_mock): - """Should truncate reason for `Member.kick`.""" - post_infraction_mock.return_value = {"foo": "bar"} - - self.cog.apply_infraction = AsyncMock() - self.cog.mod_log.ignore = Mock() - self.target.kick = Mock() - - await self.cog.apply_kick(self.ctx, self.target, "foo bar" * 3000) - self.target.kick.assert_called_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="...")) - self.cog.apply_infraction.assert_awaited_once_with( - self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value - ) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py deleted file mode 100644 index 5e4d90251..000000000 --- a/tests/bot/cogs/moderation/test_incidents.py +++ /dev/null @@ -1,770 +0,0 @@ -import asyncio -import enum -import logging -import typing as t -import unittest -from unittest.mock import AsyncMock, MagicMock, call, patch - -import aiohttp -import discord - -from bot.cogs.moderation import incidents -from bot.constants import Colours -from tests.helpers import ( - MockAsyncWebhook, - MockAttachment, - MockBot, - MockMember, - MockMessage, - MockReaction, - MockRole, - MockTextChannel, - MockUser, -) - - -class MockAsyncIterable: - """ - Helper for mocking asynchronous for loops. - - It does not appear that the `unittest` library currently provides anything that would - allow us to simply mock an async iterator, such as `discord.TextChannel.history`. - - We therefore write our own helper to wrap a regular synchronous iterable, and feed - its values via `__anext__` rather than `__next__`. - - This class was written for the purposes of testing the `Incidents` cog - it may not - be generic enough to be placed in the `tests.helpers` module. - """ - - def __init__(self, messages: t.Iterable): - """Take a sync iterable to be wrapped.""" - self.iter_messages = iter(messages) - - def __aiter__(self): - """Return `self` as we provide the `__anext__` method.""" - return self - - async def __anext__(self): - """ - Feed the next item, or raise `StopAsyncIteration`. - - Since we're wrapping a sync iterator, it will communicate that it has been depleted - by raising a `StopIteration`. The `async for` construct does not expect it, and we - therefore need to substitute it for the appropriate exception type. - """ - try: - return next(self.iter_messages) - except StopIteration: - raise StopAsyncIteration - - -class MockSignal(enum.Enum): - A = "A" - B = "B" - - -mock_404 = discord.NotFound( - response=MagicMock(aiohttp.ClientResponse), # Mock the erroneous response - message="Not found", -) - - -class TestDownloadFile(unittest.IsolatedAsyncioTestCase): - """Collection of tests for the `download_file` helper function.""" - - async def test_download_file_success(self): - """If `to_file` succeeds, function returns the acquired `discord.File`.""" - file = MagicMock(discord.File, filename="bigbadlemon.jpg") - attachment = MockAttachment(to_file=AsyncMock(return_value=file)) - - acquired_file = await incidents.download_file(attachment) - self.assertIs(file, acquired_file) - - async def test_download_file_404(self): - """If `to_file` encounters a 404, function handles the exception & returns None.""" - attachment = MockAttachment(to_file=AsyncMock(side_effect=mock_404)) - - acquired_file = await incidents.download_file(attachment) - self.assertIsNone(acquired_file) - - async def test_download_file_fail(self): - """If `to_file` fails on a non-404 error, function logs the exception & returns None.""" - arbitrary_error = discord.HTTPException(MagicMock(aiohttp.ClientResponse), "Arbitrary API error") - attachment = MockAttachment(to_file=AsyncMock(side_effect=arbitrary_error)) - - with self.assertLogs(logger=incidents.log, level=logging.ERROR): - acquired_file = await incidents.download_file(attachment) - - self.assertIsNone(acquired_file) - - -class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): - """Collection of tests for the `make_embed` helper function.""" - - async def test_make_embed_actioned(self): - """Embed is coloured green and footer contains 'Actioned' when `outcome=Signal.ACTIONED`.""" - embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember()) - - self.assertEqual(embed.colour.value, Colours.soft_green) - self.assertIn("Actioned", embed.footer.text) - - async def test_make_embed_not_actioned(self): - """Embed is coloured red and footer contains 'Rejected' when `outcome=Signal.NOT_ACTIONED`.""" - embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember()) - - self.assertEqual(embed.colour.value, Colours.soft_red) - self.assertIn("Rejected", embed.footer.text) - - async def test_make_embed_content(self): - """Incident content appears as embed description.""" - incident = MockMessage(content="this is an incident") - embed, file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) - - self.assertEqual(incident.content, embed.description) - - async def test_make_embed_with_attachment_succeeds(self): - """Incident's attachment is downloaded and displayed in the embed's image field.""" - file = MagicMock(discord.File, filename="bigbadjoe.jpg") - attachment = MockAttachment(filename="bigbadjoe.jpg") - incident = MockMessage(content="this is an incident", attachments=[attachment]) - - # Patch `download_file` to return our `file` - with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=file)): - embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) - - self.assertIs(file, returned_file) - self.assertEqual("attachment://bigbadjoe.jpg", embed.image.url) - - async def test_make_embed_with_attachment_fails(self): - """Incident's attachment fails to download, proxy url is linked instead.""" - attachment = MockAttachment(proxy_url="discord.com/bigbadjoe.jpg") - incident = MockMessage(content="this is an incident", attachments=[attachment]) - - # Patch `download_file` to return None as if the download failed - with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=None)): - embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) - - self.assertIsNone(returned_file) - - # The author name field is simply expected to have something in it, we do not assert the message - self.assertGreater(len(embed.author.name), 0) - self.assertEqual(embed.author.url, "discord.com/bigbadjoe.jpg") # However, it should link the exact url - - -@patch("bot.constants.Channels.incidents", 123) -class TestIsIncident(unittest.TestCase): - """ - Collection of tests for the `is_incident` helper function. - - In `setUp`, we will create a mock message which should qualify as an incident. Each - test case will then mutate this instance to make it **not** qualify, in various ways. - - Notice that we patch the #incidents channel id globally for this class. - """ - - def setUp(self) -> None: - """Prepare a mock message which should qualify as an incident.""" - self.incident = MockMessage( - channel=MockTextChannel(id=123), - content="this is an incident", - author=MockUser(bot=False), - pinned=False, - ) - - def test_is_incident_true(self): - """Message qualifies as an incident if unchanged.""" - self.assertTrue(incidents.is_incident(self.incident)) - - def check_false(self): - """Assert that `self.incident` does **not** qualify as an incident.""" - self.assertFalse(incidents.is_incident(self.incident)) - - def test_is_incident_false_channel(self): - """Message doesn't qualify if sent outside of #incidents.""" - self.incident.channel = MockTextChannel(id=456) - self.check_false() - - def test_is_incident_false_content(self): - """Message doesn't qualify if content begins with hash symbol.""" - self.incident.content = "# this is a comment message" - self.check_false() - - def test_is_incident_false_author(self): - """Message doesn't qualify if author is a bot.""" - self.incident.author = MockUser(bot=True) - self.check_false() - - def test_is_incident_false_pinned(self): - """Message doesn't qualify if it is pinned.""" - self.incident.pinned = True - self.check_false() - - -class TestOwnReactions(unittest.TestCase): - """Assertions for the `own_reactions` function.""" - - def test_own_reactions(self): - """Only bot's own emoji are extracted from the input incident.""" - reactions = ( - MockReaction(emoji="A", me=True), - MockReaction(emoji="B", me=True), - MockReaction(emoji="C", me=False), - ) - message = MockMessage(reactions=reactions) - self.assertSetEqual(incidents.own_reactions(message), {"A", "B"}) - - -@patch("bot.cogs.moderation.incidents.ALL_SIGNALS", {"A", "B"}) -class TestHasSignals(unittest.TestCase): - """ - Assertions for the `has_signals` function. - - We patch `ALL_SIGNALS` globally. Each test function then patches `own_reactions` - as appropriate. - """ - - def test_has_signals_true(self): - """True when `own_reactions` returns all emoji in `ALL_SIGNALS`.""" - message = MockMessage() - own_reactions = MagicMock(return_value={"A", "B"}) - - with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): - self.assertTrue(incidents.has_signals(message)) - - def test_has_signals_false(self): - """False when `own_reactions` does not return all emoji in `ALL_SIGNALS`.""" - message = MockMessage() - own_reactions = MagicMock(return_value={"A", "C"}) - - with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): - self.assertFalse(incidents.has_signals(message)) - - -@patch("bot.cogs.moderation.incidents.Signal", MockSignal) -class TestAddSignals(unittest.IsolatedAsyncioTestCase): - """ - Assertions for the `add_signals` coroutine. - - These are all fairly similar and could go into a single test function, but I found the - patching & sub-testing fairly awkward in that case and decided to split them up - to avoid unnecessary syntax noise. - """ - - def setUp(self): - """Prepare a mock incident message for tests to use.""" - self.incident = MockMessage() - - @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value=set())) - async def test_add_signals_missing(self): - """All emoji are added when none are present.""" - await incidents.add_signals(self.incident) - self.incident.add_reaction.assert_has_calls([call("A"), call("B")]) - - @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A"})) - async def test_add_signals_partial(self): - """Only missing emoji are added when some are present.""" - await incidents.add_signals(self.incident) - self.incident.add_reaction.assert_has_calls([call("B")]) - - @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A", "B"})) - async def test_add_signals_present(self): - """No emoji are added when all are present.""" - await incidents.add_signals(self.incident) - self.incident.add_reaction.assert_not_called() - - -class TestIncidents(unittest.IsolatedAsyncioTestCase): - """ - Tests for bound methods of the `Incidents` cog. - - Use this as a base class for `Incidents` tests - it will prepare a fresh instance - for each test function, but not make any assertions on its own. Tests can mutate - the instance as they wish. - """ - - def setUp(self): - """ - Prepare a fresh `Incidents` instance for each test. - - Note that this will not schedule `crawl_incidents` in the background, as everything - is being mocked. The `crawl_task` attribute will end up being None. - """ - self.cog_instance = incidents.Incidents(MockBot()) - - -@patch("asyncio.sleep", AsyncMock()) # Prevent the coro from sleeping to speed up the test -class TestCrawlIncidents(TestIncidents): - """ - Tests for the `Incidents.crawl_incidents` coroutine. - - Apart from `test_crawl_incidents_waits_until_cache_ready`, all tests in this class - will patch the return values of `is_incident` and `has_signal` and then observe - whether the `AsyncMock` for `add_signals` was awaited or not. - - The `add_signals` mock is added by each test separately to ensure it is clean (has not - been awaited by another test yet). The mock can be reset, but this appears to be the - cleaner way. - - For each test, we inject a mock channel with a history of 1 message only (see: `setUp`). - """ - - def setUp(self): - """For each test, ensure `bot.get_channel` returns a channel with 1 arbitrary message.""" - super().setUp() # First ensure we get `cog_instance` from parent - - incidents_history = MagicMock(return_value=MockAsyncIterable([MockMessage()])) - self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(history=incidents_history)) - - async def test_crawl_incidents_waits_until_cache_ready(self): - """ - The coroutine will await the `wait_until_guild_available` event. - - Since this task is schedule in the `__init__`, it is critical that it waits for the - cache to be ready, so that it can safely get the #incidents channel. - """ - await self.cog_instance.crawl_incidents() - self.cog_instance.bot.wait_until_guild_available.assert_awaited() - - @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) - @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)) # Message doesn't qualify - @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False)) - async def test_crawl_incidents_noop_if_is_not_incident(self): - """Signals are not added for a non-incident message.""" - await self.cog_instance.crawl_incidents() - incidents.add_signals.assert_not_awaited() - - @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) - @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies - @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=True)) # But already has signals - async def test_crawl_incidents_noop_if_message_already_has_signals(self): - """Signals are not added for messages which already have them.""" - await self.cog_instance.crawl_incidents() - incidents.add_signals.assert_not_awaited() - - @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) - @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies - @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False)) # And doesn't have signals - async def test_crawl_incidents_add_signals_called(self): - """Message has signals added as it does not have them yet and qualifies as an incident.""" - await self.cog_instance.crawl_incidents() - incidents.add_signals.assert_awaited_once() - - -class TestArchive(TestIncidents): - """Tests for the `Incidents.archive` coroutine.""" - - async def test_archive_webhook_not_found(self): - """ - Method recovers and returns False when the webhook is not found. - - Implicitly, this also tests that the error is handled internally and doesn't - propagate out of the method, which is just as important. - """ - self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) - self.assertFalse( - await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember()) - ) - - async def test_archive_relays_incident(self): - """ - If webhook is found, method relays `incident` properly. - - This test will assert that the fetched webhook's `send` method is fed the correct arguments, - and that the `archive` method returns True. - """ - webhook = MockAsyncWebhook() - self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) # Patch in our webhook - - # Define our own `incident` to be archived - incident = MockMessage( - content="this is an incident", - author=MockUser(name="author_name", avatar_url="author_avatar"), - id=123, - ) - built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this - - with patch("bot.cogs.moderation.incidents.make_embed", AsyncMock(return_value=(built_embed, None))): - archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember()) - - # Now we check that the webhook was given the correct args, and that `archive` returned True - webhook.send.assert_called_once_with( - embed=built_embed, - username="author_name", - avatar_url="author_avatar", - file=None, - ) - self.assertTrue(archive_return) - - async def test_archive_clyde_username(self): - """ - The archive webhook username is cleansed using `sub_clyde`. - - Discord will reject any webhook with "clyde" in the username field, as it impersonates - the official Clyde bot. Since we do not control what the username will be (the incident - author name is used), we must ensure the name is cleansed, otherwise the relay may fail. - - This test assumes the username is passed as a kwarg. If this test fails, please review - whether the passed argument is being retrieved correctly. - """ - webhook = MockAsyncWebhook() - self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) - - message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) - await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal), MockMember()) - - self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) - - -class TestMakeConfirmationTask(TestIncidents): - """ - Tests for the `Incidents.make_confirmation_task` method. - - Writing tests for this method is difficult, as it mostly just delegates the provided - information elsewhere. There is very little internal logic. Whether our approach - works conceptually is difficult to prove using unit tests. - """ - - def test_make_confirmation_task_check(self): - """ - The internal check will recognize the passed incident. - - This is a little tricky - we first pass a message with a specific `id` in, and then - retrieve the built check from the `call_args` of the `wait_for` method. This relies - on the check being passed as a kwarg. - - Once the check is retrieved, we assert that it gives True for our incident's `id`, - and False for any other. - - If this function begins to fail, first check that `created_check` is being retrieved - correctly. It should be the function that is built locally in the tested method. - """ - self.cog_instance.make_confirmation_task(MockMessage(id=123)) - - self.cog_instance.bot.wait_for.assert_called_once() - created_check = self.cog_instance.bot.wait_for.call_args.kwargs["check"] - - # The `message_id` matches the `id` of our incident - self.assertTrue(created_check(payload=MagicMock(message_id=123))) - - # This `message_id` does not match - self.assertFalse(created_check(payload=MagicMock(message_id=0))) - - -@patch("bot.cogs.moderation.incidents.ALLOWED_ROLES", {1, 2}) -@patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", AsyncMock()) # Generic awaitable -class TestProcessEvent(TestIncidents): - """Tests for the `Incidents.process_event` coroutine.""" - - async def test_process_event_bad_role(self): - """The reaction is removed when the author lacks all allowed roles.""" - incident = MockMessage() - member = MockMember(roles=[MockRole(id=0)]) # Must have role 1 or 2 - - await self.cog_instance.process_event("reaction", incident, member) - incident.remove_reaction.assert_called_once_with("reaction", member) - - async def test_process_event_bad_emoji(self): - """ - The reaction is removed when an invalid emoji is used. - - This requires that we pass in a `member` with valid roles, as we need the role check - to succeed. - """ - incident = MockMessage() - member = MockMember(roles=[MockRole(id=1)]) # Member has allowed role - - await self.cog_instance.process_event("invalid_signal", incident, member) - incident.remove_reaction.assert_called_once_with("invalid_signal", member) - - async def test_process_event_no_archive_on_investigating(self): - """Message is not archived on `Signal.INVESTIGATING`.""" - with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock()) as mocked_archive: - await self.cog_instance.process_event( - reaction=incidents.Signal.INVESTIGATING.value, - incident=MockMessage(), - member=MockMember(roles=[MockRole(id=1)]), - ) - - mocked_archive.assert_not_called() - - async def test_process_event_no_delete_if_archive_fails(self): - """ - Original message is not deleted when `Incidents.archive` returns False. - - This is the way of signaling that the relay failed, and we should not remove the original, - as that would result in losing the incident record. - """ - incident = MockMessage() - - with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock(return_value=False)): - await self.cog_instance.process_event( - reaction=incidents.Signal.ACTIONED.value, - incident=incident, - member=MockMember(roles=[MockRole(id=1)]) - ) - - incident.delete.assert_not_called() - - async def test_process_event_confirmation_task_is_awaited(self): - """Task given by `Incidents.make_confirmation_task` is awaited before method exits.""" - mock_task = AsyncMock() - - with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task): - await self.cog_instance.process_event( - reaction=incidents.Signal.ACTIONED.value, - incident=MockMessage(), - member=MockMember(roles=[MockRole(id=1)]) - ) - - mock_task.assert_awaited() - - async def test_process_event_confirmation_task_timeout_is_handled(self): - """ - Confirmation task `asyncio.TimeoutError` is handled gracefully. - - We have `make_confirmation_task` return a mock with a side effect, and then catch the - exception should it propagate out of `process_event`. This is so that we can then manually - fail the test with a more informative message than just the plain traceback. - """ - mock_task = AsyncMock(side_effect=asyncio.TimeoutError()) - - try: - with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task): - await self.cog_instance.process_event( - reaction=incidents.Signal.ACTIONED.value, - incident=MockMessage(), - member=MockMember(roles=[MockRole(id=1)]) - ) - except asyncio.TimeoutError: - self.fail("TimeoutError was not handled gracefully, and propagated out of `process_event`!") - - -class TestResolveMessage(TestIncidents): - """Tests for the `Incidents.resolve_message` coroutine.""" - - async def test_resolve_message_pass_message_id(self): - """Method will call `_get_message` with the passed `message_id`.""" - await self.cog_instance.resolve_message(123) - self.cog_instance.bot._connection._get_message.assert_called_once_with(123) - - async def test_resolve_message_in_cache(self): - """ - No API call is made if the queried message exists in the cache. - - We mock the `_get_message` return value regardless of input. Whether it finds the message - internally is considered d.py's responsibility, not ours. - """ - cached_message = MockMessage(id=123) - self.cog_instance.bot._connection._get_message = MagicMock(return_value=cached_message) - - return_value = await self.cog_instance.resolve_message(123) - - self.assertIs(return_value, cached_message) - self.cog_instance.bot.get_channel.assert_not_called() # The `fetch_message` line was never hit - - async def test_resolve_message_not_in_cache(self): - """ - The message is retrieved from the API if it isn't cached. - - This is desired behaviour for messages which exist, but were sent before the bot's - current session. - """ - self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None - - # API returns our message - uncached_message = MockMessage() - fetch_message = AsyncMock(return_value=uncached_message) - self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) - - retrieved_message = await self.cog_instance.resolve_message(123) - self.assertIs(retrieved_message, uncached_message) - - async def test_resolve_message_doesnt_exist(self): - """ - If the API returns a 404, the function handles it gracefully and returns None. - - This is an edge-case happening with racing events - event A will relay the message - to the archive and delete the original. Once event B acquires the `event_lock`, - it will not find the message in the cache, and will ask the API. - """ - self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None - - fetch_message = AsyncMock(side_effect=mock_404) - self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) - - self.assertIsNone(await self.cog_instance.resolve_message(123)) - - async def test_resolve_message_fetch_fails(self): - """ - Non-404 errors are handled, logged & None is returned. - - In contrast with a 404, this should make an error-level log. We assert that at least - one such log was made - we do not make any assertions about the log's message. - """ - self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None - - arbitrary_error = discord.HTTPException( - response=MagicMock(aiohttp.ClientResponse), - message="Arbitrary error", - ) - fetch_message = AsyncMock(side_effect=arbitrary_error) - self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) - - with self.assertLogs(logger=incidents.log, level=logging.ERROR): - self.assertIsNone(await self.cog_instance.resolve_message(123)) - - -@patch("bot.constants.Channels.incidents", 123) -class TestOnRawReactionAdd(TestIncidents): - """ - Tests for the `Incidents.on_raw_reaction_add` listener. - - Writing tests for this listener comes with additional complexity due to the listener - awaiting the `crawl_task` task. See `asyncSetUp` for further details, which attempts - to make unit testing this function possible. - """ - - def setUp(self): - """ - Prepare & assign `payload` attribute. - - This attribute represents an *ideal* payload which will not be rejected by the - listener. As each test will receive a fresh instance, it can be mutated to - observe how the listener's behaviour changes with different attributes on - the passed payload. - """ - super().setUp() # Ensure `cog_instance` is assigned - - self.payload = MagicMock( - discord.RawReactionActionEvent, - channel_id=123, # Patched at class level - message_id=456, - member=MockMember(bot=False), - emoji="reaction", - ) - - async def asyncSetUp(self): # noqa: N802 - """ - Prepare an empty task and assign it as `crawl_task`. - - It appears that the `unittest` framework does not provide anything for mocking - asyncio tasks. An `AsyncMock` instance can be called and then awaited, however, - it does not provide the `done` method or any other parts of the `asyncio.Task` - interface. - - Although we do not need to make any assertions about the task itself while - testing the listener, the code will still await it and call the `done` method, - and so we must inject something that will not fail on either action. - - Note that this is done in an `asyncSetUp`, which runs after `setUp`. - The justification is that creating an actual task requires the event - loop to be ready, which is not the case in the `setUp`. - """ - mock_task = asyncio.create_task(AsyncMock()()) # Mock async func, then a coro - self.cog_instance.crawl_task = mock_task - - async def test_on_raw_reaction_add_wrong_channel(self): - """ - Events outside of #incidents will be ignored. - - We check this by asserting that `resolve_message` was never queried. - """ - self.payload.channel_id = 0 - self.cog_instance.resolve_message = AsyncMock() - - await self.cog_instance.on_raw_reaction_add(self.payload) - self.cog_instance.resolve_message.assert_not_called() - - async def test_on_raw_reaction_add_user_is_bot(self): - """ - Events dispatched by bot accounts will be ignored. - - We check this by asserting that `resolve_message` was never queried. - """ - self.payload.member = MockMember(bot=True) - self.cog_instance.resolve_message = AsyncMock() - - await self.cog_instance.on_raw_reaction_add(self.payload) - self.cog_instance.resolve_message.assert_not_called() - - async def test_on_raw_reaction_add_message_doesnt_exist(self): - """ - Listener gracefully handles the case where `resolve_message` gives None. - - We check this by asserting that `process_event` was never called. - """ - self.cog_instance.process_event = AsyncMock() - self.cog_instance.resolve_message = AsyncMock(return_value=None) - - await self.cog_instance.on_raw_reaction_add(self.payload) - self.cog_instance.process_event.assert_not_called() - - async def test_on_raw_reaction_add_message_is_not_an_incident(self): - """ - The event won't be processed if the related message is not an incident. - - This is an edge-case that can happen if someone manually leaves a reaction - on a pinned message, or a comment. - - We check this by asserting that `process_event` was never called. - """ - self.cog_instance.process_event = AsyncMock() - self.cog_instance.resolve_message = AsyncMock(return_value=MockMessage()) - - with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)): - await self.cog_instance.on_raw_reaction_add(self.payload) - - self.cog_instance.process_event.assert_not_called() - - async def test_on_raw_reaction_add_valid_event_is_processed(self): - """ - If the reaction event is valid, it is passed to `process_event`. - - This is the case when everything goes right: - * The reaction was placed in #incidents, and not by a bot - * The message was found successfully - * The message qualifies as an incident - - Additionally, we check that all arguments were passed as expected. - """ - incident = MockMessage(id=1) - - self.cog_instance.process_event = AsyncMock() - self.cog_instance.resolve_message = AsyncMock(return_value=incident) - - with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)): - await self.cog_instance.on_raw_reaction_add(self.payload) - - self.cog_instance.process_event.assert_called_with( - "reaction", # Defined in `self.payload` - incident, - self.payload.member, - ) - - -class TestOnMessage(TestIncidents): - """ - Tests for the `Incidents.on_message` listener. - - Notice the decorators mocking the `is_incident` return value. The `is_incidents` - function is tested in `TestIsIncident` - here we do not worry about it. - """ - - @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) - async def test_on_message_incident(self): - """Messages qualifying as incidents are passed to `add_signals`.""" - incident = MockMessage() - - with patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: - await self.cog_instance.on_message(incident) - - mock_add_signals.assert_called_once_with(incident) - - @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)) - async def test_on_message_non_incident(self): - """Messages not qualifying as incidents are ignored.""" - with patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: - await self.cog_instance.on_message(MockMessage()) - - mock_add_signals.assert_not_called() diff --git a/tests/bot/cogs/moderation/test_modlog.py b/tests/bot/cogs/moderation/test_modlog.py deleted file mode 100644 index f2809f40a..000000000 --- a/tests/bot/cogs/moderation/test_modlog.py +++ /dev/null @@ -1,29 +0,0 @@ -import unittest - -import discord - -from bot.cogs.moderation.modlog import ModLog -from tests.helpers import MockBot, MockTextChannel - - -class ModLogTests(unittest.IsolatedAsyncioTestCase): - """Tests for moderation logs.""" - - def setUp(self): - self.bot = MockBot() - self.cog = ModLog(self.bot) - self.channel = MockTextChannel() - - async def test_log_entry_description_truncation(self): - """Test that embed description for ModLog entry is truncated.""" - self.bot.get_channel.return_value = self.channel - await self.cog.send_log_message( - icon_url="foo", - colour=discord.Colour.blue(), - title="bar", - text="foo bar" * 3000 - ) - embed = self.channel.send.call_args[1]["embed"] - self.assertEqual( - embed.description, ("foo bar" * 3000)[:2045] + "..." - ) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py deleted file mode 100644 index ab3d0742a..000000000 --- a/tests/bot/cogs/moderation/test_silence.py +++ /dev/null @@ -1,261 +0,0 @@ -import unittest -from unittest import mock -from unittest.mock import MagicMock, Mock - -from discord import PermissionOverwrite - -from bot.cogs.moderation.silence import Silence, SilenceNotifier -from bot.constants import Channels, Emojis, Guild, Roles -from tests.helpers import MockBot, MockContext, MockTextChannel - - -class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - self.alert_channel = MockTextChannel() - self.notifier = SilenceNotifier(self.alert_channel) - self.notifier.stop = self.notifier_stop_mock = Mock() - self.notifier.start = self.notifier_start_mock = Mock() - - def test_add_channel_adds_channel(self): - """Channel in FirstHash with current loop is added to internal set.""" - channel = Mock() - with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: - self.notifier.add_channel(channel) - silenced_channels.__setitem__.assert_called_with(channel, self.notifier._current_loop) - - def test_add_channel_starts_loop(self): - """Loop is started if `_silenced_channels` was empty.""" - self.notifier.add_channel(Mock()) - self.notifier_start_mock.assert_called_once() - - def test_add_channel_skips_start_with_channels(self): - """Loop start is not called when `_silenced_channels` is not empty.""" - with mock.patch.object(self.notifier, "_silenced_channels"): - self.notifier.add_channel(Mock()) - self.notifier_start_mock.assert_not_called() - - def test_remove_channel_removes_channel(self): - """Channel in FirstHash is removed from `_silenced_channels`.""" - channel = Mock() - with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: - self.notifier.remove_channel(channel) - silenced_channels.__delitem__.assert_called_with(channel) - - def test_remove_channel_stops_loop(self): - """Notifier loop is stopped if `_silenced_channels` is empty after remove.""" - with mock.patch.object(self.notifier, "_silenced_channels", __bool__=lambda _: False): - self.notifier.remove_channel(Mock()) - self.notifier_stop_mock.assert_called_once() - - def test_remove_channel_skips_stop_with_channels(self): - """Notifier loop is not stopped if `_silenced_channels` is not empty after remove.""" - self.notifier.remove_channel(Mock()) - self.notifier_stop_mock.assert_not_called() - - async def test_notifier_private_sends_alert(self): - """Alert is sent on 15 min intervals.""" - test_cases = (900, 1800, 2700) - for current_loop in test_cases: - with self.subTest(current_loop=current_loop): - with mock.patch.object(self.notifier, "_current_loop", new=current_loop): - await self.notifier._notifier() - self.alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> currently silenced channels: ") - self.alert_channel.send.reset_mock() - - async def test_notifier_skips_alert(self): - """Alert is skipped on first loop or not an increment of 900.""" - test_cases = (0, 15, 5000) - for current_loop in test_cases: - with self.subTest(current_loop=current_loop): - with mock.patch.object(self.notifier, "_current_loop", new=current_loop): - await self.notifier._notifier() - self.alert_channel.send.assert_not_called() - - -class SilenceTests(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - self.bot = MockBot() - self.cog = Silence(self.bot) - self.ctx = MockContext() - self.cog._verified_role = None - # Set event so command callbacks can continue. - self.cog._get_instance_vars_event.set() - - async def test_instance_vars_got_guild(self): - """Bot got guild after it became available.""" - await self.cog._get_instance_vars() - self.bot.wait_until_guild_available.assert_called_once() - self.bot.get_guild.assert_called_once_with(Guild.id) - - async def test_instance_vars_got_role(self): - """Got `Roles.verified` role from guild.""" - await self.cog._get_instance_vars() - guild = self.bot.get_guild() - guild.get_role.assert_called_once_with(Roles.verified) - - async def test_instance_vars_got_channels(self): - """Got channels from bot.""" - await self.cog._get_instance_vars() - self.bot.get_channel.called_once_with(Channels.mod_alerts) - self.bot.get_channel.called_once_with(Channels.mod_log) - - @mock.patch("bot.cogs.moderation.silence.SilenceNotifier") - async def test_instance_vars_got_notifier(self, notifier): - """Notifier was started with channel.""" - mod_log = MockTextChannel() - self.bot.get_channel.side_effect = (None, mod_log) - await self.cog._get_instance_vars() - notifier.assert_called_once_with(mod_log) - self.bot.get_channel.side_effect = None - - async def test_silence_sent_correct_discord_message(self): - """Check if proper message was sent when called with duration in channel with previous state.""" - test_cases = ( - (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), - (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), - (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), - ) - for duration, result_message, _silence_patch_return in test_cases: - with self.subTest( - silence_duration=duration, - result_message=result_message, - starting_unsilenced_state=_silence_patch_return - ): - with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): - await self.cog.silence.callback(self.cog, self.ctx, duration) - self.ctx.send.assert_called_once_with(result_message) - self.ctx.reset_mock() - - async def test_unsilence_sent_correct_discord_message(self): - """Check if proper message was sent when unsilencing channel.""" - test_cases = ( - (True, f"{Emojis.check_mark} unsilenced current channel."), - (False, f"{Emojis.cross_mark} current channel was not silenced.") - ) - for _unsilence_patch_return, result_message in test_cases: - with self.subTest( - starting_silenced_state=_unsilence_patch_return, - result_message=result_message - ): - with mock.patch.object(self.cog, "_unsilence", return_value=_unsilence_patch_return): - await self.cog.unsilence.callback(self.cog, self.ctx) - self.ctx.send.assert_called_once_with(result_message) - self.ctx.reset_mock() - - async def test_silence_private_for_false(self): - """Permissions are not set and `False` is returned in an already silenced channel.""" - perm_overwrite = Mock(send_messages=False) - channel = Mock(overwrites_for=Mock(return_value=perm_overwrite)) - - self.assertFalse(await self.cog._silence(channel, True, None)) - channel.set_permissions.assert_not_called() - - async def test_silence_private_silenced_channel(self): - """Channel had `send_message` permissions revoked.""" - channel = MockTextChannel() - self.assertTrue(await self.cog._silence(channel, False, None)) - channel.set_permissions.assert_called_once() - self.assertFalse(channel.set_permissions.call_args.kwargs['send_messages']) - - async def test_silence_private_preserves_permissions(self): - """Previous permissions were preserved when channel was silenced.""" - channel = MockTextChannel() - # Set up mock channel permission state. - mock_permissions = PermissionOverwrite() - mock_permissions_dict = dict(mock_permissions) - channel.overwrites_for.return_value = mock_permissions - await self.cog._silence(channel, False, None) - new_permissions = channel.set_permissions.call_args.kwargs - # Remove 'send_messages' key because it got changed in the method. - del new_permissions['send_messages'] - del mock_permissions_dict['send_messages'] - self.assertDictEqual(mock_permissions_dict, new_permissions) - - async def test_silence_private_notifier(self): - """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" - channel = MockTextChannel() - with mock.patch.object(self.cog, "notifier", create=True): - with self.subTest(persistent=True): - await self.cog._silence(channel, True, None) - self.cog.notifier.add_channel.assert_called_once() - - with mock.patch.object(self.cog, "notifier", create=True): - with self.subTest(persistent=False): - await self.cog._silence(channel, False, None) - self.cog.notifier.add_channel.assert_not_called() - - async def test_silence_private_added_muted_channel(self): - """Channel was added to `muted_channels` on silence.""" - channel = MockTextChannel() - with mock.patch.object(self.cog, "muted_channels") as muted_channels: - await self.cog._silence(channel, False, None) - muted_channels.add.assert_called_once_with(channel) - - async def test_unsilence_private_for_false(self): - """Permissions are not set and `False` is returned in an unsilenced channel.""" - channel = Mock() - self.assertFalse(await self.cog._unsilence(channel)) - channel.set_permissions.assert_not_called() - - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_unsilenced_channel(self, _): - """Channel had `send_message` permissions restored""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - self.assertTrue(await self.cog._unsilence(channel)) - channel.set_permissions.assert_called_once() - self.assertIsNone(channel.set_permissions.call_args.kwargs['send_messages']) - - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_removed_notifier(self, notifier): - """Channel was removed from `notifier` on unsilence.""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - await self.cog._unsilence(channel) - notifier.remove_channel.assert_called_once_with(channel) - - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_removed_muted_channel(self, _): - """Channel was removed from `muted_channels` on unsilence.""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - with mock.patch.object(self.cog, "muted_channels") as muted_channels: - await self.cog._unsilence(channel) - muted_channels.discard.assert_called_once_with(channel) - - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_preserves_permissions(self, _): - """Previous permissions were preserved when channel was unsilenced.""" - channel = MockTextChannel() - # Set up mock channel permission state. - mock_permissions = PermissionOverwrite(send_messages=False) - mock_permissions_dict = dict(mock_permissions) - channel.overwrites_for.return_value = mock_permissions - await self.cog._unsilence(channel) - new_permissions = channel.set_permissions.call_args.kwargs - # Remove 'send_messages' key because it got changed in the method. - del new_permissions['send_messages'] - del mock_permissions_dict['send_messages'] - self.assertDictEqual(mock_permissions_dict, new_permissions) - - @mock.patch("bot.cogs.moderation.silence.asyncio") - @mock.patch.object(Silence, "_mod_alerts_channel", create=True) - def test_cog_unload_starts_task(self, alert_channel, asyncio_mock): - """Task for sending an alert was created with present `muted_channels`.""" - with mock.patch.object(self.cog, "muted_channels"): - self.cog.cog_unload() - alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") - asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) - - @mock.patch("bot.cogs.moderation.silence.asyncio") - def test_cog_unload_skips_task_start(self, asyncio_mock): - """No task created with no channels.""" - self.cog.cog_unload() - asyncio_mock.create_task.assert_not_called() - - @mock.patch("bot.cogs.moderation.silence.with_role_check") - @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) - def test_cog_check(self, role_check): - """Role check is called with `MODERATION_ROLES`""" - self.cog.cog_check(self.ctx) - role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) diff --git a/tests/bot/cogs/moderation/test_slowmode.py b/tests/bot/cogs/moderation/test_slowmode.py deleted file mode 100644 index f442814c8..000000000 --- a/tests/bot/cogs/moderation/test_slowmode.py +++ /dev/null @@ -1,111 +0,0 @@ -import unittest -from unittest import mock - -from dateutil.relativedelta import relativedelta - -from bot.cogs.moderation.slowmode import Slowmode -from bot.constants import Emojis -from tests.helpers import MockBot, MockContext, MockTextChannel - - -class SlowmodeTests(unittest.IsolatedAsyncioTestCase): - - def setUp(self) -> None: - self.bot = MockBot() - self.cog = Slowmode(self.bot) - self.ctx = MockContext() - - async def test_get_slowmode_no_channel(self) -> None: - """Get slowmode without a given channel.""" - self.ctx.channel = MockTextChannel(name='python-general', slowmode_delay=5) - - await self.cog.get_slowmode(self.cog, self.ctx, None) - self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.") - - async def test_get_slowmode_with_channel(self) -> None: - """Get slowmode with a given channel.""" - text_channel = MockTextChannel(name='python-language', slowmode_delay=2) - - await self.cog.get_slowmode(self.cog, self.ctx, text_channel) - self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.') - - async def test_set_slowmode_no_channel(self) -> None: - """Set slowmode without a given channel.""" - test_cases = ( - ('helpers', 23, True, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'), - ('mods', 76526, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'), - ('admins', 97, True, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.') - ) - - for channel_name, seconds, edited, result_msg in test_cases: - with self.subTest( - channel_mention=channel_name, - seconds=seconds, - edited=edited, - result_msg=result_msg - ): - self.ctx.channel = MockTextChannel(name=channel_name) - - await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=seconds)) - - if edited: - self.ctx.channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) - else: - self.ctx.channel.edit.assert_not_called() - - self.ctx.send.assert_called_once_with(result_msg) - - self.ctx.reset_mock() - - async def test_set_slowmode_with_channel(self) -> None: - """Set slowmode with a given channel.""" - test_cases = ( - ('bot-commands', 12, True, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'), - ('mod-spam', 21, True, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'), - ('admin-spam', 4323598, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.') - ) - - for channel_name, seconds, edited, result_msg in test_cases: - with self.subTest( - channel_mention=channel_name, - seconds=seconds, - edited=edited, - result_msg=result_msg - ): - text_channel = MockTextChannel(name=channel_name) - - await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=seconds)) - - if edited: - text_channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) - else: - text_channel.edit.assert_not_called() - - self.ctx.send.assert_called_once_with(result_msg) - - self.ctx.reset_mock() - - async def test_reset_slowmode_no_channel(self) -> None: - """Reset slowmode without a given channel.""" - self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) - - await self.cog.reset_slowmode(self.cog, self.ctx, None) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.' - ) - - async def test_reset_slowmode_with_channel(self) -> None: - """Reset slowmode with a given channel.""" - text_channel = MockTextChannel(name='meta', slowmode_delay=1) - - await self.cog.reset_slowmode(self.cog, self.ctx, text_channel) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' - ) - - @mock.patch("bot.cogs.moderation.slowmode.with_role_check") - @mock.patch("bot.cogs.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3)) - def test_cog_check(self, role_check): - """Role check is called with `MODERATION_ROLES`""" - self.cog.cog_check(self.ctx) - role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py deleted file mode 100644 index fdda59a8f..000000000 --- a/tests/bot/cogs/test_cogs.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Test suite for general tests which apply to all cogs.""" - -import importlib -import pkgutil -import typing as t -import unittest -from collections import defaultdict -from types import ModuleType -from unittest import mock - -from discord.ext import commands - -from bot import cogs - - -class CommandNameTests(unittest.TestCase): - """Tests for shadowing command names and aliases.""" - - @staticmethod - def walk_commands(cog: commands.Cog) -> t.Iterator[commands.Command]: - """An iterator that recursively walks through `cog`'s commands and subcommands.""" - # Can't use Bot.walk_commands() or Cog.get_commands() cause those are instance methods. - for command in cog.__cog_commands__: - if command.parent is None: - yield command - if isinstance(command, commands.GroupMixin): - # Annoyingly it returns duplicates for each alias so use a set to fix that - yield from set(command.walk_commands()) - - @staticmethod - def walk_modules() -> t.Iterator[ModuleType]: - """Yield imported modules from the bot.cogs subpackage.""" - def on_error(name: str) -> t.NoReturn: - raise ImportError(name=name) # pragma: no cover - - # The mock prevents asyncio.get_event_loop() from being called. - with mock.patch("discord.ext.tasks.loop"): - for module in pkgutil.walk_packages(cogs.__path__, "bot.cogs.", onerror=on_error): - if not module.ispkg: - yield importlib.import_module(module.name) - - @staticmethod - def walk_cogs(module: ModuleType) -> t.Iterator[commands.Cog]: - """Yield all cogs defined in an extension.""" - for obj in module.__dict__.values(): - # Check if it's a class type cause otherwise issubclass() may raise a TypeError. - is_cog = isinstance(obj, type) and issubclass(obj, commands.Cog) - if is_cog and obj.__module__ == module.__name__: - yield obj - - @staticmethod - def get_qualified_names(command: commands.Command) -> t.List[str]: - """Return a list of all qualified names, including aliases, for the `command`.""" - names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases] - names.append(command.qualified_name) - - return names - - def get_all_commands(self) -> t.Iterator[commands.Command]: - """Yield all commands for all cogs in all extensions.""" - for module in self.walk_modules(): - for cog in self.walk_cogs(module): - for cmd in self.walk_commands(cog): - yield cmd - - def test_names_dont_shadow(self): - """Names and aliases of commands should be unique.""" - all_names = defaultdict(list) - for cmd in self.get_all_commands(): - func_name = f"{cmd.module}.{cmd.callback.__qualname__}" - - for name in self.get_qualified_names(cmd): - with self.subTest(cmd=func_name, name=name): - if name in all_names: # pragma: no cover - conflicts = ", ".join(all_names.get(name, "")) - self.fail( - f"Name '{name}' of the command {func_name} conflicts with {conflicts}." - ) - - all_names[name].append(func_name) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py deleted file mode 100644 index cfe10aebf..000000000 --- a/tests/bot/cogs/test_duck_pond.py +++ /dev/null @@ -1,548 +0,0 @@ -import asyncio -import logging -import typing -import unittest -from unittest.mock import AsyncMock, MagicMock, patch - -import discord - -from bot import constants -from bot.cogs import duck_pond -from tests import base -from tests import helpers - -MODULE_PATH = "bot.cogs.duck_pond" - - -class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): - """Tests for DuckPond functionality.""" - - @classmethod - def setUpClass(cls): - """Sets up the objects that only have to be initialized once.""" - cls.nonstaff_member = helpers.MockMember(name="Non-staffer") - - cls.staff_role = helpers.MockRole(name="Staff role", id=constants.STAFF_ROLES[0]) - cls.staff_member = helpers.MockMember(name="staffer", roles=[cls.staff_role]) - - cls.checkmark_emoji = "\N{White Heavy Check Mark}" - cls.thumbs_up_emoji = "\N{Thumbs Up Sign}" - cls.unicode_duck_emoji = "\N{Duck}" - cls.duck_pond_emoji = helpers.MockPartialEmoji(id=constants.DuckPond.custom_emojis[0]) - cls.non_duck_custom_emoji = helpers.MockPartialEmoji(id=123) - - def setUp(self): - """Sets up the objects that need to be refreshed before each test.""" - self.bot = helpers.MockBot(user=helpers.MockMember(id=46692)) - self.cog = duck_pond.DuckPond(bot=self.bot) - - def test_duck_pond_correctly_initializes(self): - """`__init__ should set `bot` and `webhook_id` attributes and schedule `fetch_webhook`.""" - bot = helpers.MockBot() - cog = MagicMock() - - duck_pond.DuckPond.__init__(cog, bot) - - self.assertEqual(cog.bot, bot) - self.assertEqual(cog.webhook_id, constants.Webhooks.duck_pond) - bot.loop.create_task.assert_called_once_with(cog.fetch_webhook()) - - def test_fetch_webhook_succeeds_without_connectivity_issues(self): - """The `fetch_webhook` method waits until `READY` event and sets the `webhook` attribute.""" - self.bot.fetch_webhook.return_value = "dummy webhook" - self.cog.webhook_id = 1 - - asyncio.run(self.cog.fetch_webhook()) - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.fetch_webhook.assert_called_once_with(1) - self.assertEqual(self.cog.webhook, "dummy webhook") - - def test_fetch_webhook_logs_when_unable_to_fetch_webhook(self): - """The `fetch_webhook` method should log an exception when it fails to fetch the webhook.""" - self.bot.fetch_webhook.side_effect = discord.HTTPException(response=MagicMock(), message="Not found.") - self.cog.webhook_id = 1 - - log = logging.getLogger('bot.cogs.duck_pond') - with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - asyncio.run(self.cog.fetch_webhook()) - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.fetch_webhook.assert_called_once_with(1) - - self.assertEqual(len(log_watcher.records), 1) - - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.ERROR) - - def test_is_staff_returns_correct_values_based_on_instance_passed(self): - """The `is_staff` method should return correct values based on the instance passed.""" - test_cases = ( - (helpers.MockUser(name="User instance"), False), - (helpers.MockMember(name="Member instance without staff role"), False), - (helpers.MockMember(name="Member instance with staff role", roles=[self.staff_role]), True) - ) - - for user, expected_return in test_cases: - actual_return = self.cog.is_staff(user) - with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return): - self.assertEqual(expected_return, actual_return) - - async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self): - """The `has_green_checkmark` method should only return `True` if one is present.""" - test_cases = ( - ( - "No reactions", helpers.MockMessage(), False - ), - ( - "No green check mark reactions", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.thumbs_up_emoji, users=[self.bot.user]) - ]), - False - ), - ( - "Green check mark reaction, but not from our bot", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member]) - ]), - False - ), - ( - "Green check mark reaction, with one from the bot", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user]) - ]), - True - ) - ) - - for description, message, expected_return in test_cases: - actual_return = await self.cog.has_green_checkmark(message) - with self.subTest( - test_case=description, - expected_return=expected_return, - actual_return=actual_return - ): - self.assertEqual(expected_return, actual_return) - - def _get_reaction( - self, - emoji: typing.Union[str, helpers.MockEmoji], - staff: int = 0, - nonstaff: int = 0 - ) -> helpers.MockReaction: - staffers = [helpers.MockMember(roles=[self.staff_role]) for _ in range(staff)] - nonstaffers = [helpers.MockMember() for _ in range(nonstaff)] - return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers) - - async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self): - """The `count_ducks` method should return the number of unique staffers who gave a duck.""" - test_cases = ( - # Simple test cases - # A message without reactions should return 0 - ( - "No reactions", - helpers.MockMessage(), - 0 - ), - # A message with a non-duck reaction from a non-staffer should return 0 - ( - "Non-duck reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, nonstaff=1)]), - 0 - ), - # A message with a non-duck reaction from a staffer should return 0 - ( - "Non-duck reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.non_duck_custom_emoji, staff=1)]), - 0 - ), - # A message with a non-duck reaction from a non-staffer and staffer should return 0 - ( - "Non-duck reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, staff=1, nonstaff=1)]), - 0 - ), - # A message with a unicode duck reaction from a non-staffer should return 0 - ( - "Unicode Duck Reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, nonstaff=1)]), - 0 - ), - # A message with a unicode duck reaction from a staffer should return 1 - ( - "Unicode Duck Reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1)]), - 1 - ), - # A message with a unicode duck reaction from a non-staffer and staffer should return 1 - ( - "Unicode Duck Reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1, nonstaff=1)]), - 1 - ), - # A message with a duckpond duck reaction from a non-staffer should return 0 - ( - "Duckpond Duck Reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, nonstaff=1)]), - 0 - ), - # A message with a duckpond duck reaction from a staffer should return 1 - ( - "Duckpond Duck Reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1)]), - 1 - ), - # A message with a duckpond duck reaction from a non-staffer and staffer should return 1 - ( - "Duckpond Duck Reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1, nonstaff=1)]), - 1 - ), - - # Complex test cases - # A message with duckpond duck reactions from 3 staffers and 2 non-staffers returns 3 - ( - "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2)]), - 3 - ), - # A staffer with multiple duck reactions only counts once - ( - "Two different duck reactions from the same staffer", - helpers.MockMessage( - reactions=[ - helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), - ] - ), - 1 - ), - # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif) - ( - "Reaction with non-Emoji/str emoij from 3 staffers + 2 non-staffers", - helpers.MockMessage(reactions=[self._get_reaction(emoji=100, staff=3, nonstaff=2)]), - 0 - ), - # We correctly sum when multiple reactions are provided. - ( - "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", - helpers.MockMessage( - reactions=[ - self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), - self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), - ] - ), - 3 + 4 - ), - ) - - for description, message, expected_count in test_cases: - actual_count = await self.cog.count_ducks(message) - with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count): - self.assertEqual(expected_count, actual_count) - - async def test_relay_message_correctly_relays_content_and_attachments(self): - """The `relay_message` method should correctly relay message content and attachments.""" - send_webhook_path = f"{MODULE_PATH}.send_webhook" - send_attachments_path = f"{MODULE_PATH}.send_attachments" - author = MagicMock( - display_name="x", - avatar_url="https://" - ) - - self.cog.webhook = helpers.MockAsyncWebhook() - - test_values = ( - (helpers.MockMessage(author=author, clean_content="", attachments=[]), False, False), - (helpers.MockMessage(author=author, clean_content="message", attachments=[]), True, False), - (helpers.MockMessage(author=author, clean_content="", attachments=["attachment"]), False, True), - (helpers.MockMessage(author=author, clean_content="message", attachments=["attachment"]), True, True), - ) - - for message, expect_webhook_call, expect_attachment_call in test_values: - with patch(send_webhook_path, new_callable=AsyncMock) as send_webhook: - with patch(send_attachments_path, new_callable=AsyncMock) as send_attachments: - with self.subTest(clean_content=message.clean_content, attachments=message.attachments): - await self.cog.relay_message(message) - - self.assertEqual(expect_webhook_call, send_webhook.called) - self.assertEqual(expect_attachment_call, send_attachments.called) - - message.add_reaction.assert_called_once_with(self.checkmark_emoji) - - @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) - async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): - """The `relay_message` method should handle irretrievable attachments.""" - message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) - side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) - - self.cog.webhook = helpers.MockAsyncWebhook() - log = logging.getLogger("bot.cogs.duck_pond") - - for side_effect in side_effects: # pragma: no cover - send_attachments.side_effect = side_effect - with patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) as send_webhook: - with self.subTest(side_effect=type(side_effect).__name__): - with self.assertNotLogs(logger=log, level=logging.ERROR): - await self.cog.relay_message(message) - - self.assertEqual(send_webhook.call_count, 2) - - @patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) - @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) - async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): - """The `relay_message` method should handle irretrievable attachments.""" - message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) - - self.cog.webhook = helpers.MockAsyncWebhook() - log = logging.getLogger("bot.cogs.duck_pond") - - side_effect = discord.HTTPException(MagicMock(), "") - send_attachments.side_effect = side_effect - with self.subTest(side_effect=type(side_effect).__name__): - with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - await self.cog.relay_message(message) - - send_webhook.assert_called_once_with( - webhook=self.cog.webhook, - content=message.clean_content, - username=message.author.display_name, - avatar_url=message.author.avatar_url - ) - - self.assertEqual(len(log_watcher.records), 1) - - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.ERROR) - - def _mock_payload(self, label: str, is_custom_emoji: bool, id_: int, emoji_name: str): - """Creates a mock `on_raw_reaction_add` payload with the specified emoji data.""" - payload = MagicMock(name=label) - payload.emoji.is_custom_emoji.return_value = is_custom_emoji - payload.emoji.id = id_ - payload.emoji.name = emoji_name - return payload - - async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self): - """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" - test_values = ( - # Custom Emojis - ( - self._mock_payload( - label="Custom Duckpond Emoji", - is_custom_emoji=True, - id_=constants.DuckPond.custom_emojis[0], - emoji_name="" - ), - True - ), - ( - self._mock_payload( - label="Custom Non-Duckpond Emoji", - is_custom_emoji=True, - id_=123, - emoji_name="" - ), - False - ), - # Unicode Emojis - ( - self._mock_payload( - label="Unicode Duck Emoji", - is_custom_emoji=False, - id_=1, - emoji_name=self.unicode_duck_emoji - ), - True - ), - ( - self._mock_payload( - label="Unicode Non-Duck Emoji", - is_custom_emoji=False, - id_=1, - emoji_name=self.thumbs_up_emoji - ), - False - ), - ) - - for payload, expected_return in test_values: - actual_return = self.cog._payload_has_duckpond_emoji(payload) - with self.subTest(case=payload._mock_name, expected_return=expected_return, actual_return=actual_return): - self.assertEqual(expected_return, actual_return) - - @patch(f"{MODULE_PATH}.discord.utils.get") - @patch(f"{MODULE_PATH}.DuckPond._payload_has_duckpond_emoji", new=MagicMock(return_value=False)) - def test_on_raw_reaction_add_returns_early_with_payload_without_duck_emoji(self, utils_get): - """The `on_raw_reaction_add` method should return early if the payload does not contain a duck emoji.""" - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload=MagicMock()))) - - # Ensure we've returned before making an unnecessary API call in the lines of code after the emoji check - utils_get.assert_not_called() - - def _raw_reaction_mocks(self, channel_id, message_id, user_id): - """Sets up mocks for tests of the `on_raw_reaction_add` event listener.""" - channel = helpers.MockTextChannel(id=channel_id) - self.bot.get_all_channels.return_value = (channel,) - - message = helpers.MockMessage(id=message_id) - - channel.fetch_message.return_value = message - - member = helpers.MockMember(id=user_id, roles=[self.staff_role]) - message.guild.members = (member,) - - payload = MagicMock(channel_id=channel_id, message_id=message_id, user_id=user_id) - - return channel, message, member, payload - - async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): - """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" - channel_id = 1234 - message_id = 2345 - user_id = 3456 - - channel, message, _, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) - - test_cases = ( - ("non-staff member", helpers.MockMember(id=user_id)), - ("bot staff member", helpers.MockMember(id=user_id, roles=[self.staff_role], bot=True)), - ) - - payload.emoji = self.duck_pond_emoji - - for description, member in test_cases: - message.guild.members = (member, ) - with self.subTest(test_case=description), patch(f"{MODULE_PATH}.DuckPond.has_green_checkmark") as checkmark: - checkmark.side_effect = AssertionError( - "Expected method to return before calling `self.has_green_checkmark`." - ) - self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) - - # Check that we did make it past the payload checks - channel.fetch_message.assert_called_once() - channel.fetch_message.reset_mock() - - @patch(f"{MODULE_PATH}.DuckPond.is_staff") - @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) - def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff): - """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot.""" - channel_id = 31415926535 - message_id = 27182818284 - user_id = 16180339887 - - channel, message, member, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) - - payload.emoji = helpers.MockPartialEmoji(name=self.unicode_duck_emoji) - payload.emoji.is_custom_emoji.return_value = False - - message.reactions = [helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.bot.user])] - - is_staff.return_value = True - count_ducks.side_effect = AssertionError("Expected method to return before calling `self.count_ducks`") - - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload))) - - # Assert that we've made it past `self.is_staff` - is_staff.assert_called_once() - - async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self): - """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" - test_cases = ( - (constants.DuckPond.threshold - 1, False), - (constants.DuckPond.threshold, True), - (constants.DuckPond.threshold + 1, True), - ) - - channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5) - - payload.emoji = self.duck_pond_emoji - - for duck_count, should_relay in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=AsyncMock) as relay_message: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: - count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_relay=should_relay): - await self.cog.on_raw_reaction_add(payload) - - # Confirm that we've made it past counting - count_ducks.assert_called_once() - - # Did we relay a message? - has_relayed = relay_message.called - self.assertEqual(has_relayed, should_relay) - - if should_relay: - relay_message.assert_called_once_with(message) - - async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): - """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks.""" - checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji) - - message = helpers.MockMessage(id=1234) - - channel = helpers.MockTextChannel(id=98765) - channel.fetch_message.return_value = message - - self.bot.get_all_channels.return_value = (channel, ) - - payload = MagicMock(channel_id=channel.id, message_id=message.id, emoji=checkmark) - - test_cases = ( - (constants.DuckPond.threshold - 1, False), - (constants.DuckPond.threshold, True), - (constants.DuckPond.threshold + 1, True), - ) - for duck_count, should_re_add_checkmark in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: - count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark): - await self.cog.on_raw_reaction_remove(payload) - - # Check if we fetched the message - channel.fetch_message.assert_called_once_with(message.id) - - # Check if we actually counted the number of ducks - count_ducks.assert_called_once_with(message) - - has_re_added_checkmark = message.add_reaction.called - self.assertEqual(should_re_add_checkmark, has_re_added_checkmark) - - if should_re_add_checkmark: - message.add_reaction.assert_called_once_with(self.checkmark_emoji) - message.add_reaction.reset_mock() - - # reset mocks - channel.fetch_message.reset_mock() - message.reset_mock() - - def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self): - """The `on_raw_reaction_remove` listener should ignore the removal of non-check mark emojis.""" - channel = helpers.MockTextChannel(id=98765) - - channel.fetch_message.side_effect = AssertionError( - "Expected method to return before calling `channel.fetch_message`" - ) - - self.bot.get_all_channels.return_value = (channel, ) - - payload = MagicMock(emoji=helpers.MockPartialEmoji(name=self.thumbs_up_emoji), channel_id=channel.id) - - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_remove(payload))) - - channel.fetch_message.assert_not_called() - - -class DuckPondSetupTests(unittest.TestCase): - """Tests setup of the `DuckPond` cog.""" - - def test_setup(self): - """Setup of the extension should call add_cog.""" - bot = helpers.MockBot() - duck_pond.setup(bot) - bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/utils/__init__.py b/tests/bot/cogs/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/utils/test_jams.py b/tests/bot/cogs/utils/test_jams.py deleted file mode 100644 index 299f436ba..000000000 --- a/tests/bot/cogs/utils/test_jams.py +++ /dev/null @@ -1,173 +0,0 @@ -import unittest -from unittest.mock import AsyncMock, MagicMock, create_autospec - -from discord import CategoryChannel - -from bot.cogs.utils import jams -from bot.constants import Roles -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel - - -def get_mock_category(channel_count: int, name: str) -> CategoryChannel: - """Return a mocked code jam category.""" - category = create_autospec(CategoryChannel, spec_set=True, instance=True) - category.name = name - category.channels = [MockTextChannel() for _ in range(channel_count)] - - return category - - -class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): - """Tests for `createteam` command.""" - - def setUp(self): - self.bot = MockBot() - self.admin_role = MockRole(name="Admins", id=Roles.admins) - self.command_user = MockMember([self.admin_role]) - self.guild = MockGuild([self.admin_role]) - self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) - self.cog = jams.CodeJams(self.bot) - - async def test_too_small_amount_of_team_members_passed(self): - """Should `ctx.send` and exit early when too small amount of members.""" - for case in (1, 2): - with self.subTest(amount_of_members=case): - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - - self.ctx.reset_mock() - members = (MockMember() for _ in range(case)) - await self.cog.createteam(self.cog, self.ctx, "foo", members) - - self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() - - async def test_duplicate_members_provided(self): - """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - - member = MockMember() - await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) - - self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() - - async def test_result_sending(self): - """Should call `ctx.send` when everything goes right.""" - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - - members = [MockMember() for _ in range(5)] - await self.cog.createteam(self.cog, self.ctx, "foo", members) - - self.cog.create_channels.assert_awaited_once() - self.cog.add_roles.assert_awaited_once() - self.ctx.send.assert_awaited_once() - - async def test_category_doesnt_exist(self): - """Should create a new code jam category.""" - subtests = ( - [], - [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], - [get_mock_category(jams.MAX_CHANNELS - 2, "other")], - ) - - for categories in subtests: - self.guild.reset_mock() - self.guild.categories = categories - - with self.subTest(categories=categories): - actual_category = await self.cog.get_category(self.guild) - - self.guild.create_category_channel.assert_awaited_once() - category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] - - self.assertFalse(category_overwrites[self.guild.default_role].read_messages) - self.assertTrue(category_overwrites[self.guild.me].read_messages) - self.assertEqual(self.guild.create_category_channel.return_value, actual_category) - - async def test_category_channel_exist(self): - """Should not try to create category channel.""" - expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME) - self.guild.categories = [ - get_mock_category(jams.MAX_CHANNELS - 2, "other"), - expected_category, - get_mock_category(0, jams.CATEGORY_NAME), - ] - - actual_category = await self.cog.get_category(self.guild) - self.assertEqual(expected_category, actual_category) - - async def test_channel_overwrites(self): - """Should have correct permission overwrites for users and roles.""" - leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] - overwrites = self.cog.get_overwrites(members, self.guild) - - # Leader permission overwrites - self.assertTrue(overwrites[leader].manage_messages) - self.assertTrue(overwrites[leader].read_messages) - self.assertTrue(overwrites[leader].manage_webhooks) - self.assertTrue(overwrites[leader].connect) - - # Other members permission overwrites - for member in members[1:]: - self.assertTrue(overwrites[member].read_messages) - self.assertTrue(overwrites[member].connect) - - # Everyone and verified role overwrite - self.assertFalse(overwrites[self.guild.default_role].read_messages) - self.assertFalse(overwrites[self.guild.default_role].connect) - self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].read_messages) - self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].connect) - - async def test_team_channels_creation(self): - """Should create new voice and text channel for team.""" - members = [MockMember() for _ in range(5)] - - self.cog.get_overwrites = MagicMock() - self.cog.get_category = AsyncMock() - self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") - actual = await self.cog.create_channels(self.guild, "my-team", members) - - self.assertEqual("foobar-channel", actual) - self.cog.get_overwrites.assert_called_once_with(members, self.guild) - self.cog.get_category.assert_awaited_once_with(self.guild) - - self.guild.create_text_channel.assert_awaited_once_with( - "my-team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value - ) - self.guild.create_voice_channel.assert_awaited_once_with( - "My Team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value - ) - - async def test_jam_roles_adding(self): - """Should add team leader role to leader and jam role to every team member.""" - leader_role = MockRole(name="Team Leader") - jam_role = MockRole(name="Jammer") - self.guild.get_role.side_effect = [leader_role, jam_role] - - leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] - await self.cog.add_roles(self.guild, members) - - leader.add_roles.assert_any_await(leader_role) - for member in members: - member.add_roles.assert_any_await(jam_role) - - -class CodeJamSetup(unittest.TestCase): - """Test for `setup` function of `CodeJam` cog.""" - - def test_setup(self): - """Should call `bot.add_cog`.""" - bot = MockBot() - jams.setup(bot) - bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/utils/test_snekbox.py b/tests/bot/cogs/utils/test_snekbox.py deleted file mode 100644 index 3e447f319..000000000 --- a/tests/bot/cogs/utils/test_snekbox.py +++ /dev/null @@ -1,409 +0,0 @@ -import asyncio -import logging -import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch - -from discord.ext import commands - -from bot import constants -from bot.cogs.utils import snekbox -from bot.cogs.utils.snekbox import Snekbox -from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser - - -class SnekboxTests(unittest.IsolatedAsyncioTestCase): - def setUp(self): - """Add mocked bot and cog to the instance.""" - self.bot = MockBot() - self.cog = Snekbox(bot=self.bot) - - async def test_post_eval(self): - """Post the eval code to the URLs.snekbox_eval_api endpoint.""" - resp = MagicMock() - resp.json = AsyncMock(return_value="return") - - context_manager = MagicMock() - context_manager.__aenter__.return_value = resp - self.bot.http_session.post.return_value = context_manager - - self.assertEqual(await self.cog.post_eval("import random"), "return") - self.bot.http_session.post.assert_called_with( - constants.URLs.snekbox_eval_api, - json={"input": "import random"}, - raise_for_status=True - ) - resp.json.assert_awaited_once() - - async def test_upload_output_reject_too_long(self): - """Reject output longer than MAX_PASTE_LEN.""" - result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) - self.assertEqual(result, "too long to upload") - - async def test_upload_output(self): - """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" - key = "MarkDiamond" - resp = MagicMock() - resp.json = AsyncMock(return_value={"key": key}) - - context_manager = MagicMock() - context_manager.__aenter__.return_value = resp - self.bot.http_session.post.return_value = context_manager - - self.assertEqual( - await self.cog.upload_output("My awesome output"), - constants.URLs.paste_service.format(key=key) - ) - self.bot.http_session.post.assert_called_with( - constants.URLs.paste_service.format(key="documents"), - data="My awesome output", - raise_for_status=True - ) - - async def test_upload_output_gracefully_fallback_if_exception_during_request(self): - """Output upload gracefully fallback if the upload fail.""" - resp = MagicMock() - resp.json = AsyncMock(side_effect=Exception) - - context_manager = MagicMock() - context_manager.__aenter__.return_value = resp - self.bot.http_session.post.return_value = context_manager - - log = logging.getLogger("bot.cogs.utils.snekbox") - with self.assertLogs(logger=log, level='ERROR'): - await self.cog.upload_output('My awesome output!') - - async def test_upload_output_gracefully_fallback_if_no_key_in_response(self): - """Output upload gracefully fallback if there is no key entry in the response body.""" - self.assertEqual((await self.cog.upload_output('My awesome output!')), None) - - def test_prepare_input(self): - cases = ( - ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'), - ('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'), - ('```\nprint("Hello world!")```', 'print("Hello world!")', 'multiline code block'), - ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'), - ) - for case, expected, testname in cases: - with self.subTest(msg=f'Extract code from {testname}.'): - self.assertEqual(self.cog.prepare_input(case), expected) - - def test_get_results_message(self): - """Return error and message according to the eval result.""" - cases = ( - ('ERROR', None, ('Your eval job has failed', 'ERROR')), - ('', 128 + snekbox.SIGKILL, ('Your eval job timed out or ran out of memory', '')), - ('', 255, ('Your eval job has failed', 'A fatal NsJail error occurred')) - ) - for stdout, returncode, expected in cases: - with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}) - self.assertEqual(actual, expected) - - @patch('bot.cogs.utils.snekbox.Signals', side_effect=ValueError) - def test_get_results_message_invalid_signal(self, mock_signals: Mock): - self.assertEqual( - self.cog.get_results_message({'stdout': '', 'returncode': 127}), - ('Your eval job has completed with return code 127', '') - ) - - @patch('bot.cogs.utils.snekbox.Signals') - def test_get_results_message_valid_signal(self, mock_signals: Mock): - mock_signals.return_value.name = 'SIGTEST' - self.assertEqual( - self.cog.get_results_message({'stdout': '', 'returncode': 127}), - ('Your eval job has completed with return code 127 (SIGTEST)', '') - ) - - def test_get_status_emoji(self): - """Return emoji according to the eval result.""" - cases = ( - (' ', -1, ':warning:'), - ('Hello world!', 0, ':white_check_mark:'), - ('Invalid beard size', -1, ':x:') - ) - for stdout, returncode, expected in cases: - with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - actual = self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}) - self.assertEqual(actual, expected) - - async def test_format_output(self): - """Test output formatting.""" - self.cog.upload_output = AsyncMock(return_value='https://testificate.com/') - - too_many_lines = ( - '001 | v\n002 | e\n003 | r\n004 | y\n005 | l\n006 | o\n' - '007 | n\n008 | g\n009 | b\n010 | e\n011 | a\n... (truncated - too many lines)' - ) - too_long_too_many_lines = ( - "\n".join( - f"{i:03d} | {line}" for i, line in enumerate(['verylongbeard' * 10] * 15, 1) - )[:1000] + "\n... (truncated - too long, too many lines)" - ) - - cases = ( - ('', ('[No output]', None), 'No output'), - ('My awesome output', ('My awesome output', None), 'One line output'), - ('<@', ("<@\u200B", None), r'Convert <@ to <@\u200B'), - ('" else mock_.__name__ + + with self.subTest(msg=subtest_msg): + _, mock_message = mock_() + await self.syncer._send_prompt(message_arg) + + calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] + mock_message.add_reaction.assert_has_calls(calls) + + +class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): + """Tests for waiting for a sync confirmation reaction on the prompt.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = TestSyncer(self.bot) + self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) + + @staticmethod + def get_message_reaction(emoji): + """Fixture to return a mock message an reaction from the given `emoji`.""" + message = helpers.MockMessage() + reaction = helpers.MockReaction(emoji=emoji, message=message) + + return message, reaction + + def test_reaction_check_for_valid_emoji_and_authors(self): + """Should return True if authors are identical or are a bot and a core dev, respectively.""" + user_subtests = ( + ( + helpers.MockMember(id=77), + helpers.MockMember(id=77), + "identical users", + ), + ( + helpers.MockMember(id=77, bot=True), + helpers.MockMember(id=43, roles=[self.core_dev_role]), + "bot author and core-dev reactor", + ), + ) + + for emoji in self.syncer._REACTION_EMOJIS: + for author, user, msg in user_subtests: + with self.subTest(author=author, user=user, emoji=emoji, msg=msg): + message, reaction = self.get_message_reaction(emoji) + ret_val = self.syncer._reaction_check(author, message, reaction, user) + + self.assertTrue(ret_val) + + def test_reaction_check_for_invalid_reactions(self): + """Should return False for invalid reaction events.""" + valid_emoji = self.syncer._REACTION_EMOJIS[0] + subtests = ( + ( + helpers.MockMember(id=77), + *self.get_message_reaction(valid_emoji), + helpers.MockMember(id=43, roles=[self.core_dev_role]), + "users are not identical", + ), + ( + helpers.MockMember(id=77, bot=True), + *self.get_message_reaction(valid_emoji), + helpers.MockMember(id=43), + "reactor lacks the core-dev role", + ), + ( + helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), + *self.get_message_reaction(valid_emoji), + helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), + "reactor is a bot", + ), + ( + helpers.MockMember(id=77), + helpers.MockMessage(id=95), + helpers.MockReaction(emoji=valid_emoji, message=helpers.MockMessage(id=26)), + helpers.MockMember(id=77), + "messages are not identical", + ), + ( + helpers.MockMember(id=77), + *self.get_message_reaction("InVaLiD"), + helpers.MockMember(id=77), + "emoji is invalid", + ), + ) + + for *args, msg in subtests: + kwargs = dict(zip(("author", "message", "reaction", "user"), args)) + with self.subTest(**kwargs, msg=msg): + ret_val = self.syncer._reaction_check(*args) + self.assertFalse(ret_val) + + async def test_wait_for_confirmation(self): + """The message should always be edited and only return True if the emoji is a check mark.""" + subtests = ( + (constants.Emojis.check_mark, True, None), + ("InVaLiD", False, None), + (None, False, asyncio.TimeoutError), + ) + + for emoji, ret_val, side_effect in subtests: + for bot in (True, False): + with self.subTest(emoji=emoji, ret_val=ret_val, side_effect=side_effect, bot=bot): + # Set up mocks + message = helpers.MockMessage() + member = helpers.MockMember(bot=bot) + + self.bot.wait_for.reset_mock() + self.bot.wait_for.return_value = (helpers.MockReaction(emoji=emoji), None) + self.bot.wait_for.side_effect = side_effect + + # Call the function + actual_return = await self.syncer._wait_for_confirmation(member, message) + + # Perform assertions + self.bot.wait_for.assert_called_once() + self.assertIn("reaction_add", self.bot.wait_for.call_args[0]) + + message.edit.assert_called_once() + kwargs = message.edit.call_args[1] + self.assertIn("content", kwargs) + + # Core devs should only be mentioned if the author is a bot. + if bot: + self.assertIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) + else: + self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) + + self.assertIs(actual_return, ret_val) + + +class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): + """Tests for main function orchestrating the sync.""" + + def setUp(self): + self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) + self.syncer = TestSyncer(self.bot) + + async def test_sync_respects_confirmation_result(self): + """The sync should abort if confirmation fails and continue if confirmed.""" + mock_message = helpers.MockMessage() + subtests = ( + (True, mock_message), + (False, None), + ) + + for confirmed, message in subtests: + with self.subTest(confirmed=confirmed): + self.syncer._sync.reset_mock() + self.syncer._get_diff.reset_mock() + + diff = _Diff({1, 2, 3}, {4, 5}, None) + self.syncer._get_diff.return_value = diff + self.syncer._get_confirmation_result = mock.AsyncMock( + return_value=(confirmed, message) + ) + + guild = helpers.MockGuild() + await self.syncer.sync(guild) + + self.syncer._get_diff.assert_called_once_with(guild) + self.syncer._get_confirmation_result.assert_called_once() + + if confirmed: + self.syncer._sync.assert_called_once_with(diff) + else: + self.syncer._sync.assert_not_called() + + async def test_sync_diff_size(self): + """The diff size should be correctly calculated.""" + subtests = ( + (6, _Diff({1, 2}, {3, 4}, {5, 6})), + (5, _Diff({1, 2, 3}, None, {4, 5})), + (0, _Diff(None, None, None)), + (0, _Diff(set(), set(), set())), + ) + + for size, diff in subtests: + with self.subTest(size=size, diff=diff): + self.syncer._get_diff.reset_mock() + self.syncer._get_diff.return_value = diff + self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) + + guild = helpers.MockGuild() + await self.syncer.sync(guild) + + self.syncer._get_diff.assert_called_once_with(guild) + self.syncer._get_confirmation_result.assert_called_once() + self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) + + async def test_sync_message_edited(self): + """The message should be edited if one was sent, even if the sync has an API error.""" + subtests = ( + (None, None, False), + (helpers.MockMessage(), None, True), + (helpers.MockMessage(), ResponseCodeError(mock.MagicMock()), True), + ) + + for message, side_effect, should_edit in subtests: + with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): + self.syncer._sync.side_effect = side_effect + self.syncer._get_confirmation_result = mock.AsyncMock( + return_value=(True, message) + ) + + guild = helpers.MockGuild() + await self.syncer.sync(guild) + + if should_edit: + message.edit.assert_called_once() + self.assertIn("content", message.edit.call_args[1]) + + async def test_sync_confirmation_context_redirect(self): + """If ctx is given, a new message should be sent and author should be ctx's author.""" + mock_member = helpers.MockMember() + subtests = ( + (None, self.bot.user, None), + (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()), + ) + + for ctx, author, message in subtests: + with self.subTest(ctx=ctx, author=author, message=message): + if ctx is not None: + ctx.send.return_value = message + + # Make sure `_get_diff` returns a MagicMock, not an AsyncMock + self.syncer._get_diff.return_value = mock.MagicMock() + + self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) + + guild = helpers.MockGuild() + await self.syncer.sync(guild, ctx) + + if ctx is not None: + ctx.send.assert_called_once() + + self.syncer._get_confirmation_result.assert_called_once() + self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) + self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) + + @mock.patch.object(constants.Sync, "max_diff", new=3) + async def test_confirmation_result_small_diff(self): + """Should always return True and the given message if the diff size is too small.""" + author = helpers.MockMember() + expected_message = helpers.MockMessage() + + for size in (3, 2): # pragma: no cover + with self.subTest(size=size): + self.syncer._send_prompt = mock.AsyncMock() + self.syncer._wait_for_confirmation = mock.AsyncMock() + + coro = self.syncer._get_confirmation_result(size, author, expected_message) + result, actual_message = await coro + + self.assertTrue(result) + self.assertEqual(actual_message, expected_message) + self.syncer._send_prompt.assert_not_called() + self.syncer._wait_for_confirmation.assert_not_called() + + @mock.patch.object(constants.Sync, "max_diff", new=3) + async def test_confirmation_result_large_diff(self): + """Should return True if confirmed and False if _send_prompt fails or aborted.""" + author = helpers.MockMember() + mock_message = helpers.MockMessage() + + subtests = ( + (True, mock_message, True, "confirmed"), + (False, None, False, "_send_prompt failed"), + (False, mock_message, False, "aborted"), + ) + + for expected_result, expected_message, confirmed, msg in subtests: # pragma: no cover + with self.subTest(msg=msg): + self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message) + self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed) + + coro = self.syncer._get_confirmation_result(4, author) + actual_result, actual_message = await coro + + self.syncer._send_prompt.assert_called_once_with(None) # message defaults to None + self.assertIs(actual_result, expected_result) + self.assertEqual(actual_message, expected_message) + + if expected_message: + self.syncer._wait_for_confirmation.assert_called_once_with( + author, expected_message + ) diff --git a/tests/bot/exts/backend/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py new file mode 100644 index 000000000..1b89564f2 --- /dev/null +++ b/tests/bot/exts/backend/sync/test_cog.py @@ -0,0 +1,416 @@ +import unittest +from unittest import mock + +import discord + +from bot import constants +from bot.api import ResponseCodeError +from bot.exts.backend import sync +from bot.exts.backend.sync._cog import Sync +from bot.exts.backend.sync._syncers import Syncer +from tests import helpers +from tests.base import CommandTestCase + + +class SyncExtensionTests(unittest.IsolatedAsyncioTestCase): + """Tests for the sync extension.""" + + @staticmethod + def test_extension_setup(): + """The Sync cog should be added.""" + bot = helpers.MockBot() + sync.setup(bot) + bot.add_cog.assert_called_once() + + +class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): + """Base class for Sync cog tests. Sets up patches for syncers.""" + + def setUp(self): + self.bot = helpers.MockBot() + + self.role_syncer_patcher = mock.patch( + "bot.exts.backend.sync._syncers.RoleSyncer", + autospec=Syncer, + spec_set=True + ) + self.user_syncer_patcher = mock.patch( + "bot.exts.backend.sync._syncers.UserSyncer", + autospec=Syncer, + spec_set=True + ) + self.RoleSyncer = self.role_syncer_patcher.start() + self.UserSyncer = self.user_syncer_patcher.start() + + self.cog = Sync(self.bot) + + def tearDown(self): + self.role_syncer_patcher.stop() + self.user_syncer_patcher.stop() + + @staticmethod + def response_error(status: int) -> ResponseCodeError: + """Fixture to return a ResponseCodeError with the given status code.""" + response = mock.MagicMock() + response.status = status + + return ResponseCodeError(response) + + +class SyncCogTests(SyncCogTestCase): + """Tests for the Sync cog.""" + + @mock.patch.object(Sync, "sync_guild", new_callable=mock.MagicMock) + def test_sync_cog_init(self, sync_guild): + """Should instantiate syncers and run a sync for the guild.""" + # Reset because a Sync cog was already instantiated in setUp. + self.RoleSyncer.reset_mock() + self.UserSyncer.reset_mock() + self.bot.loop.create_task = mock.MagicMock() + + mock_sync_guild_coro = mock.MagicMock() + sync_guild.return_value = mock_sync_guild_coro + + Sync(self.bot) + + self.RoleSyncer.assert_called_once_with(self.bot) + self.UserSyncer.assert_called_once_with(self.bot) + sync_guild.assert_called_once_with() + self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) + + async def test_sync_cog_sync_guild(self): + """Roles and users should be synced only if a guild is successfully retrieved.""" + for guild in (helpers.MockGuild(), None): + with self.subTest(guild=guild): + self.bot.reset_mock() + self.cog.role_syncer.reset_mock() + self.cog.user_syncer.reset_mock() + + self.bot.get_guild = mock.MagicMock(return_value=guild) + + await self.cog.sync_guild() + + self.bot.wait_until_guild_available.assert_called_once() + self.bot.get_guild.assert_called_once_with(constants.Guild.id) + + if guild is None: + self.cog.role_syncer.sync.assert_not_called() + self.cog.user_syncer.sync.assert_not_called() + else: + self.cog.role_syncer.sync.assert_called_once_with(guild) + self.cog.user_syncer.sync.assert_called_once_with(guild) + + async def patch_user_helper(self, side_effect: BaseException) -> None: + """Helper to set a side effect for bot.api_client.patch and then assert it is called.""" + self.bot.api_client.patch.reset_mock(side_effect=True) + self.bot.api_client.patch.side_effect = side_effect + + user_id, updated_information = 5, {"key": 123} + await self.cog.patch_user(user_id, updated_information) + + self.bot.api_client.patch.assert_called_once_with( + f"bot/users/{user_id}", + json=updated_information, + ) + + async def test_sync_cog_patch_user(self): + """A PATCH request should be sent and 404 errors ignored.""" + for side_effect in (None, self.response_error(404)): + with self.subTest(side_effect=side_effect): + await self.patch_user_helper(side_effect) + + async def test_sync_cog_patch_user_non_404(self): + """A PATCH request should be sent and the error raised if it's not a 404.""" + with self.assertRaises(ResponseCodeError): + await self.patch_user_helper(self.response_error(500)) + + +class SyncCogListenerTests(SyncCogTestCase): + """Tests for the listeners of the Sync cog.""" + + def setUp(self): + super().setUp() + self.cog.patch_user = mock.AsyncMock(spec_set=self.cog.patch_user) + + self.guild_id_patcher = mock.patch("bot.exts.backend.sync._cog.constants.Guild.id", 5) + self.guild_id = self.guild_id_patcher.start() + + self.guild = helpers.MockGuild(id=self.guild_id) + self.other_guild = helpers.MockGuild(id=0) + + def tearDown(self): + self.guild_id_patcher.stop() + + async def test_sync_cog_on_guild_role_create(self): + """A POST request should be sent with the new role's data.""" + self.assertTrue(self.cog.on_guild_role_create.__cog_listener__) + + role_data = { + "colour": 49, + "id": 777, + "name": "rolename", + "permissions": 8, + "position": 23, + } + role = helpers.MockRole(**role_data, guild=self.guild) + await self.cog.on_guild_role_create(role) + + self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) + + async def test_sync_cog_on_guild_role_create_ignores_guilds(self): + """Events from other guilds should be ignored.""" + role = helpers.MockRole(guild=self.other_guild) + await self.cog.on_guild_role_create(role) + self.bot.api_client.post.assert_not_awaited() + + async def test_sync_cog_on_guild_role_delete(self): + """A DELETE request should be sent.""" + self.assertTrue(self.cog.on_guild_role_delete.__cog_listener__) + + role = helpers.MockRole(id=99, guild=self.guild) + await self.cog.on_guild_role_delete(role) + + self.bot.api_client.delete.assert_called_once_with("bot/roles/99") + + async def test_sync_cog_on_guild_role_delete_ignores_guilds(self): + """Events from other guilds should be ignored.""" + role = helpers.MockRole(guild=self.other_guild) + await self.cog.on_guild_role_delete(role) + self.bot.api_client.delete.assert_not_awaited() + + async def test_sync_cog_on_guild_role_update(self): + """A PUT request should be sent if the colour, name, permissions, or position changes.""" + self.assertTrue(self.cog.on_guild_role_update.__cog_listener__) + + role_data = { + "colour": 49, + "id": 777, + "name": "rolename", + "permissions": 8, + "position": 23, + } + subtests = ( + (True, ("colour", "name", "permissions", "position")), + (False, ("hoist", "mentionable")), + ) + + for should_put, attributes in subtests: + for attribute in attributes: + with self.subTest(should_put=should_put, changed_attribute=attribute): + self.bot.api_client.put.reset_mock() + + after_role_data = role_data.copy() + after_role_data[attribute] = 876 + + before_role = helpers.MockRole(**role_data, guild=self.guild) + after_role = helpers.MockRole(**after_role_data, guild=self.guild) + + await self.cog.on_guild_role_update(before_role, after_role) + + if should_put: + self.bot.api_client.put.assert_called_once_with( + f"bot/roles/{after_role.id}", + json=after_role_data + ) + else: + self.bot.api_client.put.assert_not_called() + + async def test_sync_cog_on_guild_role_update_ignores_guilds(self): + """Events from other guilds should be ignored.""" + role = helpers.MockRole(guild=self.other_guild) + await self.cog.on_guild_role_update(role, role) + self.bot.api_client.put.assert_not_awaited() + + async def test_sync_cog_on_member_remove(self): + """Member should be patched to set in_guild as False.""" + self.assertTrue(self.cog.on_member_remove.__cog_listener__) + + member = helpers.MockMember(guild=self.guild) + await self.cog.on_member_remove(member) + + self.cog.patch_user.assert_called_once_with( + member.id, + json={"in_guild": False} + ) + + async def test_sync_cog_on_member_remove_ignores_guilds(self): + """Events from other guilds should be ignored.""" + member = helpers.MockMember(guild=self.other_guild) + await self.cog.on_member_remove(member) + self.cog.patch_user.assert_not_awaited() + + async def test_sync_cog_on_member_update_roles(self): + """Members should be patched if their roles have changed.""" + self.assertTrue(self.cog.on_member_update.__cog_listener__) + + # Roles are intentionally unsorted. + before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30), helpers.MockRole(id=20)] + before_member = helpers.MockMember(roles=before_roles, guild=self.guild) + after_member = helpers.MockMember(roles=before_roles[1:], guild=self.guild) + + await self.cog.on_member_update(before_member, after_member) + + data = {"roles": sorted(role.id for role in after_member.roles)} + self.cog.patch_user.assert_called_once_with(after_member.id, json=data) + + async def test_sync_cog_on_member_update_other(self): + """Members should not be patched if other attributes have changed.""" + self.assertTrue(self.cog.on_member_update.__cog_listener__) + + subtests = ( + ("activities", discord.Game("Pong"), discord.Game("Frogger")), + ("nick", "old nick", "new nick"), + ("status", discord.Status.online, discord.Status.offline), + ) + + for attribute, old_value, new_value in subtests: + with self.subTest(attribute=attribute): + self.cog.patch_user.reset_mock() + + before_member = helpers.MockMember(**{attribute: old_value}, guild=self.guild) + after_member = helpers.MockMember(**{attribute: new_value}, guild=self.guild) + + await self.cog.on_member_update(before_member, after_member) + + self.cog.patch_user.assert_not_called() + + async def test_sync_cog_on_member_update_ignores_guilds(self): + """Events from other guilds should be ignored.""" + member = helpers.MockMember(guild=self.other_guild) + await self.cog.on_member_update(member, member) + self.cog.patch_user.assert_not_awaited() + + async def test_sync_cog_on_user_update(self): + """A user should be patched only if the name, discriminator, or avatar changes.""" + self.assertTrue(self.cog.on_user_update.__cog_listener__) + + before_data = { + "name": "old name", + "discriminator": "1234", + "bot": False, + } + + subtests = ( + (True, "name", "name", "new name", "new name"), + (True, "discriminator", "discriminator", "8765", 8765), + (False, "bot", "bot", True, True), + ) + + for should_patch, attribute, api_field, value, api_value in subtests: + with self.subTest(attribute=attribute): + self.cog.patch_user.reset_mock() + + after_data = before_data.copy() + after_data[attribute] = value + before_user = helpers.MockUser(**before_data) + after_user = helpers.MockUser(**after_data) + + await self.cog.on_user_update(before_user, after_user) + + if should_patch: + self.cog.patch_user.assert_called_once() + + # Don't care if *all* keys are present; only the changed one is required + call_args = self.cog.patch_user.call_args + self.assertEqual(call_args.args[0], after_user.id) + self.assertIn("json", call_args.kwargs) + + self.assertIn("ignore_404", call_args.kwargs) + self.assertTrue(call_args.kwargs["ignore_404"]) + + json = call_args.kwargs["json"] + self.assertIn(api_field, json) + self.assertEqual(json[api_field], api_value) + else: + self.cog.patch_user.assert_not_called() + + async def on_member_join_helper(self, side_effect: Exception) -> dict: + """ + Helper to set `side_effect` for on_member_join and assert a PUT request was sent. + + The request data for the mock member is returned. All exceptions will be re-raised. + """ + member = helpers.MockMember( + discriminator="1234", + roles=[helpers.MockRole(id=22), helpers.MockRole(id=12)], + guild=self.guild, + ) + + data = { + "discriminator": int(member.discriminator), + "id": member.id, + "in_guild": True, + "name": member.name, + "roles": sorted(role.id for role in member.roles) + } + + self.bot.api_client.put.reset_mock(side_effect=True) + self.bot.api_client.put.side_effect = side_effect + + try: + await self.cog.on_member_join(member) + except Exception: + raise + finally: + self.bot.api_client.put.assert_called_once_with( + f"bot/users/{member.id}", + json=data + ) + + return data + + async def test_sync_cog_on_member_join(self): + """Should PUT user's data or POST it if the user doesn't exist.""" + for side_effect in (None, self.response_error(404)): + with self.subTest(side_effect=side_effect): + self.bot.api_client.post.reset_mock() + data = await self.on_member_join_helper(side_effect) + + if side_effect: + self.bot.api_client.post.assert_called_once_with("bot/users", json=data) + else: + self.bot.api_client.post.assert_not_called() + + async def test_sync_cog_on_member_join_non_404(self): + """ResponseCodeError should be re-raised if status code isn't a 404.""" + with self.assertRaises(ResponseCodeError): + await self.on_member_join_helper(self.response_error(500)) + + self.bot.api_client.post.assert_not_called() + + async def test_sync_cog_on_member_join_ignores_guilds(self): + """Events from other guilds should be ignored.""" + member = helpers.MockMember(guild=self.other_guild) + await self.cog.on_member_join(member) + self.bot.api_client.post.assert_not_awaited() + self.bot.api_client.put.assert_not_awaited() + + +class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): + """Tests for the commands in the Sync cog.""" + + async def test_sync_roles_command(self): + """sync() should be called on the RoleSyncer.""" + ctx = helpers.MockContext() + await self.cog.sync_roles_command.callback(self.cog, ctx) + + self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) + + async def test_sync_users_command(self): + """sync() should be called on the UserSyncer.""" + ctx = helpers.MockContext() + await self.cog.sync_users_command.callback(self.cog, ctx) + + self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) + + async def test_commands_require_admin(self): + """The sync commands should only run if the author has the administrator permission.""" + cmds = ( + self.cog.sync_group, + self.cog.sync_roles_command, + self.cog.sync_users_command, + ) + + for cmd in cmds: + with self.subTest(cmd=cmd): + await self.assertHasPermissionsCheck(cmd, {"administrator": True}) diff --git a/tests/bot/exts/backend/sync/test_roles.py b/tests/bot/exts/backend/sync/test_roles.py new file mode 100644 index 000000000..7b9f40cad --- /dev/null +++ b/tests/bot/exts/backend/sync/test_roles.py @@ -0,0 +1,157 @@ +import unittest +from unittest import mock + +import discord + +from bot.exts.backend.sync._syncers import RoleSyncer, _Diff, _Role +from tests import helpers + + +def fake_role(**kwargs): + """Fixture to return a dictionary representing a role with default values set.""" + kwargs.setdefault("id", 9) + kwargs.setdefault("name", "fake role") + kwargs.setdefault("colour", 7) + kwargs.setdefault("permissions", 0) + kwargs.setdefault("position", 55) + + return kwargs + + +class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): + """Tests for determining differences between roles in the DB and roles in the Guild cache.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = RoleSyncer(self.bot) + + @staticmethod + def get_guild(*roles): + """Fixture to return a guild object with the given roles.""" + guild = helpers.MockGuild() + guild.roles = [] + + for role in roles: + mock_role = helpers.MockRole(**role) + mock_role.colour = discord.Colour(role["colour"]) + mock_role.permissions = discord.Permissions(role["permissions"]) + guild.roles.append(mock_role) + + return guild + + async def test_empty_diff_for_identical_roles(self): + """No differences should be found if the roles in the guild and DB are identical.""" + self.bot.api_client.get.return_value = [fake_role()] + guild = self.get_guild(fake_role()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), set()) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_updated_roles(self): + """Only updated roles should be added to the 'updated' set of the diff.""" + updated_role = fake_role(id=41, name="new") + + self.bot.api_client.get.return_value = [fake_role(id=41, name="old"), fake_role()] + guild = self.get_guild(updated_role, fake_role()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), {_Role(**updated_role)}, set()) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_new_roles(self): + """Only new roles should be added to the 'created' set of the diff.""" + new_role = fake_role(id=41, name="new") + + self.bot.api_client.get.return_value = [fake_role()] + guild = self.get_guild(fake_role(), new_role) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = ({_Role(**new_role)}, set(), set()) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_deleted_roles(self): + """Only deleted roles should be added to the 'deleted' set of the diff.""" + deleted_role = fake_role(id=61, name="deleted") + + self.bot.api_client.get.return_value = [fake_role(), deleted_role] + guild = self.get_guild(fake_role()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), {_Role(**deleted_role)}) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_new_updated_and_deleted_roles(self): + """When roles are added, updated, and removed, all of them are returned properly.""" + new = fake_role(id=41, name="new") + updated = fake_role(id=71, name="updated") + deleted = fake_role(id=61, name="deleted") + + self.bot.api_client.get.return_value = [ + fake_role(), + fake_role(id=71, name="updated name"), + deleted, + ] + guild = self.get_guild(fake_role(), new, updated) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) + + self.assertEqual(actual_diff, expected_diff) + + +class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase): + """Tests for the API requests that sync roles.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = RoleSyncer(self.bot) + + async def test_sync_created_roles(self): + """Only POST requests should be made with the correct payload.""" + roles = [fake_role(id=111), fake_role(id=222)] + + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(role_tuples, set(), set()) + await self.syncer._sync(diff) + + calls = [mock.call("bot/roles", json=role) for role in roles] + self.bot.api_client.post.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.post.call_count, len(roles)) + + self.bot.api_client.put.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + async def test_sync_updated_roles(self): + """Only PUT requests should be made with the correct payload.""" + roles = [fake_role(id=111), fake_role(id=222)] + + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(set(), role_tuples, set()) + await self.syncer._sync(diff) + + calls = [mock.call(f"bot/roles/{role['id']}", json=role) for role in roles] + self.bot.api_client.put.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.put.call_count, len(roles)) + + self.bot.api_client.post.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + async def test_sync_deleted_roles(self): + """Only DELETE requests should be made with the correct payload.""" + roles = [fake_role(id=111), fake_role(id=222)] + + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(set(), set(), role_tuples) + await self.syncer._sync(diff) + + calls = [mock.call(f"bot/roles/{role['id']}") for role in roles] + self.bot.api_client.delete.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.delete.call_count, len(roles)) + + self.bot.api_client.post.assert_not_called() + self.bot.api_client.put.assert_not_called() diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py new file mode 100644 index 000000000..c0a1da35c --- /dev/null +++ b/tests/bot/exts/backend/sync/test_users.py @@ -0,0 +1,158 @@ +import unittest +from unittest import mock + +from bot.exts.backend.sync._syncers import UserSyncer, _Diff, _User +from tests import helpers + + +def fake_user(**kwargs): + """Fixture to return a dictionary representing a user with default values set.""" + kwargs.setdefault("id", 43) + kwargs.setdefault("name", "bob the test man") + kwargs.setdefault("discriminator", 1337) + kwargs.setdefault("roles", (666,)) + kwargs.setdefault("in_guild", True) + + return kwargs + + +class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): + """Tests for determining differences between users in the DB and users in the Guild cache.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = UserSyncer(self.bot) + + @staticmethod + def get_guild(*members): + """Fixture to return a guild object with the given members.""" + guild = helpers.MockGuild() + guild.members = [] + + for member in members: + member = member.copy() + del member["in_guild"] + + mock_member = helpers.MockMember(**member) + mock_member.roles = [helpers.MockRole(id=role_id) for role_id in member["roles"]] + + guild.members.append(mock_member) + + return guild + + async def test_empty_diff_for_no_users(self): + """When no users are given, an empty diff should be returned.""" + guild = self.get_guild() + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_empty_diff_for_identical_users(self): + """No differences should be found if the users in the guild and DB are identical.""" + self.bot.api_client.get.return_value = [fake_user()] + guild = self.get_guild(fake_user()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_updated_users(self): + """Only updated users should be added to the 'updated' set of the diff.""" + updated_user = fake_user(id=99, name="new") + + self.bot.api_client.get.return_value = [fake_user(id=99, name="old"), fake_user()] + guild = self.get_guild(updated_user, fake_user()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), {_User(**updated_user)}, None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_new_users(self): + """Only new users should be added to the 'created' set of the diff.""" + new_user = fake_user(id=99, name="new") + + self.bot.api_client.get.return_value = [fake_user()] + guild = self.get_guild(fake_user(), new_user) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = ({_User(**new_user)}, set(), None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_sets_in_guild_false_for_leaving_users(self): + """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" + leaving_user = fake_user(id=63, in_guild=False) + + self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63)] + guild = self.get_guild(fake_user()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), {_User(**leaving_user)}, None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_new_updated_and_leaving_users(self): + """When users are added, updated, and removed, all of them are returned properly.""" + new_user = fake_user(id=99, name="new") + updated_user = fake_user(id=55, name="updated") + leaving_user = fake_user(id=63, in_guild=False) + + self.bot.api_client.get.return_value = [fake_user(), fake_user(id=55), fake_user(id=63)] + guild = self.get_guild(fake_user(), new_user, updated_user) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_empty_diff_for_db_users_not_in_guild(self): + """When the DB knows a user the guild doesn't, no difference is found.""" + self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] + guild = self.get_guild(fake_user()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) + + +class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): + """Tests for the API requests that sync users.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = UserSyncer(self.bot) + + async def test_sync_created_users(self): + """Only POST requests should be made with the correct payload.""" + users = [fake_user(id=111), fake_user(id=222)] + + user_tuples = {_User(**user) for user in users} + diff = _Diff(user_tuples, set(), None) + await self.syncer._sync(diff) + + calls = [mock.call("bot/users", json=user) for user in users] + self.bot.api_client.post.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.post.call_count, len(users)) + + self.bot.api_client.put.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + async def test_sync_updated_users(self): + """Only PUT requests should be made with the correct payload.""" + users = [fake_user(id=111), fake_user(id=222)] + + user_tuples = {_User(**user) for user in users} + diff = _Diff(set(), user_tuples, None) + await self.syncer._sync(diff) + + calls = [mock.call(f"bot/users/{user['id']}", json=user) for user in users] + self.bot.api_client.put.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.put.call_count, len(users)) + + self.bot.api_client.post.assert_not_called() + self.bot.api_client.delete.assert_not_called() diff --git a/tests/bot/exts/backend/test_logging.py b/tests/bot/exts/backend/test_logging.py new file mode 100644 index 000000000..466f207d9 --- /dev/null +++ b/tests/bot/exts/backend/test_logging.py @@ -0,0 +1,32 @@ +import unittest +from unittest.mock import patch + +from bot import constants +from bot.exts.backend.logging import Logging +from tests.helpers import MockBot, MockTextChannel + + +class LoggingTests(unittest.IsolatedAsyncioTestCase): + """Test cases for connected login.""" + + def setUp(self): + self.bot = MockBot() + self.cog = Logging(self.bot) + self.dev_log = MockTextChannel(id=1234, name="dev-log") + + @patch("bot.exts.backend.logging.DEBUG_MODE", False) + async def test_debug_mode_false(self): + """Should send connected message to dev-log.""" + self.bot.get_channel.return_value = self.dev_log + + await self.cog.startup_greeting() + self.bot.wait_until_guild_available.assert_awaited_once_with() + self.bot.get_channel.assert_called_once_with(constants.Channels.dev_log) + self.dev_log.send.assert_awaited_once() + + @patch("bot.exts.backend.logging.DEBUG_MODE", True) + async def test_debug_mode_true(self): + """Should not send anything to dev-log.""" + await self.cog.startup_greeting() + self.bot.wait_until_guild_available.assert_awaited_once_with() + self.bot.get_channel.assert_not_called() diff --git a/tests/bot/exts/filters/__init__.py b/tests/bot/exts/filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py new file mode 100644 index 000000000..960894e5c --- /dev/null +++ b/tests/bot/exts/filters/test_antimalware.py @@ -0,0 +1,165 @@ +import unittest +from unittest.mock import AsyncMock, Mock + +from discord import NotFound + +from bot.constants import Channels, STAFF_ROLES +from bot.exts.filters import antimalware +from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole + + +class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): + """Test the AntiMalware cog.""" + + def setUp(self): + """Sets up fresh objects for each test.""" + self.bot = MockBot() + self.bot.filter_list_cache = { + "FILE_FORMAT.True": { + ".first": {}, + ".second": {}, + ".third": {}, + } + } + self.cog = antimalware.AntiMalware(self.bot) + self.message = MockMessage() + self.whitelist = [".first", ".second", ".third"] + + async def test_message_with_allowed_attachment(self): + """Messages with allowed extensions should not be deleted""" + attachment = MockAttachment(filename="python.first") + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + self.message.delete.assert_not_called() + + async def test_message_without_attachment(self): + """Messages without attachments should result in no action.""" + await self.cog.on_message(self.message) + self.message.delete.assert_not_called() + + async def test_direct_message_with_attachment(self): + """Direct messages should have no action taken.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + self.message.guild = None + + await self.cog.on_message(self.message) + + self.message.delete.assert_not_called() + + async def test_message_with_illegal_extension_gets_deleted(self): + """A message containing an illegal extension should send an embed.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + + self.message.delete.assert_called_once() + + async def test_message_send_by_staff(self): + """A message send by a member of staff should be ignored.""" + staff_role = MockRole(id=STAFF_ROLES[0]) + self.message.author.roles.append(staff_role) + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + + self.message.delete.assert_not_called() + + async def test_python_file_redirect_embed_description(self): + """A message containing a .py file should result in an embed redirecting the user to our paste site""" + attachment = MockAttachment(filename="python.py") + self.message.attachments = [attachment] + self.message.channel.send = AsyncMock() + + await self.cog.on_message(self.message) + self.message.channel.send.assert_called_once() + args, kwargs = self.message.channel.send.call_args + embed = kwargs.pop("embed") + + self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION) + + async def test_txt_file_redirect_embed_description(self): + """A message containing a .txt file should result in the correct embed.""" + attachment = MockAttachment(filename="python.txt") + self.message.attachments = [attachment] + self.message.channel.send = AsyncMock() + antimalware.TXT_EMBED_DESCRIPTION = Mock() + antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test" + + await self.cog.on_message(self.message) + self.message.channel.send.assert_called_once() + args, kwargs = self.message.channel.send.call_args + embed = kwargs.pop("embed") + cmd_channel = self.bot.get_channel(Channels.bot_commands) + + self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value) + antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention) + + async def test_other_disallowed_extension_embed_description(self): + """Test the description for a non .py/.txt disallowed extension.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + self.message.channel.send = AsyncMock() + antimalware.DISALLOWED_EMBED_DESCRIPTION = Mock() + antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value = "test" + + await self.cog.on_message(self.message) + self.message.channel.send.assert_called_once() + args, kwargs = self.message.channel.send.call_args + embed = kwargs.pop("embed") + meta_channel = self.bot.get_channel(Channels.meta) + + self.assertEqual(embed.description, antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value) + antimalware.DISALLOWED_EMBED_DESCRIPTION.format.assert_called_with( + joined_whitelist=", ".join(self.whitelist), + blocked_extensions_str=".disallowed", + meta_channel_mention=meta_channel.mention + ) + + async def test_removing_deleted_message_logs(self): + """Removing an already deleted message logs the correct message""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + self.message.delete = AsyncMock(side_effect=NotFound(response=Mock(status=""), message="")) + + with self.assertLogs(logger=antimalware.log, level="INFO"): + await self.cog.on_message(self.message) + self.message.delete.assert_called_once() + + async def test_message_with_illegal_attachment_logs(self): + """Deleting a message with an illegal attachment should result in a log.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + + with self.assertLogs(logger=antimalware.log, level="INFO"): + await self.cog.on_message(self.message) + + async def test_get_disallowed_extensions(self): + """The return value should include all non-whitelisted extensions.""" + test_values = ( + ([], []), + (self.whitelist, []), + ([".first"], []), + ([".first", ".disallowed"], [".disallowed"]), + ([".disallowed"], [".disallowed"]), + ([".disallowed", ".illegal"], [".disallowed", ".illegal"]), + ) + + for extensions, expected_disallowed_extensions in test_values: + with self.subTest(extensions=extensions, expected_disallowed_extensions=expected_disallowed_extensions): + self.message.attachments = [MockAttachment(filename=f"filename{extension}") for extension in extensions] + disallowed_extensions = self.cog._get_disallowed_extensions(self.message) + self.assertCountEqual(disallowed_extensions, expected_disallowed_extensions) + + +class AntiMalwareSetupTests(unittest.TestCase): + """Tests setup of the `AntiMalware` cog.""" + + def test_setup(self): + """Setup of the extension should call add_cog.""" + bot = MockBot() + antimalware.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/filters/test_antispam.py b/tests/bot/exts/filters/test_antispam.py new file mode 100644 index 000000000..6a0e4fded --- /dev/null +++ b/tests/bot/exts/filters/test_antispam.py @@ -0,0 +1,35 @@ +import unittest + +from bot.exts.filters import antispam + + +class AntispamConfigurationValidationTests(unittest.TestCase): + """Tests validation of the antispam cog configuration.""" + + def test_default_antispam_config_is_valid(self): + """The default antispam configuration is valid.""" + validation_errors = antispam.validate_config() + self.assertEqual(validation_errors, {}) + + def test_unknown_rule_returns_error(self): + """Configuring an unknown rule returns an error.""" + self.assertEqual( + antispam.validate_config({'invalid-rule': {}}), + {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} + ) + + def test_missing_keys_returns_error(self): + """Not configuring required keys returns an error.""" + keys = (('interval', 'max'), ('max', 'interval')) + for configured_key, unconfigured_key in keys: + with self.subTest( + configured_key=configured_key, + unconfigured_key=unconfigured_key + ): + config = {'burst': {configured_key: 10}} + error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" + + self.assertEqual( + antispam.validate_config(config), + {'burst': error} + ) diff --git a/tests/bot/exts/filters/test_security.py b/tests/bot/exts/filters/test_security.py new file mode 100644 index 000000000..c0c3baa42 --- /dev/null +++ b/tests/bot/exts/filters/test_security.py @@ -0,0 +1,54 @@ +import unittest +from unittest.mock import MagicMock + +from discord.ext.commands import NoPrivateMessage + +from bot.exts.filters import security +from tests.helpers import MockBot, MockContext + + +class SecurityCogTests(unittest.TestCase): + """Tests the `Security` cog.""" + + def setUp(self): + """Attach an instance of the cog to the class for tests.""" + self.bot = MockBot() + self.cog = security.Security(self.bot) + self.ctx = MockContext() + + def test_check_additions(self): + """The cog should add its checks after initialization.""" + self.bot.check.assert_any_call(self.cog.check_on_guild) + self.bot.check.assert_any_call(self.cog.check_not_bot) + + def test_check_not_bot_returns_false_for_humans(self): + """The bot check should return `True` when invoked with human authors.""" + self.ctx.author.bot = False + self.assertTrue(self.cog.check_not_bot(self.ctx)) + + def test_check_not_bot_returns_true_for_robots(self): + """The bot check should return `False` when invoked with robotic authors.""" + self.ctx.author.bot = True + self.assertFalse(self.cog.check_not_bot(self.ctx)) + + def test_check_on_guild_raises_when_outside_of_guild(self): + """When invoked outside of a guild, `check_on_guild` should cause an error.""" + self.ctx.guild = None + + with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): + self.cog.check_on_guild(self.ctx) + + def test_check_on_guild_returns_true_inside_of_guild(self): + """When invoked inside of a guild, `check_on_guild` should return `True`.""" + self.ctx.guild = "lemon's lemonade stand" + self.assertTrue(self.cog.check_on_guild(self.ctx)) + + +class SecurityCogLoadTests(unittest.TestCase): + """Tests loading the `Security` cog.""" + + def test_security_cog_load(self): + """Setup of the extension should call add_cog.""" + bot = MagicMock() + security.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py new file mode 100644 index 000000000..a0ff8a877 --- /dev/null +++ b/tests/bot/exts/filters/test_token_remover.py @@ -0,0 +1,310 @@ +import unittest +from re import Match +from unittest import mock +from unittest.mock import MagicMock + +from discord import Colour, NotFound + +from bot import constants +from bot.exts.filters import token_remover +from bot.exts.filters.token_remover import Token, TokenRemover +from bot.exts.moderation.modlog import ModLog +from tests.helpers import MockBot, MockMessage, autospec + + +class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): + """Tests the `TokenRemover` cog.""" + + def setUp(self): + """Adds the cog, a bot, and a message to the instance for usage in tests.""" + self.bot = MockBot() + self.cog = TokenRemover(bot=self.bot) + + self.msg = MockMessage(id=555, content="hello world") + self.msg.channel.mention = "#lemonade-stand" + self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) + self.msg.author.avatar_url_as.return_value = "picture-lemon.png" + + def test_is_valid_user_id_valid(self): + """Should consider user IDs valid if they decode entirely to ASCII digits.""" + ids = ( + "NDcyMjY1OTQzMDYyNDEzMzMy", + "NDc1MDczNjI5Mzk5NTQ3OTA0", + "NDY3MjIzMjMwNjUwNzc3NjQx", + ) + + for user_id in ids: + with self.subTest(user_id=user_id): + result = TokenRemover.is_valid_user_id(user_id) + self.assertTrue(result) + + def test_is_valid_user_id_invalid(self): + """Should consider non-digit and non-ASCII IDs invalid.""" + ids = ( + ("SGVsbG8gd29ybGQ", "non-digit ASCII"), + ("0J_RgNC40LLQtdGCINC80LjRgA", "cyrillic text"), + ("4pO14p6L4p6C4pG34p264pGl8J-EiOKSj-KCieKBsA", "Unicode digits"), + ("4oaA4oaB4oWh4oWi4Lyz4Lyq4Lyr4LG9", "Unicode numerals"), + ("8J2fjvCdn5nwnZ-k8J2fr_Cdn7rgravvvJngr6c", "Unicode decimals"), + ("{hello}[world]&(bye!)", "ASCII invalid Base64"), + ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), + ) + + for user_id, msg in ids: + with self.subTest(msg=msg): + result = TokenRemover.is_valid_user_id(user_id) + self.assertFalse(result) + + def test_is_valid_timestamp_valid(self): + """Should consider timestamps valid if they're greater than the Discord epoch.""" + timestamps = ( + "XsyRkw", + "Xrim9Q", + "XsyR-w", + "XsySD_", + "Dn9r_A", + ) + + for timestamp in timestamps: + with self.subTest(timestamp=timestamp): + result = TokenRemover.is_valid_timestamp(timestamp) + self.assertTrue(result) + + def test_is_valid_timestamp_invalid(self): + """Should consider timestamps invalid if they're before Discord epoch or can't be parsed.""" + timestamps = ( + ("B4Yffw", "DISCORD_EPOCH - TOKEN_EPOCH - 1"), + ("ew", "123"), + ("AoIKgA", "42076800"), + ("{hello}[world]&(bye!)", "ASCII invalid Base64"), + ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), + ) + + for timestamp, msg in timestamps: + with self.subTest(msg=msg): + result = TokenRemover.is_valid_timestamp(timestamp) + self.assertFalse(result) + + def test_mod_log_property(self): + """The `mod_log` property should ask the bot to return the `ModLog` cog.""" + self.bot.get_cog.return_value = 'lemon' + self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) + self.bot.get_cog.assert_called_once_with('ModLog') + + async def test_on_message_edit_uses_on_message(self): + """The edit listener should delegate handling of the message to the normal listener.""" + self.cog.on_message = mock.create_autospec(self.cog.on_message, spec_set=True) + + await self.cog.on_message_edit(MockMessage(), self.msg) + self.cog.on_message.assert_awaited_once_with(self.msg) + + @autospec(TokenRemover, "find_token_in_message", "take_action") + async def test_on_message_takes_action(self, find_token_in_message, take_action): + """Should take action if a valid token is found when a message is sent.""" + cog = TokenRemover(self.bot) + found_token = "foobar" + find_token_in_message.return_value = found_token + + await cog.on_message(self.msg) + + find_token_in_message.assert_called_once_with(self.msg) + take_action.assert_awaited_once_with(cog, self.msg, found_token) + + @autospec(TokenRemover, "find_token_in_message", "take_action") + async def test_on_message_skips_missing_token(self, find_token_in_message, take_action): + """Shouldn't take action if a valid token isn't found when a message is sent.""" + cog = TokenRemover(self.bot) + find_token_in_message.return_value = False + + await cog.on_message(self.msg) + + find_token_in_message.assert_called_once_with(self.msg) + take_action.assert_not_awaited() + + @autospec(TokenRemover, "find_token_in_message") + async def test_on_message_ignores_dms_bots(self, find_token_in_message): + """Shouldn't parse a message if it is a DM or authored by a bot.""" + cog = TokenRemover(self.bot) + dm_msg = MockMessage(guild=None) + bot_msg = MockMessage(author=MagicMock(bot=True)) + + for msg in (dm_msg, bot_msg): + await cog.on_message(msg) + find_token_in_message.assert_not_called() + + @autospec("bot.exts.filters.token_remover", "TOKEN_RE") + def test_find_token_no_matches(self, token_re): + """None should be returned if the regex matches no tokens in a message.""" + token_re.finditer.return_value = () + + return_value = TokenRemover.find_token_in_message(self.msg) + + self.assertIsNone(return_value) + token_re.finditer.assert_called_once_with(self.msg.content) + + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + @autospec("bot.exts.filters.token_remover", "Token") + @autospec("bot.exts.filters.token_remover", "TOKEN_RE") + def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp): + """The first match with a valid user ID and timestamp should be returned as a `Token`.""" + matches = [ + mock.create_autospec(Match, spec_set=True, instance=True), + mock.create_autospec(Match, spec_set=True, instance=True), + ] + tokens = [ + mock.create_autospec(Token, spec_set=True, instance=True), + mock.create_autospec(Token, spec_set=True, instance=True), + ] + + token_re.finditer.return_value = matches + token_cls.side_effect = tokens + is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid. + is_valid_timestamp.return_value = True + + return_value = TokenRemover.find_token_in_message(self.msg) + + self.assertEqual(tokens[1], return_value) + token_re.finditer.assert_called_once_with(self.msg.content) + + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + @autospec("bot.exts.filters.token_remover", "Token") + @autospec("bot.exts.filters.token_remover", "TOKEN_RE") + def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp): + """None should be returned if no matches have valid user IDs or timestamps.""" + token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] + token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) + is_valid_id.return_value = False + is_valid_timestamp.return_value = False + + return_value = TokenRemover.find_token_in_message(self.msg) + + self.assertIsNone(return_value) + token_re.finditer.assert_called_once_with(self.msg.content) + + def test_regex_invalid_tokens(self): + """Messages without anything looking like a token are not matched.""" + tokens = ( + "", + "lemon wins", + "..", + "x.y", + "x.y.", + ".y.z", + ".y.", + "..z", + "x..z", + " . . ", + "\n.\n.\n", + "hellö.world.bye", + "base64.nötbåse64.morebase64", + "19jd3J.dfkm3d.€víł§tüff", + ) + + for token in tokens: + with self.subTest(token=token): + results = token_remover.TOKEN_RE.findall(token) + self.assertEqual(len(results), 0) + + def test_regex_valid_tokens(self): + """Messages that look like tokens should be matched.""" + # Don't worry, these tokens have been invalidated. + tokens = ( + "NDcyMjY1OTQzMDYy_DEzMz-y.XsyRkw.VXmErH7j511turNpfURmb0rVNm8", + "NDcyMjY1OTQzMDYyNDEzMzMy.Xrim9Q.Ysnu2wacjaKs7qnoo46S8Dm2us8", + "NDc1MDczNjI5Mzk5NTQ3OTA0.XsyR-w.sJf6omBPORBPju3WJEIAcwW9Zds", + "NDY3MjIzMjMwNjUwNzc3NjQx.XsySD_.s45jqDV_Iisn-symw0yDRrk_jf4", + ) + + for token in tokens: + with self.subTest(token=token): + results = token_remover.TOKEN_RE.fullmatch(token) + self.assertIsNotNone(results, f"{token} was not matched by the regex") + + def test_regex_matches_multiple_valid(self): + """Should support multiple matches in the middle of a string.""" + token_1 = "NDY3MjIzMjMwNjUwNzc3NjQx.XsyWGg.uFNEQPCc4ePwGh7egG8UicQssz8" + token_2 = "NDcyMjY1OTQzMDYyNDEzMzMy.XsyWMw.l8XPnDqb0lp-EiQ2g_0xVFT1pyc" + message = f"garbage {token_1} hello {token_2} world" + + results = token_remover.TOKEN_RE.finditer(message) + results = [match[0] for match in results] + self.assertCountEqual((token_1, token_2), results) + + @autospec("bot.exts.filters.token_remover", "LOG_MESSAGE") + def test_format_log_message(self, log_message): + """Should correctly format the log message with info from the message and token.""" + token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") + log_message.format.return_value = "Howdy" + + return_value = TokenRemover.format_log_message(self.msg, token) + + self.assertEqual(return_value, log_message.format.return_value) + log_message.format.assert_called_once_with( + author=self.msg.author, + author_id=self.msg.author.id, + channel=self.msg.channel.mention, + user_id=token.user_id, + timestamp=token.timestamp, + hmac="x" * len(token.hmac), + ) + + @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) + @autospec("bot.exts.filters.token_remover", "log") + @autospec(TokenRemover, "format_log_message") + async def test_take_action(self, format_log_message, logger, mod_log_property): + """Should delete the message and send a mod log.""" + cog = TokenRemover(self.bot) + mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True) + token = mock.create_autospec(Token, spec_set=True, instance=True) + log_msg = "testing123" + + mod_log_property.return_value = mod_log + format_log_message.return_value = log_msg + + await cog.take_action(self.msg, token) + + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_called_once_with( + token_remover.DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) + ) + + format_log_message.assert_called_once_with(self.msg, token) + logger.debug.assert_called_with(log_msg) + self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") + + mod_log.ignore.assert_called_once_with(constants.Event.message_delete, self.msg.id) + mod_log.send_log_message.assert_called_once_with( + icon_url=constants.Icons.token_removed, + colour=Colour(constants.Colours.soft_red), + title="Token removed!", + text=log_msg, + thumbnail=self.msg.author.avatar_url_as.return_value, + channel_id=constants.Channels.mod_alerts + ) + + @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) + async def test_take_action_delete_failure(self, mod_log_property): + """Shouldn't send any messages if the token message can't be deleted.""" + cog = TokenRemover(self.bot) + mod_log_property.return_value = mock.create_autospec(ModLog, spec_set=True, instance=True) + self.msg.delete.side_effect = NotFound(MagicMock(), MagicMock()) + + token = mock.create_autospec(Token, spec_set=True, instance=True) + await cog.take_action(self.msg, token) + + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_not_awaited() + + +class TokenRemoverExtensionTests(unittest.TestCase): + """Tests for the token_remover extension.""" + + @autospec("bot.exts.filters.token_remover", "TokenRemover") + def test_extension_setup(self, cog): + """The TokenRemover cog should be added.""" + bot = MockBot() + token_remover.setup(bot) + + cog.assert_called_once_with(bot) + bot.add_cog.assert_called_once() + self.assertTrue(isinstance(bot.add_cog.call_args.args[0], TokenRemover)) diff --git a/tests/bot/exts/info/__init__.py b/tests/bot/exts/info/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py new file mode 100644 index 000000000..be47d42ef --- /dev/null +++ b/tests/bot/exts/info/test_information.py @@ -0,0 +1,584 @@ +import asyncio +import textwrap +import unittest +import unittest.mock + +import discord + +from bot import constants +from bot.exts.info import information +from bot.utils.checks import InWhitelistCheckFailure +from tests import helpers + +COG_PATH = "bot.exts.info.information.Information" + + +class InformationCogTests(unittest.TestCase): + """Tests the Information cog.""" + + @classmethod + def setUpClass(cls): + cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderators) + + def setUp(self): + """Sets up fresh objects for each test.""" + self.bot = helpers.MockBot() + + self.cog = information.Information(self.bot) + + self.ctx = helpers.MockContext() + self.ctx.author.roles.append(self.moderator_role) + + def test_roles_command_command(self): + """Test if the `role_info` command correctly returns the `moderator_role`.""" + self.ctx.guild.roles.append(self.moderator_role) + + self.cog.roles_info.can_run = unittest.mock.AsyncMock() + self.cog.roles_info.can_run.return_value = True + + coroutine = self.cog.roles_info.callback(self.cog, self.ctx) + + self.assertIsNone(asyncio.run(coroutine)) + self.ctx.send.assert_called_once() + + _, kwargs = self.ctx.send.call_args + embed = kwargs.pop('embed') + + self.assertEqual(embed.title, "Role information (Total 1 role)") + self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") + + def test_role_info_command(self): + """Tests the `role info` command.""" + dummy_role = helpers.MockRole( + name="Dummy", + id=112233445566778899, + colour=discord.Colour.blurple(), + position=10, + members=[self.ctx.author], + permissions=discord.Permissions(0) + ) + + admin_role = helpers.MockRole( + name="Admins", + id=998877665544332211, + colour=discord.Colour.red(), + position=3, + members=[self.ctx.author], + permissions=discord.Permissions(0), + ) + + self.ctx.guild.roles.append([dummy_role, admin_role]) + + self.cog.role_info.can_run = unittest.mock.AsyncMock() + self.cog.role_info.can_run.return_value = True + + coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) + + self.assertIsNone(asyncio.run(coroutine)) + + self.assertEqual(self.ctx.send.call_count, 2) + + (_, dummy_kwargs), (_, admin_kwargs) = self.ctx.send.call_args_list + + dummy_embed = dummy_kwargs["embed"] + admin_embed = admin_kwargs["embed"] + + self.assertEqual(dummy_embed.title, "Dummy info") + self.assertEqual(dummy_embed.colour, discord.Colour.blurple()) + + self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id)) + self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}") + self.assertEqual(dummy_embed.fields[2].value, "0.63 0.48 218") + self.assertEqual(dummy_embed.fields[3].value, "1") + self.assertEqual(dummy_embed.fields[4].value, "10") + self.assertEqual(dummy_embed.fields[5].value, "0") + + self.assertEqual(admin_embed.title, "Admins info") + self.assertEqual(admin_embed.colour, discord.Colour.red()) + + @unittest.mock.patch('bot.exts.info.information.time_since') + def test_server_info_command(self, time_since_patch): + time_since_patch.return_value = '2 days ago' + + self.ctx.guild = helpers.MockGuild( + features=('lemons', 'apples'), + region="The Moon", + roles=[self.moderator_role], + channels=[ + discord.TextChannel( + state={}, + guild=self.ctx.guild, + data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} + ), + discord.CategoryChannel( + state={}, + guild=self.ctx.guild, + data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} + ), + discord.VoiceChannel( + state={}, + guild=self.ctx.guild, + data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} + ) + ], + members=[ + *(helpers.MockMember(status=discord.Status.online) for _ in range(2)), + *(helpers.MockMember(status=discord.Status.idle) for _ in range(1)), + *(helpers.MockMember(status=discord.Status.dnd) for _ in range(4)), + *(helpers.MockMember(status=discord.Status.offline) for _ in range(3)), + ], + member_count=1_234, + icon_url='a-lemon.jpg', + ) + + coroutine = self.cog.server_info.callback(self.cog, self.ctx) + self.assertIsNone(asyncio.run(coroutine)) + + time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') + _, kwargs = self.ctx.send.call_args + embed = kwargs.pop('embed') + self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual( + embed.description, + textwrap.dedent( + f""" + **Server information** + Created: {time_since_patch.return_value} + Voice region: {self.ctx.guild.region} + Features: {', '.join(self.ctx.guild.features)} + + **Channel counts** + Category channels: 1 + Text channels: 1 + Voice channels: 1 + Staff channels: 0 + + **Member counts** + Members: {self.ctx.guild.member_count:,} + Staff members: 0 + Roles: {len(self.ctx.guild.roles)} + + **Member statuses** + {constants.Emojis.status_online} 2 + {constants.Emojis.status_idle} 1 + {constants.Emojis.status_dnd} 4 + {constants.Emojis.status_offline} 3 + """ + ) + ) + self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') + + +class UserInfractionHelperMethodTests(unittest.TestCase): + """Tests for the helper methods of the `!user` command.""" + + def setUp(self): + """Common set-up steps done before for each test.""" + self.bot = helpers.MockBot() + self.bot.api_client.get = unittest.mock.AsyncMock() + self.cog = information.Information(self.bot) + self.member = helpers.MockMember(id=1234) + + def test_user_command_helper_method_get_requests(self): + """The helper methods should form the correct get requests.""" + test_values = ( + { + "helper_method": self.cog.basic_user_infraction_counts, + "expected_args": ("bot/infractions", {'hidden': 'False', 'user__id': str(self.member.id)}), + }, + { + "helper_method": self.cog.expanded_user_infraction_counts, + "expected_args": ("bot/infractions", {'user__id': str(self.member.id)}), + }, + { + "helper_method": self.cog.user_nomination_counts, + "expected_args": ("bot/nominations", {'user__id': str(self.member.id)}), + }, + ) + + for test_value in test_values: + helper_method = test_value["helper_method"] + endpoint, params = test_value["expected_args"] + + with self.subTest(method=helper_method, endpoint=endpoint, params=params): + asyncio.run(helper_method(self.member)) + self.bot.api_client.get.assert_called_once_with(endpoint, params=params) + self.bot.api_client.get.reset_mock() + + def _method_subtests(self, method, test_values, default_header): + """Helper method that runs the subtests for the different helper methods.""" + for test_value in test_values: + api_response = test_value["api response"] + expected_lines = test_value["expected_lines"] + + with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines): + self.bot.api_client.get.return_value = api_response + + expected_output = "\n".join(default_header + expected_lines) + actual_output = asyncio.run(method(self.member)) + + self.assertEqual(expected_output, actual_output) + + def test_basic_user_infraction_counts_returns_correct_strings(self): + """The method should correctly list both the total and active number of non-hidden infractions.""" + test_values = ( + # No infractions means zero counts + { + "api response": [], + "expected_lines": ["Total: 0", "Active: 0"], + }, + # Simple, single-infraction dictionaries + { + "api response": [{"type": "ban", "active": True}], + "expected_lines": ["Total: 1", "Active: 1"], + }, + { + "api response": [{"type": "ban", "active": False}], + "expected_lines": ["Total: 1", "Active: 0"], + }, + # Multiple infractions with various `active` status + { + "api response": [ + {"type": "ban", "active": True}, + {"type": "kick", "active": False}, + {"type": "ban", "active": True}, + {"type": "ban", "active": False}, + ], + "expected_lines": ["Total: 4", "Active: 2"], + }, + ) + + header = ["**Infractions**"] + + self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) + + def test_expanded_user_infraction_counts_returns_correct_strings(self): + """The method should correctly list the total and active number of all infractions split by infraction type.""" + test_values = ( + { + "api response": [], + "expected_lines": ["This user has never received an infraction."], + }, + # Shows non-hidden inactive infraction as expected + { + "api response": [{"type": "kick", "active": False, "hidden": False}], + "expected_lines": ["Kicks: 1"], + }, + # Shows non-hidden active infraction as expected + { + "api response": [{"type": "mute", "active": True, "hidden": False}], + "expected_lines": ["Mutes: 1 (1 active)"], + }, + # Shows hidden inactive infraction as expected + { + "api response": [{"type": "superstar", "active": False, "hidden": True}], + "expected_lines": ["Superstars: 1"], + }, + # Shows hidden active infraction as expected + { + "api response": [{"type": "ban", "active": True, "hidden": True}], + "expected_lines": ["Bans: 1 (1 active)"], + }, + # Correctly displays tally of multiple infractions of mixed properties in alphabetical order + { + "api response": [ + {"type": "kick", "active": False, "hidden": True}, + {"type": "ban", "active": True, "hidden": True}, + {"type": "superstar", "active": True, "hidden": True}, + {"type": "mute", "active": True, "hidden": True}, + {"type": "ban", "active": False, "hidden": False}, + {"type": "note", "active": False, "hidden": True}, + {"type": "note", "active": False, "hidden": True}, + {"type": "warn", "active": False, "hidden": False}, + {"type": "note", "active": False, "hidden": True}, + ], + "expected_lines": [ + "Bans: 2 (1 active)", + "Kicks: 1", + "Mutes: 1 (1 active)", + "Notes: 3", + "Superstars: 1 (1 active)", + "Warns: 1", + ], + }, + ) + + header = ["**Infractions**"] + + self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) + + def test_user_nomination_counts_returns_correct_strings(self): + """The method should list the number of active and historical nominations for the user.""" + test_values = ( + { + "api response": [], + "expected_lines": ["This user has never been nominated."], + }, + { + "api response": [{'active': True}], + "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], + }, + { + "api response": [{'active': True}, {'active': False}], + "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], + }, + { + "api response": [{'active': False}], + "expected_lines": ["This user has 1 historical nomination, but is currently not nominated."], + }, + { + "api response": [{'active': False}, {'active': False}], + "expected_lines": ["This user has 2 historical nominations, but is currently not nominated."], + }, + + ) + + header = ["**Nominations**"] + + self._method_subtests(self.cog.user_nomination_counts, test_values, header) + + +@unittest.mock.patch("bot.exts.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) +@unittest.mock.patch("bot.exts.info.information.constants.MODERATION_CHANNELS", new=[50]) +class UserEmbedTests(unittest.TestCase): + """Tests for the creation of the `!user` embed.""" + + def setUp(self): + """Common set-up steps done before for each test.""" + self.bot = helpers.MockBot() + self.bot.api_client.get = unittest.mock.AsyncMock() + self.cog = information.Information(self.bot) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): + """The embed should use the string representation of the user if they don't have a nick.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) + user = helpers.MockMember() + user.nick = None + user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.title, "Mr. Hemlock") + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_nick_in_title_if_available(self): + """The embed should use the nick if it's available.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) + user = helpers.MockMember() + user.nick = "Cat lover" + user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_ignores_everyone_role(self): + """Created `!user` embeds should not contain mention of the @everyone-role.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) + admins_role = helpers.MockRole(name='Admins') + admins_role.colour = 100 + + # A `MockMember` has the @Everyone role by default; we add the Admins to that. + user = helpers.MockMember(roles=[admins_role], top_role=admins_role) + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertIn("&Admins", embed.description) + self.assertNotIn("&Everyone", embed.description) + + @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) + @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) + def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): + """The embed should contain expanded infractions and nomination info in mod channels.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) + + moderators_role = helpers.MockRole(name='Moderators') + moderators_role.colour = 100 + + infraction_counts.return_value = "expanded infractions info" + nomination_counts.return_value = "nomination info" + + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + infraction_counts.assert_called_once_with(user) + nomination_counts.assert_called_once_with(user) + + self.assertEqual( + textwrap.dedent(f""" + **User Information** + Created: {"1 year ago"} + Profile: {user.mention} + ID: {user.id} + + **Member Information** + Joined: {"1 year ago"} + Roles: &Moderators + + expanded infractions info + + nomination info + """).strip(), + embed.description + ) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) + def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): + """The embed should contain only basic infraction data outside of mod channels.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) + + moderators_role = helpers.MockRole(name='Moderators') + moderators_role.colour = 100 + + infraction_counts.return_value = "basic infractions info" + + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + infraction_counts.assert_called_once_with(user) + + self.assertEqual( + textwrap.dedent(f""" + **User Information** + Created: {"1 year ago"} + Profile: {user.mention} + ID: {user.id} + + **Member Information** + Joined: {"1 year ago"} + Roles: &Moderators + + basic infractions info + """).strip(), + embed.description + ) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): + """The embed should be created with the colour of the top role, if a top role is available.""" + ctx = helpers.MockContext() + + moderators_role = helpers.MockRole(name='Moderators') + moderators_role.colour = 100 + + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): + """The embed should be created with a blurple colour if the user has no assigned roles.""" + ctx = helpers.MockContext() + + user = helpers.MockMember(id=217) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.colour, discord.Colour.blurple()) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): + """The embed thumbnail should be set to the user's avatar in `png` format.""" + ctx = helpers.MockContext() + + user = helpers.MockMember(id=217) + user.avatar_url_as.return_value = "avatar url" + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + user.avatar_url_as.assert_called_once_with(static_format="png") + self.assertEqual(embed.thumbnail.url, "avatar url") + + +@unittest.mock.patch("bot.exts.info.information.constants") +class UserCommandTests(unittest.TestCase): + """Tests for the `!user` command.""" + + def setUp(self): + """Set up steps executed before each test is run.""" + self.bot = helpers.MockBot() + self.cog = information.Information(self.bot) + + self.moderator_role = helpers.MockRole(name="Moderators", id=2, position=10) + self.flautist_role = helpers.MockRole(name="Flautists", id=3, position=2) + self.bassist_role = helpers.MockRole(name="Bassists", id=4, position=3) + + self.author = helpers.MockMember(id=1, name="syntaxaire") + self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role]) + self.target = helpers.MockMember(id=3, name="__fluzz__") + + def test_regular_member_cannot_target_another_member(self, constants): + """A regular user should not be able to use `!user` targeting another user.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + + ctx = helpers.MockContext(author=self.author) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + + ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") + + def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): + """A regular user should not be able to use this command outside of bot-commands.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot_commands = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) + + msg = "Sorry, but you may only use this command within <#50>." + with self.assertRaises(InWhitelistCheckFailure, msg=msg): + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") + def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): + """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot_commands = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + create_embed.assert_called_once_with(ctx, self.author) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") + def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): + """A user should target itself with `!user` when a `user` argument was not provided.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot_commands = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) + + create_embed.assert_called_once_with(ctx, self.author) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") + def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): + """Staff members should be able to bypass the bot-commands channel restriction.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot_commands = 50 + + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + create_embed.assert_called_once_with(ctx, self.moderator) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") + def test_moderators_can_target_another_member(self, create_embed, constants): + """A moderator should be able to use `!user` targeting another user.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + constants.STAFF_ROLES = [self.moderator_role.id] + + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + + create_embed.assert_called_once_with(ctx, self.target) + ctx.send.assert_called_once() diff --git a/tests/bot/exts/moderation/__init__.py b/tests/bot/exts/moderation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/moderation/infraction/__init__.py b/tests/bot/exts/moderation/infraction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py new file mode 100644 index 000000000..be1b649e1 --- /dev/null +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -0,0 +1,55 @@ +import textwrap +import unittest +from unittest.mock import AsyncMock, Mock, patch + +from bot.exts.moderation.infraction.infractions import Infractions +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole + + +class TruncationTests(unittest.IsolatedAsyncioTestCase): + """Tests for ban and kick command reason truncation.""" + + def setUp(self): + self.bot = MockBot() + self.cog = Infractions(self.bot) + self.user = MockMember(id=1234, top_role=MockRole(id=3577, position=10)) + self.target = MockMember(id=1265, top_role=MockRole(id=9876, position=0)) + self.guild = MockGuild(id=4567) + self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild) + + @patch("bot.exts.moderation.infraction._utils.get_active_infraction") + @patch("bot.exts.moderation.infraction._utils.post_infraction") + async def test_apply_ban_reason_truncation(self, post_infraction_mock, get_active_mock): + """Should truncate reason for `ctx.guild.ban`.""" + get_active_mock.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.cog.apply_infraction = AsyncMock() + self.bot.get_cog.return_value = AsyncMock() + self.cog.mod_log.ignore = Mock() + self.ctx.guild.ban = Mock() + + await self.cog.apply_ban(self.ctx, self.target, "foo bar" * 3000) + self.ctx.guild.ban.assert_called_once_with( + self.target, + reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."), + delete_message_days=0 + ) + self.cog.apply_infraction.assert_awaited_once_with( + self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value + ) + + @patch("bot.exts.moderation.infraction._utils.post_infraction") + async def test_apply_kick_reason_truncation(self, post_infraction_mock): + """Should truncate reason for `Member.kick`.""" + post_infraction_mock.return_value = {"foo": "bar"} + + self.cog.apply_infraction = AsyncMock() + self.cog.mod_log.ignore = Mock() + self.target.kick = Mock() + + await self.cog.apply_kick(self.ctx, self.target, "foo bar" * 3000) + self.target.kick.assert_called_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="...")) + self.cog.apply_infraction.assert_awaited_once_with( + self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value + ) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py new file mode 100644 index 000000000..cbf7f7bcf --- /dev/null +++ b/tests/bot/exts/moderation/test_incidents.py @@ -0,0 +1,770 @@ +import asyncio +import enum +import logging +import typing as t +import unittest +from unittest.mock import AsyncMock, MagicMock, call, patch + +import aiohttp +import discord + +from bot.constants import Colours +from bot.exts.moderation import incidents +from tests.helpers import ( + MockAsyncWebhook, + MockAttachment, + MockBot, + MockMember, + MockMessage, + MockReaction, + MockRole, + MockTextChannel, + MockUser, +) + + +class MockAsyncIterable: + """ + Helper for mocking asynchronous for loops. + + It does not appear that the `unittest` library currently provides anything that would + allow us to simply mock an async iterator, such as `discord.TextChannel.history`. + + We therefore write our own helper to wrap a regular synchronous iterable, and feed + its values via `__anext__` rather than `__next__`. + + This class was written for the purposes of testing the `Incidents` cog - it may not + be generic enough to be placed in the `tests.helpers` module. + """ + + def __init__(self, messages: t.Iterable): + """Take a sync iterable to be wrapped.""" + self.iter_messages = iter(messages) + + def __aiter__(self): + """Return `self` as we provide the `__anext__` method.""" + return self + + async def __anext__(self): + """ + Feed the next item, or raise `StopAsyncIteration`. + + Since we're wrapping a sync iterator, it will communicate that it has been depleted + by raising a `StopIteration`. The `async for` construct does not expect it, and we + therefore need to substitute it for the appropriate exception type. + """ + try: + return next(self.iter_messages) + except StopIteration: + raise StopAsyncIteration + + +class MockSignal(enum.Enum): + A = "A" + B = "B" + + +mock_404 = discord.NotFound( + response=MagicMock(aiohttp.ClientResponse), # Mock the erroneous response + message="Not found", +) + + +class TestDownloadFile(unittest.IsolatedAsyncioTestCase): + """Collection of tests for the `download_file` helper function.""" + + async def test_download_file_success(self): + """If `to_file` succeeds, function returns the acquired `discord.File`.""" + file = MagicMock(discord.File, filename="bigbadlemon.jpg") + attachment = MockAttachment(to_file=AsyncMock(return_value=file)) + + acquired_file = await incidents.download_file(attachment) + self.assertIs(file, acquired_file) + + async def test_download_file_404(self): + """If `to_file` encounters a 404, function handles the exception & returns None.""" + attachment = MockAttachment(to_file=AsyncMock(side_effect=mock_404)) + + acquired_file = await incidents.download_file(attachment) + self.assertIsNone(acquired_file) + + async def test_download_file_fail(self): + """If `to_file` fails on a non-404 error, function logs the exception & returns None.""" + arbitrary_error = discord.HTTPException(MagicMock(aiohttp.ClientResponse), "Arbitrary API error") + attachment = MockAttachment(to_file=AsyncMock(side_effect=arbitrary_error)) + + with self.assertLogs(logger=incidents.log, level=logging.ERROR): + acquired_file = await incidents.download_file(attachment) + + self.assertIsNone(acquired_file) + + +class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): + """Collection of tests for the `make_embed` helper function.""" + + async def test_make_embed_actioned(self): + """Embed is coloured green and footer contains 'Actioned' when `outcome=Signal.ACTIONED`.""" + embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember()) + + self.assertEqual(embed.colour.value, Colours.soft_green) + self.assertIn("Actioned", embed.footer.text) + + async def test_make_embed_not_actioned(self): + """Embed is coloured red and footer contains 'Rejected' when `outcome=Signal.NOT_ACTIONED`.""" + embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember()) + + self.assertEqual(embed.colour.value, Colours.soft_red) + self.assertIn("Rejected", embed.footer.text) + + async def test_make_embed_content(self): + """Incident content appears as embed description.""" + incident = MockMessage(content="this is an incident") + embed, file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + + self.assertEqual(incident.content, embed.description) + + async def test_make_embed_with_attachment_succeeds(self): + """Incident's attachment is downloaded and displayed in the embed's image field.""" + file = MagicMock(discord.File, filename="bigbadjoe.jpg") + attachment = MockAttachment(filename="bigbadjoe.jpg") + incident = MockMessage(content="this is an incident", attachments=[attachment]) + + # Patch `download_file` to return our `file` + with patch("bot.exts.moderation.incidents.download_file", AsyncMock(return_value=file)): + embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + + self.assertIs(file, returned_file) + self.assertEqual("attachment://bigbadjoe.jpg", embed.image.url) + + async def test_make_embed_with_attachment_fails(self): + """Incident's attachment fails to download, proxy url is linked instead.""" + attachment = MockAttachment(proxy_url="discord.com/bigbadjoe.jpg") + incident = MockMessage(content="this is an incident", attachments=[attachment]) + + # Patch `download_file` to return None as if the download failed + with patch("bot.exts.moderation.incidents.download_file", AsyncMock(return_value=None)): + embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + + self.assertIsNone(returned_file) + + # The author name field is simply expected to have something in it, we do not assert the message + self.assertGreater(len(embed.author.name), 0) + self.assertEqual(embed.author.url, "discord.com/bigbadjoe.jpg") # However, it should link the exact url + + +@patch("bot.constants.Channels.incidents", 123) +class TestIsIncident(unittest.TestCase): + """ + Collection of tests for the `is_incident` helper function. + + In `setUp`, we will create a mock message which should qualify as an incident. Each + test case will then mutate this instance to make it **not** qualify, in various ways. + + Notice that we patch the #incidents channel id globally for this class. + """ + + def setUp(self) -> None: + """Prepare a mock message which should qualify as an incident.""" + self.incident = MockMessage( + channel=MockTextChannel(id=123), + content="this is an incident", + author=MockUser(bot=False), + pinned=False, + ) + + def test_is_incident_true(self): + """Message qualifies as an incident if unchanged.""" + self.assertTrue(incidents.is_incident(self.incident)) + + def check_false(self): + """Assert that `self.incident` does **not** qualify as an incident.""" + self.assertFalse(incidents.is_incident(self.incident)) + + def test_is_incident_false_channel(self): + """Message doesn't qualify if sent outside of #incidents.""" + self.incident.channel = MockTextChannel(id=456) + self.check_false() + + def test_is_incident_false_content(self): + """Message doesn't qualify if content begins with hash symbol.""" + self.incident.content = "# this is a comment message" + self.check_false() + + def test_is_incident_false_author(self): + """Message doesn't qualify if author is a bot.""" + self.incident.author = MockUser(bot=True) + self.check_false() + + def test_is_incident_false_pinned(self): + """Message doesn't qualify if it is pinned.""" + self.incident.pinned = True + self.check_false() + + +class TestOwnReactions(unittest.TestCase): + """Assertions for the `own_reactions` function.""" + + def test_own_reactions(self): + """Only bot's own emoji are extracted from the input incident.""" + reactions = ( + MockReaction(emoji="A", me=True), + MockReaction(emoji="B", me=True), + MockReaction(emoji="C", me=False), + ) + message = MockMessage(reactions=reactions) + self.assertSetEqual(incidents.own_reactions(message), {"A", "B"}) + + +@patch("bot.exts.moderation.incidents.ALL_SIGNALS", {"A", "B"}) +class TestHasSignals(unittest.TestCase): + """ + Assertions for the `has_signals` function. + + We patch `ALL_SIGNALS` globally. Each test function then patches `own_reactions` + as appropriate. + """ + + def test_has_signals_true(self): + """True when `own_reactions` returns all emoji in `ALL_SIGNALS`.""" + message = MockMessage() + own_reactions = MagicMock(return_value={"A", "B"}) + + with patch("bot.exts.moderation.incidents.own_reactions", own_reactions): + self.assertTrue(incidents.has_signals(message)) + + def test_has_signals_false(self): + """False when `own_reactions` does not return all emoji in `ALL_SIGNALS`.""" + message = MockMessage() + own_reactions = MagicMock(return_value={"A", "C"}) + + with patch("bot.exts.moderation.incidents.own_reactions", own_reactions): + self.assertFalse(incidents.has_signals(message)) + + +@patch("bot.exts.moderation.incidents.Signal", MockSignal) +class TestAddSignals(unittest.IsolatedAsyncioTestCase): + """ + Assertions for the `add_signals` coroutine. + + These are all fairly similar and could go into a single test function, but I found the + patching & sub-testing fairly awkward in that case and decided to split them up + to avoid unnecessary syntax noise. + """ + + def setUp(self): + """Prepare a mock incident message for tests to use.""" + self.incident = MockMessage() + + @patch("bot.exts.moderation.incidents.own_reactions", MagicMock(return_value=set())) + async def test_add_signals_missing(self): + """All emoji are added when none are present.""" + await incidents.add_signals(self.incident) + self.incident.add_reaction.assert_has_calls([call("A"), call("B")]) + + @patch("bot.exts.moderation.incidents.own_reactions", MagicMock(return_value={"A"})) + async def test_add_signals_partial(self): + """Only missing emoji are added when some are present.""" + await incidents.add_signals(self.incident) + self.incident.add_reaction.assert_has_calls([call("B")]) + + @patch("bot.exts.moderation.incidents.own_reactions", MagicMock(return_value={"A", "B"})) + async def test_add_signals_present(self): + """No emoji are added when all are present.""" + await incidents.add_signals(self.incident) + self.incident.add_reaction.assert_not_called() + + +class TestIncidents(unittest.IsolatedAsyncioTestCase): + """ + Tests for bound methods of the `Incidents` cog. + + Use this as a base class for `Incidents` tests - it will prepare a fresh instance + for each test function, but not make any assertions on its own. Tests can mutate + the instance as they wish. + """ + + def setUp(self): + """ + Prepare a fresh `Incidents` instance for each test. + + Note that this will not schedule `crawl_incidents` in the background, as everything + is being mocked. The `crawl_task` attribute will end up being None. + """ + self.cog_instance = incidents.Incidents(MockBot()) + + +@patch("asyncio.sleep", AsyncMock()) # Prevent the coro from sleeping to speed up the test +class TestCrawlIncidents(TestIncidents): + """ + Tests for the `Incidents.crawl_incidents` coroutine. + + Apart from `test_crawl_incidents_waits_until_cache_ready`, all tests in this class + will patch the return values of `is_incident` and `has_signal` and then observe + whether the `AsyncMock` for `add_signals` was awaited or not. + + The `add_signals` mock is added by each test separately to ensure it is clean (has not + been awaited by another test yet). The mock can be reset, but this appears to be the + cleaner way. + + For each test, we inject a mock channel with a history of 1 message only (see: `setUp`). + """ + + def setUp(self): + """For each test, ensure `bot.get_channel` returns a channel with 1 arbitrary message.""" + super().setUp() # First ensure we get `cog_instance` from parent + + incidents_history = MagicMock(return_value=MockAsyncIterable([MockMessage()])) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(history=incidents_history)) + + async def test_crawl_incidents_waits_until_cache_ready(self): + """ + The coroutine will await the `wait_until_guild_available` event. + + Since this task is schedule in the `__init__`, it is critical that it waits for the + cache to be ready, so that it can safely get the #incidents channel. + """ + await self.cog_instance.crawl_incidents() + self.cog_instance.bot.wait_until_guild_available.assert_awaited() + + @patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) + @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=False)) # Message doesn't qualify + @patch("bot.exts.moderation.incidents.has_signals", MagicMock(return_value=False)) + async def test_crawl_incidents_noop_if_is_not_incident(self): + """Signals are not added for a non-incident message.""" + await self.cog_instance.crawl_incidents() + incidents.add_signals.assert_not_awaited() + + @patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) + @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies + @patch("bot.exts.moderation.incidents.has_signals", MagicMock(return_value=True)) # But already has signals + async def test_crawl_incidents_noop_if_message_already_has_signals(self): + """Signals are not added for messages which already have them.""" + await self.cog_instance.crawl_incidents() + incidents.add_signals.assert_not_awaited() + + @patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) + @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies + @patch("bot.exts.moderation.incidents.has_signals", MagicMock(return_value=False)) # And doesn't have signals + async def test_crawl_incidents_add_signals_called(self): + """Message has signals added as it does not have them yet and qualifies as an incident.""" + await self.cog_instance.crawl_incidents() + incidents.add_signals.assert_awaited_once() + + +class TestArchive(TestIncidents): + """Tests for the `Incidents.archive` coroutine.""" + + async def test_archive_webhook_not_found(self): + """ + Method recovers and returns False when the webhook is not found. + + Implicitly, this also tests that the error is handled internally and doesn't + propagate out of the method, which is just as important. + """ + self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) + self.assertFalse( + await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember()) + ) + + async def test_archive_relays_incident(self): + """ + If webhook is found, method relays `incident` properly. + + This test will assert that the fetched webhook's `send` method is fed the correct arguments, + and that the `archive` method returns True. + """ + webhook = MockAsyncWebhook() + self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) # Patch in our webhook + + # Define our own `incident` to be archived + incident = MockMessage( + content="this is an incident", + author=MockUser(name="author_name", avatar_url="author_avatar"), + id=123, + ) + built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this + + with patch("bot.exts.moderation.incidents.make_embed", AsyncMock(return_value=(built_embed, None))): + archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember()) + + # Now we check that the webhook was given the correct args, and that `archive` returned True + webhook.send.assert_called_once_with( + embed=built_embed, + username="author_name", + avatar_url="author_avatar", + file=None, + ) + self.assertTrue(archive_return) + + async def test_archive_clyde_username(self): + """ + The archive webhook username is cleansed using `sub_clyde`. + + Discord will reject any webhook with "clyde" in the username field, as it impersonates + the official Clyde bot. Since we do not control what the username will be (the incident + author name is used), we must ensure the name is cleansed, otherwise the relay may fail. + + This test assumes the username is passed as a kwarg. If this test fails, please review + whether the passed argument is being retrieved correctly. + """ + webhook = MockAsyncWebhook() + self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) + + message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) + await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal), MockMember()) + + self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) + + +class TestMakeConfirmationTask(TestIncidents): + """ + Tests for the `Incidents.make_confirmation_task` method. + + Writing tests for this method is difficult, as it mostly just delegates the provided + information elsewhere. There is very little internal logic. Whether our approach + works conceptually is difficult to prove using unit tests. + """ + + def test_make_confirmation_task_check(self): + """ + The internal check will recognize the passed incident. + + This is a little tricky - we first pass a message with a specific `id` in, and then + retrieve the built check from the `call_args` of the `wait_for` method. This relies + on the check being passed as a kwarg. + + Once the check is retrieved, we assert that it gives True for our incident's `id`, + and False for any other. + + If this function begins to fail, first check that `created_check` is being retrieved + correctly. It should be the function that is built locally in the tested method. + """ + self.cog_instance.make_confirmation_task(MockMessage(id=123)) + + self.cog_instance.bot.wait_for.assert_called_once() + created_check = self.cog_instance.bot.wait_for.call_args.kwargs["check"] + + # The `message_id` matches the `id` of our incident + self.assertTrue(created_check(payload=MagicMock(message_id=123))) + + # This `message_id` does not match + self.assertFalse(created_check(payload=MagicMock(message_id=0))) + + +@patch("bot.exts.moderation.incidents.ALLOWED_ROLES", {1, 2}) +@patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", AsyncMock()) # Generic awaitable +class TestProcessEvent(TestIncidents): + """Tests for the `Incidents.process_event` coroutine.""" + + async def test_process_event_bad_role(self): + """The reaction is removed when the author lacks all allowed roles.""" + incident = MockMessage() + member = MockMember(roles=[MockRole(id=0)]) # Must have role 1 or 2 + + await self.cog_instance.process_event("reaction", incident, member) + incident.remove_reaction.assert_called_once_with("reaction", member) + + async def test_process_event_bad_emoji(self): + """ + The reaction is removed when an invalid emoji is used. + + This requires that we pass in a `member` with valid roles, as we need the role check + to succeed. + """ + incident = MockMessage() + member = MockMember(roles=[MockRole(id=1)]) # Member has allowed role + + await self.cog_instance.process_event("invalid_signal", incident, member) + incident.remove_reaction.assert_called_once_with("invalid_signal", member) + + async def test_process_event_no_archive_on_investigating(self): + """Message is not archived on `Signal.INVESTIGATING`.""" + with patch("bot.exts.moderation.incidents.Incidents.archive", AsyncMock()) as mocked_archive: + await self.cog_instance.process_event( + reaction=incidents.Signal.INVESTIGATING.value, + incident=MockMessage(), + member=MockMember(roles=[MockRole(id=1)]), + ) + + mocked_archive.assert_not_called() + + async def test_process_event_no_delete_if_archive_fails(self): + """ + Original message is not deleted when `Incidents.archive` returns False. + + This is the way of signaling that the relay failed, and we should not remove the original, + as that would result in losing the incident record. + """ + incident = MockMessage() + + with patch("bot.exts.moderation.incidents.Incidents.archive", AsyncMock(return_value=False)): + await self.cog_instance.process_event( + reaction=incidents.Signal.ACTIONED.value, + incident=incident, + member=MockMember(roles=[MockRole(id=1)]) + ) + + incident.delete.assert_not_called() + + async def test_process_event_confirmation_task_is_awaited(self): + """Task given by `Incidents.make_confirmation_task` is awaited before method exits.""" + mock_task = AsyncMock() + + with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task): + await self.cog_instance.process_event( + reaction=incidents.Signal.ACTIONED.value, + incident=MockMessage(), + member=MockMember(roles=[MockRole(id=1)]) + ) + + mock_task.assert_awaited() + + async def test_process_event_confirmation_task_timeout_is_handled(self): + """ + Confirmation task `asyncio.TimeoutError` is handled gracefully. + + We have `make_confirmation_task` return a mock with a side effect, and then catch the + exception should it propagate out of `process_event`. This is so that we can then manually + fail the test with a more informative message than just the plain traceback. + """ + mock_task = AsyncMock(side_effect=asyncio.TimeoutError()) + + try: + with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task): + await self.cog_instance.process_event( + reaction=incidents.Signal.ACTIONED.value, + incident=MockMessage(), + member=MockMember(roles=[MockRole(id=1)]) + ) + except asyncio.TimeoutError: + self.fail("TimeoutError was not handled gracefully, and propagated out of `process_event`!") + + +class TestResolveMessage(TestIncidents): + """Tests for the `Incidents.resolve_message` coroutine.""" + + async def test_resolve_message_pass_message_id(self): + """Method will call `_get_message` with the passed `message_id`.""" + await self.cog_instance.resolve_message(123) + self.cog_instance.bot._connection._get_message.assert_called_once_with(123) + + async def test_resolve_message_in_cache(self): + """ + No API call is made if the queried message exists in the cache. + + We mock the `_get_message` return value regardless of input. Whether it finds the message + internally is considered d.py's responsibility, not ours. + """ + cached_message = MockMessage(id=123) + self.cog_instance.bot._connection._get_message = MagicMock(return_value=cached_message) + + return_value = await self.cog_instance.resolve_message(123) + + self.assertIs(return_value, cached_message) + self.cog_instance.bot.get_channel.assert_not_called() # The `fetch_message` line was never hit + + async def test_resolve_message_not_in_cache(self): + """ + The message is retrieved from the API if it isn't cached. + + This is desired behaviour for messages which exist, but were sent before the bot's + current session. + """ + self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None + + # API returns our message + uncached_message = MockMessage() + fetch_message = AsyncMock(return_value=uncached_message) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + + retrieved_message = await self.cog_instance.resolve_message(123) + self.assertIs(retrieved_message, uncached_message) + + async def test_resolve_message_doesnt_exist(self): + """ + If the API returns a 404, the function handles it gracefully and returns None. + + This is an edge-case happening with racing events - event A will relay the message + to the archive and delete the original. Once event B acquires the `event_lock`, + it will not find the message in the cache, and will ask the API. + """ + self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None + + fetch_message = AsyncMock(side_effect=mock_404) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + + self.assertIsNone(await self.cog_instance.resolve_message(123)) + + async def test_resolve_message_fetch_fails(self): + """ + Non-404 errors are handled, logged & None is returned. + + In contrast with a 404, this should make an error-level log. We assert that at least + one such log was made - we do not make any assertions about the log's message. + """ + self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None + + arbitrary_error = discord.HTTPException( + response=MagicMock(aiohttp.ClientResponse), + message="Arbitrary error", + ) + fetch_message = AsyncMock(side_effect=arbitrary_error) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + + with self.assertLogs(logger=incidents.log, level=logging.ERROR): + self.assertIsNone(await self.cog_instance.resolve_message(123)) + + +@patch("bot.constants.Channels.incidents", 123) +class TestOnRawReactionAdd(TestIncidents): + """ + Tests for the `Incidents.on_raw_reaction_add` listener. + + Writing tests for this listener comes with additional complexity due to the listener + awaiting the `crawl_task` task. See `asyncSetUp` for further details, which attempts + to make unit testing this function possible. + """ + + def setUp(self): + """ + Prepare & assign `payload` attribute. + + This attribute represents an *ideal* payload which will not be rejected by the + listener. As each test will receive a fresh instance, it can be mutated to + observe how the listener's behaviour changes with different attributes on + the passed payload. + """ + super().setUp() # Ensure `cog_instance` is assigned + + self.payload = MagicMock( + discord.RawReactionActionEvent, + channel_id=123, # Patched at class level + message_id=456, + member=MockMember(bot=False), + emoji="reaction", + ) + + async def asyncSetUp(self): # noqa: N802 + """ + Prepare an empty task and assign it as `crawl_task`. + + It appears that the `unittest` framework does not provide anything for mocking + asyncio tasks. An `AsyncMock` instance can be called and then awaited, however, + it does not provide the `done` method or any other parts of the `asyncio.Task` + interface. + + Although we do not need to make any assertions about the task itself while + testing the listener, the code will still await it and call the `done` method, + and so we must inject something that will not fail on either action. + + Note that this is done in an `asyncSetUp`, which runs after `setUp`. + The justification is that creating an actual task requires the event + loop to be ready, which is not the case in the `setUp`. + """ + mock_task = asyncio.create_task(AsyncMock()()) # Mock async func, then a coro + self.cog_instance.crawl_task = mock_task + + async def test_on_raw_reaction_add_wrong_channel(self): + """ + Events outside of #incidents will be ignored. + + We check this by asserting that `resolve_message` was never queried. + """ + self.payload.channel_id = 0 + self.cog_instance.resolve_message = AsyncMock() + + await self.cog_instance.on_raw_reaction_add(self.payload) + self.cog_instance.resolve_message.assert_not_called() + + async def test_on_raw_reaction_add_user_is_bot(self): + """ + Events dispatched by bot accounts will be ignored. + + We check this by asserting that `resolve_message` was never queried. + """ + self.payload.member = MockMember(bot=True) + self.cog_instance.resolve_message = AsyncMock() + + await self.cog_instance.on_raw_reaction_add(self.payload) + self.cog_instance.resolve_message.assert_not_called() + + async def test_on_raw_reaction_add_message_doesnt_exist(self): + """ + Listener gracefully handles the case where `resolve_message` gives None. + + We check this by asserting that `process_event` was never called. + """ + self.cog_instance.process_event = AsyncMock() + self.cog_instance.resolve_message = AsyncMock(return_value=None) + + await self.cog_instance.on_raw_reaction_add(self.payload) + self.cog_instance.process_event.assert_not_called() + + async def test_on_raw_reaction_add_message_is_not_an_incident(self): + """ + The event won't be processed if the related message is not an incident. + + This is an edge-case that can happen if someone manually leaves a reaction + on a pinned message, or a comment. + + We check this by asserting that `process_event` was never called. + """ + self.cog_instance.process_event = AsyncMock() + self.cog_instance.resolve_message = AsyncMock(return_value=MockMessage()) + + with patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=False)): + await self.cog_instance.on_raw_reaction_add(self.payload) + + self.cog_instance.process_event.assert_not_called() + + async def test_on_raw_reaction_add_valid_event_is_processed(self): + """ + If the reaction event is valid, it is passed to `process_event`. + + This is the case when everything goes right: + * The reaction was placed in #incidents, and not by a bot + * The message was found successfully + * The message qualifies as an incident + + Additionally, we check that all arguments were passed as expected. + """ + incident = MockMessage(id=1) + + self.cog_instance.process_event = AsyncMock() + self.cog_instance.resolve_message = AsyncMock(return_value=incident) + + with patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)): + await self.cog_instance.on_raw_reaction_add(self.payload) + + self.cog_instance.process_event.assert_called_with( + "reaction", # Defined in `self.payload` + incident, + self.payload.member, + ) + + +class TestOnMessage(TestIncidents): + """ + Tests for the `Incidents.on_message` listener. + + Notice the decorators mocking the `is_incident` return value. The `is_incidents` + function is tested in `TestIsIncident` - here we do not worry about it. + """ + + @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) + async def test_on_message_incident(self): + """Messages qualifying as incidents are passed to `add_signals`.""" + incident = MockMessage() + + with patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: + await self.cog_instance.on_message(incident) + + mock_add_signals.assert_called_once_with(incident) + + @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=False)) + async def test_on_message_non_incident(self): + """Messages not qualifying as incidents are ignored.""" + with patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: + await self.cog_instance.on_message(MockMessage()) + + mock_add_signals.assert_not_called() diff --git a/tests/bot/exts/moderation/test_modlog.py b/tests/bot/exts/moderation/test_modlog.py new file mode 100644 index 000000000..f8f142484 --- /dev/null +++ b/tests/bot/exts/moderation/test_modlog.py @@ -0,0 +1,29 @@ +import unittest + +import discord + +from bot.exts.moderation.modlog import ModLog +from tests.helpers import MockBot, MockTextChannel + + +class ModLogTests(unittest.IsolatedAsyncioTestCase): + """Tests for moderation logs.""" + + def setUp(self): + self.bot = MockBot() + self.cog = ModLog(self.bot) + self.channel = MockTextChannel() + + async def test_log_entry_description_truncation(self): + """Test that embed description for ModLog entry is truncated.""" + self.bot.get_channel.return_value = self.channel + await self.cog.send_log_message( + icon_url="foo", + colour=discord.Colour.blue(), + title="bar", + text="foo bar" * 3000 + ) + embed = self.channel.send.call_args[1]["embed"] + self.assertEqual( + embed.description, ("foo bar" * 3000)[:2045] + "..." + ) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py new file mode 100644 index 000000000..8c4fb764a --- /dev/null +++ b/tests/bot/exts/moderation/test_silence.py @@ -0,0 +1,261 @@ +import unittest +from unittest import mock +from unittest.mock import MagicMock, Mock + +from discord import PermissionOverwrite + +from bot.constants import Channels, Emojis, Guild, Roles +from bot.exts.moderation.silence import Silence, SilenceNotifier +from tests.helpers import MockBot, MockContext, MockTextChannel + + +class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.alert_channel = MockTextChannel() + self.notifier = SilenceNotifier(self.alert_channel) + self.notifier.stop = self.notifier_stop_mock = Mock() + self.notifier.start = self.notifier_start_mock = Mock() + + def test_add_channel_adds_channel(self): + """Channel in FirstHash with current loop is added to internal set.""" + channel = Mock() + with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: + self.notifier.add_channel(channel) + silenced_channels.__setitem__.assert_called_with(channel, self.notifier._current_loop) + + def test_add_channel_starts_loop(self): + """Loop is started if `_silenced_channels` was empty.""" + self.notifier.add_channel(Mock()) + self.notifier_start_mock.assert_called_once() + + def test_add_channel_skips_start_with_channels(self): + """Loop start is not called when `_silenced_channels` is not empty.""" + with mock.patch.object(self.notifier, "_silenced_channels"): + self.notifier.add_channel(Mock()) + self.notifier_start_mock.assert_not_called() + + def test_remove_channel_removes_channel(self): + """Channel in FirstHash is removed from `_silenced_channels`.""" + channel = Mock() + with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: + self.notifier.remove_channel(channel) + silenced_channels.__delitem__.assert_called_with(channel) + + def test_remove_channel_stops_loop(self): + """Notifier loop is stopped if `_silenced_channels` is empty after remove.""" + with mock.patch.object(self.notifier, "_silenced_channels", __bool__=lambda _: False): + self.notifier.remove_channel(Mock()) + self.notifier_stop_mock.assert_called_once() + + def test_remove_channel_skips_stop_with_channels(self): + """Notifier loop is not stopped if `_silenced_channels` is not empty after remove.""" + self.notifier.remove_channel(Mock()) + self.notifier_stop_mock.assert_not_called() + + async def test_notifier_private_sends_alert(self): + """Alert is sent on 15 min intervals.""" + test_cases = (900, 1800, 2700) + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + with mock.patch.object(self.notifier, "_current_loop", new=current_loop): + await self.notifier._notifier() + self.alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> currently silenced channels: ") + self.alert_channel.send.reset_mock() + + async def test_notifier_skips_alert(self): + """Alert is skipped on first loop or not an increment of 900.""" + test_cases = (0, 15, 5000) + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + with mock.patch.object(self.notifier, "_current_loop", new=current_loop): + await self.notifier._notifier() + self.alert_channel.send.assert_not_called() + + +class SilenceTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Silence(self.bot) + self.ctx = MockContext() + self.cog._verified_role = None + # Set event so command callbacks can continue. + self.cog._get_instance_vars_event.set() + + async def test_instance_vars_got_guild(self): + """Bot got guild after it became available.""" + await self.cog._get_instance_vars() + self.bot.wait_until_guild_available.assert_called_once() + self.bot.get_guild.assert_called_once_with(Guild.id) + + async def test_instance_vars_got_role(self): + """Got `Roles.verified` role from guild.""" + await self.cog._get_instance_vars() + guild = self.bot.get_guild() + guild.get_role.assert_called_once_with(Roles.verified) + + async def test_instance_vars_got_channels(self): + """Got channels from bot.""" + await self.cog._get_instance_vars() + self.bot.get_channel.called_once_with(Channels.mod_alerts) + self.bot.get_channel.called_once_with(Channels.mod_log) + + @mock.patch("bot.exts.moderation.silence.SilenceNotifier") + async def test_instance_vars_got_notifier(self, notifier): + """Notifier was started with channel.""" + mod_log = MockTextChannel() + self.bot.get_channel.side_effect = (None, mod_log) + await self.cog._get_instance_vars() + notifier.assert_called_once_with(mod_log) + self.bot.get_channel.side_effect = None + + async def test_silence_sent_correct_discord_message(self): + """Check if proper message was sent when called with duration in channel with previous state.""" + test_cases = ( + (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), + (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), + (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), + ) + for duration, result_message, _silence_patch_return in test_cases: + with self.subTest( + silence_duration=duration, + result_message=result_message, + starting_unsilenced_state=_silence_patch_return + ): + with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): + await self.cog.silence.callback(self.cog, self.ctx, duration) + self.ctx.send.assert_called_once_with(result_message) + self.ctx.reset_mock() + + async def test_unsilence_sent_correct_discord_message(self): + """Check if proper message was sent when unsilencing channel.""" + test_cases = ( + (True, f"{Emojis.check_mark} unsilenced current channel."), + (False, f"{Emojis.cross_mark} current channel was not silenced.") + ) + for _unsilence_patch_return, result_message in test_cases: + with self.subTest( + starting_silenced_state=_unsilence_patch_return, + result_message=result_message + ): + with mock.patch.object(self.cog, "_unsilence", return_value=_unsilence_patch_return): + await self.cog.unsilence.callback(self.cog, self.ctx) + self.ctx.send.assert_called_once_with(result_message) + self.ctx.reset_mock() + + async def test_silence_private_for_false(self): + """Permissions are not set and `False` is returned in an already silenced channel.""" + perm_overwrite = Mock(send_messages=False) + channel = Mock(overwrites_for=Mock(return_value=perm_overwrite)) + + self.assertFalse(await self.cog._silence(channel, True, None)) + channel.set_permissions.assert_not_called() + + async def test_silence_private_silenced_channel(self): + """Channel had `send_message` permissions revoked.""" + channel = MockTextChannel() + self.assertTrue(await self.cog._silence(channel, False, None)) + channel.set_permissions.assert_called_once() + self.assertFalse(channel.set_permissions.call_args.kwargs['send_messages']) + + async def test_silence_private_preserves_permissions(self): + """Previous permissions were preserved when channel was silenced.""" + channel = MockTextChannel() + # Set up mock channel permission state. + mock_permissions = PermissionOverwrite() + mock_permissions_dict = dict(mock_permissions) + channel.overwrites_for.return_value = mock_permissions + await self.cog._silence(channel, False, None) + new_permissions = channel.set_permissions.call_args.kwargs + # Remove 'send_messages' key because it got changed in the method. + del new_permissions['send_messages'] + del mock_permissions_dict['send_messages'] + self.assertDictEqual(mock_permissions_dict, new_permissions) + + async def test_silence_private_notifier(self): + """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" + channel = MockTextChannel() + with mock.patch.object(self.cog, "notifier", create=True): + with self.subTest(persistent=True): + await self.cog._silence(channel, True, None) + self.cog.notifier.add_channel.assert_called_once() + + with mock.patch.object(self.cog, "notifier", create=True): + with self.subTest(persistent=False): + await self.cog._silence(channel, False, None) + self.cog.notifier.add_channel.assert_not_called() + + async def test_silence_private_added_muted_channel(self): + """Channel was added to `muted_channels` on silence.""" + channel = MockTextChannel() + with mock.patch.object(self.cog, "muted_channels") as muted_channels: + await self.cog._silence(channel, False, None) + muted_channels.add.assert_called_once_with(channel) + + async def test_unsilence_private_for_false(self): + """Permissions are not set and `False` is returned in an unsilenced channel.""" + channel = Mock() + self.assertFalse(await self.cog._unsilence(channel)) + channel.set_permissions.assert_not_called() + + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_unsilenced_channel(self, _): + """Channel had `send_message` permissions restored""" + perm_overwrite = MagicMock(send_messages=False) + channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + self.assertTrue(await self.cog._unsilence(channel)) + channel.set_permissions.assert_called_once() + self.assertIsNone(channel.set_permissions.call_args.kwargs['send_messages']) + + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_removed_notifier(self, notifier): + """Channel was removed from `notifier` on unsilence.""" + perm_overwrite = MagicMock(send_messages=False) + channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + await self.cog._unsilence(channel) + notifier.remove_channel.assert_called_once_with(channel) + + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_removed_muted_channel(self, _): + """Channel was removed from `muted_channels` on unsilence.""" + perm_overwrite = MagicMock(send_messages=False) + channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + with mock.patch.object(self.cog, "muted_channels") as muted_channels: + await self.cog._unsilence(channel) + muted_channels.discard.assert_called_once_with(channel) + + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_preserves_permissions(self, _): + """Previous permissions were preserved when channel was unsilenced.""" + channel = MockTextChannel() + # Set up mock channel permission state. + mock_permissions = PermissionOverwrite(send_messages=False) + mock_permissions_dict = dict(mock_permissions) + channel.overwrites_for.return_value = mock_permissions + await self.cog._unsilence(channel) + new_permissions = channel.set_permissions.call_args.kwargs + # Remove 'send_messages' key because it got changed in the method. + del new_permissions['send_messages'] + del mock_permissions_dict['send_messages'] + self.assertDictEqual(mock_permissions_dict, new_permissions) + + @mock.patch("bot.exts.moderation.silence.asyncio") + @mock.patch.object(Silence, "_mod_alerts_channel", create=True) + def test_cog_unload_starts_task(self, alert_channel, asyncio_mock): + """Task for sending an alert was created with present `muted_channels`.""" + with mock.patch.object(self.cog, "muted_channels"): + self.cog.cog_unload() + alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") + asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) + + @mock.patch("bot.exts.moderation.silence.asyncio") + def test_cog_unload_skips_task_start(self, asyncio_mock): + """No task created with no channels.""" + self.cog.cog_unload() + asyncio_mock.create_task.assert_not_called() + + @mock.patch("bot.exts.moderation.silence.with_role_check") + @mock.patch("bot.exts.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) + def test_cog_check(self, role_check): + """Role check is called with `MODERATION_ROLES`""" + self.cog.cog_check(self.ctx) + role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) diff --git a/tests/bot/exts/moderation/test_slowmode.py b/tests/bot/exts/moderation/test_slowmode.py new file mode 100644 index 000000000..e90394ab9 --- /dev/null +++ b/tests/bot/exts/moderation/test_slowmode.py @@ -0,0 +1,111 @@ +import unittest +from unittest import mock + +from dateutil.relativedelta import relativedelta + +from bot.constants import Emojis +from bot.exts.moderation.slowmode import Slowmode +from tests.helpers import MockBot, MockContext, MockTextChannel + + +class SlowmodeTests(unittest.IsolatedAsyncioTestCase): + + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Slowmode(self.bot) + self.ctx = MockContext() + + async def test_get_slowmode_no_channel(self) -> None: + """Get slowmode without a given channel.""" + self.ctx.channel = MockTextChannel(name='python-general', slowmode_delay=5) + + await self.cog.get_slowmode(self.cog, self.ctx, None) + self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.") + + async def test_get_slowmode_with_channel(self) -> None: + """Get slowmode with a given channel.""" + text_channel = MockTextChannel(name='python-language', slowmode_delay=2) + + await self.cog.get_slowmode(self.cog, self.ctx, text_channel) + self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.') + + async def test_set_slowmode_no_channel(self) -> None: + """Set slowmode without a given channel.""" + test_cases = ( + ('helpers', 23, True, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'), + ('mods', 76526, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'), + ('admins', 97, True, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.') + ) + + for channel_name, seconds, edited, result_msg in test_cases: + with self.subTest( + channel_mention=channel_name, + seconds=seconds, + edited=edited, + result_msg=result_msg + ): + self.ctx.channel = MockTextChannel(name=channel_name) + + await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=seconds)) + + if edited: + self.ctx.channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) + else: + self.ctx.channel.edit.assert_not_called() + + self.ctx.send.assert_called_once_with(result_msg) + + self.ctx.reset_mock() + + async def test_set_slowmode_with_channel(self) -> None: + """Set slowmode with a given channel.""" + test_cases = ( + ('bot-commands', 12, True, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'), + ('mod-spam', 21, True, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'), + ('admin-spam', 4323598, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.') + ) + + for channel_name, seconds, edited, result_msg in test_cases: + with self.subTest( + channel_mention=channel_name, + seconds=seconds, + edited=edited, + result_msg=result_msg + ): + text_channel = MockTextChannel(name=channel_name) + + await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=seconds)) + + if edited: + text_channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) + else: + text_channel.edit.assert_not_called() + + self.ctx.send.assert_called_once_with(result_msg) + + self.ctx.reset_mock() + + async def test_reset_slowmode_no_channel(self) -> None: + """Reset slowmode without a given channel.""" + self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) + + await self.cog.reset_slowmode(self.cog, self.ctx, None) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.' + ) + + async def test_reset_slowmode_with_channel(self) -> None: + """Reset slowmode with a given channel.""" + text_channel = MockTextChannel(name='meta', slowmode_delay=1) + + await self.cog.reset_slowmode(self.cog, self.ctx, text_channel) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' + ) + + @mock.patch("bot.exts.moderation.slowmode.with_role_check") + @mock.patch("bot.exts.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3)) + def test_cog_check(self, role_check): + """Role check is called with `MODERATION_ROLES`""" + self.cog.cog_check(self.ctx) + role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) diff --git a/tests/bot/exts/test_cogs.py b/tests/bot/exts/test_cogs.py new file mode 100644 index 000000000..775c40722 --- /dev/null +++ b/tests/bot/exts/test_cogs.py @@ -0,0 +1,81 @@ +"""Test suite for general tests which apply to all cogs.""" + +import importlib +import pkgutil +import typing as t +import unittest +from collections import defaultdict +from types import ModuleType +from unittest import mock + +from discord.ext import commands + +from bot import exts + + +class CommandNameTests(unittest.TestCase): + """Tests for shadowing command names and aliases.""" + + @staticmethod + def walk_commands(cog: commands.Cog) -> t.Iterator[commands.Command]: + """An iterator that recursively walks through `cog`'s commands and subcommands.""" + # Can't use Bot.walk_commands() or Cog.get_commands() cause those are instance methods. + for command in cog.__cog_commands__: + if command.parent is None: + yield command + if isinstance(command, commands.GroupMixin): + # Annoyingly it returns duplicates for each alias so use a set to fix that + yield from set(command.walk_commands()) + + @staticmethod + def walk_modules() -> t.Iterator[ModuleType]: + """Yield imported modules from the bot.exts subpackage.""" + def on_error(name: str) -> t.NoReturn: + raise ImportError(name=name) # pragma: no cover + + # The mock prevents asyncio.get_event_loop() from being called. + with mock.patch("discord.ext.tasks.loop"): + prefix = f"{exts.__name__}." + for module in pkgutil.walk_packages(exts.__path__, prefix, onerror=on_error): + if not module.ispkg: + yield importlib.import_module(module.name) + + @staticmethod + def walk_cogs(module: ModuleType) -> t.Iterator[commands.Cog]: + """Yield all cogs defined in an extension.""" + for obj in module.__dict__.values(): + # Check if it's a class type cause otherwise issubclass() may raise a TypeError. + is_cog = isinstance(obj, type) and issubclass(obj, commands.Cog) + if is_cog and obj.__module__ == module.__name__: + yield obj + + @staticmethod + def get_qualified_names(command: commands.Command) -> t.List[str]: + """Return a list of all qualified names, including aliases, for the `command`.""" + names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases] + names.append(command.qualified_name) + + return names + + def get_all_commands(self) -> t.Iterator[commands.Command]: + """Yield all commands for all cogs in all extensions.""" + for module in self.walk_modules(): + for cog in self.walk_cogs(module): + for cmd in self.walk_commands(cog): + yield cmd + + def test_names_dont_shadow(self): + """Names and aliases of commands should be unique.""" + all_names = defaultdict(list) + for cmd in self.get_all_commands(): + func_name = f"{cmd.module}.{cmd.callback.__qualname__}" + + for name in self.get_qualified_names(cmd): + with self.subTest(cmd=func_name, name=name): + if name in all_names: # pragma: no cover + conflicts = ", ".join(all_names.get(name, "")) + self.fail( + f"Name '{name}' of the command {func_name} conflicts with {conflicts}." + ) + + all_names[name].append(func_name) diff --git a/tests/bot/exts/test_duck_pond.py b/tests/bot/exts/test_duck_pond.py new file mode 100644 index 000000000..f6d977482 --- /dev/null +++ b/tests/bot/exts/test_duck_pond.py @@ -0,0 +1,548 @@ +import asyncio +import logging +import typing +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +import discord + +from bot import constants +from bot.exts import duck_pond +from tests import base +from tests import helpers + +MODULE_PATH = "bot.exts.duck_pond" + + +class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): + """Tests for DuckPond functionality.""" + + @classmethod + def setUpClass(cls): + """Sets up the objects that only have to be initialized once.""" + cls.nonstaff_member = helpers.MockMember(name="Non-staffer") + + cls.staff_role = helpers.MockRole(name="Staff role", id=constants.STAFF_ROLES[0]) + cls.staff_member = helpers.MockMember(name="staffer", roles=[cls.staff_role]) + + cls.checkmark_emoji = "\N{White Heavy Check Mark}" + cls.thumbs_up_emoji = "\N{Thumbs Up Sign}" + cls.unicode_duck_emoji = "\N{Duck}" + cls.duck_pond_emoji = helpers.MockPartialEmoji(id=constants.DuckPond.custom_emojis[0]) + cls.non_duck_custom_emoji = helpers.MockPartialEmoji(id=123) + + def setUp(self): + """Sets up the objects that need to be refreshed before each test.""" + self.bot = helpers.MockBot(user=helpers.MockMember(id=46692)) + self.cog = duck_pond.DuckPond(bot=self.bot) + + def test_duck_pond_correctly_initializes(self): + """`__init__ should set `bot` and `webhook_id` attributes and schedule `fetch_webhook`.""" + bot = helpers.MockBot() + cog = MagicMock() + + duck_pond.DuckPond.__init__(cog, bot) + + self.assertEqual(cog.bot, bot) + self.assertEqual(cog.webhook_id, constants.Webhooks.duck_pond) + bot.loop.create_task.assert_called_once_with(cog.fetch_webhook()) + + def test_fetch_webhook_succeeds_without_connectivity_issues(self): + """The `fetch_webhook` method waits until `READY` event and sets the `webhook` attribute.""" + self.bot.fetch_webhook.return_value = "dummy webhook" + self.cog.webhook_id = 1 + + asyncio.run(self.cog.fetch_webhook()) + + self.bot.wait_until_guild_available.assert_called_once() + self.bot.fetch_webhook.assert_called_once_with(1) + self.assertEqual(self.cog.webhook, "dummy webhook") + + def test_fetch_webhook_logs_when_unable_to_fetch_webhook(self): + """The `fetch_webhook` method should log an exception when it fails to fetch the webhook.""" + self.bot.fetch_webhook.side_effect = discord.HTTPException(response=MagicMock(), message="Not found.") + self.cog.webhook_id = 1 + + log = logging.getLogger('bot.exts.duck_pond') + with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: + asyncio.run(self.cog.fetch_webhook()) + + self.bot.wait_until_guild_available.assert_called_once() + self.bot.fetch_webhook.assert_called_once_with(1) + + self.assertEqual(len(log_watcher.records), 1) + + record = log_watcher.records[0] + self.assertEqual(record.levelno, logging.ERROR) + + def test_is_staff_returns_correct_values_based_on_instance_passed(self): + """The `is_staff` method should return correct values based on the instance passed.""" + test_cases = ( + (helpers.MockUser(name="User instance"), False), + (helpers.MockMember(name="Member instance without staff role"), False), + (helpers.MockMember(name="Member instance with staff role", roles=[self.staff_role]), True) + ) + + for user, expected_return in test_cases: + actual_return = self.cog.is_staff(user) + with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return): + self.assertEqual(expected_return, actual_return) + + async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self): + """The `has_green_checkmark` method should only return `True` if one is present.""" + test_cases = ( + ( + "No reactions", helpers.MockMessage(), False + ), + ( + "No green check mark reactions", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), + helpers.MockReaction(emoji=self.thumbs_up_emoji, users=[self.bot.user]) + ]), + False + ), + ( + "Green check mark reaction, but not from our bot", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), + helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member]) + ]), + False + ), + ( + "Green check mark reaction, with one from the bot", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), + helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user]) + ]), + True + ) + ) + + for description, message, expected_return in test_cases: + actual_return = await self.cog.has_green_checkmark(message) + with self.subTest( + test_case=description, + expected_return=expected_return, + actual_return=actual_return + ): + self.assertEqual(expected_return, actual_return) + + def _get_reaction( + self, + emoji: typing.Union[str, helpers.MockEmoji], + staff: int = 0, + nonstaff: int = 0 + ) -> helpers.MockReaction: + staffers = [helpers.MockMember(roles=[self.staff_role]) for _ in range(staff)] + nonstaffers = [helpers.MockMember() for _ in range(nonstaff)] + return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers) + + async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self): + """The `count_ducks` method should return the number of unique staffers who gave a duck.""" + test_cases = ( + # Simple test cases + # A message without reactions should return 0 + ( + "No reactions", + helpers.MockMessage(), + 0 + ), + # A message with a non-duck reaction from a non-staffer should return 0 + ( + "Non-duck reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, nonstaff=1)]), + 0 + ), + # A message with a non-duck reaction from a staffer should return 0 + ( + "Non-duck reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.non_duck_custom_emoji, staff=1)]), + 0 + ), + # A message with a non-duck reaction from a non-staffer and staffer should return 0 + ( + "Non-duck reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, staff=1, nonstaff=1)]), + 0 + ), + # A message with a unicode duck reaction from a non-staffer should return 0 + ( + "Unicode Duck Reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, nonstaff=1)]), + 0 + ), + # A message with a unicode duck reaction from a staffer should return 1 + ( + "Unicode Duck Reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1)]), + 1 + ), + # A message with a unicode duck reaction from a non-staffer and staffer should return 1 + ( + "Unicode Duck Reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1, nonstaff=1)]), + 1 + ), + # A message with a duckpond duck reaction from a non-staffer should return 0 + ( + "Duckpond Duck Reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, nonstaff=1)]), + 0 + ), + # A message with a duckpond duck reaction from a staffer should return 1 + ( + "Duckpond Duck Reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1)]), + 1 + ), + # A message with a duckpond duck reaction from a non-staffer and staffer should return 1 + ( + "Duckpond Duck Reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1, nonstaff=1)]), + 1 + ), + + # Complex test cases + # A message with duckpond duck reactions from 3 staffers and 2 non-staffers returns 3 + ( + "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2)]), + 3 + ), + # A staffer with multiple duck reactions only counts once + ( + "Two different duck reactions from the same staffer", + helpers.MockMessage( + reactions=[ + helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), + ] + ), + 1 + ), + # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif) + ( + "Reaction with non-Emoji/str emoij from 3 staffers + 2 non-staffers", + helpers.MockMessage(reactions=[self._get_reaction(emoji=100, staff=3, nonstaff=2)]), + 0 + ), + # We correctly sum when multiple reactions are provided. + ( + "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", + helpers.MockMessage( + reactions=[ + self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), + self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), + ] + ), + 3 + 4 + ), + ) + + for description, message, expected_count in test_cases: + actual_count = await self.cog.count_ducks(message) + with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count): + self.assertEqual(expected_count, actual_count) + + async def test_relay_message_correctly_relays_content_and_attachments(self): + """The `relay_message` method should correctly relay message content and attachments.""" + send_webhook_path = f"{MODULE_PATH}.send_webhook" + send_attachments_path = f"{MODULE_PATH}.send_attachments" + author = MagicMock( + display_name="x", + avatar_url="https://" + ) + + self.cog.webhook = helpers.MockAsyncWebhook() + + test_values = ( + (helpers.MockMessage(author=author, clean_content="", attachments=[]), False, False), + (helpers.MockMessage(author=author, clean_content="message", attachments=[]), True, False), + (helpers.MockMessage(author=author, clean_content="", attachments=["attachment"]), False, True), + (helpers.MockMessage(author=author, clean_content="message", attachments=["attachment"]), True, True), + ) + + for message, expect_webhook_call, expect_attachment_call in test_values: + with patch(send_webhook_path, new_callable=AsyncMock) as send_webhook: + with patch(send_attachments_path, new_callable=AsyncMock) as send_attachments: + with self.subTest(clean_content=message.clean_content, attachments=message.attachments): + await self.cog.relay_message(message) + + self.assertEqual(expect_webhook_call, send_webhook.called) + self.assertEqual(expect_attachment_call, send_attachments.called) + + message.add_reaction.assert_called_once_with(self.checkmark_emoji) + + @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) + async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): + """The `relay_message` method should handle irretrievable attachments.""" + message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) + side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) + + self.cog.webhook = helpers.MockAsyncWebhook() + log = logging.getLogger("bot.exts.duck_pond") + + for side_effect in side_effects: # pragma: no cover + send_attachments.side_effect = side_effect + with patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) as send_webhook: + with self.subTest(side_effect=type(side_effect).__name__): + with self.assertNotLogs(logger=log, level=logging.ERROR): + await self.cog.relay_message(message) + + self.assertEqual(send_webhook.call_count, 2) + + @patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) + @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) + async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): + """The `relay_message` method should handle irretrievable attachments.""" + message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) + + self.cog.webhook = helpers.MockAsyncWebhook() + log = logging.getLogger("bot.exts.duck_pond") + + side_effect = discord.HTTPException(MagicMock(), "") + send_attachments.side_effect = side_effect + with self.subTest(side_effect=type(side_effect).__name__): + with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: + await self.cog.relay_message(message) + + send_webhook.assert_called_once_with( + webhook=self.cog.webhook, + content=message.clean_content, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + + self.assertEqual(len(log_watcher.records), 1) + + record = log_watcher.records[0] + self.assertEqual(record.levelno, logging.ERROR) + + def _mock_payload(self, label: str, is_custom_emoji: bool, id_: int, emoji_name: str): + """Creates a mock `on_raw_reaction_add` payload with the specified emoji data.""" + payload = MagicMock(name=label) + payload.emoji.is_custom_emoji.return_value = is_custom_emoji + payload.emoji.id = id_ + payload.emoji.name = emoji_name + return payload + + async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self): + """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" + test_values = ( + # Custom Emojis + ( + self._mock_payload( + label="Custom Duckpond Emoji", + is_custom_emoji=True, + id_=constants.DuckPond.custom_emojis[0], + emoji_name="" + ), + True + ), + ( + self._mock_payload( + label="Custom Non-Duckpond Emoji", + is_custom_emoji=True, + id_=123, + emoji_name="" + ), + False + ), + # Unicode Emojis + ( + self._mock_payload( + label="Unicode Duck Emoji", + is_custom_emoji=False, + id_=1, + emoji_name=self.unicode_duck_emoji + ), + True + ), + ( + self._mock_payload( + label="Unicode Non-Duck Emoji", + is_custom_emoji=False, + id_=1, + emoji_name=self.thumbs_up_emoji + ), + False + ), + ) + + for payload, expected_return in test_values: + actual_return = self.cog._payload_has_duckpond_emoji(payload) + with self.subTest(case=payload._mock_name, expected_return=expected_return, actual_return=actual_return): + self.assertEqual(expected_return, actual_return) + + @patch(f"{MODULE_PATH}.discord.utils.get") + @patch(f"{MODULE_PATH}.DuckPond._payload_has_duckpond_emoji", new=MagicMock(return_value=False)) + def test_on_raw_reaction_add_returns_early_with_payload_without_duck_emoji(self, utils_get): + """The `on_raw_reaction_add` method should return early if the payload does not contain a duck emoji.""" + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload=MagicMock()))) + + # Ensure we've returned before making an unnecessary API call in the lines of code after the emoji check + utils_get.assert_not_called() + + def _raw_reaction_mocks(self, channel_id, message_id, user_id): + """Sets up mocks for tests of the `on_raw_reaction_add` event listener.""" + channel = helpers.MockTextChannel(id=channel_id) + self.bot.get_all_channels.return_value = (channel,) + + message = helpers.MockMessage(id=message_id) + + channel.fetch_message.return_value = message + + member = helpers.MockMember(id=user_id, roles=[self.staff_role]) + message.guild.members = (member,) + + payload = MagicMock(channel_id=channel_id, message_id=message_id, user_id=user_id) + + return channel, message, member, payload + + async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): + """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" + channel_id = 1234 + message_id = 2345 + user_id = 3456 + + channel, message, _, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) + + test_cases = ( + ("non-staff member", helpers.MockMember(id=user_id)), + ("bot staff member", helpers.MockMember(id=user_id, roles=[self.staff_role], bot=True)), + ) + + payload.emoji = self.duck_pond_emoji + + for description, member in test_cases: + message.guild.members = (member, ) + with self.subTest(test_case=description), patch(f"{MODULE_PATH}.DuckPond.has_green_checkmark") as checkmark: + checkmark.side_effect = AssertionError( + "Expected method to return before calling `self.has_green_checkmark`." + ) + self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) + + # Check that we did make it past the payload checks + channel.fetch_message.assert_called_once() + channel.fetch_message.reset_mock() + + @patch(f"{MODULE_PATH}.DuckPond.is_staff") + @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) + def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff): + """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot.""" + channel_id = 31415926535 + message_id = 27182818284 + user_id = 16180339887 + + channel, message, member, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) + + payload.emoji = helpers.MockPartialEmoji(name=self.unicode_duck_emoji) + payload.emoji.is_custom_emoji.return_value = False + + message.reactions = [helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.bot.user])] + + is_staff.return_value = True + count_ducks.side_effect = AssertionError("Expected method to return before calling `self.count_ducks`") + + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload))) + + # Assert that we've made it past `self.is_staff` + is_staff.assert_called_once() + + async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self): + """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" + test_cases = ( + (constants.DuckPond.threshold - 1, False), + (constants.DuckPond.threshold, True), + (constants.DuckPond.threshold + 1, True), + ) + + channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5) + + payload.emoji = self.duck_pond_emoji + + for duck_count, should_relay in test_cases: + with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=AsyncMock) as relay_message: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: + count_ducks.return_value = duck_count + with self.subTest(duck_count=duck_count, should_relay=should_relay): + await self.cog.on_raw_reaction_add(payload) + + # Confirm that we've made it past counting + count_ducks.assert_called_once() + + # Did we relay a message? + has_relayed = relay_message.called + self.assertEqual(has_relayed, should_relay) + + if should_relay: + relay_message.assert_called_once_with(message) + + async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): + """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks.""" + checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji) + + message = helpers.MockMessage(id=1234) + + channel = helpers.MockTextChannel(id=98765) + channel.fetch_message.return_value = message + + self.bot.get_all_channels.return_value = (channel, ) + + payload = MagicMock(channel_id=channel.id, message_id=message.id, emoji=checkmark) + + test_cases = ( + (constants.DuckPond.threshold - 1, False), + (constants.DuckPond.threshold, True), + (constants.DuckPond.threshold + 1, True), + ) + for duck_count, should_re_add_checkmark in test_cases: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: + count_ducks.return_value = duck_count + with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark): + await self.cog.on_raw_reaction_remove(payload) + + # Check if we fetched the message + channel.fetch_message.assert_called_once_with(message.id) + + # Check if we actually counted the number of ducks + count_ducks.assert_called_once_with(message) + + has_re_added_checkmark = message.add_reaction.called + self.assertEqual(should_re_add_checkmark, has_re_added_checkmark) + + if should_re_add_checkmark: + message.add_reaction.assert_called_once_with(self.checkmark_emoji) + message.add_reaction.reset_mock() + + # reset mocks + channel.fetch_message.reset_mock() + message.reset_mock() + + def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self): + """The `on_raw_reaction_remove` listener should ignore the removal of non-check mark emojis.""" + channel = helpers.MockTextChannel(id=98765) + + channel.fetch_message.side_effect = AssertionError( + "Expected method to return before calling `channel.fetch_message`" + ) + + self.bot.get_all_channels.return_value = (channel, ) + + payload = MagicMock(emoji=helpers.MockPartialEmoji(name=self.thumbs_up_emoji), channel_id=channel.id) + + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_remove(payload))) + + channel.fetch_message.assert_not_called() + + +class DuckPondSetupTests(unittest.TestCase): + """Tests setup of the `DuckPond` cog.""" + + def test_setup(self): + """Setup of the extension should call add_cog.""" + bot = helpers.MockBot() + duck_pond.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/utils/__init__.py b/tests/bot/exts/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py new file mode 100644 index 000000000..45e7b5b51 --- /dev/null +++ b/tests/bot/exts/utils/test_jams.py @@ -0,0 +1,173 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, create_autospec + +from discord import CategoryChannel + +from bot.constants import Roles +from bot.exts.utils import jams +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel + + +def get_mock_category(channel_count: int, name: str) -> CategoryChannel: + """Return a mocked code jam category.""" + category = create_autospec(CategoryChannel, spec_set=True, instance=True) + category.name = name + category.channels = [MockTextChannel() for _ in range(channel_count)] + + return category + + +class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): + """Tests for `createteam` command.""" + + def setUp(self): + self.bot = MockBot() + self.admin_role = MockRole(name="Admins", id=Roles.admins) + self.command_user = MockMember([self.admin_role]) + self.guild = MockGuild([self.admin_role]) + self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) + self.cog = jams.CodeJams(self.bot) + + async def test_too_small_amount_of_team_members_passed(self): + """Should `ctx.send` and exit early when too small amount of members.""" + for case in (1, 2): + with self.subTest(amount_of_members=case): + self.cog.create_channels = AsyncMock() + self.cog.add_roles = AsyncMock() + + self.ctx.reset_mock() + members = (MockMember() for _ in range(case)) + await self.cog.createteam(self.cog, self.ctx, "foo", members) + + self.ctx.send.assert_awaited_once() + self.cog.create_channels.assert_not_awaited() + self.cog.add_roles.assert_not_awaited() + + async def test_duplicate_members_provided(self): + """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" + self.cog.create_channels = AsyncMock() + self.cog.add_roles = AsyncMock() + + member = MockMember() + await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) + + self.ctx.send.assert_awaited_once() + self.cog.create_channels.assert_not_awaited() + self.cog.add_roles.assert_not_awaited() + + async def test_result_sending(self): + """Should call `ctx.send` when everything goes right.""" + self.cog.create_channels = AsyncMock() + self.cog.add_roles = AsyncMock() + + members = [MockMember() for _ in range(5)] + await self.cog.createteam(self.cog, self.ctx, "foo", members) + + self.cog.create_channels.assert_awaited_once() + self.cog.add_roles.assert_awaited_once() + self.ctx.send.assert_awaited_once() + + async def test_category_doesnt_exist(self): + """Should create a new code jam category.""" + subtests = ( + [], + [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], + [get_mock_category(jams.MAX_CHANNELS - 2, "other")], + ) + + for categories in subtests: + self.guild.reset_mock() + self.guild.categories = categories + + with self.subTest(categories=categories): + actual_category = await self.cog.get_category(self.guild) + + self.guild.create_category_channel.assert_awaited_once() + category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] + + self.assertFalse(category_overwrites[self.guild.default_role].read_messages) + self.assertTrue(category_overwrites[self.guild.me].read_messages) + self.assertEqual(self.guild.create_category_channel.return_value, actual_category) + + async def test_category_channel_exist(self): + """Should not try to create category channel.""" + expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME) + self.guild.categories = [ + get_mock_category(jams.MAX_CHANNELS - 2, "other"), + expected_category, + get_mock_category(0, jams.CATEGORY_NAME), + ] + + actual_category = await self.cog.get_category(self.guild) + self.assertEqual(expected_category, actual_category) + + async def test_channel_overwrites(self): + """Should have correct permission overwrites for users and roles.""" + leader = MockMember() + members = [leader] + [MockMember() for _ in range(4)] + overwrites = self.cog.get_overwrites(members, self.guild) + + # Leader permission overwrites + self.assertTrue(overwrites[leader].manage_messages) + self.assertTrue(overwrites[leader].read_messages) + self.assertTrue(overwrites[leader].manage_webhooks) + self.assertTrue(overwrites[leader].connect) + + # Other members permission overwrites + for member in members[1:]: + self.assertTrue(overwrites[member].read_messages) + self.assertTrue(overwrites[member].connect) + + # Everyone and verified role overwrite + self.assertFalse(overwrites[self.guild.default_role].read_messages) + self.assertFalse(overwrites[self.guild.default_role].connect) + self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].read_messages) + self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].connect) + + async def test_team_channels_creation(self): + """Should create new voice and text channel for team.""" + members = [MockMember() for _ in range(5)] + + self.cog.get_overwrites = MagicMock() + self.cog.get_category = AsyncMock() + self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") + actual = await self.cog.create_channels(self.guild, "my-team", members) + + self.assertEqual("foobar-channel", actual) + self.cog.get_overwrites.assert_called_once_with(members, self.guild) + self.cog.get_category.assert_awaited_once_with(self.guild) + + self.guild.create_text_channel.assert_awaited_once_with( + "my-team", + overwrites=self.cog.get_overwrites.return_value, + category=self.cog.get_category.return_value + ) + self.guild.create_voice_channel.assert_awaited_once_with( + "My Team", + overwrites=self.cog.get_overwrites.return_value, + category=self.cog.get_category.return_value + ) + + async def test_jam_roles_adding(self): + """Should add team leader role to leader and jam role to every team member.""" + leader_role = MockRole(name="Team Leader") + jam_role = MockRole(name="Jammer") + self.guild.get_role.side_effect = [leader_role, jam_role] + + leader = MockMember() + members = [leader] + [MockMember() for _ in range(4)] + await self.cog.add_roles(self.guild, members) + + leader.add_roles.assert_any_await(leader_role) + for member in members: + member.add_roles.assert_any_await(jam_role) + + +class CodeJamSetup(unittest.TestCase): + """Test for `setup` function of `CodeJam` cog.""" + + def test_setup(self): + """Should call `bot.add_cog`.""" + bot = MockBot() + jams.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py new file mode 100644 index 000000000..f7b861035 --- /dev/null +++ b/tests/bot/exts/utils/test_snekbox.py @@ -0,0 +1,409 @@ +import asyncio +import logging +import unittest +from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch + +from discord.ext import commands + +from bot import constants +from bot.exts.utils import snekbox +from bot.exts.utils.snekbox import Snekbox +from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser + + +class SnekboxTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + """Add mocked bot and cog to the instance.""" + self.bot = MockBot() + self.cog = Snekbox(bot=self.bot) + + async def test_post_eval(self): + """Post the eval code to the URLs.snekbox_eval_api endpoint.""" + resp = MagicMock() + resp.json = AsyncMock(return_value="return") + + context_manager = MagicMock() + context_manager.__aenter__.return_value = resp + self.bot.http_session.post.return_value = context_manager + + self.assertEqual(await self.cog.post_eval("import random"), "return") + self.bot.http_session.post.assert_called_with( + constants.URLs.snekbox_eval_api, + json={"input": "import random"}, + raise_for_status=True + ) + resp.json.assert_awaited_once() + + async def test_upload_output_reject_too_long(self): + """Reject output longer than MAX_PASTE_LEN.""" + result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) + self.assertEqual(result, "too long to upload") + + async def test_upload_output(self): + """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" + key = "MarkDiamond" + resp = MagicMock() + resp.json = AsyncMock(return_value={"key": key}) + + context_manager = MagicMock() + context_manager.__aenter__.return_value = resp + self.bot.http_session.post.return_value = context_manager + + self.assertEqual( + await self.cog.upload_output("My awesome output"), + constants.URLs.paste_service.format(key=key) + ) + self.bot.http_session.post.assert_called_with( + constants.URLs.paste_service.format(key="documents"), + data="My awesome output", + raise_for_status=True + ) + + async def test_upload_output_gracefully_fallback_if_exception_during_request(self): + """Output upload gracefully fallback if the upload fail.""" + resp = MagicMock() + resp.json = AsyncMock(side_effect=Exception) + + context_manager = MagicMock() + context_manager.__aenter__.return_value = resp + self.bot.http_session.post.return_value = context_manager + + log = logging.getLogger("bot.exts.utils.snekbox") + with self.assertLogs(logger=log, level='ERROR'): + await self.cog.upload_output('My awesome output!') + + async def test_upload_output_gracefully_fallback_if_no_key_in_response(self): + """Output upload gracefully fallback if there is no key entry in the response body.""" + self.assertEqual((await self.cog.upload_output('My awesome output!')), None) + + def test_prepare_input(self): + cases = ( + ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'), + ('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'), + ('```\nprint("Hello world!")```', 'print("Hello world!")', 'multiline code block'), + ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'), + ) + for case, expected, testname in cases: + with self.subTest(msg=f'Extract code from {testname}.'): + self.assertEqual(self.cog.prepare_input(case), expected) + + def test_get_results_message(self): + """Return error and message according to the eval result.""" + cases = ( + ('ERROR', None, ('Your eval job has failed', 'ERROR')), + ('', 128 + snekbox.SIGKILL, ('Your eval job timed out or ran out of memory', '')), + ('', 255, ('Your eval job has failed', 'A fatal NsJail error occurred')) + ) + for stdout, returncode, expected in cases: + with self.subTest(stdout=stdout, returncode=returncode, expected=expected): + actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}) + self.assertEqual(actual, expected) + + @patch('bot.exts.utils.snekbox.Signals', side_effect=ValueError) + def test_get_results_message_invalid_signal(self, mock_signals: Mock): + self.assertEqual( + self.cog.get_results_message({'stdout': '', 'returncode': 127}), + ('Your eval job has completed with return code 127', '') + ) + + @patch('bot.exts.utils.snekbox.Signals') + def test_get_results_message_valid_signal(self, mock_signals: Mock): + mock_signals.return_value.name = 'SIGTEST' + self.assertEqual( + self.cog.get_results_message({'stdout': '', 'returncode': 127}), + ('Your eval job has completed with return code 127 (SIGTEST)', '') + ) + + def test_get_status_emoji(self): + """Return emoji according to the eval result.""" + cases = ( + (' ', -1, ':warning:'), + ('Hello world!', 0, ':white_check_mark:'), + ('Invalid beard size', -1, ':x:') + ) + for stdout, returncode, expected in cases: + with self.subTest(stdout=stdout, returncode=returncode, expected=expected): + actual = self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}) + self.assertEqual(actual, expected) + + async def test_format_output(self): + """Test output formatting.""" + self.cog.upload_output = AsyncMock(return_value='https://testificate.com/') + + too_many_lines = ( + '001 | v\n002 | e\n003 | r\n004 | y\n005 | l\n006 | o\n' + '007 | n\n008 | g\n009 | b\n010 | e\n011 | a\n... (truncated - too many lines)' + ) + too_long_too_many_lines = ( + "\n".join( + f"{i:03d} | {line}" for i, line in enumerate(['verylongbeard' * 10] * 15, 1) + )[:1000] + "\n... (truncated - too long, too many lines)" + ) + + cases = ( + ('', ('[No output]', None), 'No output'), + ('My awesome output', ('My awesome output', None), 'One line output'), + ('<@', ("<@\u200B", None), r'Convert <@ to <@\u200B'), + ('