diff options
Diffstat (limited to '')
| -rw-r--r-- | tests/bot/exts/backend/test_error_handler.py | 62 | ||||
| -rw-r--r-- | tests/bot/exts/info/test_information.py | 76 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/infraction/test_infractions.py | 175 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/infraction/test_utils.py | 36 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/test_clean.py | 104 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/test_incidents.py | 94 | ||||
| -rw-r--r-- | tests/bot/exts/utils/test_snekbox.py | 183 | ||||
| -rw-r--r-- | tests/bot/test_converters.py | 17 | ||||
| -rw-r--r-- | tests/bot/utils/test_time.py | 47 | 
9 files changed, 537 insertions, 257 deletions
| diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index 462f718e6..35fa0ee59 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -337,14 +337,12 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase):      async def test_try_get_tag_get_command(self):          """Should call `Bot.get_command` with `tags get` argument."""          self.bot.get_command.reset_mock() -        self.ctx.invoked_with = "foo"          await self.cog.try_get_tag(self.ctx)          self.bot.get_command.assert_called_once_with("tags get")      async def test_try_get_tag_invoked_from_error_handler(self):          """`self.ctx` should have `invoked_from_error_handler` `True`."""          self.ctx.invoked_from_error_handler = False -        self.ctx.invoked_with = "foo"          await self.cog.try_get_tag(self.ctx)          self.assertTrue(self.ctx.invoked_from_error_handler) @@ -359,38 +357,12 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase):          err = errors.CommandError()          self.tag.get_command.can_run = AsyncMock(side_effect=err)          self.cog.on_command_error = AsyncMock() -        self.ctx.invoked_with = "foo"          self.assertIsNone(await self.cog.try_get_tag(self.ctx))          self.cog.on_command_error.assert_awaited_once_with(self.ctx, err) -    @patch("bot.exts.backend.error_handler.TagNameConverter") -    async def test_try_get_tag_convert_success(self, tag_converter): -        """Converting tag should successful.""" -        self.ctx.invoked_with = "foo" -        tag_converter.convert = AsyncMock(return_value="foo") -        self.assertIsNone(await self.cog.try_get_tag(self.ctx)) -        tag_converter.convert.assert_awaited_once_with(self.ctx, "foo") -        self.ctx.invoke.assert_awaited_once() - -    @patch("bot.exts.backend.error_handler.TagNameConverter") -    async def test_try_get_tag_convert_fail(self, tag_converter): -        """Converting tag should raise `BadArgument`.""" -        self.ctx.reset_mock() -        self.ctx.invoked_with = "bar" -        tag_converter.convert = AsyncMock(side_effect=errors.BadArgument()) -        self.assertIsNone(await self.cog.try_get_tag(self.ctx)) -        self.ctx.invoke.assert_not_awaited() - -    async def test_try_get_tag_ctx_invoke(self): -        """Should call `ctx.invoke` with proper args/kwargs.""" -        self.ctx.reset_mock() -        self.ctx.invoked_with = "foo" -        self.assertIsNone(await self.cog.try_get_tag(self.ctx)) -        self.ctx.invoke.assert_awaited_once_with(self.tag.get_command, tag_name="foo") -      async def test_dont_call_suggestion_tag_sent(self):          """Should never call command suggestion if tag is already sent.""" -        self.ctx.invoked_with = "foo" +        self.ctx.message = MagicMock(content="foo")          self.ctx.invoke = AsyncMock(return_value=True)          self.cog.send_command_suggestion = AsyncMock() @@ -572,38 +544,6 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase):                  push_scope_mock.set_extra.has_calls(set_extra_calls) -class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): -    """Other `ErrorHandler` tests.""" - -    def setUp(self): -        self.bot = MockBot() -        self.ctx = MockContext() - -    async def test_get_help_command_command_specified(self): -        """Should return coroutine of help command of specified command.""" -        self.ctx.command = "foo" -        result = ErrorHandler.get_help_command(self.ctx) -        expected = self.ctx.send_help("foo") -        self.assertEqual(result.__qualname__, expected.__qualname__) -        self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals) - -        # Await coroutines to avoid warnings -        await result -        await expected - -    async def test_get_help_command_no_command_specified(self): -        """Should return coroutine of help command.""" -        self.ctx.command = None -        result = ErrorHandler.get_help_command(self.ctx) -        expected = self.ctx.send_help() -        self.assertEqual(result.__qualname__, expected.__qualname__) -        self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals) - -        # Await coroutines to avoid warnings -        await result -        await expected - -  class ErrorHandlerSetupTests(unittest.TestCase):      """Tests for `ErrorHandler` `setup` function.""" diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 632287322..d896b7652 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -1,6 +1,7 @@  import textwrap  import unittest  import unittest.mock +from datetime import datetime  import discord @@ -276,6 +277,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) +    @unittest.mock.patch( +        f"{COG_PATH}.user_messages", +        new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) +    )      async def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self):          """The embed should use the string representation of the user if they don't have a nick."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -284,8 +289,9 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          user.nick = None          user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")          user.colour = 0 +        user.created_at = user.joined_at = datetime.utcnow() -        embed = await self.cog.create_user_embed(ctx, user) +        embed = await self.cog.create_user_embed(ctx, user, False)          self.assertEqual(embed.title, "Mr. Hemlock") @@ -293,6 +299,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) +    @unittest.mock.patch( +        f"{COG_PATH}.user_messages", +        new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) +    )      async def test_create_user_embed_uses_nick_in_title_if_available(self):          """The embed should use the nick if it's available."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -301,8 +311,9 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          user.nick = "Cat lover"          user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")          user.colour = 0 +        user.created_at = user.joined_at = datetime.utcnow() -        embed = await self.cog.create_user_embed(ctx, user) +        embed = await self.cog.create_user_embed(ctx, user, False)          self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") @@ -310,6 +321,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) +    @unittest.mock.patch( +        f"{COG_PATH}.user_messages", +        new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) +    )      async def test_create_user_embed_ignores_everyone_role(self):          """Created `!user` embeds should not contain mention of the @everyone-role."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -317,14 +332,19 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          # A `MockMember` has the @Everyone role by default; we add the Admins to that.          user = helpers.MockMember(roles=[admins_role], colour=100) +        user.created_at = user.joined_at = datetime.utcnow() -        embed = await self.cog.create_user_embed(ctx, user) +        embed = await self.cog.create_user_embed(ctx, user, False)          self.assertIn("&Admins", embed.fields[1].value)          self.assertNotIn("&Everyone", embed.fields[1].value)      @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock)      @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) +    @unittest.mock.patch( +        f"{COG_PATH}.user_messages", +        new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) +    )      async def test_create_user_embed_expanded_information_in_moderation_channels(              self,              nomination_counts, @@ -339,7 +359,8 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          nomination_counts.return_value = ("Nominations", "nomination info")          user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) -        embed = await self.cog.create_user_embed(ctx, user) +        user.created_at = user.joined_at = datetime.utcfromtimestamp(1) +        embed = await self.cog.create_user_embed(ctx, user, False)          infraction_counts.assert_called_once_with(user)          nomination_counts.assert_called_once_with(user) @@ -363,16 +384,23 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          )      @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) -    async def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): +    @unittest.mock.patch(f"{COG_PATH}.user_messages", new_callable=unittest.mock.AsyncMock) +    async def test_create_user_embed_basic_information_outside_of_moderation_channels( +        self, +        user_messages, +        infraction_counts, +    ):          """The embed should contain only basic infraction data outside of mod channels."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100))          moderators_role = helpers.MockRole(name='Moderators')          infraction_counts.return_value = ("Infractions", "basic infractions info") +        user_messages.return_value = ("Messages", "user message counts")          user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) -        embed = await self.cog.create_user_embed(ctx, user) +        user.created_at = user.joined_at = datetime.utcfromtimestamp(1) +        embed = await self.cog.create_user_embed(ctx, user, False)          infraction_counts.assert_called_once_with(user) @@ -394,14 +422,23 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          )          self.assertEqual( -            "basic infractions info", +            "user message counts",              embed.fields[2].value          ) +        self.assertEqual( +            "basic infractions info", +            embed.fields[3].value +        ) +      @unittest.mock.patch(          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) +    @unittest.mock.patch( +        f"{COG_PATH}.user_messages", +        new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) +    )      async 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() @@ -409,7 +446,8 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          moderators_role = helpers.MockRole(name='Moderators')          user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) -        embed = await self.cog.create_user_embed(ctx, user) +        user.created_at = user.joined_at = datetime.utcnow() +        embed = await self.cog.create_user_embed(ctx, user, False)          self.assertEqual(embed.colour, discord.Colour(100)) @@ -417,12 +455,17 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) +    @unittest.mock.patch( +        f"{COG_PATH}.user_messages", +        new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) +    )      async def test_create_user_embed_uses_og_blurple_colour_when_user_has_no_roles(self):          """The embed should be created with the og blurple colour if the user has no assigned roles."""          ctx = helpers.MockContext()          user = helpers.MockMember(id=217, colour=discord.Colour.default()) -        embed = await self.cog.create_user_embed(ctx, user) +        user.created_at = user.joined_at = datetime.utcnow() +        embed = await self.cog.create_user_embed(ctx, user, False)          self.assertEqual(embed.colour, discord.Colour.og_blurple()) @@ -430,13 +473,18 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) +    @unittest.mock.patch( +        f"{COG_PATH}.user_messages", +        new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) +    )      async def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self):          """The embed thumbnail should be set to the user's avatar in `png` format."""          ctx = helpers.MockContext()          user = helpers.MockMember(id=217, colour=0) +        user.created_at = user.joined_at = datetime.utcnow()          user.display_avatar.url = "avatar url" -        embed = await self.cog.create_user_embed(ctx, user) +        embed = await self.cog.create_user_embed(ctx, user, False)          self.assertEqual(embed.thumbnail.url, "avatar url") @@ -489,7 +537,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase):          await self.cog.user_info(self.cog, ctx) -        create_embed.assert_called_once_with(ctx, self.author) +        create_embed.assert_called_once_with(ctx, self.author, False)          ctx.send.assert_called_once()      @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") @@ -500,7 +548,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase):          await self.cog.user_info(self.cog, ctx, self.author) -        create_embed.assert_called_once_with(ctx, self.author) +        create_embed.assert_called_once_with(ctx, self.author, False)          ctx.send.assert_called_once()      @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") @@ -511,7 +559,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase):          await self.cog.user_info(self.cog, ctx) -        create_embed.assert_called_once_with(ctx, self.moderator) +        create_embed.assert_called_once_with(ctx, self.moderator, False)          ctx.send.assert_called_once()      @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") @@ -523,5 +571,5 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase):          await self.cog.user_info(self.cog, ctx, self.target) -        create_embed.assert_called_once_with(ctx, self.target) +        create_embed.assert_called_once_with(ctx, self.target, False)          ctx.send.assert_called_once() diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 4d01e18a5..052048053 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,13 +1,15 @@  import inspect  import textwrap  import unittest -from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, DEFAULT, MagicMock, Mock, patch  from discord.errors import NotFound  from bot.constants import Event +from bot.exts.moderation.clean import Clean  from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction.infractions import Infractions +from bot.exts.moderation.infraction.management import ModManagement  from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockUser, autospec @@ -62,8 +64,8 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):  @patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) -class VoiceBanTests(unittest.IsolatedAsyncioTestCase): -    """Tests for voice ban related functions and commands.""" +class VoiceMuteTests(unittest.IsolatedAsyncioTestCase): +    """Tests for voice mute related functions and commands."""      def setUp(self):          self.bot = MockBot() @@ -73,59 +75,59 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):          self.ctx = MockContext(bot=self.bot, author=self.mod)          self.cog = Infractions(self.bot) -    async def test_permanent_voice_ban(self): -        """Should call voice ban applying function without expiry.""" -        self.cog.apply_voice_ban = AsyncMock() -        self.assertIsNone(await self.cog.voiceban(self.cog, self.ctx, self.user, reason="foobar")) -        self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at=None) +    async def test_permanent_voice_mute(self): +        """Should call voice mute applying function without expiry.""" +        self.cog.apply_voice_mute = AsyncMock() +        self.assertIsNone(await self.cog.voicemute(self.cog, self.ctx, self.user, reason="foobar")) +        self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at=None) -    async def test_temporary_voice_ban(self): -        """Should call voice ban applying function with expiry.""" -        self.cog.apply_voice_ban = AsyncMock() -        self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar")) -        self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") +    async def test_temporary_voice_mute(self): +        """Should call voice mute applying function with expiry.""" +        self.cog.apply_voice_mute = AsyncMock() +        self.assertIsNone(await self.cog.tempvoicemute(self.cog, self.ctx, self.user, "baz", reason="foobar")) +        self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") -    async def test_voice_unban(self): +    async def test_voice_unmute(self):          """Should call infraction pardoning function."""          self.cog.pardon_infraction = AsyncMock() -        self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) -        self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) +        self.assertIsNone(await self.cog.unvoicemute(self.cog, self.ctx, self.user)) +        self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_mute", self.user)      @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")      @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") -    async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): -        """Should return early when user already have Voice Ban infraction.""" +    async def test_voice_mute_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): +        """Should return early when user already have Voice Mute infraction."""          get_active_infraction.return_value = {"foo": "bar"} -        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) -        get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_ban") +        self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar")) +        get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_mute")          post_infraction_mock.assert_not_awaited()      @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")      @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") -    async def test_voice_ban_infraction_post_failed(self, get_active_infraction, post_infraction_mock): +    async def test_voice_mute_infraction_post_failed(self, get_active_infraction, post_infraction_mock):          """Should return early when posting infraction fails."""          self.cog.mod_log.ignore = MagicMock()          get_active_infraction.return_value = None          post_infraction_mock.return_value = None -        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) +        self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar"))          post_infraction_mock.assert_awaited_once()          self.cog.mod_log.ignore.assert_not_called()      @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")      @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") -    async def test_voice_ban_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock): -        """Should pass all kwargs passed to apply_voice_ban to post_infraction.""" +    async def test_voice_mute_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock): +        """Should pass all kwargs passed to apply_voice_mute to post_infraction."""          get_active_infraction.return_value = None          # We don't want that this continue yet          post_infraction_mock.return_value = None -        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar", my_kwarg=23)) +        self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar", my_kwarg=23))          post_infraction_mock.assert_awaited_once_with( -            self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23 +            self.ctx, self.user, "voice_mute", "foobar", active=True, my_kwarg=23          )      @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")      @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") -    async def test_voice_ban_mod_log_ignore(self, get_active_infraction, post_infraction_mock): +    async def test_voice_mute_mod_log_ignore(self, get_active_infraction, post_infraction_mock):          """Should ignore Voice Verified role removing."""          self.cog.mod_log.ignore = MagicMock()          self.cog.apply_infraction = AsyncMock() @@ -134,11 +136,11 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):          get_active_infraction.return_value = None          post_infraction_mock.return_value = {"foo": "bar"} -        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) +        self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar"))          self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id)      async def action_tester(self, action, reason: str) -> None: -        """Helper method to test voice ban action.""" +        """Helper method to test voice mute action."""          self.assertTrue(inspect.iscoroutine(action))          await action @@ -147,7 +149,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):      @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")      @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") -    async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock): +    async def test_voice_mute_apply_infraction(self, get_active_infraction, post_infraction_mock):          """Should ignore Voice Verified role removing."""          self.cog.mod_log.ignore = MagicMock()          self.cog.apply_infraction = AsyncMock() @@ -156,22 +158,22 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):          post_infraction_mock.return_value = {"foo": "bar"}          reason = "foobar" -        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, reason)) +        self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, reason))          self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY)          await self.action_tester(self.cog.apply_infraction.call_args[0][-1], reason)      @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")      @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") -    async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infraction_mock): -        """Should truncate reason for voice ban.""" +    async def test_voice_mute_truncate_reason(self, get_active_infraction, post_infraction_mock): +        """Should truncate reason for voice mute."""          self.cog.mod_log.ignore = MagicMock()          self.cog.apply_infraction = AsyncMock()          get_active_infraction.return_value = None          post_infraction_mock.return_value = {"foo": "bar"} -        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000)) +        self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar" * 3000))          self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY)          # Test action @@ -180,14 +182,14 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):      @autospec(_utils, "post_infraction", "get_active_infraction", return_value=None)      @autospec(Infractions, "apply_infraction") -    async def test_voice_ban_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _): -        """Should voice ban user that left the guild without throwing an error.""" +    async def test_voice_mute_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _): +        """Should voice mute user that left the guild without throwing an error."""          infraction = {"foo": "bar"}          post_infraction_mock.return_value = {"foo": "bar"}          user = MockUser() -        await self.cog.voiceban(self.cog, self.ctx, user, reason=None) -        post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_ban", None, active=True, expires_at=None) +        await self.cog.voicemute(self.cog, self.ctx, user, reason=None) +        post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_mute", None, active=True, expires_at=None)          apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, ANY)          # Test action @@ -195,22 +197,22 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):          self.assertTrue(inspect.iscoroutine(action))          await action -    async def test_voice_unban_user_not_found(self): +    async def test_voice_unmute_user_not_found(self):          """Should include info to return dict when user was not found from guild."""          self.guild.get_member.return_value = None          self.guild.fetch_member.side_effect = NotFound(Mock(status=404), "Not found") -        result = await self.cog.pardon_voice_ban(self.user.id, self.guild) +        result = await self.cog.pardon_voice_mute(self.user.id, self.guild)          self.assertEqual(result, {"Info": "User was not found in the guild."})      @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon")      @patch("bot.exts.moderation.infraction.infractions.format_user") -    async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock): +    async def test_voice_unmute_user_found(self, format_user_mock, notify_pardon_mock):          """Should add role back with ignoring, notify user and return log dictionary.."""          self.guild.get_member.return_value = self.user          notify_pardon_mock.return_value = True          format_user_mock.return_value = "my-user" -        result = await self.cog.pardon_voice_ban(self.user.id, self.guild) +        result = await self.cog.pardon_voice_mute(self.user.id, self.guild)          self.assertEqual(result, {              "Member": "my-user",              "DM": "Sent" @@ -219,15 +221,100 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):      @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon")      @patch("bot.exts.moderation.infraction.infractions.format_user") -    async def test_voice_unban_dm_fail(self, format_user_mock, notify_pardon_mock): +    async def test_voice_unmute_dm_fail(self, format_user_mock, notify_pardon_mock):          """Should add role back with ignoring, notify user and return log dictionary.."""          self.guild.get_member.return_value = self.user          notify_pardon_mock.return_value = False          format_user_mock.return_value = "my-user" -        result = await self.cog.pardon_voice_ban(self.user.id, self.guild) +        result = await self.cog.pardon_voice_mute(self.user.id, self.guild)          self.assertEqual(result, {              "Member": "my-user",              "DM": "**Failed**"          })          notify_pardon_mock.assert_awaited_once() + + +class CleanBanTests(unittest.IsolatedAsyncioTestCase): +    """Tests for cleanban functionality.""" + +    def setUp(self): +        self.bot = MockBot() +        self.mod = MockMember(roles=[MockRole(id=7890123, position=10)]) +        self.user = MockMember(roles=[MockRole(id=123456, position=1)]) +        self.guild = MockGuild() +        self.ctx = MockContext(bot=self.bot, author=self.mod) +        self.cog = Infractions(self.bot) +        self.clean_cog = Clean(self.bot) +        self.management_cog = ModManagement(self.bot) + +        self.cog.apply_ban = AsyncMock(return_value={"id": 42}) +        self.log_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" +        self.clean_cog._clean_messages = AsyncMock(return_value=self.log_url) + +    def mock_get_cog(self, enable_clean, enable_manage): +        """Mock get cog factory that allows the user to specify whether clean and manage cogs are enabled.""" +        def inner(name): +            if name == "ModManagement": +                return self.management_cog if enable_manage else None +            elif name == "Clean": +                return self.clean_cog if enable_clean else None +            else: +                return DEFAULT +        return inner + +    async def test_cleanban_falls_back_to_native_purge_without_clean_cog(self): +        """Should fallback to native purge if the Clean cog is not available.""" +        self.bot.get_cog.side_effect = self.mock_get_cog(False, False) + +        self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar")) +        self.cog.apply_ban.assert_awaited_once_with( +            self.ctx, +            self.user, +            "FooBar", +            purge_days=1, +            expires_at=None, +        ) + +    async def test_cleanban_doesnt_purge_messages_if_clean_cog_available(self): +        """Cleanban command should use the native purge messages if the clean cog is available.""" +        self.bot.get_cog.side_effect = self.mock_get_cog(True, False) + +        self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar")) +        self.cog.apply_ban.assert_awaited_once_with( +            self.ctx, +            self.user, +            "FooBar", +            expires_at=None, +        ) + +    @patch("bot.exts.moderation.infraction.infractions.Age") +    async def test_cleanban_uses_clean_cog_when_available(self, mocked_age_converter): +        """Test cleanban uses the clean cog to clean messages if it's available.""" +        self.bot.api_client.patch = AsyncMock() +        self.bot.get_cog.side_effect = self.mock_get_cog(True, False) + +        mocked_age_converter.return_value.convert = AsyncMock(return_value="81M") +        self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar")) + +        self.clean_cog._clean_messages.assert_awaited_once_with( +            self.ctx, +            users=[self.user], +            channels="*", +            first_limit="81M", +            attempt_delete_invocation=False, +        ) + +    async def test_cleanban_edits_infraction_reason(self): +        """Ensure cleanban edits the ban reason with a link to the clean log.""" +        self.bot.get_cog.side_effect = self.mock_get_cog(True, True) + +        self.management_cog.infraction_append = AsyncMock() +        self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar")) + +        self.management_cog.infraction_append.assert_awaited_once_with( +            self.ctx, +            {"id": 42}, +            None, +            reason=f"[Clean log]({self.log_url})" +        ) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 72eebb254..ff81ddd65 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -15,7 +15,10 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):      """Tests Moderation utils."""      def setUp(self): -        self.bot = MockBot() +        patcher = patch("bot.instance", new=MockBot()) +        self.bot = patcher.start() +        self.addCleanup(patcher.stop) +          self.member = MockMember(id=1234)          self.user = MockUser(id=1234)          self.ctx = MockContext(bot=self.bot, author=self.member) @@ -123,8 +126,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                  else:                      self.ctx.send.assert_not_awaited() +    @unittest.skip("Current time needs to be patched so infraction duration is correct.")      @patch("bot.exts.moderation.infraction._utils.send_private_embed") -    async def test_notify_infraction(self, send_private_embed_mock): +    async def test_send_infraction_embed(self, send_private_embed_mock):          """          Should send an embed of a certain format as a DM and return `True` if DM successful. @@ -132,7 +136,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):          """          test_cases = [              { -                "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), +                "args": (dict(id=0, type="ban", reason=None, expires_at=datetime(2020, 2, 26, 9, 20)), self.user),                  "expected_output": Embed(                      title=utils.INFRACTION_TITLE,                      description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -145,12 +149,12 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL, -                    icon_url=Icons.token_removed +                    icon_url=Icons.user_ban                  ),                  "send_result": True              },              { -                "args": (self.user, "warning", None, "Test reason."), +                "args": (dict(id=0, type="warning", reason="Test reason.", expires_at=None), self.user),                  "expected_output": Embed(                      title=utils.INFRACTION_TITLE,                      description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -163,14 +167,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL, -                    icon_url=Icons.token_removed +                    icon_url=Icons.user_warn                  ),                  "send_result": False              },              # Note that this test case asserts that the DM that *would* get sent to the user is formatted              # correctly, even though that message is deliberately never sent.              { -                "args": (self.user, "note", None, None, Icons.defcon_denied), +                "args": (dict(id=0, type="note", reason=None, expires_at=None), self.user),                  "expected_output": Embed(                      title=utils.INFRACTION_TITLE,                      description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -183,12 +187,12 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL, -                    icon_url=Icons.defcon_denied +                    icon_url=Icons.user_warn                  ),                  "send_result": False              },              { -                "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), +                "args": (dict(id=0, type="mute", reason="Test", expires_at=datetime(2020, 2, 26, 9, 20)), self.user),                  "expected_output": Embed(                      title=utils.INFRACTION_TITLE,                      description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -201,12 +205,12 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL, -                    icon_url=Icons.defcon_denied +                    icon_url=Icons.user_mute                  ),                  "send_result": False              },              { -                "args": (self.user, "mute", None, "foo bar" * 4000, Icons.defcon_denied), +                "args": (dict(id=0, type="mute", reason="foo bar" * 4000, expires_at=None), self.user),                  "expected_output": Embed(                      title=utils.INFRACTION_TITLE,                      description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -219,7 +223,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL, -                    icon_url=Icons.defcon_denied +                    icon_url=Icons.user_mute                  ),                  "send_result": True              } @@ -238,7 +242,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                  self.assertEqual(embed.to_dict(), case["expected_output"].to_dict()) -                send_private_embed_mock.assert_awaited_once_with(case["args"][0], embed) +                send_private_embed_mock.assert_awaited_once_with(case["args"][1], embed)      @patch("bot.exts.moderation.infraction._utils.send_private_embed")      async def test_notify_pardon(self, send_private_embed_mock): @@ -313,7 +317,8 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase):              "type": "ban",              "user": self.member.id,              "active": False, -            "expires_at": now.isoformat() +            "expires_at": now.isoformat(), +            "dm_sent": False          }          self.ctx.bot.api_client.post.return_value = "foo" @@ -350,7 +355,8 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase):              "reason": "Test reason",              "type": "mute",              "user": self.user.id, -            "active": True +            "active": True, +            "dm_sent": False          }          self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] diff --git a/tests/bot/exts/moderation/test_clean.py b/tests/bot/exts/moderation/test_clean.py new file mode 100644 index 000000000..d7647fa48 --- /dev/null +++ b/tests/bot/exts/moderation/test_clean.py @@ -0,0 +1,104 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from bot.exts.moderation.clean import Clean +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockMessage, MockRole, MockTextChannel + + +class CleanTests(unittest.IsolatedAsyncioTestCase): +    """Tests for clean cog functionality.""" + +    def setUp(self): +        self.bot = MockBot() +        self.mod = MockMember(roles=[MockRole(id=7890123, position=10)]) +        self.user = MockMember(roles=[MockRole(id=123456, position=1)]) +        self.guild = MockGuild() +        self.ctx = MockContext(bot=self.bot, author=self.mod) +        self.cog = Clean(self.bot) + +        self.log_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" +        self.cog._modlog_cleaned_messages = AsyncMock(return_value=self.log_url) + +        self.cog._use_cache = MagicMock(return_value=True) +        self.cog._delete_found = AsyncMock(return_value=[42, 84]) + +    @patch("bot.exts.moderation.clean.is_mod_channel") +    async def test_clean_deletes_invocation_in_non_mod_channel(self, mod_channel_check): +        """Clean command should delete the invocation message if ran in a non mod channel.""" +        mod_channel_check.return_value = False +        self.ctx.message.delete = AsyncMock() + +        self.assertIsNone(await self.cog._delete_invocation(self.ctx)) + +        self.ctx.message.delete.assert_awaited_once() + +    @patch("bot.exts.moderation.clean.is_mod_channel") +    async def test_clean_doesnt_delete_invocation_in_mod_channel(self, mod_channel_check): +        """Clean command should not delete the invocation message if ran in a mod channel.""" +        mod_channel_check.return_value = True +        self.ctx.message.delete = AsyncMock() + +        self.assertIsNone(await self.cog._delete_invocation(self.ctx)) + +        self.ctx.message.delete.assert_not_awaited() + +    async def test_clean_doesnt_attempt_deletion_when_attempt_delete_invocation_is_false(self): +        """Clean command should not attempt to delete the invocation message if attempt_delete_invocation is false.""" +        self.cog._delete_invocation = AsyncMock() +        self.bot.get_channel = MagicMock(return_value=False) + +        self.assertEqual( +            await self.cog._clean_messages( +                self.ctx, +                None, +                first_limit=MockMessage(), +                attempt_delete_invocation=False, +            ), +            self.log_url, +        ) + +        self.cog._delete_invocation.assert_not_awaited() + +    @patch("bot.exts.moderation.clean.is_mod_channel") +    async def test_clean_replies_with_success_message_when_ran_in_mod_channel(self, mod_channel_check): +        """Clean command should reply to the message with a confirmation message if invoked in a mod channel.""" +        mod_channel_check.return_value = True +        self.ctx.reply = AsyncMock() + +        self.assertEqual( +            await self.cog._clean_messages( +                self.ctx, +                None, +                first_limit=MockMessage(), +                attempt_delete_invocation=False, +            ), +            self.log_url, +        ) + +        self.ctx.reply.assert_awaited_once() +        sent_message = self.ctx.reply.await_args[0][0] +        self.assertIn(self.log_url, sent_message) +        self.assertIn("2 messages", sent_message) + +    @patch("bot.exts.moderation.clean.is_mod_channel") +    async def test_clean_send_success_message_to_mods_when_ran_in_non_mod_channel(self, mod_channel_check): +        """Clean command should send a confirmation message to #mods if invoked in a non-mod channel.""" +        mod_channel_check.return_value = False +        mocked_mods = MockTextChannel(id=1234567) +        mocked_mods.send = AsyncMock() +        self.bot.get_channel = MagicMock(return_value=mocked_mods) + +        self.assertEqual( +            await self.cog._clean_messages( +                self.ctx, +                None, +                first_limit=MockMessage(), +                attempt_delete_invocation=False, +            ), +            self.log_url, +        ) + +        mocked_mods.send.assert_awaited_once() +        sent_message = mocked_mods.send.await_args[0][0] +        self.assertIn(self.log_url, sent_message) +        self.assertIn("2 messages", sent_message) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index ccc842050..cfe0c4b03 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -3,13 +3,16 @@ import enum  import logging  import typing as t  import unittest +from unittest import mock  from unittest.mock import AsyncMock, MagicMock, Mock, call, patch  import aiohttp  import discord +from async_rediscache import RedisSession  from bot.constants import Colours  from bot.exts.moderation import incidents +from bot.utils.messages import format_user  from tests.helpers import (      MockAsyncWebhook, MockAttachment, MockBot, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel,      MockUser @@ -276,6 +279,22 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase):      the instance as they wish.      """ +    session = None + +    async def flush(self): +        """Flush everything from the database to prevent carry-overs between tests.""" +        with await self.session.pool as connection: +            await connection.flushall() + +    async def asyncSetUp(self):  # noqa: N802 +        self.session = RedisSession(use_fakeredis=True) +        await self.session.connect() +        await self.flush() + +    async def asyncTearDown(self):  # noqa: N802 +        if self.session: +            await self.session.close() +      def setUp(self):          """          Prepare a fresh `Incidents` instance for each test. @@ -506,7 +525,7 @@ class TestProcessEvent(TestIncidents):          with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task):              await self.cog_instance.process_event(                  reaction=incidents.Signal.ACTIONED.value, -                incident=MockMessage(), +                incident=MockMessage(id=123),                  member=MockMember(roles=[MockRole(id=1)])              ) @@ -526,7 +545,7 @@ class TestProcessEvent(TestIncidents):              with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task):                  await self.cog_instance.process_event(                      reaction=incidents.Signal.ACTIONED.value, -                    incident=MockMessage(), +                    incident=MockMessage(id=123),                      member=MockMember(roles=[MockRole(id=1)])                  )          except asyncio.TimeoutError: @@ -761,3 +780,74 @@ class TestOnMessage(TestIncidents):              await self.cog_instance.on_message(MockMessage())          mock_add_signals.assert_not_called() + + +class TestMessageLinkEmbeds(TestIncidents): +    """Tests for `extract_message_links` coroutine.""" + +    async def test_shorten_text(self): +        """Test all cases of text shortening by mocking messages.""" +        tests = { +            "thisisasingleword"*10: "thisisasinglewordthisisasinglewordthisisasinglewor...", + +            "\n".join("Lets make a new line test".split()): "Lets\nmake\na...", + +            'Hello, World!' * 300: ( +                "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!" +                "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!" +                "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!" +                "Hello, World!Hello, World!H..." +            ) +        } + +        for content, expected_conversion in tests.items(): +            with self.subTest(content=content, expected_conversion=expected_conversion): +                conversion = incidents.shorten_text(content) +                self.assertEqual(conversion, expected_conversion) + +    async def extract_and_form_message_link_embeds(self): +        """ +        Extract message links from a mocked message and form the message link embed. + +        Considers all types of message links, discord supports. +        """ +        self.guild_id_patcher = mock.patch("bot.exts.backend.sync._cog.constants.Guild.id", 5) +        self.guild_id = self.guild_id_patcher.start() + +        msg = MockMessage(id=555, content="Hello, World!" * 3000) +        msg.channel.mention = "#lemonade-stand" + +        msg_links = [ +            # Valid Message links +            f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}", +            f"http://canary.discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}", + +            # Invalid Message links +            f"https://discord.com/channels/{msg.channel.discord_id}/{msg.discord_id}", +            f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}000/{msg.discord_id}", +        ] + +        incident_msg = MockMessage( +            id=777, +            content=( +                f"I would like to report the following messages, " +                f"as they break our rules: \n{', '.join(msg_links)}" +            ) +        ) + +        with patch( +                "bot.exts.moderation.incidents.Incidents.extract_message_links", AsyncMock() +        ) as mock_extract_message_links: +            embeds = mock_extract_message_links(incident_msg) +            description = ( +                f"**Author:** {format_user(msg.author)}\n" +                f"**Channel:** {msg.channel.mention} ({msg.channel.category}/#{msg.channel.name})\n" +                f"**Content:** {('Hello, World!' * 3000)[:300] + '...'}\n" +            ) + +            # Check number of embeds returned with number of valid links +            self.assertEqual(len(embeds), 2) + +            # Check for the embed descriptions +            for embed in embeds: +                self.assertEqual(embed.description, description) diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py index 321a92445..f68a20089 100644 --- a/tests/bot/exts/utils/test_snekbox.py +++ b/tests/bot/exts/utils/test_snekbox.py @@ -2,6 +2,7 @@ import asyncio  import unittest  from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch +from discord import AllowedMentions  from discord.ext import commands  from bot import constants @@ -16,7 +17,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          self.bot = MockBot()          self.cog = Snekbox(bot=self.bot) -    async def test_post_eval(self): +    async def test_post_job(self):          """Post the eval code to the URLs.snekbox_eval_api endpoint."""          resp = MagicMock()          resp.json = AsyncMock(return_value="return") @@ -25,7 +26,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          context_manager.__aenter__.return_value = resp          self.bot.http_session.post.return_value = context_manager -        self.assertEqual(await self.cog.post_eval("import random"), "return") +        self.assertEqual(await self.cog.post_job("import random"), "return")          self.bot.http_session.post.assert_called_with(              constants.URLs.snekbox_eval_api,              json={"input": "import random"}, @@ -44,7 +45,8 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          await self.cog.upload_output("Test output.")          mock_paste_util.assert_called_once_with("Test output.", extension="txt") -    def test_prepare_input(self): +    async def test_codeblock_converter(self): +        ctx = MockContext()          cases = (              ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'),              ('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'), @@ -60,7 +62,24 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          )          for case, expected, testname in cases:              with self.subTest(msg=f'Extract code from {testname}.'): -                self.assertEqual(self.cog.prepare_input(case), expected) +                self.assertEqual( +                    '\n'.join(await snekbox.CodeblockConverter.convert(ctx, case)), expected +                ) + +    def test_prepare_timeit_input(self): +        """Test the prepare_timeit_input codeblock detection.""" +        base_args = ('-m', 'timeit', '-s') +        cases = ( +            (['print("Hello World")'], '', 'single block of code'), +            (['x = 1', 'print(x)'], 'x = 1', 'two blocks of code'), +            (['x = 1', 'print(x)', 'print("Some other code.")'], 'x = 1', 'three blocks of code') +        ) + +        for case, setup_code, testname in cases: +            setup = snekbox.TIMEIT_SETUP_WRAPPER.format(setup=setup_code) +            expected = ('\n'.join(case[1:] if setup_code else case), [*base_args, setup]) +            with self.subTest(msg=f'Test with {testname} and expected return {expected}'): +                self.assertEqual(self.cog.prepare_timeit_input(case), expected)      def test_get_results_message(self):          """Return error and message according to the eval result.""" @@ -71,13 +90,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          )          for stdout, returncode, expected in cases:              with self.subTest(stdout=stdout, returncode=returncode, expected=expected): -                actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}) +                actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}, 'eval')                  self.assertEqual(actual, expected)      @patch('bot.exts.utils.snekbox.Signals', side_effect=ValueError)      def test_get_results_message_invalid_signal(self, mock_signals: Mock):          self.assertEqual( -            self.cog.get_results_message({'stdout': '', 'returncode': 127}), +            self.cog.get_results_message({'stdout': '', 'returncode': 127}, 'eval'),              ('Your eval job has completed with return code 127', '')          ) @@ -85,7 +104,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):      def test_get_results_message_valid_signal(self, mock_signals: Mock):          mock_signals.return_value.name = 'SIGTEST'          self.assertEqual( -            self.cog.get_results_message({'stdout': '', 'returncode': 127}), +            self.cog.get_results_message({'stdout': '', 'returncode': 127}, 'eval'),              ('Your eval job has completed with return code 127 (SIGTEST)', '')          ) @@ -155,28 +174,29 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          """Test the eval command procedure."""          ctx = MockContext()          response = MockMessage() -        self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') -        self.cog.send_eval = AsyncMock(return_value=response) -        self.cog.continue_eval = AsyncMock(return_value=None) +        ctx.command = MagicMock() -        await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode') -        self.cog.prepare_input.assert_called_once_with('MyAwesomeCode') -        self.cog.send_eval.assert_called_once_with(ctx, 'MyAwesomeFormattedCode') -        self.cog.continue_eval.assert_called_once_with(ctx, response) +        self.cog.send_job = AsyncMock(return_value=response) +        self.cog.continue_job = AsyncMock(return_value=(None, None)) + +        await self.cog.eval_command(self.cog, ctx=ctx, code=['MyAwesomeCode']) +        self.cog.send_job.assert_called_once_with(ctx, 'MyAwesomeCode', args=None, job_name='eval') +        self.cog.continue_job.assert_called_once_with(ctx, response, ctx.command)      async def test_eval_command_evaluate_twice(self):          """Test the eval and re-eval command procedure."""          ctx = MockContext()          response = MockMessage() -        self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') -        self.cog.send_eval = AsyncMock(return_value=response) -        self.cog.continue_eval = AsyncMock() -        self.cog.continue_eval.side_effect = ('MyAwesomeCode-2', None) - -        await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode') -        self.cog.prepare_input.has_calls(call('MyAwesomeCode'), call('MyAwesomeCode-2')) -        self.cog.send_eval.assert_called_with(ctx, 'MyAwesomeFormattedCode') -        self.cog.continue_eval.assert_called_with(ctx, response) +        ctx.command = MagicMock() +        self.cog.send_job = AsyncMock(return_value=response) +        self.cog.continue_job = AsyncMock() +        self.cog.continue_job.side_effect = (('MyAwesomeFormattedCode', None), (None, None)) + +        await self.cog.eval_command(self.cog, ctx=ctx, code=['MyAwesomeCode']) +        self.cog.send_job.assert_called_with( +            ctx, 'MyAwesomeFormattedCode', args=None, job_name='eval' +        ) +        self.cog.continue_job.assert_called_with(ctx, response, ctx.command)      async def test_eval_command_reject_two_eval_at_the_same_time(self):          """Test if the eval command rejects an eval if the author already have a running eval.""" @@ -190,90 +210,99 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):              "@LemonLemonishBeard#0042 You've already got a job running - please wait for it to finish!"          ) -    async def test_eval_command_call_help(self): -        """Test if the eval command call the help command if no code is provided.""" -        ctx = MockContext(command="sentinel") -        await self.cog.eval_command(self.cog, ctx=ctx, code='') -        ctx.send_help.assert_called_once_with(ctx.command) - -    async def test_send_eval(self): -        """Test the send_eval function.""" +    async def test_send_job(self): +        """Test the send_job function."""          ctx = MockContext()          ctx.message = MockMessage()          ctx.send = AsyncMock() -        ctx.author.mention = '@LemonLemonishBeard#0042' +        ctx.author = MockUser(mention='@LemonLemonishBeard#0042') -        self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) +        self.cog.post_job = AsyncMock(return_value={'stdout': '', 'returncode': 0})          self.cog.get_results_message = MagicMock(return_value=('Return code 0', ''))          self.cog.get_status_emoji = MagicMock(return_value=':yay!:')          self.cog.format_output = AsyncMock(return_value=('[No output]', None))          mocked_filter_cog = MagicMock() -        mocked_filter_cog.filter_eval = AsyncMock(return_value=False) +        mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=False)          self.bot.get_cog.return_value = mocked_filter_cog -        await self.cog.send_eval(ctx, 'MyAwesomeCode') -        ctx.send.assert_called_once_with( +        await self.cog.send_job(ctx, 'MyAwesomeCode', job_name='eval') + +        ctx.send.assert_called_once() +        self.assertEqual( +            ctx.send.call_args.args[0],              '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```\n[No output]\n```'          ) -        self.cog.post_eval.assert_called_once_with('MyAwesomeCode') +        allowed_mentions = ctx.send.call_args.kwargs['allowed_mentions'] +        expected_allowed_mentions = AllowedMentions(everyone=False, roles=False, users=[ctx.author]) +        self.assertEqual(allowed_mentions.to_dict(), expected_allowed_mentions.to_dict()) + +        self.cog.post_job.assert_called_once_with('MyAwesomeCode', args=None)          self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0}) -        self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0}) +        self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0}, 'eval')          self.cog.format_output.assert_called_once_with('') -    async def test_send_eval_with_paste_link(self): -        """Test the send_eval function with a too long output that generate a paste link.""" +    async def test_send_job_with_paste_link(self): +        """Test the send_job function with a too long output that generate a paste link."""          ctx = MockContext()          ctx.message = MockMessage()          ctx.send = AsyncMock()          ctx.author.mention = '@LemonLemonishBeard#0042' -        self.cog.post_eval = AsyncMock(return_value={'stdout': 'Way too long beard', 'returncode': 0}) +        self.cog.post_job = AsyncMock(return_value={'stdout': 'Way too long beard', 'returncode': 0})          self.cog.get_results_message = MagicMock(return_value=('Return code 0', ''))          self.cog.get_status_emoji = MagicMock(return_value=':yay!:')          self.cog.format_output = AsyncMock(return_value=('Way too long beard', 'lookatmybeard.com'))          mocked_filter_cog = MagicMock() -        mocked_filter_cog.filter_eval = AsyncMock(return_value=False) +        mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=False)          self.bot.get_cog.return_value = mocked_filter_cog -        await self.cog.send_eval(ctx, 'MyAwesomeCode') -        ctx.send.assert_called_once_with( +        await self.cog.send_job(ctx, 'MyAwesomeCode', job_name='eval') + +        ctx.send.assert_called_once() +        self.assertEqual( +            ctx.send.call_args.args[0],              '@LemonLemonishBeard#0042 :yay!: Return code 0.'              '\n\n```\nWay too long beard\n```\nFull output: lookatmybeard.com'          ) -        self.cog.post_eval.assert_called_once_with('MyAwesomeCode') + +        self.cog.post_job.assert_called_once_with('MyAwesomeCode', args=None)          self.cog.get_status_emoji.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) -        self.cog.get_results_message.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) +        self.cog.get_results_message.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}, 'eval')          self.cog.format_output.assert_called_once_with('Way too long beard') -    async def test_send_eval_with_non_zero_eval(self): -        """Test the send_eval function with a code returning a non-zero code.""" +    async def test_send_job_with_non_zero_eval(self): +        """Test the send_job function with a code returning a non-zero code."""          ctx = MockContext()          ctx.message = MockMessage()          ctx.send = AsyncMock()          ctx.author.mention = '@LemonLemonishBeard#0042' -        self.cog.post_eval = AsyncMock(return_value={'stdout': 'ERROR', 'returncode': 127}) +        self.cog.post_job = AsyncMock(return_value={'stdout': 'ERROR', 'returncode': 127})          self.cog.get_results_message = MagicMock(return_value=('Return code 127', 'Beard got stuck in the eval'))          self.cog.get_status_emoji = MagicMock(return_value=':nope!:')          self.cog.format_output = AsyncMock()  # This function isn't called          mocked_filter_cog = MagicMock() -        mocked_filter_cog.filter_eval = AsyncMock(return_value=False) +        mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=False)          self.bot.get_cog.return_value = mocked_filter_cog -        await self.cog.send_eval(ctx, 'MyAwesomeCode') -        ctx.send.assert_called_once_with( +        await self.cog.send_job(ctx, 'MyAwesomeCode', job_name='eval') + +        ctx.send.assert_called_once() +        self.assertEqual( +            ctx.send.call_args.args[0],              '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```\nBeard got stuck in the eval\n```'          ) -        self.cog.post_eval.assert_called_once_with('MyAwesomeCode') + +        self.cog.post_job.assert_called_once_with('MyAwesomeCode', args=None)          self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) -        self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) +        self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}, 'eval')          self.cog.format_output.assert_not_called()      @patch("bot.exts.utils.snekbox.partial") -    async def test_continue_eval_does_continue(self, partial_mock): -        """Test that the continue_eval function does continue if required conditions are met.""" +    async def test_continue_job_does_continue(self, partial_mock): +        """Test that the continue_job function does continue if required conditions are met."""          ctx = MockContext(message=MockMessage(add_reaction=AsyncMock(), clear_reactions=AsyncMock()))          response = MockMessage(delete=AsyncMock())          new_msg = MockMessage() @@ -281,30 +310,30 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          expected = "NewCode"          self.cog.get_code = create_autospec(self.cog.get_code, spec_set=True, return_value=expected) -        actual = await self.cog.continue_eval(ctx, response) -        self.cog.get_code.assert_awaited_once_with(new_msg) -        self.assertEqual(actual, expected) +        actual = await self.cog.continue_job(ctx, response, self.cog.eval_command) +        self.cog.get_code.assert_awaited_once_with(new_msg, ctx.command) +        self.assertEqual(actual, (expected, None))          self.bot.wait_for.assert_has_awaits(              (                  call(                      'message_edit', -                    check=partial_mock(snekbox.predicate_eval_message_edit, ctx), -                    timeout=snekbox.REEVAL_TIMEOUT, +                    check=partial_mock(snekbox.predicate_message_edit, ctx), +                    timeout=snekbox.REDO_TIMEOUT,                  ), -                call('reaction_add', check=partial_mock(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) +                call('reaction_add', check=partial_mock(snekbox.predicate_emoji_reaction, ctx), timeout=10)              )          ) -        ctx.message.add_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) -        ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) +        ctx.message.add_reaction.assert_called_once_with(snekbox.REDO_EMOJI) +        ctx.message.clear_reaction.assert_called_once_with(snekbox.REDO_EMOJI)          response.delete.assert_called_once() -    async def test_continue_eval_does_not_continue(self): +    async def test_continue_job_does_not_continue(self):          ctx = MockContext(message=MockMessage(clear_reactions=AsyncMock()))          self.bot.wait_for.side_effect = asyncio.TimeoutError -        actual = await self.cog.continue_eval(ctx, MockMessage()) -        self.assertEqual(actual, None) -        ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) +        actual = await self.cog.continue_job(ctx, MockMessage(), self.cog.eval_command) +        self.assertEqual(actual, (None, None)) +        ctx.message.clear_reaction.assert_called_once_with(snekbox.REDO_EMOJI)      async def test_get_code(self):          """Should return 1st arg (or None) if eval cmd in message, otherwise return full content.""" @@ -327,13 +356,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):                  self.bot.get_context.return_value = MockContext(command=command)                  message = MockMessage(content=content) -                actual_code = await self.cog.get_code(message) +                actual_code = await self.cog.get_code(message, self.cog.eval_command)                  self.bot.get_context.assert_awaited_once_with(message)                  self.assertEqual(actual_code, expected_code) -    def test_predicate_eval_message_edit(self): -        """Test the predicate_eval_message_edit function.""" +    def test_predicate_message_edit(self): +        """Test the predicate_message_edit function."""          msg0 = MockMessage(id=1, content='abc')          msg1 = MockMessage(id=2, content='abcdef')          msg2 = MockMessage(id=1, content='abcdef') @@ -346,18 +375,18 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          for ctx_msg, new_msg, expected, testname in cases:              with self.subTest(msg=f'Messages with {testname} return {expected}'):                  ctx = MockContext(message=ctx_msg) -                actual = snekbox.predicate_eval_message_edit(ctx, ctx_msg, new_msg) +                actual = snekbox.predicate_message_edit(ctx, ctx_msg, new_msg)                  self.assertEqual(actual, expected) -    def test_predicate_eval_emoji_reaction(self): -        """Test the predicate_eval_emoji_reaction function.""" +    def test_predicate_emoji_reaction(self): +        """Test the predicate_emoji_reaction function."""          valid_reaction = MockReaction(message=MockMessage(id=1)) -        valid_reaction.__str__.return_value = snekbox.REEVAL_EMOJI +        valid_reaction.__str__.return_value = snekbox.REDO_EMOJI          valid_ctx = MockContext(message=MockMessage(id=1), author=MockUser(id=2))          valid_user = MockUser(id=2)          invalid_reaction_id = MockReaction(message=MockMessage(id=42)) -        invalid_reaction_id.__str__.return_value = snekbox.REEVAL_EMOJI +        invalid_reaction_id.__str__.return_value = snekbox.REDO_EMOJI          invalid_user_id = MockUser(id=42)          invalid_reaction_str = MockReaction(message=MockMessage(id=1))          invalid_reaction_str.__str__.return_value = ':longbeard:' @@ -370,7 +399,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          )          for reaction, user, expected, testname in cases:              with self.subTest(msg=f'Test with {testname} and expected return {expected}'): -                actual = snekbox.predicate_eval_emoji_reaction(valid_ctx, reaction, user) +                actual = snekbox.predicate_emoji_reaction(valid_ctx, reaction, user)                  self.assertEqual(actual, expected) diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index 988b3857b..1bb678db2 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch  from dateutil.relativedelta import relativedelta  from discord.ext.commands import BadArgument -from bot.converters import Duration, HushDurationConverter, ISODateTime, PackageName, TagNameConverter +from bot.converters import Duration, HushDurationConverter, ISODateTime, PackageName  class ConverterTests(unittest.IsolatedAsyncioTestCase): @@ -19,21 +19,6 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase):          cls.fixed_utc_now = datetime.fromisoformat('2019-01-01T00:00:00+00:00') -    async def test_tag_name_converter_for_invalid(self): -        """TagNameConverter should raise the correct exception for invalid tag names.""" -        test_values = ( -            ('👋', "Don't be ridiculous, you can't use that character!"), -            ('', "Tag names should not be empty, or filled with whitespace."), -            ('  ', "Tag names should not be empty, or filled with whitespace."), -            ('42', "Tag names must contain at least one letter."), -            ('x' * 128, "Are you insane? That's way too long!"), -        ) - -        for invalid_name, exception_message in test_values: -            with self.subTest(invalid_name=invalid_name, exception_message=exception_message): -                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): -                    await TagNameConverter.convert(self.context, invalid_name) -      async def test_package_name_for_valid(self):          """PackageName returns valid package names unchanged."""          test_values = ('foo', 'le_mon', 'num83r') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index a3dcbfc0a..120d65176 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -13,13 +13,15 @@ class TimeTests(unittest.TestCase):          """humanize_delta should be able to handle unknown units, and will not abort."""          # Does not abort for unknown units, as the unit name is checked          # against the attribute of the relativedelta instance. -        self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'elephants', 2), '2 days and 2 hours') +        actual = time.humanize_delta(relativedelta(days=2, hours=2), precision='elephants', max_units=2) +        self.assertEqual(actual, '2 days and 2 hours')      def test_humanize_delta_handle_high_units(self):          """humanize_delta should be able to handle very high units."""          # Very high maximum units, but it only ever iterates over          # each value the relativedelta might have. -        self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'hours', 20), '2 days and 2 hours') +        actual = time.humanize_delta(relativedelta(days=2, hours=2), precision='hours', max_units=20) +        self.assertEqual(actual, '2 days and 2 hours')      def test_humanize_delta_should_normal_usage(self):          """Testing humanize delta.""" @@ -32,7 +34,8 @@ class TimeTests(unittest.TestCase):          for delta, precision, max_units, expected in test_cases:              with self.subTest(delta=delta, precision=precision, max_units=max_units, expected=expected): -                self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) +                actual = time.humanize_delta(delta, precision=precision, max_units=max_units) +                self.assertEqual(actual, expected)      def test_humanize_delta_raises_for_invalid_max_units(self):          """humanize_delta should raises ValueError('max_units must be positive') for invalid max_units.""" @@ -40,22 +43,11 @@ class TimeTests(unittest.TestCase):          for max_units in test_cases:              with self.subTest(max_units=max_units), self.assertRaises(ValueError) as error: -                time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) -            self.assertEqual(str(error.exception), 'max_units must be positive') - -    def test_parse_rfc1123(self): -        """Testing parse_rfc1123.""" -        self.assertEqual( -            time.parse_rfc1123('Sun, 15 Sep 2019 12:00:00 GMT'), -            datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc) -        ) - -    def test_format_infraction(self): -        """Testing format_infraction.""" -        self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '<t:1576108860:f>') +                time.humanize_delta(relativedelta(days=2, hours=2), precision='hours', max_units=max_units) +            self.assertEqual(str(error.exception), 'max_units must be positive.') -    def test_format_infraction_with_duration_none_expiry(self): -        """format_infraction_with_duration should work for None expiry.""" +    def test_format_with_duration_none_expiry(self): +        """format_with_duration should work for None expiry."""          test_cases = (              (None, None, None, None), @@ -67,10 +59,10 @@ class TimeTests(unittest.TestCase):          for expiry, date_from, max_units, expected in test_cases:              with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): -                self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) +                self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected) -    def test_format_infraction_with_duration_custom_units(self): -        """format_infraction_with_duration should work for custom max_units.""" +    def test_format_with_duration_custom_units(self): +        """format_with_duration should work for custom max_units."""          test_cases = (              ('3000-12-12T00:01:00Z', datetime(3000, 12, 11, 12, 5, 5, tzinfo=timezone.utc), 6,               '<t:32533488060:f> (11 hours, 55 minutes and 55 seconds)'), @@ -80,10 +72,10 @@ class TimeTests(unittest.TestCase):          for expiry, date_from, max_units, expected in test_cases:              with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): -                self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) +                self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected) -    def test_format_infraction_with_duration_normal_usage(self): -        """format_infraction_with_duration should work for normal usage, across various durations.""" +    def test_format_with_duration_normal_usage(self): +        """format_with_duration should work for normal usage, across various durations."""          utc = timezone.utc          test_cases = (              ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5, tzinfo=utc), 2, @@ -105,11 +97,11 @@ class TimeTests(unittest.TestCase):          for expiry, date_from, max_units, expected in test_cases:              with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): -                self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) +                self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected)      def test_until_expiration_with_duration_none_expiry(self): -        """until_expiration should work for None expiry.""" -        self.assertEqual(time.until_expiration(None), None) +        """until_expiration should return "Permanent" is expiry is None.""" +        self.assertEqual(time.until_expiration(None), "Permanent")      def test_until_expiration_with_duration_custom_units(self):          """until_expiration should work for custom max_units.""" @@ -130,7 +122,6 @@ class TimeTests(unittest.TestCase):              ('3000-12-12T00:00:00Z', '<t:32533488000:R>'),              ('3000-11-23T20:09:00Z', '<t:32531918940:R>'),              ('3000-11-23T20:09:00Z', '<t:32531918940:R>'), -            (None, None),          )          for expiry, expected in test_cases: | 
