diff options
Diffstat (limited to 'tests')
-rw-r--r-- | tests/bot/cogs/test_duck_pond.py | 649 |
1 files changed, 490 insertions, 159 deletions
diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 088d8ac79..ceefc286f 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -1,193 +1,524 @@ import asyncio import logging +import typing import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch + +import discord from bot import constants from bot.cogs import duck_pond -from tests.helpers import MockBot, MockEmoji, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel +from tests import base +from tests import helpers + +MODULE_PATH = "bot.cogs.duck_pond" + + +class DuckPondTests(base.LoggingTestCase): + """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]) -class DuckPondTest(unittest.TestCase): - """Tests the `DuckPond` cog.""" + 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): - """Adds the cog, a bot, and the mocks we'll need for our tests.""" - self.bot = MockBot() + """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) - # 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] + 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_loop.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_ready.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_ready.assert_called_once() + self.bot.fetch_webhook.assert_called_once_with(1) + + self.assertEqual(len(log_watcher.records), 1) + + [record] = log_watcher.records + self.assertEqual(record.message, f"Failed to fetch webhook with id `{self.cog.webhook_id}`") + 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) ) - self.unicode_duck_reaction_2 = MockReaction( - emoji=self.unicode_duck_emoji, - user_list=[self.admin_member_2] + + 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) + + @helpers.async_test + 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), + helpers.MockReaction(emoji=self.thumbs_up_emoji) + ]), + False + ), + ( + "Green check mark reaction, but not from our bot", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji), + 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), + helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user]) + ]), + True + ) ) - self.bot_reaction = MockReaction( - emoji=self.yellow_ducky_emoji, - user_list=[self.bot_member] + + 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 test_send_webhook_correctly_passes_on_arguments(self): + """The `send_webhook` method should pass the arguments to the webhook correctly.""" + self.cog.webhook = helpers.MockAsyncWebhook() + + content = "fake content" + username = "fake username" + avatar_url = "fake avatar_url" + embed = "fake embed" + + asyncio.run(self.cog.send_webhook(content, username, avatar_url, embed)) + + self.cog.webhook.send.assert_called_once_with( + content=content, + username=username, + avatar_url=avatar_url, + embed=embed ) - self.contrib_reaction = MockReaction( - emoji=self.yellow_ducky_emoji, - user_list=[self.contrib_member] + + def test_send_webhook_logs_when_sending_message_fails(self): + """The `send_webhook` method should catch a `discord.HTTPException` and log accordingly.""" + self.cog.webhook = helpers.MockAsyncWebhook() + self.cog.webhook.send.side_effect = discord.HTTPException(response=MagicMock(), message="Something failed.") + + log = logging.getLogger('bot.cogs.duck_pond') + with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: + asyncio.run(self.cog.send_webhook()) + + self.assertEqual(len(log_watcher.records), 1) + + [record] = log_watcher.records + self.assertEqual(record.message, "Failed to send a message to the Duck Pool webhook") + self.assertEqual(record.levelno, logging.ERROR) + + 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) + + @helpers.async_test + 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 + ), ) - # 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) + 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) + + @helpers.async_test + async def test_relay_message_to_duck_pond_correctly_relays_content_and_attachments(self): + """The `relay_message_to_duck_pond` method should correctly relay message content and attachments.""" + send_webhook_path = f"{MODULE_PATH}.DuckPond.send_webhook" + send_attachments_path = f"{MODULE_PATH}.send_attachments" + + self.cog.webhook = helpers.MockAsyncWebhook() + + test_values = ( + (helpers.MockMessage(clean_content="", attachments=[]), False, False), + (helpers.MockMessage(clean_content="message", attachments=[]), True, False), + (helpers.MockMessage(clean_content="", attachments=["attachment"]), False, True), + (helpers.MockMessage(clean_content="message", attachments=["attachment"]), True, True), ) - self.double_mixed_duck_message = MockMessage( - reactions=(self.unicode_duck_reaction_1, self.yellow_ducky_reaction) + + for message, expect_webhook_call, expect_attachment_call in test_values: + with patch(send_webhook_path, new_callable=helpers.AsyncMock) as send_webhook: + with patch(send_attachments_path, new_callable=helpers.AsyncMock) as send_attachments: + with self.subTest(clean_content=message.clean_content, attachments=message.attachments): + await self.cog.relay_message_to_duck_pond(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) + message.reset_mock() + + @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) + @helpers.async_test + async def test_relay_message_to_duck_pond_handles_send_attachments_exceptions(self, send_attachments, send_webhook): + """The `relay_message_to_duck_pond` method should handle exceptions when calling `send_attachment`.""" + + 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") + + # Subtests for the first `except` block + for side_effect in side_effects: + send_attachments.side_effect = side_effect + with self.subTest(side_effect=type(side_effect).__name__): + with self.assertNotLogs(logger=log, level=logging.ERROR): + await self.cog.relay_message_to_duck_pond(message) + + self.assertEqual(send_webhook.call_count, 2) + send_webhook.reset_mock() + + # Subtests for the second `except` block + 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_to_duck_pond(message) + + send_webhook.assert_called_once_with( + 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 + self.assertEqual(record.message, "Failed to send an attachment to the webhook") + self.assertEqual(record.levelno, logging.ERROR) + + 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 + + @helpers.async_test + async def test_on_raw_reaction_add_returns_for_non_relevant_emojis(self): + """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" + payload_custom_emoji = MagicMock(label="Non-Duck Custom Emoji") + payload_custom_emoji.emoji.is_custom_emoji.return_value = True + payload_custom_emoji.emoji.id = 12345 + + payload_unicode_emoji = MagicMock(label="Non-Duck Unicode Emoji") + payload_unicode_emoji.emoji.is_custom_emoji.return_value = False + payload_unicode_emoji.emoji.name = self.thumbs_up_emoji + + for payload in (payload_custom_emoji, payload_unicode_emoji): + with self.subTest(case=payload.label), patch(f"{MODULE_PATH}.discord.utils.get") as discord_utils_get: + self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) + discord_utils_get.assert_not_called() + + @helpers.async_test + 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)), ) - 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, + 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=helpers.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() + + @patch(f"{MODULE_PATH}.DuckPond.relay_message_to_duck_pond", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) + @helpers.async_test + async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self, count_ducks, message_relay): + """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), ) - with self.subTest(): - asyncio.run(self.cog.on_raw_reaction_add(payload)) - self.bot.cog.send_webhook.assert_not_called() + 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: + 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() + count_ducks.reset_mock() + + # Did we relay a message? + has_relayed = message_relay.called + self.assertEqual(has_relayed, should_relay) + + if should_relay: + message_relay.assert_called_once_with(message) + message_relay.reset_mock() - 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,) + @helpers.async_test + 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) - payload = MagicMock( # RawReactionActionEvent - channel_id=self.CHANNEL_ID, - message_id=self.MESSAGE_ID, - user_id=self.CONTRIB_ID, + 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_readd_checkmark in test_cases: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) as count_ducks: + count_ducks.return_value = duck_count + with self.subTest(duck_count=duck_count, should_readd_checkmark=should_readd_checkmark): + await self.cog.on_raw_reaction_remove(payload) + + # Check if we fetched the message + channel.fetch_message.assert_called_once_with(message.id) - with self.subTest(): - asyncio.run(self.cog.on_raw_reaction_add(payload)) - self.bot.cog.send_webhook.assert_not_called() + # Check if we actually counted the number of ducks + count_ducks.assert_called_once_with(message) - 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,) + has_readded_checkmark = message.add_reaction.called + self.assertEqual(should_readd_checkmark, has_readded_checkmark) - payload = MagicMock( # RawReactionActionEvent - channel_id=self.CHANNEL_ID, - message_id=self.MESSAGE_ID, - user_id=self.ADMIN_ID, + if should_readd_checkmark: + message.add_reaction.assert_called_once_with(self.checkmark_emoji) + message.add_reaction.reset_mock() + + # reset mocks + channel.fetch_message.reset_mock() + count_ducks.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`" ) - with self.subTest(): - asyncio.run(self.cog.on_raw_reaction_add(payload)) - self.bot.cog.send_webhook.assert_called_once() + self.bot.get_all_channels.return_value = (channel, ) + + payload = MagicMock(emoji=helpers.MockPartialEmoji(name=self.thumbs_up_emoji), channel_id=channel.id) - def test_raw_reaction_remove_rejects_non_checkmarks(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_remove(payload))) - def test_raw_reaction_remove_prevents_checkmark_removal(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + channel.fetch_message.assert_not_called() class DuckPondSetupTests(unittest.TestCase): @@ -195,7 +526,7 @@ class DuckPondSetupTests(unittest.TestCase): def test_setup(self): """Setup of the cog should log a message at `INFO` level.""" - bot = MockBot() + bot = helpers.MockBot() log = logging.getLogger('bot.cogs.duck_pond') with self.assertLogs(logger=log, level=logging.INFO) as log_watcher: |