diff options
Diffstat (limited to 'tests')
-rw-r--r-- | tests/README.md | 1 | ||||
-rw-r--r-- | tests/bot/cogs/test_duck_pond.py | 206 | ||||
-rw-r--r-- | tests/helpers.py | 35 |
3 files changed, 241 insertions, 1 deletions
diff --git a/tests/README.md b/tests/README.md index 6ab9bc93e..d052de2f6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -15,6 +15,7 @@ We are using the following modules and packages for our unit tests: To ensure the results you obtain on your personal machine are comparable to those generated in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts: - `pipenv run test` will run `unittest` with `coverage.py` +- `pipenv run test path/to/test.py` will run a specific test. - `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. If you want a coverage report, make sure to run the tests with `pipenv run test` *first*. diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py new file mode 100644 index 000000000..088d8ac79 --- /dev/null +++ b/tests/bot/cogs/test_duck_pond.py @@ -0,0 +1,206 @@ +import asyncio +import logging +import unittest +from unittest.mock import MagicMock + +from bot import constants +from bot.cogs import duck_pond +from tests.helpers import MockBot, MockEmoji, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel + + +class DuckPondTest(unittest.TestCase): + """Tests the `DuckPond` cog.""" + + def setUp(self): + """Adds the cog, a bot, and the mocks we'll need for our tests.""" + self.bot = MockBot() + self.cog = duck_pond.DuckPond(bot=self.bot) + + # Set up some constants + self.CHANNEL_ID = 555 + self.MESSAGE_ID = 666 + self.BOT_ID = 777 + self.CONTRIB_ID = 888 + self.ADMIN_ID = 999 + + # Override the constants we'll be needing + constants.STAFF_ROLES = (123,) + constants.DuckPond.custom_emojis = (789,) + constants.DuckPond.threshold = 1 + + # Set up some roles + self.admin_role = MockRole(name="Admins", role_id=123) + self.contrib_role = MockRole(name="Contributor", role_id=456) + + # Set up some users + self.admin_member_1 = MockMember(roles=(self.admin_role,), id=self.ADMIN_ID) + self.admin_member_2 = MockMember(roles=(self.admin_role,), id=911) + self.contrib_member = MockMember(roles=(self.contrib_role,), id=self.CONTRIB_ID) + self.bot_member = MockMember(roles=(self.contrib_role,), id=self.BOT_ID, bot=True) + self.no_role_member = MockMember() + + # Set up emojis + self.checkmark_emoji = "✅" + self.thumbs_up_emoji = "👍" + self.unicode_duck_emoji = "🦆" + self.yellow_ducky_emoji = MockEmoji(id=789) + + # Set up reactions + self.checkmark_reaction = MockReaction( + emoji=self.checkmark_emoji, + user_list=[self.admin_member_1] + ) + self.thumbs_up_reaction = MockReaction( + emoji=self.thumbs_up_emoji, + user_list=[self.admin_member_1, self.contrib_member] + ) + self.yellow_ducky_reaction = MockReaction( + emoji=self.yellow_ducky_emoji, + user_list=[self.admin_member_1, self.contrib_member] + ) + self.unicode_duck_reaction_1 = MockReaction( + emoji=self.unicode_duck_emoji, + user_list=[self.admin_member_1] + ) + self.unicode_duck_reaction_2 = MockReaction( + emoji=self.unicode_duck_emoji, + user_list=[self.admin_member_2] + ) + self.bot_reaction = MockReaction( + emoji=self.yellow_ducky_emoji, + user_list=[self.bot_member] + ) + self.contrib_reaction = MockReaction( + emoji=self.yellow_ducky_emoji, + user_list=[self.contrib_member] + ) + + # Set up a messages + self.checkmark_message = MockMessage(reactions=(self.checkmark_reaction,)) + self.thumbs_up_message = MockMessage(reactions=(self.thumbs_up_reaction,)) + self.yellow_ducky_message = MockMessage(reactions=(self.yellow_ducky_reaction,)) + self.unicode_duck_message = MockMessage(reactions=(self.unicode_duck_reaction_1,)) + self.double_unicode_duck_message = MockMessage( + reactions=(self.unicode_duck_reaction_1, self.unicode_duck_reaction_2) + ) + self.double_mixed_duck_message = MockMessage( + reactions=(self.unicode_duck_reaction_1, self.yellow_ducky_reaction) + ) + + self.bot_message = MockMessage(reactions=(self.bot_reaction,)) + self.contrib_message = MockMessage(reactions=(self.contrib_reaction,)) + self.no_reaction_message = MockMessage() + + # Set up some channels + self.text_channel = MockTextChannel(id=self.CHANNEL_ID) + + @staticmethod + def _mock_send_webhook(content, username, avatar_url, embed): + """Mock for the send_webhook method in DuckPond""" + + def test_is_staff_correctly_identifies_staff(self): + """Test that is_staff correctly identifies a staff member.""" + with self.subTest(): + self.assertTrue(self.cog.is_staff(self.admin_member_1)) + self.assertFalse(self.cog.is_staff(self.contrib_member)) + self.assertFalse(self.cog.is_staff(self.no_role_member)) + + def test_has_green_checkmark_correctly_identifies_messages(self): + """Test that has_green_checkmark recognizes messages with checkmarks.""" + with self.subTest(): + self.assertTrue(self.cog.has_green_checkmark(self.checkmark_message)) + self.assertFalse(self.cog.has_green_checkmark(self.thumbs_up_message)) + self.assertFalse(self.cog.has_green_checkmark(self.no_reaction_message)) + + def test_count_custom_duck_emojis(self): + """Test that count_ducks counts custom ducks correctly.""" + count_no_ducks = self.cog.count_ducks(self.thumbs_up_message) + count_one_duck = self.cog.count_ducks(self.yellow_ducky_message) + with self.subTest(): + self.assertEqual(asyncio.run(count_no_ducks), 0) + self.assertEqual(asyncio.run(count_one_duck), 1) + + def test_count_unicode_duck_emojis(self): + """Test that count_ducks counts unicode ducks correctly.""" + count_one_duck = self.cog.count_ducks(self.unicode_duck_message) + count_two_ducks = self.cog.count_ducks(self.double_unicode_duck_message) + + with self.subTest(): + self.assertEqual(asyncio.run(count_one_duck), 1) + self.assertEqual(asyncio.run(count_two_ducks), 2) + + def test_count_mixed_duck_emojis(self): + """Test that count_ducks counts mixed ducks correctly.""" + count_two_ducks = self.cog.count_ducks(self.double_mixed_duck_message) + + with self.subTest(): + self.assertEqual(asyncio.run(count_two_ducks), 2) + + def test_raw_reaction_add_rejects_bot(self): + """Test that send_webhook is not called if the user is a bot.""" + self.text_channel.fetch_message.return_value = self.bot_message + self.bot.get_all_channels.return_value = (self.text_channel,) + + payload = MagicMock( # RawReactionActionEvent + channel_id=self.CHANNEL_ID, + message_id=self.MESSAGE_ID, + user_id=self.BOT_ID, + ) + + with self.subTest(): + asyncio.run(self.cog.on_raw_reaction_add(payload)) + self.bot.cog.send_webhook.assert_not_called() + + def test_raw_reaction_add_rejects_non_staff(self): + """Test that send_webhook is not called if the user is not a member of staff.""" + self.text_channel.fetch_message.return_value = self.contrib_message + self.bot.get_all_channels.return_value = (self.text_channel,) + + payload = MagicMock( # RawReactionActionEvent + channel_id=self.CHANNEL_ID, + message_id=self.MESSAGE_ID, + user_id=self.CONTRIB_ID, + ) + + with self.subTest(): + asyncio.run(self.cog.on_raw_reaction_add(payload)) + self.bot.cog.send_webhook.assert_not_called() + + def test_raw_reaction_add_sends_message_on_valid_input(self): + """Test that send_webhook is called if payload is valid.""" + self.text_channel.fetch_message.return_value = self.unicode_duck_message + self.bot.get_all_channels.return_value = (self.text_channel,) + + payload = MagicMock( # RawReactionActionEvent + channel_id=self.CHANNEL_ID, + message_id=self.MESSAGE_ID, + user_id=self.ADMIN_ID, + ) + + with self.subTest(): + asyncio.run(self.cog.on_raw_reaction_add(payload)) + self.bot.cog.send_webhook.assert_called_once() + + def test_raw_reaction_remove_rejects_non_checkmarks(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_raw_reaction_remove_prevents_checkmark_removal(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + +class DuckPondSetupTests(unittest.TestCase): + """Tests setup of the `DuckPond` cog.""" + + def test_setup(self): + """Setup of the cog should log a message at `INFO` level.""" + bot = MockBot() + log = logging.getLogger('bot.cogs.duck_pond') + + with self.assertLogs(logger=log, level=logging.INFO) as log_watcher: + duck_pond.setup(bot) + line = log_watcher.output[0] + + bot.add_cog.assert_called_once() + self.assertIn("Cog loaded: DuckPond", line) diff --git a/tests/helpers.py b/tests/helpers.py index 8a14aeef4..22f07934f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -120,8 +120,30 @@ class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): Python 3.8 will introduce an AsyncMock class in the standard library that will have some more features; this stand-in only overwrites the `__call__` method to an async version. """ + async def __call__(self, *args, **kwargs): - return super(AsyncMock, self).__call__(*args, **kwargs) + return super().__call__(*args, **kwargs) + + +class AsyncIteratorMock: + """ + A class to mock asyncronous iterators. + + This allows async for, which is used in certain Discord.py objects. For example, + an async iterator is returned by the Reaction.users() coroutine. + """ + + def __init__(self, sequence): + self.iter = iter(sequence) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.iter) + except StopIteration: + raise StopAsyncIteration # Create a guild instance to get a realistic Mock of `discord.Guild` @@ -244,6 +266,7 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec_set=bot_instance, **kwargs) @@ -281,6 +304,7 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): Instances of this class will follow the specifications of `discord.TextChannel` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()} super().__init__(spec_set=channel_instance, **collections.ChainMap(kwargs, default_kwargs)) @@ -322,6 +346,7 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.ext.commands.Context` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec_set=context_instance, **kwargs) self.bot = kwargs.get('bot', MockBot()) @@ -337,6 +362,7 @@ class MockMessage(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Message` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec_set=message_instance, **kwargs) self.author = kwargs.get('author', MockMember()) @@ -354,6 +380,7 @@ class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Emoji` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec_set=emoji_instance, **kwargs) self.guild = kwargs.get('guild', MockGuild()) @@ -369,6 +396,7 @@ class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.PartialEmoji` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec_set=partial_emoji_instance, **kwargs) @@ -383,7 +411,12 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Reaction` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec_set=reaction_instance, **kwargs) self.emoji = kwargs.get('emoji', MockEmoji()) self.message = kwargs.get('message', MockMessage()) + self.user_list = AsyncIteratorMock(kwargs.get('user_list', [])) + + def users(self): + return self.user_list |