Repository: python-discord/bot

Issue:
Add infraction count to !user output for mods
When doing `!user` on a mod, display the amount of infractions they've handed out to users by type.

Something like:

Bans: 3
Warnings: 21
Notes: 42

etc..

I would say that probably the shadow versions of an infraction should combine with the non-shadow version, and maybe for extra points make a verbose option to display them separately.


Discussion:
I'm looking into this issue. #Oiry on discord
I'm now basically setting up versions and dependencies... 

Hey @ilysia,

Some other things related to the `!user` command have come up for which I'm implementing a fix. Since this issue hasn't seen any activity in three weeks, I'm going to include these requested features in that PR and unassign you from this issue.

Resolving patch:
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -3,6 +3,7 @@
 import pprint
 import textwrap
 import typing
+from collections import defaultdict
 from typing import Any, Mapping, Optional
 
 import discord
@@ -11,7 +12,7 @@
 from discord.ext.commands import Bot, BucketType, Cog, Context, command, group
 from discord.utils import escape_markdown
 
-from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES
+from bot import constants
 from bot.decorators import InChannelCheckFailure, in_channel, with_role
 from bot.utils.checks import cooldown_with_role_bypass, with_role_check
 from bot.utils.time import time_since
@@ -25,7 +26,7 @@ class Information(Cog):
     def __init__(self, bot: Bot):
         self.bot = bot
 
-    @with_role(*MODERATION_ROLES)
+    @with_role(*constants.MODERATION_ROLES)
     @command(name="roles")
     async def roles_info(self, ctx: Context) -> None:
         """Returns a list of all roles and their corresponding IDs."""
@@ -49,7 +50,7 @@ async def roles_info(self, ctx: Context) -> None:
 
         await ctx.send(embed=embed)
 
-    @with_role(*MODERATION_ROLES)
+    @with_role(*constants.MODERATION_ROLES)
     @command(name="role")
     async def role_info(self, ctx: Context, *roles: typing.Union[Role, str]) -> None:
         """
@@ -149,10 +150,10 @@ async def server_info(self, ctx: Context) -> None:
                 Channel categories: {category_channels}
 
                 **Members**
-                {Emojis.status_online} {online}
-                {Emojis.status_idle} {idle}
-                {Emojis.status_dnd} {dnd}
-                {Emojis.status_offline} {offline}
+                {constants.Emojis.status_online} {online}
+                {constants.Emojis.status_idle} {idle}
+                {constants.Emojis.status_dnd} {dnd}
+                {constants.Emojis.status_offline} {offline}
             """)
         )
 
@@ -161,24 +162,27 @@ async def server_info(self, ctx: Context) -> None:
         await ctx.send(embed=embed)
 
     @command(name="user", aliases=["user_info", "member", "member_info"])
-    async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False) -> None:
+    async def user_info(self, ctx: Context, user: Member = None) -> None:
         """Returns info about a user."""
         if user is None:
             user = ctx.author
 
         # Do a role check if this is being executed on someone other than the caller
-        if user != ctx.author and not with_role_check(ctx, *MODERATION_ROLES):
+        if user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES):
             await ctx.send("You may not use this command on users other than yourself.")
             return
 
-        # Non-moderators may only do this in #bot-commands and can't see hidden infractions.
-        if not with_role_check(ctx, *STAFF_ROLES):
-            if not ctx.channel.id == Channels.bot:
-                raise InChannelCheckFailure(Channels.bot)
-            # Hide hidden infractions for users without a moderation role
-            hidden = False
+        # Non-staff may only do this in #bot-commands
+        if not with_role_check(ctx, *constants.STAFF_ROLES):
+            if not ctx.channel.id == constants.Channels.bot:
+                raise InChannelCheckFailure(constants.Channels.bot)
 
-        # User information
+        embed = await self.create_user_embed(ctx, user)
+
+        await ctx.send(embed=embed)
+
+    async def create_user_embed(self, ctx: Context, user: Member) -> Embed:
+        """Creates an embed containing information on the `user`."""
         created = time_since(user.created_at, max_units=3)
 
         # Custom status
