diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/bot/cogs/test_information.py | 448 | ||||
| -rw-r--r-- | tests/bot/cogs/test_token_remover.py | 2 | ||||
| -rw-r--r-- | tests/bot/rules/test_links.py | 101 | ||||
| -rw-r--r-- | tests/bot/test_api.py | 4 | ||||
| -rw-r--r-- | tests/bot/test_utils.py | 52 | ||||
| -rw-r--r-- | tests/bot/utils/test_checks.py | 6 | ||||
| -rw-r--r-- | tests/helpers.py | 391 | ||||
| -rw-r--r-- | tests/test_helpers.py | 163 | 
8 files changed, 870 insertions, 297 deletions
| diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 9bbd35a91..4496a2ae0 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -7,7 +7,11 @@ import discord  from bot import constants  from bot.cogs import information -from tests.helpers import AsyncMock, MockBot, MockContext, MockGuild, MockMember, MockRole +from bot.decorators import InChannelCheckFailure +from tests import helpers + + +COG_PATH = "bot.cogs.information.Information"  class InformationCogTests(unittest.TestCase): @@ -15,22 +19,22 @@ class InformationCogTests(unittest.TestCase):      @classmethod      def setUpClass(cls): -        cls.moderator_role = MockRole(name="Moderator", role_id=constants.Roles.moderator) +        cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderator)      def setUp(self):          """Sets up fresh objects for each test.""" -        self.bot = MockBot() +        self.bot = helpers.MockBot()          self.cog = information.Information(self.bot) -        self.ctx = MockContext() +        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 = AsyncMock() +        self.cog.roles_info.can_run = helpers.AsyncMock()          self.cog.roles_info.can_run.return_value = True          coroutine = self.cog.roles_info.callback(self.cog, self.ctx) @@ -48,18 +52,18 @@ class InformationCogTests(unittest.TestCase):      def test_role_info_command(self):          """Tests the `role info` command.""" -        dummy_role = MockRole( +        dummy_role = helpers.MockRole(              name="Dummy", -            role_id=112233445566778899, +            id=112233445566778899,              colour=discord.Colour.blurple(),              position=10,              members=[self.ctx.author],              permissions=discord.Permissions(0)          ) -        admin_role = MockRole( +        admin_role = helpers.MockRole(              name="Admins", -            role_id=998877665544332211, +            id=998877665544332211,              colour=discord.Colour.red(),              position=3,              members=[self.ctx.author], @@ -68,7 +72,7 @@ class InformationCogTests(unittest.TestCase):          self.ctx.guild.roles.append([dummy_role, admin_role]) -        self.cog.role_info.can_run = AsyncMock() +        self.cog.role_info.can_run = helpers.AsyncMock()          self.cog.role_info.can_run.return_value = True          coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) @@ -99,7 +103,7 @@ class InformationCogTests(unittest.TestCase):      def test_server_info_command(self, time_since_patch):          time_since_patch.return_value = '2 days ago' -        self.ctx.guild = MockGuild( +        self.ctx.guild = helpers.MockGuild(              features=('lemons', 'apples'),              region="The Moon",              roles=[self.moderator_role], @@ -121,10 +125,10 @@ class InformationCogTests(unittest.TestCase):                  )              ],              members=[ -                *(MockMember(status='online') for _ in range(2)), -                *(MockMember(status='idle') for _ in range(1)), -                *(MockMember(status='dnd') for _ in range(4)), -                *(MockMember(status='offline') for _ in range(3)), +                *(helpers.MockMember(status='online') for _ in range(2)), +                *(helpers.MockMember(status='idle') for _ in range(1)), +                *(helpers.MockMember(status='dnd') for _ in range(4)), +                *(helpers.MockMember(status='offline') for _ in range(3)),              ],              member_count=1_234,              icon_url='a-lemon.jpg', @@ -162,3 +166,417 @@ class InformationCogTests(unittest.TestCase):              )          )          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 = helpers.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) + + [email protected]("bot.cogs.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) [email protected]("bot.cogs.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 = helpers.AsyncMock() +        self.cog = information.Information(self.bot) + +    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.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=helpers.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=helpers.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=helpers.AsyncMock) +    @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=helpers.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=helpers.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=helpers.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=helpers.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=helpers.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(format="png") +        self.assertEqual(embed.thumbnail.url, "avatar url") + + [email protected]("bot.cogs.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 = 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(InChannelCheckFailure, msg=msg): +            asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + +    @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) +    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 = 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.information.Information.create_user_embed", new_callable=helpers.AsyncMock) +    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 = 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.information.Information.create_user_embed", new_callable=helpers.AsyncMock) +    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 = 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.information.Information.create_user_embed", new_callable=helpers.AsyncMock) +    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/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index dfb1bafc9..3276cf5a5 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -24,7 +24,7 @@ class TokenRemoverTests(unittest.TestCase):          self.bot.get_cog.return_value.send_log_message = AsyncMock()          self.cog = TokenRemover(bot=self.bot) -        self.msg = MockMessage(message_id=555, content='') +        self.msg = MockMessage(id=555, content='')          self.msg.author.__str__ = MagicMock()          self.msg.author.__str__.return_value = 'lemon'          self.msg.author.bot = False diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py new file mode 100644 index 000000000..be832843b --- /dev/null +++ b/tests/bot/rules/test_links.py @@ -0,0 +1,101 @@ +import unittest +from typing import List, NamedTuple, Tuple + +from bot.rules import links +from tests.helpers import async_test + + +class FakeMessage(NamedTuple): +    author: str +    content: str + + +class Case(NamedTuple): +    recent_messages: List[FakeMessage] +    relevant_messages: Tuple[FakeMessage] +    culprit: Tuple[str] +    total_links: int + + +def msg(author: str, total_links: int) -> FakeMessage: +    """Makes a message with *total_links* links.""" +    content = " ".join(["https://pydis.com"] * total_links) +    return FakeMessage(author=author, content=content) + + +class LinksTests(unittest.TestCase): +    """Tests applying the `links` rule.""" + +    def setUp(self): +        self.config = { +            "max": 2, +            "interval": 10 +        } + +    @async_test +    async def test_links_within_limit(self): +        """Messages with an allowed amount of links.""" +        cases = ( +            [msg("bob", 0)], +            [msg("bob", 2)], +            [msg("bob", 3)],  # Filter only applies if len(messages_with_links) > 1 +            [msg("bob", 1), msg("bob", 1)], +            [msg("bob", 2), msg("alice", 2)]  # Only messages from latest author count +        ) + +        for recent_messages in cases: +            last_message = recent_messages[0] + +            with self.subTest( +                last_message=last_message, +                recent_messages=recent_messages, +                config=self.config +            ): +                self.assertIsNone( +                    await links.apply(last_message, recent_messages, self.config) +                ) + +    @async_test +    async def test_links_exceeding_limit(self): +        """Messages with a a higher than allowed amount of links.""" +        cases = ( +            Case( +                [msg("bob", 1), msg("bob", 2)], +                (msg("bob", 1), msg("bob", 2)), +                ("bob",), +                3 +            ), +            Case( +                [msg("alice", 1), msg("alice", 1), msg("alice", 1)], +                (msg("alice", 1), msg("alice", 1), msg("alice", 1)), +                ("alice",), +                3 +            ), +            Case( +                [msg("alice", 2), msg("bob", 3), msg("alice", 1)], +                (msg("alice", 2), msg("alice", 1)), +                ("alice",), +                3 +            ) +        ) + +        for recent_messages, relevant_messages, culprit, total_links in cases: +            last_message = recent_messages[0] + +            with self.subTest( +                last_message=last_message, +                recent_messages=recent_messages, +                relevant_messages=relevant_messages, +                culprit=culprit, +                total_links=total_links, +                config=self.config +            ): +                desired_output = ( +                    f"sent {total_links} links in {self.config['interval']}s", +                    culprit, +                    relevant_messages +                ) +                self.assertTupleEqual( +                    await links.apply(last_message, recent_messages, self.config), +                    desired_output +                ) diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py index e0ede0eb1..5a88adc5c 100644 --- a/tests/bot/test_api.py +++ b/tests/bot/test_api.py @@ -121,7 +121,9 @@ class LoggingHandlerTests(LoggingTestCase):      def test_schedule_queued_tasks_for_nonempty_queue(self):          """`APILoggingHandler` should schedule logs when the queue is not empty.""" -        with self.assertLogs(level=logging.DEBUG) as logs, patch('asyncio.create_task') as create_task: +        log = logging.getLogger("bot.api") + +        with self.assertLogs(logger=log, level=logging.DEBUG) as logs, patch('asyncio.create_task') as create_task:              self.log_handler.queue = [555]              self.log_handler.schedule_queued_tasks()              self.assertListEqual(self.log_handler.queue, []) diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py new file mode 100644 index 000000000..58ae2a81a --- /dev/null +++ b/tests/bot/test_utils.py @@ -0,0 +1,52 @@ +import unittest + +from bot import utils + + +class CaseInsensitiveDictTests(unittest.TestCase): +    """Tests for the `CaseInsensitiveDict` container.""" + +    def test_case_insensitive_key_access(self): +        """Tests case insensitive key access and storage.""" +        instance = utils.CaseInsensitiveDict() + +        key = 'LEMON' +        value = 'trees' + +        instance[key] = value +        self.assertIn(key, instance) +        self.assertEqual(instance.get(key), value) +        self.assertEqual(instance.get(key.casefold()), value) +        self.assertEqual(instance.pop(key.casefold()), value) +        self.assertNotIn(key, instance) +        self.assertNotIn(key.casefold(), instance) + +        instance.setdefault(key, value) +        del instance[key] +        self.assertNotIn(key, instance) + +    def test_initialization_from_kwargs(self): +        """Tests creating the dictionary from keyword arguments.""" +        instance = utils.CaseInsensitiveDict({'FOO': 'bar'}) +        self.assertEqual(instance['foo'], 'bar') + +    def test_update_from_other_mapping(self): +        """Tests updating the dictionary from another mapping.""" +        instance = utils.CaseInsensitiveDict() +        instance.update({'FOO': 'bar'}) +        self.assertEqual(instance['foo'], 'bar') + + +class ChunkTests(unittest.TestCase): +    """Tests the `chunk` method.""" + +    def test_empty_chunking(self): +        """Tests chunking on an empty iterable.""" +        generator = utils.chunks(iterable=[], size=5) +        self.assertEqual(list(generator), []) + +    def test_list_chunking(self): +        """Tests chunking a non-empty list.""" +        iterable = [1, 2, 3, 4, 5] +        generator = utils.chunks(iterable=iterable, size=2) +        self.assertEqual(list(generator), [[1, 2], [3, 4], [5]]) diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py index 19b758336..9610771e5 100644 --- a/tests/bot/utils/test_checks.py +++ b/tests/bot/utils/test_checks.py @@ -22,7 +22,7 @@ class ChecksTests(unittest.TestCase):      def test_with_role_check_with_guild_and_required_role(self):          """`with_role_check` returns `True` if `Context.author` has the required role.""" -        self.ctx.author.roles.append(MockRole(role_id=10)) +        self.ctx.author.roles.append(MockRole(id=10))          self.assertTrue(checks.with_role_check(self.ctx, 10))      def test_without_role_check_without_guild(self): @@ -33,13 +33,13 @@ class ChecksTests(unittest.TestCase):      def test_without_role_check_returns_false_with_unwanted_role(self):          """`without_role_check` returns `False` if `Context.author` has unwanted role."""          role_id = 42 -        self.ctx.author.roles.append(MockRole(role_id=role_id)) +        self.ctx.author.roles.append(MockRole(id=role_id))          self.assertFalse(checks.without_role_check(self.ctx, role_id))      def test_without_role_check_returns_true_without_unwanted_role(self):          """`without_role_check` returns `True` if `Context.author` does not have unwanted role."""          role_id = 42 -        self.ctx.author.roles.append(MockRole(role_id=role_id)) +        self.ctx.author.roles.append(MockRole(id=role_id))          self.assertTrue(checks.without_role_check(self.ctx, role_id + 10))      def test_in_channel_check_for_correct_channel(self): diff --git a/tests/helpers.py b/tests/helpers.py index 892d42e6c..8a14aeef4 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,14 +1,28 @@  from __future__ import annotations  import asyncio +import collections  import functools +import inspect +import itertools +import logging  import unittest.mock -from typing import Iterable, Optional +from typing import Any, Iterable, Optional  import discord  from discord.ext.commands import Bot, Context +for logger in logging.Logger.manager.loggerDict.values(): +    # Set all loggers to CRITICAL by default to prevent screen clutter during testing + +    if not isinstance(logger, logging.Logger): +        # There might be some logging.PlaceHolder objects in there +        continue + +    logger.setLevel(logging.CRITICAL) + +  def async_test(wrapped):      """      Run a test case via asyncio. @@ -24,19 +38,6 @@ def async_test(wrapped):      return wrapper -# TODO: Remove me in Python 3.8 -class AsyncMock(unittest.mock.MagicMock): -    """ -    A MagicMock subclass to mock async callables. - -    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) - -  class HashableMixin(discord.mixins.EqualityComparable):      """      Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. @@ -61,15 +62,66 @@ class ColourMixin:          self.colour = color -class AttributeMock: -    """Ensures attributes of our mock types will be instantiated with the correct mock type.""" +class CustomMockMixin: +    """ +    Provides common functionality for our custom Mock types. + +    The cooperative `__init__` automatically creates `AsyncMock` attributes for every coroutine +    function `inspect` detects in the `spec` instance we provide. In addition, this mixin takes care +    of making sure child mocks are instantiated with the correct class. By default, the mock of the +    children will be `unittest.mock.MagicMock`, but this can be overwritten by setting the attribute +    `child_mock_type` on the custom mock inheriting from this mixin. +    """ + +    child_mock_type = unittest.mock.MagicMock +    discord_id = itertools.count(0) + +    def __init__(self, spec_set: Any = None, **kwargs): +        name = kwargs.pop('name', None)  # `name` has special meaning for Mock classes, so we need to set it manually. +        super().__init__(spec_set=spec_set, **kwargs) + +        if name: +            self.name = name +        if spec_set: +            self._extract_coroutine_methods_from_spec_instance(spec_set) + +    def _get_child_mock(self, **kw): +        """ +        Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes. + +        Mock objects automatically create children when you access an attribute or call a method on them. By default, +        the class of these children is the type of the parent itself. However, this would mean that the children created +        for our custom mock types would also be instances of that custom mock type. This is not desirable, as attributes +        of, e.g., a `Bot` object are not `Bot` objects themselves. The Python docs for `unittest.mock` hint that +        overwriting this method is the best way to deal with that. + +        This override will look for an attribute called `child_mock_type` and use that as the type of the child mock. +        """ +        klass = self.child_mock_type + +        if self._mock_sealed: +            attribute = "." + kw["name"] if "name" in kw else "()" +            mock_name = self._extract_mock_name() + attribute +            raise AttributeError(mock_name) + +        return klass(**kw) -    def __new__(cls, *args, **kwargs): -        """Stops the regular parent class from propagating to newly mocked attributes.""" -        if 'parent' in kwargs: -            return cls.attribute_mocktype(*args, **kwargs) +    def _extract_coroutine_methods_from_spec_instance(self, source: Any) -> None: +        """Automatically detect coroutine functions in `source` and set them as AsyncMock attributes.""" +        for name, _method in inspect.getmembers(source, inspect.iscoroutinefunction): +            setattr(self, name, AsyncMock()) -        return super().__new__(cls) + +# TODO: Remove me in Python 3.8 +class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): +    """ +    A MagicMock subclass to mock async callables. + +    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)  # Create a guild instance to get a realistic Mock of `discord.Guild` @@ -95,7 +147,7 @@ guild_data = {  guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) -class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): +class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin):      """      A `Mock` subclass to mock `discord.Guild` objects. @@ -121,81 +173,33 @@ class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin):      For more info, see the `Mocking` section in `tests/README.md`.      """ +    def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: +        default_kwargs = {'id': next(self.discord_id), 'members': []} +        super().__init__(spec_set=guild_instance, **collections.ChainMap(kwargs, default_kwargs)) -    attribute_mocktype = unittest.mock.MagicMock - -    def __init__( -        self, -        guild_id: int = 1, -        roles: Optional[Iterable[MockRole]] = None, -        members: Optional[Iterable[MockMember]] = None, -        **kwargs, -    ) -> None: -        super().__init__(spec=guild_instance, **kwargs) - -        self.id = guild_id - -        self.roles = [MockRole("@everyone", 1)] +        self.roles = [MockRole(name="@everyone", position=1, id=0)]          if roles:              self.roles.extend(roles) -        self.members = [] -        if members: -            self.members.extend(members) - -        # `discord.Guild` coroutines -        self.create_category_channel = AsyncMock() -        self.ban = AsyncMock() -        self.bans = AsyncMock() -        self.create_category = AsyncMock() -        self.create_custom_emoji = AsyncMock() -        self.create_role = AsyncMock() -        self.create_text_channel = AsyncMock() -        self.create_voice_channel = AsyncMock() -        self.delete = AsyncMock() -        self.edit = AsyncMock() -        self.estimate_pruned_members = AsyncMock() -        self.fetch_ban = AsyncMock() -        self.fetch_channels = AsyncMock() -        self.fetch_emoji = AsyncMock() -        self.fetch_emojis = AsyncMock() -        self.fetch_member = AsyncMock() -        self.invites = AsyncMock() -        self.kick = AsyncMock() -        self.leave = AsyncMock() -        self.prune_members = AsyncMock() -        self.unban = AsyncMock() -        self.vanity_invite = AsyncMock() -        self.webhooks = AsyncMock() -        self.widget = AsyncMock() -  # Create a Role instance to get a realistic Mock of `discord.Role`  role_data = {'name': 'role', 'id': 1}  role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) -class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin):      """      A Mock subclass to mock `discord.Role` objects.      Instances of this class will follow the specifications of `discord.Role` instances. For more      information, see the `MockGuild` docstring.      """ +    def __init__(self, **kwargs) -> None: +        default_kwargs = {'id': next(self.discord_id), 'name': 'role', 'position': 1} +        super().__init__(spec_set=role_instance, **collections.ChainMap(kwargs, default_kwargs)) -    attribute_mocktype = unittest.mock.MagicMock - -    def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: -        super().__init__(spec=role_instance, **kwargs) - -        self.name = name -        self.id = role_id -        self.position = position -        self.mention = f'&{self.name}' - -        # 'discord.Role' coroutines -        self.delete = AsyncMock() -        self.edit = AsyncMock() +        if 'mention' not in kwargs: +            self.mention = f'&{self.name}'      def __lt__(self, other):          """Simplified position-based comparisons similar to those of `discord.Role`.""" @@ -208,126 +212,50 @@ state_mock = unittest.mock.MagicMock()  member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) -class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin):      """      A Mock subclass to mock Member objects.      Instances of this class will follow the specifications of `discord.Member` instances. For more      information, see the `MockGuild` docstring.      """ +    def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: +        default_kwargs = {'name': 'member', 'id': next(self.discord_id)} +        super().__init__(spec_set=member_instance, **collections.ChainMap(kwargs, default_kwargs)) -    attribute_mocktype = unittest.mock.MagicMock - -    def __init__( -        self, -        name: str = "member", -        user_id: int = 1, -        roles: Optional[Iterable[MockRole]] = None, -        **kwargs, -    ) -> None: -        super().__init__(spec=member_instance, **kwargs) - -        self.name = name -        self.id = user_id - -        self.roles = [MockRole("@everyone", 1)] +        self.roles = [MockRole(name="@everyone", position=1, id=0)]          if roles:              self.roles.extend(roles) -        self.mention = f"@{self.name}" - -        # `discord.Member` coroutines -        self.add_roles = AsyncMock() -        self.ban = AsyncMock() -        self.edit = AsyncMock() -        self.fetch_message = AsyncMock() -        self.kick = AsyncMock() -        self.move_to = AsyncMock() -        self.pins = AsyncMock() -        self.remove_roles = AsyncMock() -        self.send = AsyncMock() -        self.trigger_typing = AsyncMock() -        self.unban = AsyncMock() +        if 'mention' not in kwargs: +            self.mention = f"@{self.name}"  # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot`  bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) +bot_instance.http_session = None +bot_instance.api_client = None -class MockBot(AttributeMock, unittest.mock.MagicMock): +class MockBot(CustomMockMixin, unittest.mock.MagicMock):      """      A MagicMock subclass to mock Bot objects.      Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances.      For more information, see the `MockGuild` docstring.      """ - -    attribute_mocktype = unittest.mock.MagicMock -      def __init__(self, **kwargs) -> None: -        super().__init__(spec=bot_instance, **kwargs) - -        # `discord.ext.commands.Bot` coroutines -        self._before_invoke = AsyncMock() -        self._after_invoke = AsyncMock() -        self.application_info = AsyncMock() -        self.change_presence = AsyncMock() -        self.connect = AsyncMock() -        self.close = AsyncMock() -        self.create_guild = AsyncMock() -        self.delete_invite = AsyncMock() -        self.fetch_channel = AsyncMock() -        self.fetch_guild = AsyncMock() -        self.fetch_guilds = AsyncMock() -        self.fetch_invite = AsyncMock() -        self.fetch_user = AsyncMock() -        self.fetch_user_profile = AsyncMock() -        self.fetch_webhook = AsyncMock() -        self.fetch_widget = AsyncMock() -        self.get_context = AsyncMock() -        self.get_prefix = AsyncMock() -        self.invoke = AsyncMock() -        self.is_owner = AsyncMock() -        self.login = AsyncMock() -        self.logout = AsyncMock() -        self.on_command_error = AsyncMock() -        self.on_error = AsyncMock() -        self.process_commands = AsyncMock() -        self.request_offline_members = AsyncMock() -        self.start = AsyncMock() -        self.wait_until_ready = AsyncMock() -        self.wait_for = AsyncMock() - +        super().__init__(spec_set=bot_instance, **kwargs) -# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` -context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) - - -class MockContext(AttributeMock, unittest.mock.MagicMock): -    """ -    A MagicMock subclass to mock Context objects. - -    Instances of this class will follow the specifications of `discord.ext.commands.Context` -    instances. For more information, see the `MockGuild` docstring. -    """ +        # self.wait_for is *not* a coroutine function, but returns a coroutine nonetheless and +        # and should therefore be awaited. (The documentation calls it a coroutine as well, which +        # is technically incorrect, since it's a regular def.) +        self.wait_for = AsyncMock() -    attribute_mocktype = unittest.mock.MagicMock - -    def __init__(self, **kwargs) -> None: -        super().__init__(spec=context_instance, **kwargs) -        self.bot = MockBot() -        self.guild = MockGuild() -        self.author = MockMember() -        self.command = unittest.mock.MagicMock() - -        # `discord.ext.commands.Context` coroutines -        self.fetch_message = AsyncMock() -        self.invoke = AsyncMock() -        self.pins = AsyncMock() -        self.reinvoke = AsyncMock() -        self.send = AsyncMock() -        self.send_help = AsyncMock() -        self.trigger_typing = AsyncMock() +        # Since calling `create_task` on our MockBot does not actually schedule the coroutine object +        # as a task in the asyncio loop, this `side_effect` calls `close()` on the coroutine object +        # to prevent "has not been awaited"-warnings. +        self.loop.create_task.side_effect = lambda coroutine: coroutine.close()  # Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` @@ -346,38 +274,19 @@ guild = unittest.mock.MagicMock()  channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) -class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin): +class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):      """      A MagicMock subclass to mock TextChannel objects.      Instances of this class will follow the specifications of `discord.TextChannel` instances. For      more information, see the `MockGuild` docstring.      """ - -    attribute_mocktype = unittest.mock.MagicMock -      def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: -        super().__init__(spec=channel_instance, **kwargs) -        self.id = channel_id -        self.name = name -        self.guild = MockGuild() -        self.mention = f"#{self.name}" - -        # `discord.TextChannel` coroutines -        self.clone = AsyncMock() -        self.create_invite = AsyncMock() -        self.create_webhook = AsyncMock() -        self.delete = AsyncMock() -        self.delete_messages = AsyncMock() -        self.edit = AsyncMock() -        self.fetch_message = AsyncMock() -        self.invites = AsyncMock() -        self.pins = AsyncMock() -        self.purge = AsyncMock() -        self.send = AsyncMock() -        self.set_permissions = AsyncMock() -        self.trigger_typing = AsyncMock() -        self.webhooks = AsyncMock() +        default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()} +        super().__init__(spec_set=channel_instance, **collections.ChainMap(kwargs, default_kwargs)) + +        if 'mention' not in kwargs: +            self.mention = f"#{self.name}"  # Create a Message instance to get a realistic MagicMock of `discord.Message` @@ -402,27 +311,79 @@ channel = unittest.mock.MagicMock()  message_instance = discord.Message(state=state, channel=channel, data=message_data) -class MockMessage(AttributeMock, unittest.mock.MagicMock): +# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` +context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) + + +class MockContext(CustomMockMixin, unittest.mock.MagicMock): +    """ +    A MagicMock subclass to mock Context objects. + +    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()) +        self.guild = kwargs.get('guild', MockGuild()) +        self.author = kwargs.get('author', MockMember()) +        self.channel = kwargs.get('channel', MockTextChannel()) + + +class MockMessage(CustomMockMixin, unittest.mock.MagicMock):      """      A MagicMock subclass to mock Message objects.      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()) +        self.channel = kwargs.get('channel', MockTextChannel()) + + +emoji_data = {'require_colons': True, 'managed': True, 'id': 1, 'name': 'hyperlemon'} +emoji_instance = discord.Emoji(guild=MockGuild(), state=unittest.mock.MagicMock(), data=emoji_data) + + +class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): +    """ +    A MagicMock subclass to mock Emoji objects. + +    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()) + + +partial_emoji_instance = discord.PartialEmoji(animated=False, name='guido') + + +class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): +    """ +    A MagicMock subclass to mock PartialEmoji objects. + +    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) -    attribute_mocktype = unittest.mock.MagicMock +reaction_instance = discord.Reaction(message=MockMessage(), data={'me': True}, emoji=MockEmoji()) + + +class MockReaction(CustomMockMixin, unittest.mock.MagicMock): +    """ +    A MagicMock subclass to mock Reaction objects. + +    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=message_instance, **kwargs) -        self.author = MockMember() -        self.channel = MockTextChannel() - -        # `discord.Message` coroutines -        self.ack = AsyncMock() -        self.add_reaction = AsyncMock() -        self.clear_reactions = AsyncMock() -        self.delete = AsyncMock() -        self.edit = AsyncMock() -        self.pin = AsyncMock() -        self.remove_reaction = AsyncMock() -        self.unpin = AsyncMock() +        super().__init__(spec_set=reaction_instance, **kwargs) +        self.emoji = kwargs.get('emoji', MockEmoji()) +        self.message = kwargs.get('message', MockMessage()) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f08239981..7894e104a 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -19,7 +19,6 @@ class DiscordMocksTests(unittest.TestCase):          self.assertIsInstance(role, discord.Role)          self.assertEqual(role.name, "role") -        self.assertEqual(role.id, 1)          self.assertEqual(role.position, 1)          self.assertEqual(role.mention, "&role") @@ -27,7 +26,7 @@ class DiscordMocksTests(unittest.TestCase):          """Test if MockRole initializes with the arguments provided."""          role = helpers.MockRole(              name="Admins", -            role_id=90210, +            id=90210,              position=10,          ) @@ -67,22 +66,21 @@ class DiscordMocksTests(unittest.TestCase):          self.assertIsInstance(member, discord.Member)          self.assertEqual(member.name, "member") -        self.assertEqual(member.id, 1) -        self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1)]) +        self.assertListEqual(member.roles, [helpers.MockRole(name="@everyone", position=1, id=0)])          self.assertEqual(member.mention, "@member")      def test_mock_member_alternative_arguments(self):          """Test if MockMember initializes with the arguments provided.""" -        core_developer = helpers.MockRole("Core Developer", 2) +        core_developer = helpers.MockRole(name="Core Developer", position=2)          member = helpers.MockMember(              name="Mark", -            user_id=12345, +            id=12345,              roles=[core_developer]          )          self.assertEqual(member.name, "Mark")          self.assertEqual(member.id, 12345) -        self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1), core_developer]) +        self.assertListEqual(member.roles, [helpers.MockRole(name="@everyone", position=1, id=0), core_developer])          self.assertEqual(member.mention, "@Mark")      def test_mock_member_accepts_dynamic_arguments(self): @@ -102,19 +100,19 @@ class DiscordMocksTests(unittest.TestCase):          # The `spec` argument makes sure `isistance` checks with `discord.Guild` pass          self.assertIsInstance(guild, discord.Guild) -        self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1)]) +        self.assertListEqual(guild.roles, [helpers.MockRole(name="@everyone", position=1, id=0)])          self.assertListEqual(guild.members, [])      def test_mock_guild_alternative_arguments(self):          """Test if MockGuild initializes with the arguments provided.""" -        core_developer = helpers.MockRole("Core Developer", 2) +        core_developer = helpers.MockRole(name="Core Developer", position=2)          guild = helpers.MockGuild(              roles=[core_developer], -            members=[helpers.MockMember(user_id=54321)], +            members=[helpers.MockMember(id=54321)],          ) -        self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1), core_developer]) -        self.assertListEqual(guild.members, [helpers.MockMember(user_id=54321)]) +        self.assertListEqual(guild.roles, [helpers.MockRole(name="@everyone", position=1, id=0), core_developer]) +        self.assertListEqual(guild.members, [helpers.MockMember(id=54321)])      def test_mock_guild_accepts_dynamic_arguments(self):          """Test if MockGuild accepts and sets abitrary keyword arguments.""" @@ -191,51 +189,30 @@ class DiscordMocksTests(unittest.TestCase):                  with self.assertRaises(AttributeError):                      mock.the_cake_is_a_lie -    def test_custom_mock_methods_are_valid_discord_object_methods(self): -        """The `AsyncMock` attributes of the mocks should be valid for the class they're mocking.""" -        mocks = ( -            (helpers.MockGuild, helpers.guild_instance), -            (helpers.MockRole, helpers.role_instance), -            (helpers.MockMember, helpers.member_instance), -            (helpers.MockBot, helpers.bot_instance), -            (helpers.MockContext, helpers.context_instance), -            (helpers.MockTextChannel, helpers.channel_instance), -            (helpers.MockMessage, helpers.message_instance), +    def test_mocks_use_mention_when_provided_as_kwarg(self): +        """The mock should use the passed `mention` instead of the default one if present.""" +        test_cases = ( +            (helpers.MockRole, "role mention"), +            (helpers.MockMember, "member mention"), +            (helpers.MockTextChannel, "channel mention"),          ) -        for mock_class, instance in mocks: -            mock = mock_class() -            async_methods = ( -                attr for attr in dir(mock) if isinstance(getattr(mock, attr), helpers.AsyncMock) -            ) - -            # spec_mock = unittest.mock.MagicMock(spec=instance) -            for method in async_methods: -                with self.subTest(mock_class=mock_class, method=method): -                    try: -                        getattr(instance, method) -                    except AttributeError: -                        msg = f"method {method} is not a method attribute of {instance.__class__}" -                        self.fail(msg) - -    @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') -    def test_the_custom_mock_methods_test(self, subtest_mock): -        """The custom method test should raise AssertionError for invalid methods.""" -        class FakeMockBot(helpers.AttributeMock, unittest.mock.MagicMock): -            """Fake MockBot class with invalid attribute/method `release_the_walrus`.""" - -            attribute_mocktype = unittest.mock.MagicMock +        for mock_type, mention in test_cases: +            with self.subTest(mock_type=mock_type, mention=mention): +                mock = mock_type(mention=mention) +                self.assertEqual(mock.mention, mention) -            def __init__(self, **kwargs): -                super().__init__(spec=helpers.bot_instance, **kwargs) +    def test_create_test_on_mock_bot_closes_passed_coroutine(self): +        """`bot.loop.create_task` should close the passed coroutine object to prevent warnings.""" +        async def dementati(): +            """Dummy coroutine for testing purposes.""" -                # Fake attribute -                self.release_the_walrus = helpers.AsyncMock() +        coroutine_object = dementati() -        with unittest.mock.patch("tests.helpers.MockBot", new=FakeMockBot): -            msg = "method release_the_walrus is not a valid method of <class 'discord.ext.commands.bot.Bot'>" -            with self.assertRaises(AssertionError, msg=msg): -                self.test_custom_mock_methods_are_valid_discord_object_methods() +        bot = helpers.MockBot() +        bot.loop.create_task(coroutine_object) +        with self.assertRaises(RuntimeError, msg="cannot reuse already awaited coroutine"): +            asyncio.run(coroutine_object)  class MockObjectTests(unittest.TestCase): @@ -266,14 +243,14 @@ class MockObjectTests(unittest.TestCase):      def test_hashable_mixin_uses_id_for_equality_comparison(self):          """Test if the HashableMixing uses the id attribute for hashing.""" -        class MockScragly(unittest.mock.Mock, helpers.HashableMixin): +        class MockScragly(helpers.HashableMixin):              pass -        scragly = MockScragly(spec=object) +        scragly = MockScragly()          scragly.id = 10 -        eevee = MockScragly(spec=object) +        eevee = MockScragly()          eevee.id = 10 -        python = MockScragly(spec=object) +        python = MockScragly()          python.id = 20          self.assertTrue(scragly == eevee) @@ -281,14 +258,14 @@ class MockObjectTests(unittest.TestCase):      def test_hashable_mixin_uses_id_for_nonequality_comparison(self):          """Test if the HashableMixing uses the id attribute for hashing.""" -        class MockScragly(unittest.mock.Mock, helpers.HashableMixin): +        class MockScragly(helpers.HashableMixin):              pass -        scragly = MockScragly(spec=object) +        scragly = MockScragly()          scragly.id = 10 -        eevee = MockScragly(spec=object) +        eevee = MockScragly()          eevee.id = 10 -        python = MockScragly(spec=object) +        python = MockScragly()          python.id = 20          self.assertTrue(scragly != python) @@ -298,7 +275,7 @@ class MockObjectTests(unittest.TestCase):          """Test if the MagicMock subclasses that implement the HashableMixin use id for hash."""          for mock in self.hashable_mocks:              with self.subTest(mock_class=mock): -                instance = helpers.MockRole(role_id=100) +                instance = helpers.MockRole(id=100)                  self.assertEqual(hash(instance), instance.id)      def test_mock_class_with_hashable_mixin_uses_id_for_equality(self): @@ -331,6 +308,18 @@ class MockObjectTests(unittest.TestCase):                  self.assertFalse(instance_one != instance_two)                  self.assertTrue(instance_one != instance_three) +    def test_custom_mock_mixin_accepts_mock_seal(self): +        """The `CustomMockMixin` should support `unittest.mock.seal`.""" +        class MyMock(helpers.CustomMockMixin, unittest.mock.MagicMock): + +            child_mock_type = unittest.mock.MagicMock +            pass + +        mock = MyMock() +        unittest.mock.seal(mock) +        with self.assertRaises(AttributeError, msg="MyMock.shirayuki"): +            mock.shirayuki = "hello!" +      def test_spec_propagation_of_mock_subclasses(self):          """Test if the `spec` does not propagate to attributes of the mock object."""          test_values = ( @@ -339,6 +328,10 @@ class MockObjectTests(unittest.TestCase):              (helpers.MockMember, "display_name"),              (helpers.MockBot, "owner_id"),              (helpers.MockContext, "command_failed"), +            (helpers.MockMessage, "mention_everyone"), +            (helpers.MockEmoji, 'managed'), +            (helpers.MockPartialEmoji, 'url'), +            (helpers.MockReaction, 'me'),          )          for mock_type, valid_attribute in test_values: @@ -346,7 +339,53 @@ class MockObjectTests(unittest.TestCase):                  mock = mock_type()                  self.assertTrue(isinstance(mock, mock_type))                  attribute = getattr(mock, valid_attribute) -                self.assertTrue(isinstance(attribute, mock_type.attribute_mocktype)) +                self.assertTrue(isinstance(attribute, mock_type.child_mock_type)) + +    def test_extract_coroutine_methods_from_spec_instance_should_extract_all_and_only_coroutines(self): +        """Test if all coroutine functions are extracted, but not regular methods or attributes.""" +        class CoroutineDonor: +            def __init__(self): +                self.some_attribute = 'alpha' + +            async def first_coroutine(): +                """This coroutine function should be extracted.""" + +            async def second_coroutine(): +                """This coroutine function should be extracted.""" + +            def regular_method(): +                """This regular function should not be extracted.""" + +        class Receiver: +            pass + +        donor = CoroutineDonor() +        receiver = Receiver() + +        helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance(receiver, donor) + +        self.assertIsInstance(receiver.first_coroutine, helpers.AsyncMock) +        self.assertIsInstance(receiver.second_coroutine, helpers.AsyncMock) +        self.assertFalse(hasattr(receiver, 'regular_method')) +        self.assertFalse(hasattr(receiver, 'some_attribute')) + +    @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) +    @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") +    def test_custom_mock_mixin_init_with_spec(self, extract_method_mock): +        """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" +        spec_set = "pydis" + +        helpers.CustomMockMixin(spec_set=spec_set) + +        extract_method_mock.assert_called_once_with(spec_set) + +    @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) +    @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") +    def test_custom_mock_mixin_init_without_spec(self, extract_method_mock): +        """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" +        helpers.CustomMockMixin() + +        extract_method_mock.assert_not_called()      def test_async_mock_provides_coroutine_for_dunder_call(self):          """Test if AsyncMock objects have a coroutine for their __call__ method.""" | 
