diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/bot/cogs/test_information.py | 444 | ||||
| -rw-r--r-- | tests/bot/test_utils.py | 52 | 
2 files changed, 483 insertions, 13 deletions
| diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 9bbd35a91..5c34541d8 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", role_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,7 +52,7 @@ 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,              colour=discord.Colour.blurple(), @@ -57,7 +61,7 @@ class InformationCogTests(unittest.TestCase):              permissions=discord.Permissions(0)          ) -        admin_role = MockRole( +        admin_role = helpers.MockRole(              name="Admins",              role_id=998877665544332211,              colour=discord.Colour.red(), @@ -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(user_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(channel_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(channel_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(channel_id=1)) +        admins_role = helpers.MockRole('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(channel_id=50)) + +        moderators_role = helpers.MockRole('Moderators') +        moderators_role.colour = 100 + +        infraction_counts.return_value = "expanded infractions info" +        nomination_counts.return_value = "nomination info" + +        user = helpers.MockMember(user_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(channel_id=100)) + +        moderators_role = helpers.MockRole('Moderators') +        moderators_role.colour = 100 + +        infraction_counts.return_value = "basic infractions info" + +        user = helpers.MockMember(user_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('Moderators') +        moderators_role.colour = 100 + +        user = helpers.MockMember(user_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(user_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(user_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("Moderators", role_id=2, position=10) +        self.flautist_role = helpers.MockRole("Flautists", role_id=3, position=2) +        self.bassist_role = helpers.MockRole("Bassists", role_id=4, position=3) + +        self.author = helpers.MockMember(user_id=1, name="syntaxaire") +        self.moderator = helpers.MockMember(user_id=2, name="riffautae", roles=[self.moderator_role]) +        self.target = helpers.MockMember(user_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(channel_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(channel_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(channel_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(channel_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(channel_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/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]]) | 
