Repository: python-discord/bot

Issue:
Write unit tests for `bot/cogs/moderation/utils.py`
Write unit tests for [`bot/cogs/moderation/utils.py`](../blob/master/bot/cogs/moderation/utils.py).

## Implementation details
Please make sure to read the general information in the [meta issue](553) and the [testing README](../blob/master/tests/README.md). We are aiming for a 100% [branch coverage](https://coverage.readthedocs.io/en/stable/branch.html) for this file, but if you think that is not possible, please discuss that in this issue.

## Additional information
If you want to work on this issue, **please make sure that you get assigned to it** by one of the core devs before starting to work on it. We would like to prevent the situation that multiple people are working on the same issue. To get assigned, leave a comment showing your interesting in tackling this issue.



Discussion:
Can I work on it?
Yep, go ahead.
Let's wait with writing unittests until #783 is merged.

Resolving patch:
diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py
--- a/bot/exts/moderation/infraction/_utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -1,5 +1,4 @@
 import logging
-import textwrap
 import typing as t
 from datetime import datetime
 
@@ -28,6 +27,18 @@
 UserSnowflake = t.Union[UserObject, discord.Object]
 Infraction = t.Dict[str, t.Union[str, int, bool]]
 
+APPEAL_EMAIL = "appeals@pythondiscord.com"
+
+INFRACTION_TITLE = f"Please review our rules over at {RULES_URL}"
+INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}"
+INFRACTION_AUTHOR_NAME = "Infraction information"
+
+INFRACTION_DESCRIPTION_TEMPLATE = (
+    "**Type:** {type}\n"
+    "**Expires:** {expires}\n"
+    "**Reason:** {reason}\n"
+)
+
 
 async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:
     """
@@ -142,25 +153,27 @@ async def notify_infraction(
     """DM a user about their new infraction and return True if the DM is successful."""
     log.trace(f"Sending {user} a DM about their {infr_type} infraction.")
 
-    text = textwrap.dedent(f"""
-        **Type:** {infr_type.capitalize()}
-        **Expires:** {expires_at or "N/A"}
-        **Reason:** {reason or "No reason provided."}
-    """)
+    text = INFRACTION_DESCRIPTION_TEMPLATE.format(
+        type=infr_type.capitalize(),
+        expires=expires_at or "N/A",
+        reason=reason or "No reason provided."
+    )
+
+    # For case when other fields than reason is too long and this reach limit, then force-shorten string
+    if len(text) > 2048:
+        text = f"{text[:2045]}..."
 
     embed = discord.Embed(
-        description=textwrap.shorten(text, width=2048, placeholder="..."),
+        description=text,
         colour=Colours.soft_red
     )
 
-    embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL)
-    embed.title = f"Please review our rules over at {RULES_URL}"
+    embed.set_author(name=INFRACTION_AUTHOR_NAME, icon_url=icon_url, url=RULES_URL)
+    embed.title = INFRACTION_TITLE
     embed.url = RULES_URL
 
     if infr_type in APPEALABLE_INFRACTIONS:
-        embed.set_footer(
-            text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com"
-        )
+        embed.set_footer(text=INFRACTION_APPEAL_FOOTER)
 
     return await send_private_embed(user, embed)
 


Test patch:
diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py
new file mode 100644
--- /dev/null
+++ b/tests/bot/exts/moderation/infraction/test_utils.py
@@ -0,0 +1,359 @@
+import unittest
+from collections import namedtuple
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, call, patch
+
+from discord import Embed, Forbidden, HTTPException, NotFound
+
+from bot.api import ResponseCodeError
+from bot.constants import Colours, Icons
+from bot.exts.moderation.infraction import _utils as utils
+from tests.helpers import MockBot, MockContext, MockMember, MockUser
+
+
+class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
+    """Tests Moderation utils."""
+
+    def setUp(self):
+        self.bot = MockBot()
+        self.member = MockMember(id=1234)
+        self.user = MockUser(id=1234)
+        self.ctx = MockContext(bot=self.bot, author=self.member)
+
+    async def test_post_user(self):
+        """Should POST a new user and return the response if successful or otherwise send an error message."""
+        user = MockUser(discriminator=5678, id=1234, name="Test user")
+        not_user = MagicMock(discriminator=3333, id=5678, name="Wrong user")
+        test_cases = [
+            {
+                "user": user,
+                "post_result": "bar",
+                "raise_error": None,
+                "payload": {
+                    "discriminator": 5678,
+                    "id": self.user.id,
+                    "in_guild": False,
+                    "name": "Test user",
+                    "roles": []
+                }
+            },
+            {
+                "user": self.member,
+                "post_result": "foo",
+                "raise_error": ResponseCodeError(MagicMock(status=400), "foo"),
+                "payload": {
+                    "discriminator": 0,
+                    "id": self.member.id,
+                    "in_guild": False,
+                    "name": "Name unknown",
+                    "roles": []
+                }
+            },
+            {
+                "user": not_user,
+                "post_result": "bar",
+                "raise_error": None,
+                "payload": {
+                    "discriminator": not_user.discriminator,
+                    "id": not_user.id,
+                    "in_guild": False,
+                    "name": not_user.name,
+                    "roles": []
+                }
+            }
+        ]
+
+        for case in test_cases:
+            user = case["user"]
+            post_result = case["post_result"]
+            raise_error = case["raise_error"]
+            payload = case["payload"]
+
+            with self.subTest(user=user, post_result=post_result, raise_error=raise_error, payload=payload):
+                self.bot.api_client.post.reset_mock(side_effect=True)
+                self.ctx.bot.api_client.post.return_value = post_result
+
+                self.ctx.bot.api_client.post.side_effect = raise_error
+
+                result = await utils.post_user(self.ctx, user)
+
+                if raise_error:
+                    self.assertIsNone(result)
+                    self.ctx.send.assert_awaited_once()
+                    self.assertIn(str(raise_error.status), self.ctx.send.call_args[0][0])
+                else:
+                    self.assertEqual(result, post_result)
+                    self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload)
+
+    async def test_get_active_infraction(self):
+        """
+        Should request the API for active infractions and return infraction if the user has one or `None` otherwise.
+
+        A message should be sent to the context indicating a user already has an infraction, if that's the case.
+        """
+        test_case = namedtuple("test_case", ["get_return_value", "expected_output", "infraction_nr", "send_msg"])
+        test_cases = [
+            test_case([], None, None, True),
+            test_case([{"id": 123987}], {"id": 123987}, "123987", False),
+            test_case([{"id": 123987}], {"id": 123987}, "123987", True)
+        ]
+
+        for case in test_cases:
+            with self.subTest(return_value=case.get_return_value, expected=case.expected_output):
+                self.bot.api_client.get.reset_mock()
+                self.ctx.send.reset_mock()
+
+                params = {
+                    "active": "true",
+                    "type": "ban",
+                    "user__id": str(self.member.id)
+                }
+
+                self.bot.api_client.get.return_value = case.get_return_value
+
+                result = await utils.get_active_infraction(self.ctx, self.member, "ban", send_msg=case.send_msg)
+                self.assertEqual(result, case.expected_output)
+                self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params=params)
+
+                if case.send_msg and case.get_return_value:
+                    self.ctx.send.assert_awaited_once()
+                    sent_message = self.ctx.send.call_args[0][0]
+                    self.assertIn(case.infraction_nr, sent_message)
+                    self.assertIn("ban", sent_message)
+                else:
+                    self.ctx.send.assert_not_awaited()
+
+    @patch("bot.exts.moderation.infraction._utils.send_private_embed")
+    async def test_notify_infraction(self, send_private_embed_mock):
+        """
+        Should send an embed of a certain format as a DM and return `True` if DM successful.
+
+        Appealable infractions should have the appeal message in the embed's footer.
+        """
+        test_cases = [
+            {
+                "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"),
+                "expected_output": Embed(
+                    title=utils.INFRACTION_TITLE,
+                    description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+                        type="Ban",
+                        expires="2020-02-26 09:20 (23 hours and 59 minutes)",
+                        reason="No reason provided."
+                    ),
+                    colour=Colours.soft_red,
+                    url=utils.RULES_URL
+                ).set_author(
+                    name=utils.INFRACTION_AUTHOR_NAME,
+                    url=utils.RULES_URL,
+                    icon_url=Icons.token_removed
+                ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER),
+                "send_result": True
+            },
+            {
+                "args": (self.user, "warning", None, "Test reason."),
+                "expected_output": Embed(
+                    title=utils.INFRACTION_TITLE,
+                    description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+                        type="Warning",
+                        expires="N/A",
+                        reason="Test reason."
+                    ),
+                    colour=Colours.soft_red,
+                    url=utils.RULES_URL
+                ).set_author(
+                    name=utils.INFRACTION_AUTHOR_NAME,
+                    url=utils.RULES_URL,
+                    icon_url=Icons.token_removed
+                ),
+                "send_result": False
+            },
+            {
+                "args": (self.user, "note", None, None, Icons.defcon_denied),
+                "expected_output": Embed(
+                    title=utils.INFRACTION_TITLE,
+                    description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+                        type="Note",
+                        expires="N/A",
+                        reason="No reason provided."
+                    ),
+                    colour=Colours.soft_red,
+                    url=utils.RULES_URL
+                ).set_author(
+                    name=utils.INFRACTION_AUTHOR_NAME,
+                    url=utils.RULES_URL,
+                    icon_url=Icons.defcon_denied
+                ),
+                "send_result": False
+            },
+            {
+                "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied),
+                "expected_output": Embed(
+                    title=utils.INFRACTION_TITLE,
+                    description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+                        type="Mute",
+                        expires="2020-02-26 09:20 (23 hours and 59 minutes)",
+                        reason="Test"
+                    ),
+                    colour=Colours.soft_red,
+                    url=utils.RULES_URL
+                ).set_author(
+                    name=utils.INFRACTION_AUTHOR_NAME,
+                    url=utils.RULES_URL,
+                    icon_url=Icons.defcon_denied
+                ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER),
+                "send_result": False
+            },
+            {
+                "args": (self.user, "mute", None, "foo bar" * 4000, Icons.defcon_denied),
+                "expected_output": Embed(
+                    title=utils.INFRACTION_TITLE,
+                    description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+                        type="Mute",
+                        expires="N/A",
+                        reason="foo bar" * 4000
+                    )[:2045] + "...",
+                    colour=Colours.soft_red,
+                    url=utils.RULES_URL
+                ).set_author(
+                    name=utils.INFRACTION_AUTHOR_NAME,
+                    url=utils.RULES_URL,
+                    icon_url=Icons.defcon_denied
+                ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER),
+                "send_result": True
+            }
+        ]
+
+        for case in test_cases:
+            with self.subTest(args=case["args"], expected=case["expected_output"], send=case["send_result"]):
+                send_private_embed_mock.reset_mock()
+
+                send_private_embed_mock.return_value = case["send_result"]
+                result = await utils.notify_infraction(*case["args"])
+
+                self.assertEqual(case["send_result"], result)
+
+                embed = send_private_embed_mock.call_args[0][1]
+
+                self.assertEqual(embed.to_dict(), case["expected_output"].to_dict())
+
+                send_private_embed_mock.assert_awaited_once_with(case["args"][0], embed)
+
+    @patch("bot.exts.moderation.infraction._utils.send_private_embed")
+    async def test_notify_pardon(self, send_private_embed_mock):
+        """Should send an embed of a certain format as a DM and return `True` if DM successful."""
+        test_case = namedtuple("test_case", ["args", "icon", "send_result"])
+        test_cases = [
+            test_case((self.user, "Test title", "Example content"), Icons.user_verified, True),
+            test_case((self.user, "Test title", "Example content", Icons.user_update), Icons.user_update, False)
+        ]
+
+        for case in test_cases:
+            expected = Embed(
+                description="Example content",
+                colour=Colours.soft_green
+            ).set_author(
+                name="Test title",
+                icon_url=case.icon
+            )
+
+            with self.subTest(args=case.args, expected=expected):
+                send_private_embed_mock.reset_mock()
+
+                send_private_embed_mock.return_value = case.send_result
+
+                result = await utils.notify_pardon(*case.args)
+                self.assertEqual(case.send_result, result)
+
+                embed = send_private_embed_mock.call_args[0][1]
+                self.assertEqual(embed.to_dict(), expected.to_dict())
+
+                send_private_embed_mock.assert_awaited_once_with(case.args[0], embed)
+
+    async def test_send_private_embed(self):
+        """Should DM the user and return `True` on success or `False` on failure."""
+        embed = Embed(title="Test", description="Test val")
+
+        test_case = namedtuple("test_case", ["expected_output", "raised_exception"])
+        test_cases = [
+            test_case(True, None),
+            test_case(False, HTTPException(AsyncMock(), AsyncMock())),
+            test_case(False, Forbidden(AsyncMock(), AsyncMock())),
+            test_case(False, NotFound(AsyncMock(), AsyncMock()))
+        ]
+
+        for case in test_cases:
+            with self.subTest(expected=case.expected_output, raised=case.raised_exception):
+                self.user.send.reset_mock(side_effect=True)
+                self.user.send.side_effect = case.raised_exception
+
+                result = await utils.send_private_embed(self.user, embed)
+
+                self.assertEqual(result, case.expected_output)
+                self.user.send.assert_awaited_once_with(embed=embed)
+
+
+class TestPostInfraction(unittest.IsolatedAsyncioTestCase):
+    """Tests for the `post_infraction` function."""
+
+    def setUp(self):
+        self.bot = MockBot()
+        self.member = MockMember(id=1234)
+        self.user = MockUser(id=1234)
+        self.ctx = MockContext(bot=self.bot, author=self.member)
+
+    async def test_normal_post_infraction(self):
+        """Should return response from POST request if there are no errors."""
+        now = datetime.now()
+        payload = {
+            "actor": self.ctx.author.id,
+            "hidden": True,
+            "reason": "Test reason",
+            "type": "ban",
+            "user": self.member.id,
+            "active": False,
+            "expires_at": now.isoformat()
+        }
+
+        self.ctx.bot.api_client.post.return_value = "foo"
+        actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False)
+
+        self.assertEqual(actual, "foo")
+        self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload)
+
+    async def test_unknown_error_post_infraction(self):
+        """Should send an error message to chat when a non-400 error occurs."""
+        self.ctx.bot.api_client.post.side_effect = ResponseCodeError(AsyncMock(), AsyncMock())
+        self.ctx.bot.api_client.post.side_effect.status = 500
+
+        actual = await utils.post_infraction(self.ctx, self.user, "ban", "Test reason")
+        self.assertIsNone(actual)
+
+        self.assertTrue("500" in self.ctx.send.call_args[0][0])
+
+    @patch("bot.exts.moderation.infraction._utils.post_user", return_value=None)
+    async def test_user_not_found_none_post_infraction(self, post_user_mock):
+        """Should abort and return `None` when a new user fails to be posted."""
+        self.bot.api_client.post.side_effect = ResponseCodeError(MagicMock(status=400), {"user": "foo"})
+
+        actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason")
+        self.assertIsNone(actual)
+        post_user_mock.assert_awaited_once_with(self.ctx, self.user)
+
+    @patch("bot.exts.moderation.infraction._utils.post_user", return_value="bar")
+    async def test_first_fail_second_success_user_post_infraction(self, post_user_mock):
+        """Should post the user if they don't exist, POST infraction again, and return the response if successful."""
+        payload = {
+            "actor": self.ctx.author.id,
+            "hidden": False,
+            "reason": "Test reason",
+            "type": "mute",
+            "user": self.user.id,
+            "active": True
+        }
+
+        self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"]
+
+        actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason")
+        self.assertEqual(actual, "foo")
+        self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2)
+        post_user_mock.assert_awaited_once_with(self.ctx, self.user)