@@ -192,35 +196,11 @@ async def user_info(self, ctx: Context, user: Member = None, hidden: bool = Fals
         if user.nick:
             name = f"{user.nick} ({name})"
 
-        # Member information
         joined = time_since(user.joined_at, precision="days")
-
-        # You're welcome, Volcyyyyyyyyyyyyyyyy
         roles = ", ".join(role.mention for role in user.roles if role.name != "@everyone")
 
-        # Infractions
-        infractions = await self.bot.api_client.get(
-            'bot/infractions',
-            params={
-                'hidden': str(hidden),
-                'user__id': str(user.id)
-            }
-        )
-
-        infr_total = 0
-        infr_active = 0
-
-        # At least it's readable.
-        for infr in infractions:
-            if infr["active"]:
-                infr_active += 1
-
-            infr_total += 1
-
-        # Let's build the embed now
-        embed = Embed(
-            title=name,
-            description=textwrap.dedent(f"""
+        description = [
+            textwrap.dedent(f"""
                 **User Information**
                 Created: {created}
                 Profile: {user.mention}
@@ -229,17 +209,109 @@ async def user_info(self, ctx: Context, user: Member = None, hidden: bool = Fals
                 **Member Information**
                 Joined: {joined}
                 Roles: {roles or None}
+            """).strip()
+        ]
 
-                **Infractions**
-                Total: {infr_total}
-                Active: {infr_active}
-            """)
+        # Show more verbose output in moderation channels for infractions and nominations
+        if ctx.channel.id in constants.MODERATION_CHANNELS:
+            description.append(await self.expanded_user_infraction_counts(user))
+            description.append(await self.user_nomination_counts(user))
+        else:
+            description.append(await self.basic_user_infraction_counts(user))
+
+        # Let's build the embed now
+        embed = Embed(
+            title=name,
+            description="\n\n".join(description)
         )
 
         embed.set_thumbnail(url=user.avatar_url_as(format="png"))
         embed.colour = user.top_role.colour if roles else Colour.blurple()
 
-        await ctx.send(embed=embed)
+        return embed
+
+    async def basic_user_infraction_counts(self, member: Member) -> str:
+        """Gets the total and active infraction counts for the given `member`."""
+        infractions = await self.bot.api_client.get(
+            'bot/infractions',
+            params={
+                'hidden': 'False',
+                'user__id': str(member.id)
+            }
+        )
+
+        total_infractions = len(infractions)
+        active_infractions = sum(infraction['active'] for infraction in infractions)
+
+        infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}"
+
+        return infraction_output
+
+    async def expanded_user_infraction_counts(self, member: Member) -> str:
+        """
+        Gets expanded infraction counts for the given `member`.
+
+        The counts will be split by infraction type and the number of active infractions for each type will indicated
+        in the output as well.
+        """
+        infractions = await self.bot.api_client.get(
+            'bot/infractions',
+            params={
+                'user__id': str(member.id)
+            }
+        )
+
+        infraction_output = ["**Infractions**"]
+        if not infractions:
+            infraction_output.append("This user has never received an infraction.")
+        else:
+            # Count infractions split by `type` and `active` status for this user
+            infraction_types = set()
+            infraction_counter = defaultdict(int)
+            for infraction in infractions:
+                infraction_type = infraction["type"]
+                infraction_active = 'active' if infraction["active"] else 'inactive'
+
+                infraction_types.add(infraction_type)
+                infraction_counter[f"{infraction_active} {infraction_type}"] += 1
+
+            # Format the output of the infraction counts
+            for infraction_type in sorted(infraction_types):
+                active_count = infraction_counter[f"active {infraction_type}"]
+                total_count = active_count + infraction_counter[f"inactive {infraction_type}"]
+
+                line = f"{infraction_type.capitalize()}s: {total_count}"
+                if active_count:
+                    line += f" ({active_count} active)"
+
+                infraction_output.append(line)
+
+        return "\n".join(infraction_output)
+
+    async def user_nomination_counts(self, member: Member) -> str:
+        """Gets the active and historical nomination counts for the given `member`."""
+        nominations = await self.bot.api_client.get(
+            'bot/nominations',
+            params={
+                'user__id': str(member.id)
+            }
+        )
+
+        output = ["**Nominations**"]
+
+        if not nominations:
+            output.append("This user has never been nominated.")
+        else:
+            count = len(nominations)
+            is_currently_nominated = any(nomination["active"] for nomination in nominations)
+            nomination_noun = "nomination" if count == 1 else "nominations"
+
+            if is_currently_nominated:
+                output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).")
+            else:
+                output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.")
+
+        return "\n".join(output)
 
     def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str:
         """Format a mapping to be readable to a human."""
@@ -276,9 +348,9 @@ def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] =
         # remove trailing whitespace
         return out.rstrip()
 
-    @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=STAFF_ROLES)
+    @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES)
     @group(invoke_without_command=True)
-    @in_channel(Channels.bot, bypass_roles=STAFF_ROLES)
+    @in_channel(constants.Channels.bot, bypass_roles=constants.STAFF_ROLES)
     async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False) -> None:
         """Shows information about the raw API response."""
         # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling


Test patch:
diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py
--- a/tests/bot/cogs/test_information.py
+++ b/tests/bot/cogs/test_information.py
@@ -7,7 +7,11 @@
 
 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 @@ def test_roles_command_command(self):
 
     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 @@ def test_role_info_command(self):
             permissions=discord.Permissions(0)
         )
 
-        admin_role = MockRole(
+        admin_role = helpers.MockRole(
             name="Admins",
             role_id=998877665544332211,
             colour=discord.Colour.red(),
@@ -68,7 +72,7 @@ def test_role_info_command(self):
 
         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 @@ def test_role_info_command(self):
     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 @@ def test_server_info_command(self, time_since_patch):
                 )
             ],
             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 @@ def test_server_info_command(self, time_since_patch):
             )
         )
         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)
+
+
+@unittest.mock.patch("bot.cogs.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago"))
+@unittest.mock.patch("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")
+
+
+@unittest.mock.patch("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()