diff options
Diffstat (limited to '')
| -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: | 
