aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Sebastiaan Zeeff <[email protected]>2019-10-28 15:54:31 +0100
committerGravatar Sebastiaan Zeeff <[email protected]>2019-10-31 22:20:20 +0100
commit6e57f41d727c9cde51d36adc04393c3723d62472 (patch)
tree77c67226b01ac3e16a866f50b20b7b46333436f8
parentMerge pull request #629 from python-discord/unittest-helpers-proper-child-mock (diff)
Enhance the output of the user command
https://github.com/python-discord/bot/issues/628 https://github.com/python-discord/bot/issues/339 This commit introduces several changes to the output of the `!user` command for moderation staff. The output for regular users has not changed. Changes: - When issued in a moderation channel, the infraction count of the user will now be broken down by type as described in #339. This allows moderators to get a quicker overview of someone's history by providing more information. The command will display the total number of infractions per type, with the number of active infractions in parentheses behind it if there are any. This change also means that there no longer a need for the `hidden` parameter: When issued in a moderation channel, hidden infractions are included by default; when issued outside of a mod channel, the command will be equal to what a regular user would get. In addition to broken-down infraction info, the command now also shows information about the nominations of a user when it's issued inside of a moderation channel. - The code has been refactored to smaller units that take care of a single action to make unit testing easier. I have included tests that cover the command and all of the new helper methods. Tests for the other methods/commands in the cog will be added in the specific issue calling for tests for this cog (#581) This commit closes #628 and closes #339
-rw-r--r--bot/cogs/information.py170
-rw-r--r--tests/bot/cogs/test_information.py444
2 files changed, 552 insertions, 62 deletions
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index 3a7ba0444..4a3af7edd 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -3,6 +3,7 @@ import logging
import pprint
import textwrap
import typing
+from collections import defaultdict
from typing import Any, Mapping, Optional
import discord
@@ -10,7 +11,7 @@ from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, V
from discord.ext import commands
from discord.ext.commands import Bot, BucketType, Cog, Context, command, group
-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
@@ -24,7 +25,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."""
@@ -48,7 +49,7 @@ class Information(Cog):
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:
"""
@@ -148,10 +149,10 @@ class Information(Cog):
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}
""")
)
@@ -160,59 +161,38 @@ class Information(Cog):
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)
name = str(user)
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}
@@ -221,17 +201,109 @@ class Information(Cog):
**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."""
@@ -268,9 +340,9 @@ class Information(Cog):
# 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
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()