From 1dc08b8622b4d99bc7c480da5f91f774fdfd0787 Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Tue, 22 Oct 2019 21:53:39 +0530 Subject: Modify in_channel_check to accept list of channels - Update test cases for in_channel_check --- tests/utils/test_checks.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'tests') diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py index 7121acebd..ef1144ac9 100644 --- a/tests/utils/test_checks.py +++ b/tests/utils/test_checks.py @@ -57,10 +57,10 @@ def test_without_role_check_without_unwanted_role(context): def test_in_channel_check_for_correct_channel(context): - context.channel.id = 42 - assert checks.in_channel_check(context, context.channel.id) + context.channel.id = [42] + assert checks.in_channel_check(context, *context.channel.id) def test_in_channel_check_for_incorrect_channel(context): - context.channel.id = 42 - assert not checks.in_channel_check(context, context.channel.id + 10) + context.channel.id = [42 + 10] + assert not checks.in_channel_check(context, *context.channel.id) -- cgit v1.2.3 From a11596de969a53853151ad8a5ca2d6564227e0ab Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Tue, 22 Oct 2019 22:37:22 +0530 Subject: Add test cases for in_channel_check --- tests/bot/utils/test_checks.py | 8 ++++++++ tests/utils/test_checks.py | 0 2 files changed, 8 insertions(+) delete mode 100644 tests/utils/test_checks.py (limited to 'tests') diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py index 22dc93073..19b758336 100644 --- a/tests/bot/utils/test_checks.py +++ b/tests/bot/utils/test_checks.py @@ -41,3 +41,11 @@ class ChecksTests(unittest.TestCase): role_id = 42 self.ctx.author.roles.append(MockRole(role_id=role_id)) self.assertTrue(checks.without_role_check(self.ctx, role_id + 10)) + + def test_in_channel_check_for_correct_channel(self): + self.ctx.channel.id = 42 + self.assertTrue(checks.in_channel_check(self.ctx, *[42])) + + def test_in_channel_check_for_incorrect_channel(self): + self.ctx.channel.id = 42 + 10 + self.assertFalse(checks.in_channel_check(self.ctx, *[42])) diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py deleted file mode 100644 index e69de29bb..000000000 -- cgit v1.2.3 From c5d0eb473a9a1dc486dd2dd60603463435e49da4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 28 Oct 2019 15:56:34 +0100 Subject: Change generation of child mocks - https://docs.python.org/3/library/unittest.mock.html We previously used an override of the `__new__` method to prevent our custom mock types from instantiating their children with their own type instead of a general mock type like `MagicMock` or `Mock`. As it turns out, the Python documentation suggests another method of doing this that does not involve overriding `__new__`. This commit implements this new method to make sure we're using the idiomatic way of handling this. The suggested method is overriding the `_get_child_mock` method in the subclass. To make our code DRY, I've created a mixin that should come BEFORE the mock type we're subclassing in the MRO. --- In addition, I have also added this new mixin to our `AsyncMock` class to make sure that its `__call__` method returns a proper mock object after it has been awaited. This makes sure that subsequent attribute access on the returned object is mocked as expected. --- tests/helpers.py | 85 +++++++++++++++++++++++++++++++-------------------- tests/test_helpers.py | 18 +++++++++-- 2 files changed, 67 insertions(+), 36 deletions(-) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 892d42e6c..9375d0986 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -24,19 +24,6 @@ def async_test(wrapped): return wrapper -# TODO: Remove me in Python 3.8 -class AsyncMock(unittest.mock.MagicMock): - """ - A MagicMock subclass to mock async callables. - - Python 3.8 will introduce an AsyncMock class in the standard library that will have some more - features; this stand-in only overwrites the `__call__` method to an async version. - """ - - async def __call__(self, *args, **kwargs): - return super(AsyncMock, self).__call__(*args, **kwargs) - - class HashableMixin(discord.mixins.EqualityComparable): """ Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. @@ -61,15 +48,43 @@ class ColourMixin: self.colour = color -class AttributeMock: +class GetChildMockMixin: """Ensures attributes of our mock types will be instantiated with the correct mock type.""" - def __new__(cls, *args, **kwargs): - """Stops the regular parent class from propagating to newly mocked attributes.""" - if 'parent' in kwargs: - return cls.attribute_mocktype(*args, **kwargs) + def _get_child_mock(self, **kw): + """ + Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes. + + Mock objects automatically create children when you access an attribute or call a method on them. By default, + the class of these children is the type of the parent itself. However, this would mean that the children created + for our custom mock types would also be instances of that custom mock type. This is not desirable, as attributes + of, e.g., a `Bot` object are not `Bot` objects themselves. The Python docs for `unittest.mock` hint that + overwriting this method is the best way to deal with that. + + This override will look for an attribute called `child_mock_type` and use that as the type of the child mock. + """ + klass = self.child_mock_type - return super().__new__(cls) + if self._mock_sealed: + attribute = "." + kw["name"] if "name" in kw else "()" + mock_name = self._extract_mock_name() + attribute + raise AttributeError(mock_name) + + return klass(**kw) + + +# TODO: Remove me in Python 3.8 +class AsyncMock(GetChildMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock async callables. + + Python 3.8 will introduce an AsyncMock class in the standard library that will have some more + features; this stand-in only overwrites the `__call__` method to an async version. + """ + child_mock_type = unittest.mock.MagicMock + + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) # Create a guild instance to get a realistic Mock of `discord.Guild` @@ -95,7 +110,7 @@ guild_data = { guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) -class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): +class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): """ A `Mock` subclass to mock `discord.Guild` objects. @@ -122,7 +137,7 @@ class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__( self, @@ -175,7 +190,7 @@ role_data = {'name': 'role', 'id': 1} role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) -class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockRole(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock `discord.Role` objects. @@ -183,7 +198,7 @@ class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: super().__init__(spec=role_instance, **kwargs) @@ -208,7 +223,7 @@ state_mock = unittest.mock.MagicMock() member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) -class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockMember(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock Member objects. @@ -216,7 +231,7 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__( self, @@ -254,7 +269,7 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) -class MockBot(AttributeMock, unittest.mock.MagicMock): +class MockBot(GetChildMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Bot objects. @@ -262,11 +277,15 @@ class MockBot(AttributeMock, unittest.mock.MagicMock): For more information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs) -> None: super().__init__(spec=bot_instance, **kwargs) + # Our custom attributes and methods + self.http_session = unittest.mock.MagicMock() + self.api_client = unittest.mock.MagicMock() + # `discord.ext.commands.Bot` coroutines self._before_invoke = AsyncMock() self._after_invoke = AsyncMock() @@ -303,7 +322,7 @@ class MockBot(AttributeMock, unittest.mock.MagicMock): context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) -class MockContext(AttributeMock, unittest.mock.MagicMock): +class MockContext(GetChildMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Context objects. @@ -311,7 +330,7 @@ class MockContext(AttributeMock, unittest.mock.MagicMock): instances. For more information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs) -> None: super().__init__(spec=context_instance, **kwargs) @@ -346,7 +365,7 @@ guild = unittest.mock.MagicMock() channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) -class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin): +class MockTextChannel(GetChildMockMixin, unittest.mock.Mock, HashableMixin): """ A MagicMock subclass to mock TextChannel objects. @@ -354,7 +373,7 @@ class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin): more information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: super().__init__(spec=channel_instance, **kwargs) @@ -402,7 +421,7 @@ channel = unittest.mock.MagicMock() message_instance = discord.Message(state=state, channel=channel, data=message_data) -class MockMessage(AttributeMock, unittest.mock.MagicMock): +class MockMessage(GetChildMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Message objects. @@ -410,7 +429,7 @@ class MockMessage(AttributeMock, unittest.mock.MagicMock): information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs) -> None: super().__init__(spec=message_instance, **kwargs) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f08239981..62007ff4e 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -221,10 +221,10 @@ class DiscordMocksTests(unittest.TestCase): @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') def test_the_custom_mock_methods_test(self, subtest_mock): """The custom method test should raise AssertionError for invalid methods.""" - class FakeMockBot(helpers.AttributeMock, unittest.mock.MagicMock): + class FakeMockBot(helpers.GetChildMockMixin, unittest.mock.MagicMock): """Fake MockBot class with invalid attribute/method `release_the_walrus`.""" - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs): super().__init__(spec=helpers.bot_instance, **kwargs) @@ -331,6 +331,18 @@ class MockObjectTests(unittest.TestCase): self.assertFalse(instance_one != instance_two) self.assertTrue(instance_one != instance_three) + def test_get_child_mock_mixin_accepts_mock_seal(self): + """The `GetChildMockMixin` should support `unittest.mock.seal`.""" + class MyMock(helpers.GetChildMockMixin, unittest.mock.MagicMock): + + child_mock_type = unittest.mock.MagicMock + pass + + mock = MyMock() + unittest.mock.seal(mock) + with self.assertRaises(AttributeError, msg="MyMock.shirayuki"): + mock.shirayuki = "hello!" + def test_spec_propagation_of_mock_subclasses(self): """Test if the `spec` does not propagate to attributes of the mock object.""" test_values = ( @@ -346,7 +358,7 @@ class MockObjectTests(unittest.TestCase): mock = mock_type() self.assertTrue(isinstance(mock, mock_type)) attribute = getattr(mock, valid_attribute) - self.assertTrue(isinstance(attribute, mock_type.attribute_mocktype)) + self.assertTrue(isinstance(attribute, mock_type.child_mock_type)) def test_async_mock_provides_coroutine_for_dunder_call(self): """Test if AsyncMock objects have a coroutine for their __call__ method.""" -- cgit v1.2.3 From 618ba6a523dababde230382e1965ecc89f23aaf5 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 29 Oct 2019 10:04:37 +0100 Subject: Enhance custom mock helpers I have enhanced the custom mocks defined in `tests/helpers.py` in a couple of important ways. 1. Automatically create AsyncMock attributes using `inspect` Our previous approach, hard-coding AsynckMock attributes for all the coroutine function methods defined for the class we are trying to mock is prone to human error and not resilient against changes introduced in updates of the library we are using. Instead, I have now created a helper method in our `CustomMockMixin` (formerly `GetChildMockMixin`) that automatically inspects the spec instance we've passed for `coroutine functions` using the `inspect` module. It then sets the according attributes with instances of the AsyncMock class. There is one caveat: `discord.py` very rarely defines regular methods that return a coroutine object. Since the returned coroutine should still be awaited, these regular methods should also be mocked with an AsyncMock. However, since they are regular methods, `inspect` does not detect them and they have to be added manually. (The only case of this I've found so far is `Client.wait_for`.) 2. Properly set special attributes using `kwargs.get` As we want attributes that point to other discord.py objects to use our custom mocks (.e.g, `Message.author` should use `MockMember`), the `__init__` method of our custom mocks make sure to correctly instantiate these attributes. However, the way we previously did that means we can't instantiate the custom mock with a mock instance we provide, since this special instantiation would overwrite the custom object we'd passed. I've solved this by using `kwargs.get`, with a new mock as the default value. This makes sure we only create a new mock if we didn't pass a custom one: ```py class MockMesseage: def __init__(self, **kwargs): self.author = kwargs.get('author', MockMember()) ``` As you can see, we will only create a new MockMember if we did not pass an `author` argument. 3. Factoring out duplicate lines Since our `CustomMockMixin` is a parent to all of our custom mock types, it makes sense to use it to factor out common code of all of our custom mocks. I've made the following changes: - Set a default child mock type in the mixin. - Create an `__init__` that takes care of the `inspect` of point 1 This means we won't have to repeat this in all of the child classes. 4. Three new Mock types: Emoji, PartialEmoji, and Reaction I have added three more custom mocks: - MockEmoji - MockPartialEmoji - MockReaction --- tests/helpers.py | 253 +++++++++++++++++++------------------------------- tests/test_helpers.py | 58 +++++++++++- 2 files changed, 150 insertions(+), 161 deletions(-) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 9375d0986..673beae3f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio import functools +import inspect import unittest.mock -from typing import Iterable, Optional +from typing import Any, Iterable, Optional import discord from discord.ext.commands import Bot, Context @@ -48,9 +49,16 @@ class ColourMixin: self.colour = color -class GetChildMockMixin: +class CustomMockMixin: """Ensures attributes of our mock types will be instantiated with the correct mock type.""" + child_mock_type = unittest.mock.MagicMock + + def __init__(self, spec: Any = None, **kwargs): + super().__init__(spec=spec, **kwargs) + if spec: + self._extract_coroutine_methods_from_spec_instance(spec) + def _get_child_mock(self, **kw): """ Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes. @@ -72,17 +80,20 @@ class GetChildMockMixin: return klass(**kw) + def _extract_coroutine_methods_from_spec_instance(self, source: Any) -> None: + """Automatically detect coroutine functions in `source` and set them as AsyncMock attributes.""" + for name, _method in inspect.getmembers(source, inspect.iscoroutinefunction): + setattr(self, name, AsyncMock()) + # TODO: Remove me in Python 3.8 -class AsyncMock(GetChildMockMixin, unittest.mock.MagicMock): +class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock async callables. Python 3.8 will introduce an AsyncMock class in the standard library that will have some more features; this stand-in only overwrites the `__call__` method to an async version. """ - child_mock_type = unittest.mock.MagicMock - async def __call__(self, *args, **kwargs): return super(AsyncMock, self).__call__(*args, **kwargs) @@ -110,7 +121,7 @@ guild_data = { guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) -class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): +class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): """ A `Mock` subclass to mock `discord.Guild` objects. @@ -136,9 +147,6 @@ class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ - - child_mock_type = unittest.mock.MagicMock - def __init__( self, guild_id: int = 1, @@ -158,39 +166,13 @@ class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): if members: self.members.extend(members) - # `discord.Guild` coroutines - self.create_category_channel = AsyncMock() - self.ban = AsyncMock() - self.bans = AsyncMock() - self.create_category = AsyncMock() - self.create_custom_emoji = AsyncMock() - self.create_role = AsyncMock() - self.create_text_channel = AsyncMock() - self.create_voice_channel = AsyncMock() - self.delete = AsyncMock() - self.edit = AsyncMock() - self.estimate_pruned_members = AsyncMock() - self.fetch_ban = AsyncMock() - self.fetch_channels = AsyncMock() - self.fetch_emoji = AsyncMock() - self.fetch_emojis = AsyncMock() - self.fetch_member = AsyncMock() - self.invites = AsyncMock() - self.kick = AsyncMock() - self.leave = AsyncMock() - self.prune_members = AsyncMock() - self.unban = AsyncMock() - self.vanity_invite = AsyncMock() - self.webhooks = AsyncMock() - self.widget = AsyncMock() - # Create a Role instance to get a realistic Mock of `discord.Role` role_data = {'name': 'role', 'id': 1} role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) -class MockRole(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock `discord.Role` objects. @@ -208,10 +190,6 @@ class MockRole(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin self.position = position self.mention = f'&{self.name}' - # 'discord.Role' coroutines - self.delete = AsyncMock() - self.edit = AsyncMock() - def __lt__(self, other): """Simplified position-based comparisons similar to those of `discord.Role`.""" return self.position < other.position @@ -223,16 +201,13 @@ state_mock = unittest.mock.MagicMock() member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) -class MockMember(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock Member objects. Instances of this class will follow the specifications of `discord.Member` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__( self, name: str = "member", @@ -251,34 +226,18 @@ class MockMember(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMix self.mention = f"@{self.name}" - # `discord.Member` coroutines - self.add_roles = AsyncMock() - self.ban = AsyncMock() - self.edit = AsyncMock() - self.fetch_message = AsyncMock() - self.kick = AsyncMock() - self.move_to = AsyncMock() - self.pins = AsyncMock() - self.remove_roles = AsyncMock() - self.send = AsyncMock() - self.trigger_typing = AsyncMock() - self.unban = AsyncMock() - # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) -class MockBot(GetChildMockMixin, unittest.mock.MagicMock): +class MockBot(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Bot objects. Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__(self, **kwargs) -> None: super().__init__(spec=bot_instance, **kwargs) @@ -286,69 +245,12 @@ class MockBot(GetChildMockMixin, unittest.mock.MagicMock): self.http_session = unittest.mock.MagicMock() self.api_client = unittest.mock.MagicMock() - # `discord.ext.commands.Bot` coroutines - self._before_invoke = AsyncMock() - self._after_invoke = AsyncMock() - self.application_info = AsyncMock() - self.change_presence = AsyncMock() - self.connect = AsyncMock() - self.close = AsyncMock() - self.create_guild = AsyncMock() - self.delete_invite = AsyncMock() - self.fetch_channel = AsyncMock() - self.fetch_guild = AsyncMock() - self.fetch_guilds = AsyncMock() - self.fetch_invite = AsyncMock() - self.fetch_user = AsyncMock() - self.fetch_user_profile = AsyncMock() - self.fetch_webhook = AsyncMock() - self.fetch_widget = AsyncMock() - self.get_context = AsyncMock() - self.get_prefix = AsyncMock() - self.invoke = AsyncMock() - self.is_owner = AsyncMock() - self.login = AsyncMock() - self.logout = AsyncMock() - self.on_command_error = AsyncMock() - self.on_error = AsyncMock() - self.process_commands = AsyncMock() - self.request_offline_members = AsyncMock() - self.start = AsyncMock() - self.wait_until_ready = AsyncMock() + # self.wait_for is *not* a coroutine function, but returns a coroutine nonetheless and + # and should therefore be awaited. (The documentation calls it a coroutine as well, which + # is technically incorrect, since it's a regular def.) self.wait_for = AsyncMock() -# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` -context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) - - -class MockContext(GetChildMockMixin, unittest.mock.MagicMock): - """ - A MagicMock subclass to mock Context objects. - - Instances of this class will follow the specifications of `discord.ext.commands.Context` - instances. For more information, see the `MockGuild` docstring. - """ - - child_mock_type = unittest.mock.MagicMock - - def __init__(self, **kwargs) -> None: - super().__init__(spec=context_instance, **kwargs) - self.bot = MockBot() - self.guild = MockGuild() - self.author = MockMember() - self.command = unittest.mock.MagicMock() - - # `discord.ext.commands.Context` coroutines - self.fetch_message = AsyncMock() - self.invoke = AsyncMock() - self.pins = AsyncMock() - self.reinvoke = AsyncMock() - self.send = AsyncMock() - self.send_help = AsyncMock() - self.trigger_typing = AsyncMock() - - # Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` channel_data = { 'id': 1, @@ -365,39 +267,20 @@ guild = unittest.mock.MagicMock() channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) -class MockTextChannel(GetChildMockMixin, unittest.mock.Mock, HashableMixin): +class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): """ A MagicMock subclass to mock TextChannel objects. Instances of this class will follow the specifications of `discord.TextChannel` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: super().__init__(spec=channel_instance, **kwargs) self.id = channel_id self.name = name - self.guild = MockGuild() + self.guild = kwargs.get('guild', MockGuild()) self.mention = f"#{self.name}" - # `discord.TextChannel` coroutines - self.clone = AsyncMock() - self.create_invite = AsyncMock() - self.create_webhook = AsyncMock() - self.delete = AsyncMock() - self.delete_messages = AsyncMock() - self.edit = AsyncMock() - self.fetch_message = AsyncMock() - self.invites = AsyncMock() - self.pins = AsyncMock() - self.purge = AsyncMock() - self.send = AsyncMock() - self.set_permissions = AsyncMock() - self.trigger_typing = AsyncMock() - self.webhooks = AsyncMock() - # Create a Message instance to get a realistic MagicMock of `discord.Message` message_data = { @@ -421,27 +304,83 @@ channel = unittest.mock.MagicMock() message_instance = discord.Message(state=state, channel=channel, data=message_data) -class MockMessage(GetChildMockMixin, unittest.mock.MagicMock): +# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` +context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) + + +class MockContext(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Context objects. + + Instances of this class will follow the specifications of `discord.ext.commands.Context` + instances. For more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=context_instance, **kwargs) + self.bot = kwargs.get('bot', MockBot()) + self.guild = kwargs.get('guild', MockGuild()) + self.author = kwargs.get('author', MockMember()) + self.channel = kwargs.get('channel', MockTextChannel()) + self.command = kwargs.get('command', unittest.mock.MagicMock()) + + +class MockMessage(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Message objects. Instances of this class will follow the specifications of `discord.Message` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=message_instance, **kwargs) + self.author = kwargs.get('author', MockMember()) + self.channel = kwargs.get('channel', MockTextChannel()) - child_mock_type = unittest.mock.MagicMock +emoji_data = {'require_colons': True, 'managed': True, 'id': 1, 'name': 'hyperlemon'} +emoji_instance = discord.Emoji(guild=MockGuild(), state=unittest.mock.MagicMock(), data=emoji_data) + + +class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Emoji objects. + + Instances of this class will follow the specifications of `discord.Emoji` instances. For more + information, see the `MockGuild` docstring. + """ def __init__(self, **kwargs) -> None: - super().__init__(spec=message_instance, **kwargs) - self.author = MockMember() - self.channel = MockTextChannel() - - # `discord.Message` coroutines - self.ack = AsyncMock() - self.add_reaction = AsyncMock() - self.clear_reactions = AsyncMock() - self.delete = AsyncMock() - self.edit = AsyncMock() - self.pin = AsyncMock() - self.remove_reaction = AsyncMock() - self.unpin = AsyncMock() + super().__init__(spec=emoji_instance, **kwargs) + self.guild = kwargs.get('guild', MockGuild()) + + # Get all coroutine functions and set them as AsyncMock attributes + self._extract_coroutine_methods_from_spec_instance(emoji_instance) + + +partial_emoji_instance = discord.PartialEmoji(animated=False, name='guido') + + +class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock PartialEmoji objects. + + Instances of this class will follow the specifications of `discord.PartialEmoji` instances. For + more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=partial_emoji_instance, **kwargs) + + +reaction_instance = discord.Reaction(message=MockMessage(), data={'me': True}, emoji=MockEmoji()) + + +class MockReaction(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Reaction objects. + + Instances of this class will follow the specifications of `discord.Reaction` instances. For + more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=reaction_instance, **kwargs) + self.emoji = kwargs.get('emoji', MockEmoji()) + self.message = kwargs.get('message', MockMessage()) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 62007ff4e..2b58634dd 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -221,7 +221,7 @@ class DiscordMocksTests(unittest.TestCase): @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') def test_the_custom_mock_methods_test(self, subtest_mock): """The custom method test should raise AssertionError for invalid methods.""" - class FakeMockBot(helpers.GetChildMockMixin, unittest.mock.MagicMock): + class FakeMockBot(helpers.CustomMockMixin, unittest.mock.MagicMock): """Fake MockBot class with invalid attribute/method `release_the_walrus`.""" child_mock_type = unittest.mock.MagicMock @@ -331,9 +331,9 @@ class MockObjectTests(unittest.TestCase): self.assertFalse(instance_one != instance_two) self.assertTrue(instance_one != instance_three) - def test_get_child_mock_mixin_accepts_mock_seal(self): - """The `GetChildMockMixin` should support `unittest.mock.seal`.""" - class MyMock(helpers.GetChildMockMixin, unittest.mock.MagicMock): + def test_custom_mock_mixin_accepts_mock_seal(self): + """The `CustomMockMixin` should support `unittest.mock.seal`.""" + class MyMock(helpers.CustomMockMixin, unittest.mock.MagicMock): child_mock_type = unittest.mock.MagicMock pass @@ -351,6 +351,10 @@ class MockObjectTests(unittest.TestCase): (helpers.MockMember, "display_name"), (helpers.MockBot, "owner_id"), (helpers.MockContext, "command_failed"), + (helpers.MockMessage, "mention_everyone"), + (helpers.MockEmoji, 'managed'), + (helpers.MockPartialEmoji, 'url'), + (helpers.MockReaction, 'me'), ) for mock_type, valid_attribute in test_values: @@ -360,6 +364,52 @@ class MockObjectTests(unittest.TestCase): attribute = getattr(mock, valid_attribute) self.assertTrue(isinstance(attribute, mock_type.child_mock_type)) + def test_extract_coroutine_methods_from_spec_instance_should_extract_all_and_only_coroutines(self): + """Test if all coroutine functions are extracted, but not regular methods or attributes.""" + class CoroutineDonor: + def __init__(self): + self.some_attribute = 'alpha' + + async def first_coroutine(): + """This coroutine function should be extracted.""" + + async def second_coroutine(): + """This coroutine function should be extracted.""" + + def regular_method(): + """This regular function should not be extracted.""" + + class Receiver: + pass + + donor = CoroutineDonor() + receiver = Receiver() + + helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance(receiver, donor) + + self.assertIsInstance(receiver.first_coroutine, helpers.AsyncMock) + self.assertIsInstance(receiver.second_coroutine, helpers.AsyncMock) + self.assertFalse(hasattr(receiver, 'regular_method')) + self.assertFalse(hasattr(receiver, 'some_attribute')) + + @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) + @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") + def test_custom_mock_mixin_init_with_spec(self, extract_method_mock): + """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" + spec = "pydis" + + helpers.CustomMockMixin(spec=spec) + + extract_method_mock.assert_called_once_with(spec) + + @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) + @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") + def test_custom_mock_mixin_init_without_spec(self, extract_method_mock): + """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" + helpers.CustomMockMixin() + + extract_method_mock.assert_not_called() + def test_async_mock_provides_coroutine_for_dunder_call(self): """Test if AsyncMock objects have a coroutine for their __call__ method.""" async_mock = helpers.AsyncMock() -- cgit v1.2.3 From 586ae18842de0dd92e93945c63ed5e6cd158c1f7 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 30 Oct 2019 22:54:27 +0100 Subject: Update docstring and remove redundant attribute I accidentally forgot to update the docstring of `CustomMockMixin`, which changed quite dramatically in scope with the last commit. This commit remedies that. In addition, I inadvertently forgot to remove the `child_mock_type` class attribute from `MockRole`. Since it uses the default value, it is no longer necessary to specify it in the child class as well. --- tests/helpers.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 673beae3f..8496ba031 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -50,7 +50,15 @@ class ColourMixin: class CustomMockMixin: - """Ensures attributes of our mock types will be instantiated with the correct mock type.""" + """ + Provides common functionality for our custom Mock types. + + The cooperative `__init__` automatically creates `AsyncMock` attributes for every coroutine + function `inspect` detects in the `spec` instance we provide. In addition, this mixin takes care + of making sure child mocks are instantiated with the correct class. By default, the mock of the + children will be `unittest.mock.MagicMock`, but this can be overwritten by setting the attribute + `child_mock_type` on the custom mock inheriting from this mixin. + """ child_mock_type = unittest.mock.MagicMock @@ -179,9 +187,6 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): Instances of this class will follow the specifications of `discord.Role` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: super().__init__(spec=role_instance, **kwargs) -- cgit v1.2.3 From a98485e173208cc2272f5c6355ddaf5858050403 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Thu, 31 Oct 2019 07:39:03 +0100 Subject: Figure out which tests we need. This adds empty tests for all the tests I'd like to add to this pull request. It also adds a few more duckies to the emoji constant list, and adds a single line of clarification to the testing readme. --- bot/constants.py | 8 +++- tests/README.md | 1 + tests/bot/cogs/test_duck_pond.py | 80 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 tests/bot/cogs/test_duck_pond.py (limited to 'tests') diff --git a/bot/constants.py b/bot/constants.py index 79845711d..dbbf32063 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -261,8 +261,13 @@ class Emojis(metaclass=YAMLGetter): pencil: str cross_mark: str - ducky: int + ducky_yellow: int ducky_blurple: int + ducky_regal: int + ducky_camo: int + ducky_ninja: int + ducky_devil: int + ducky_tube: int class Icons(metaclass=YAMLGetter): @@ -341,7 +346,6 @@ class Channels(metaclass=YAMLGetter): defcon: int devlog: int devtest: int - duck_pond: int help_0: int help_1: int help_2: int diff --git a/tests/README.md b/tests/README.md index 6ab9bc93e..d052de2f6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -15,6 +15,7 @@ We are using the following modules and packages for our unit tests: To ensure the results you obtain on your personal machine are comparable to those generated in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts: - `pipenv run test` will run `unittest` with `coverage.py` +- `pipenv run test path/to/test.py` will run a specific test. - `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. If you want a coverage report, make sure to run the tests with `pipenv run test` *first*. diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py new file mode 100644 index 000000000..79f11843b --- /dev/null +++ b/tests/bot/cogs/test_duck_pond.py @@ -0,0 +1,80 @@ +import logging +import unittest +from unittest.mock import MagicMock + +from bot.cogs import duck_pond +from tests.helpers import MockBot, MockMessage + + +class DuckPondTest(unittest.TestCase): + """Tests the `DuckPond` cog.""" + + def setUp(self): + """Adds the cog, a bot, and a message to the instance for usage in tests.""" + self.bot = MockBot() + self.cog = duck_pond.DuckPond(bot=self.bot) + + self.msg = MockMessage(message_id=555, content='') + self.msg.author.__str__ = MagicMock() + self.msg.author.__str__.return_value = 'lemon' + self.msg.author.bot = False + self.msg.author.avatar_url_as.return_value = 'picture-lemon.png' + self.msg.author.id = 42 + self.msg.author.mention = '@lemon' + self.msg.channel.mention = "#lemonade-stand" + + def test_is_staff_correctly_identifies_staff(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_has_green_checkmark(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_count_custom_duck_emojis(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_count_unicode_duck_emojis(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_count_mixed_duck_emojis(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_raw_reaction_add_rejects_bot(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_raw_reaction_add_rejects_non_staff(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_raw_reaction_add_sends_message_on_valid_input(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_raw_reaction_remove_rejects_non_checkmarks(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + def test_raw_reaction_remove_prevents_checkmark_removal(self): + """A string decoding to numeric characters is a valid user ID.""" + pass + + +class DuckPondSetupTests(unittest.TestCase): + """Tests setup of the `DuckPond` cog.""" + + def test_setup(self): + """Setup of the cog should log a message at `INFO` level.""" + bot = MockBot() + log = logging.getLogger('bot.cogs.duck_pond') + + with self.assertLogs(logger=log, level=logging.INFO) as log_watcher: + duck_pond.setup(bot) + line = log_watcher.output[0] + + bot.add_cog.assert_called_once() + self.assertIn("Cog loaded: DuckPond", line) -- cgit v1.2.3 From 6e57f41d727c9cde51d36adc04393c3723d62472 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 28 Oct 2019 15:54:31 +0100 Subject: Enhance the output of the user command https://github.com/python-discord/bot/issues/628 https://github.com/python-discord/bot/issues/339 This commit introduces several changes to the output of the `!user` command for moderation staff. The output for regular users has not changed. Changes: - When issued in a moderation channel, the infraction count of the user will now be broken down by type as described in #339. This allows moderators to get a quicker overview of someone's history by providing more information. The command will display the total number of infractions per type, with the number of active infractions in parentheses behind it if there are any. This change also means that there no longer a need for the `hidden` parameter: When issued in a moderation channel, hidden infractions are included by default; when issued outside of a mod channel, the command will be equal to what a regular user would get. In addition to broken-down infraction info, the command now also shows information about the nominations of a user when it's issued inside of a moderation channel. - The code has been refactored to smaller units that take care of a single action to make unit testing easier. I have included tests that cover the command and all of the new helper methods. Tests for the other methods/commands in the cog will be added in the specific issue calling for tests for this cog (#581) This commit closes #628 and closes #339 --- bot/cogs/information.py | 170 ++++++++++---- tests/bot/cogs/test_information.py | 444 +++++++++++++++++++++++++++++++++++-- 2 files changed, 552 insertions(+), 62 deletions(-) (limited to 'tests') diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 3a7ba0444..4a3af7edd 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -3,6 +3,7 @@ import logging import pprint import textwrap import typing +from collections import defaultdict from typing import Any, Mapping, Optional import discord @@ -10,7 +11,7 @@ from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, V from discord.ext import commands from discord.ext.commands import Bot, BucketType, Cog, Context, command, group -from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES +from bot import constants from bot.decorators import InChannelCheckFailure, in_channel, with_role from bot.utils.checks import cooldown_with_role_bypass, with_role_check from bot.utils.time import time_since @@ -24,7 +25,7 @@ class Information(Cog): def __init__(self, bot: Bot): self.bot = bot - @with_role(*MODERATION_ROLES) + @with_role(*constants.MODERATION_ROLES) @command(name="roles") async def roles_info(self, ctx: Context) -> None: """Returns a list of all roles and their corresponding IDs.""" @@ -48,7 +49,7 @@ class Information(Cog): await ctx.send(embed=embed) - @with_role(*MODERATION_ROLES) + @with_role(*constants.MODERATION_ROLES) @command(name="role") async def role_info(self, ctx: Context, *roles: typing.Union[Role, str]) -> None: """ @@ -148,10 +149,10 @@ class Information(Cog): Channel categories: {category_channels} **Members** - {Emojis.status_online} {online} - {Emojis.status_idle} {idle} - {Emojis.status_dnd} {dnd} - {Emojis.status_offline} {offline} + {constants.Emojis.status_online} {online} + {constants.Emojis.status_idle} {idle} + {constants.Emojis.status_dnd} {dnd} + {constants.Emojis.status_offline} {offline} """) ) @@ -160,59 +161,38 @@ class Information(Cog): await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False) -> None: + async def user_info(self, ctx: Context, user: Member = None) -> None: """Returns info about a user.""" if user is None: user = ctx.author # Do a role check if this is being executed on someone other than the caller - if user != ctx.author and not with_role_check(ctx, *MODERATION_ROLES): + if user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): await ctx.send("You may not use this command on users other than yourself.") return - # Non-moderators may only do this in #bot-commands and can't see hidden infractions. - if not with_role_check(ctx, *STAFF_ROLES): - if not ctx.channel.id == Channels.bot: - raise InChannelCheckFailure(Channels.bot) - # Hide hidden infractions for users without a moderation role - hidden = False + # Non-staff may only do this in #bot-commands + if not with_role_check(ctx, *constants.STAFF_ROLES): + if not ctx.channel.id == constants.Channels.bot: + raise InChannelCheckFailure(constants.Channels.bot) - # User information + embed = await self.create_user_embed(ctx, user) + + await ctx.send(embed=embed) + + async def create_user_embed(self, ctx: Context, user: Member) -> Embed: + """Creates an embed containing information on the `user`.""" created = time_since(user.created_at, max_units=3) name = str(user) if user.nick: name = f"{user.nick} ({name})" - # Member information joined = time_since(user.joined_at, precision="days") - - # You're welcome, Volcyyyyyyyyyyyyyyyy roles = ", ".join(role.mention for role in user.roles if role.name != "@everyone") - # Infractions - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'hidden': str(hidden), - 'user__id': str(user.id) - } - ) - - infr_total = 0 - infr_active = 0 - - # At least it's readable. - for infr in infractions: - if infr["active"]: - infr_active += 1 - - infr_total += 1 - - # Let's build the embed now - embed = Embed( - title=name, - description=textwrap.dedent(f""" + description = [ + textwrap.dedent(f""" **User Information** Created: {created} Profile: {user.mention} @@ -221,17 +201,109 @@ class Information(Cog): **Member Information** Joined: {joined} Roles: {roles or None} + """).strip() + ] - **Infractions** - Total: {infr_total} - Active: {infr_active} - """) + # Show more verbose output in moderation channels for infractions and nominations + if ctx.channel.id in constants.MODERATION_CHANNELS: + description.append(await self.expanded_user_infraction_counts(user)) + description.append(await self.user_nomination_counts(user)) + else: + description.append(await self.basic_user_infraction_counts(user)) + + # Let's build the embed now + embed = Embed( + title=name, + description="\n\n".join(description) ) embed.set_thumbnail(url=user.avatar_url_as(format="png")) embed.colour = user.top_role.colour if roles else Colour.blurple() - await ctx.send(embed=embed) + return embed + + async def basic_user_infraction_counts(self, member: Member) -> str: + """Gets the total and active infraction counts for the given `member`.""" + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'hidden': 'False', + 'user__id': str(member.id) + } + ) + + total_infractions = len(infractions) + active_infractions = sum(infraction['active'] for infraction in infractions) + + infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" + + return infraction_output + + async def expanded_user_infraction_counts(self, member: Member) -> str: + """ + Gets expanded infraction counts for the given `member`. + + The counts will be split by infraction type and the number of active infractions for each type will indicated + in the output as well. + """ + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'user__id': str(member.id) + } + ) + + infraction_output = ["**Infractions**"] + if not infractions: + infraction_output.append("This user has never received an infraction.") + else: + # Count infractions split by `type` and `active` status for this user + infraction_types = set() + infraction_counter = defaultdict(int) + for infraction in infractions: + infraction_type = infraction["type"] + infraction_active = 'active' if infraction["active"] else 'inactive' + + infraction_types.add(infraction_type) + infraction_counter[f"{infraction_active} {infraction_type}"] += 1 + + # Format the output of the infraction counts + for infraction_type in sorted(infraction_types): + active_count = infraction_counter[f"active {infraction_type}"] + total_count = active_count + infraction_counter[f"inactive {infraction_type}"] + + line = f"{infraction_type.capitalize()}s: {total_count}" + if active_count: + line += f" ({active_count} active)" + + infraction_output.append(line) + + return "\n".join(infraction_output) + + async def user_nomination_counts(self, member: Member) -> str: + """Gets the active and historical nomination counts for the given `member`.""" + nominations = await self.bot.api_client.get( + 'bot/nominations', + params={ + 'user__id': str(member.id) + } + ) + + output = ["**Nominations**"] + + if not nominations: + output.append("This user has never been nominated.") + else: + count = len(nominations) + is_currently_nominated = any(nomination["active"] for nomination in nominations) + nomination_noun = "nomination" if count == 1 else "nominations" + + if is_currently_nominated: + output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") + else: + output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") + + return "\n".join(output) def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: """Format a mapping to be readable to a human.""" @@ -268,9 +340,9 @@ class Information(Cog): # remove trailing whitespace return out.rstrip() - @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=STAFF_ROLES) + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) @group(invoke_without_command=True) - @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) + @in_channel(constants.Channels.bot, bypass_roles=constants.STAFF_ROLES) async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False) -> None: """Shows information about the raw API response.""" # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 9bbd35a91..5c34541d8 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -7,7 +7,11 @@ import discord from bot import constants from bot.cogs import information -from tests.helpers import AsyncMock, MockBot, MockContext, MockGuild, MockMember, MockRole +from bot.decorators import InChannelCheckFailure +from tests import helpers + + +COG_PATH = "bot.cogs.information.Information" class InformationCogTests(unittest.TestCase): @@ -15,22 +19,22 @@ class InformationCogTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.moderator_role = MockRole(name="Moderator", role_id=constants.Roles.moderator) + cls.moderator_role = helpers.MockRole(name="Moderator", role_id=constants.Roles.moderator) def setUp(self): """Sets up fresh objects for each test.""" - self.bot = MockBot() + self.bot = helpers.MockBot() self.cog = information.Information(self.bot) - self.ctx = MockContext() + self.ctx = helpers.MockContext() self.ctx.author.roles.append(self.moderator_role) def test_roles_command_command(self): """Test if the `role_info` command correctly returns the `moderator_role`.""" self.ctx.guild.roles.append(self.moderator_role) - self.cog.roles_info.can_run = AsyncMock() + self.cog.roles_info.can_run = helpers.AsyncMock() self.cog.roles_info.can_run.return_value = True coroutine = self.cog.roles_info.callback(self.cog, self.ctx) @@ -48,7 +52,7 @@ class InformationCogTests(unittest.TestCase): def test_role_info_command(self): """Tests the `role info` command.""" - dummy_role = MockRole( + dummy_role = helpers.MockRole( name="Dummy", role_id=112233445566778899, colour=discord.Colour.blurple(), @@ -57,7 +61,7 @@ class InformationCogTests(unittest.TestCase): permissions=discord.Permissions(0) ) - admin_role = MockRole( + admin_role = helpers.MockRole( name="Admins", role_id=998877665544332211, colour=discord.Colour.red(), @@ -68,7 +72,7 @@ class InformationCogTests(unittest.TestCase): self.ctx.guild.roles.append([dummy_role, admin_role]) - self.cog.role_info.can_run = AsyncMock() + self.cog.role_info.can_run = helpers.AsyncMock() self.cog.role_info.can_run.return_value = True coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) @@ -99,7 +103,7 @@ class InformationCogTests(unittest.TestCase): def test_server_info_command(self, time_since_patch): time_since_patch.return_value = '2 days ago' - self.ctx.guild = MockGuild( + self.ctx.guild = helpers.MockGuild( features=('lemons', 'apples'), region="The Moon", roles=[self.moderator_role], @@ -121,10 +125,10 @@ class InformationCogTests(unittest.TestCase): ) ], members=[ - *(MockMember(status='online') for _ in range(2)), - *(MockMember(status='idle') for _ in range(1)), - *(MockMember(status='dnd') for _ in range(4)), - *(MockMember(status='offline') for _ in range(3)), + *(helpers.MockMember(status='online') for _ in range(2)), + *(helpers.MockMember(status='idle') for _ in range(1)), + *(helpers.MockMember(status='dnd') for _ in range(4)), + *(helpers.MockMember(status='offline') for _ in range(3)), ], member_count=1_234, icon_url='a-lemon.jpg', @@ -162,3 +166,417 @@ class InformationCogTests(unittest.TestCase): ) ) self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') + + +class UserInfractionHelperMethodTests(unittest.TestCase): + """Tests for the helper methods of the `!user` command.""" + + def setUp(self): + """Common set-up steps done before for each test.""" + self.bot = helpers.MockBot() + self.bot.api_client.get = helpers.AsyncMock() + self.cog = information.Information(self.bot) + self.member = helpers.MockMember(user_id=1234) + + def test_user_command_helper_method_get_requests(self): + """The helper methods should form the correct get requests.""" + test_values = ( + { + "helper_method": self.cog.basic_user_infraction_counts, + "expected_args": ("bot/infractions", {'hidden': 'False', 'user__id': str(self.member.id)}), + }, + { + "helper_method": self.cog.expanded_user_infraction_counts, + "expected_args": ("bot/infractions", {'user__id': str(self.member.id)}), + }, + { + "helper_method": self.cog.user_nomination_counts, + "expected_args": ("bot/nominations", {'user__id': str(self.member.id)}), + }, + ) + + for test_value in test_values: + helper_method = test_value["helper_method"] + endpoint, params = test_value["expected_args"] + + with self.subTest(method=helper_method, endpoint=endpoint, params=params): + asyncio.run(helper_method(self.member)) + self.bot.api_client.get.assert_called_once_with(endpoint, params=params) + self.bot.api_client.get.reset_mock() + + def _method_subtests(self, method, test_values, default_header): + """Helper method that runs the subtests for the different helper methods.""" + for test_value in test_values: + api_response = test_value["api response"] + expected_lines = test_value["expected_lines"] + + with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines): + self.bot.api_client.get.return_value = api_response + + expected_output = "\n".join(default_header + expected_lines) + actual_output = asyncio.run(method(self.member)) + + self.assertEqual(expected_output, actual_output) + + def test_basic_user_infraction_counts_returns_correct_strings(self): + """The method should correctly list both the total and active number of non-hidden infractions.""" + test_values = ( + # No infractions means zero counts + { + "api response": [], + "expected_lines": ["Total: 0", "Active: 0"], + }, + # Simple, single-infraction dictionaries + { + "api response": [{"type": "ban", "active": True}], + "expected_lines": ["Total: 1", "Active: 1"], + }, + { + "api response": [{"type": "ban", "active": False}], + "expected_lines": ["Total: 1", "Active: 0"], + }, + # Multiple infractions with various `active` status + { + "api response": [ + {"type": "ban", "active": True}, + {"type": "kick", "active": False}, + {"type": "ban", "active": True}, + {"type": "ban", "active": False}, + ], + "expected_lines": ["Total: 4", "Active: 2"], + }, + ) + + header = ["**Infractions**"] + + self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) + + def test_expanded_user_infraction_counts_returns_correct_strings(self): + """The method should correctly list the total and active number of all infractions split by infraction type.""" + test_values = ( + { + "api response": [], + "expected_lines": ["This user has never received an infraction."], + }, + # Shows non-hidden inactive infraction as expected + { + "api response": [{"type": "kick", "active": False, "hidden": False}], + "expected_lines": ["Kicks: 1"], + }, + # Shows non-hidden active infraction as expected + { + "api response": [{"type": "mute", "active": True, "hidden": False}], + "expected_lines": ["Mutes: 1 (1 active)"], + }, + # Shows hidden inactive infraction as expected + { + "api response": [{"type": "superstar", "active": False, "hidden": True}], + "expected_lines": ["Superstars: 1"], + }, + # Shows hidden active infraction as expected + { + "api response": [{"type": "ban", "active": True, "hidden": True}], + "expected_lines": ["Bans: 1 (1 active)"], + }, + # Correctly displays tally of multiple infractions of mixed properties in alphabetical order + { + "api response": [ + {"type": "kick", "active": False, "hidden": True}, + {"type": "ban", "active": True, "hidden": True}, + {"type": "superstar", "active": True, "hidden": True}, + {"type": "mute", "active": True, "hidden": True}, + {"type": "ban", "active": False, "hidden": False}, + {"type": "note", "active": False, "hidden": True}, + {"type": "note", "active": False, "hidden": True}, + {"type": "warn", "active": False, "hidden": False}, + {"type": "note", "active": False, "hidden": True}, + ], + "expected_lines": [ + "Bans: 2 (1 active)", + "Kicks: 1", + "Mutes: 1 (1 active)", + "Notes: 3", + "Superstars: 1 (1 active)", + "Warns: 1", + ], + }, + ) + + header = ["**Infractions**"] + + self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) + + def test_user_nomination_counts_returns_correct_strings(self): + """The method should list the number of active and historical nominations for the user.""" + test_values = ( + { + "api response": [], + "expected_lines": ["This user has never been nominated."], + }, + { + "api response": [{'active': True}], + "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], + }, + { + "api response": [{'active': True}, {'active': False}], + "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], + }, + { + "api response": [{'active': False}], + "expected_lines": ["This user has 1 historical nomination, but is currently not nominated."], + }, + { + "api response": [{'active': False}, {'active': False}], + "expected_lines": ["This user has 2 historical nominations, but is currently not nominated."], + }, + + ) + + header = ["**Nominations**"] + + self._method_subtests(self.cog.user_nomination_counts, test_values, header) + + +@unittest.mock.patch("bot.cogs.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) +@unittest.mock.patch("bot.cogs.information.constants.MODERATION_CHANNELS", new=[50]) +class UserEmbedTests(unittest.TestCase): + """Tests for the creation of the `!user` embed.""" + + def setUp(self): + """Common set-up steps done before for each test.""" + self.bot = helpers.MockBot() + self.bot.api_client.get = helpers.AsyncMock() + self.cog = information.Information(self.bot) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): + """The embed should use the string representation of the user if they don't have a nick.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) + user = helpers.MockMember() + user.nick = None + user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.title, "Mr. Hemlock") + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_nick_in_title_if_available(self): + """The embed should use the nick if it's available.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) + user = helpers.MockMember() + user.nick = "Cat lover" + user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_ignores_everyone_role(self): + """Created `!user` embeds should not contain mention of the @everyone-role.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) + admins_role = helpers.MockRole('Admins') + admins_role.colour = 100 + + # A `MockMember` has the @Everyone role by default; we add the Admins to that. + user = helpers.MockMember(roles=[admins_role], top_role=admins_role) + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertIn("&Admins", embed.description) + self.assertNotIn("&Everyone", embed.description) + + @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=helpers.AsyncMock) + @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=helpers.AsyncMock) + def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): + """The embed should contain expanded infractions and nomination info in mod channels.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=50)) + + moderators_role = helpers.MockRole('Moderators') + moderators_role.colour = 100 + + infraction_counts.return_value = "expanded infractions info" + nomination_counts.return_value = "nomination info" + + user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + infraction_counts.assert_called_once_with(user) + nomination_counts.assert_called_once_with(user) + + self.assertEqual( + textwrap.dedent(f""" + **User Information** + Created: {"1 year ago"} + Profile: {user.mention} + ID: {user.id} + + **Member Information** + Joined: {"1 year ago"} + Roles: &Moderators + + expanded infractions info + + nomination info + """).strip(), + embed.description + ) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=helpers.AsyncMock) + def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): + """The embed should contain only basic infraction data outside of mod channels.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=100)) + + moderators_role = helpers.MockRole('Moderators') + moderators_role.colour = 100 + + infraction_counts.return_value = "basic infractions info" + + user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + infraction_counts.assert_called_once_with(user) + + self.assertEqual( + textwrap.dedent(f""" + **User Information** + Created: {"1 year ago"} + Profile: {user.mention} + ID: {user.id} + + **Member Information** + Joined: {"1 year ago"} + Roles: &Moderators + + basic infractions info + """).strip(), + embed.description + ) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): + """The embed should be created with the colour of the top role, if a top role is available.""" + ctx = helpers.MockContext() + + moderators_role = helpers.MockRole('Moderators') + moderators_role.colour = 100 + + user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): + """The embed should be created with a blurple colour if the user has no assigned roles.""" + ctx = helpers.MockContext() + + user = helpers.MockMember(user_id=217) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.colour, discord.Colour.blurple()) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): + """The embed thumbnail should be set to the user's avatar in `png` format.""" + ctx = helpers.MockContext() + + user = helpers.MockMember(user_id=217) + user.avatar_url_as.return_value = "avatar url" + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + user.avatar_url_as.assert_called_once_with(format="png") + self.assertEqual(embed.thumbnail.url, "avatar url") + + +@unittest.mock.patch("bot.cogs.information.constants") +class UserCommandTests(unittest.TestCase): + """Tests for the `!user` command.""" + + def setUp(self): + """Set up steps executed before each test is run.""" + self.bot = helpers.MockBot() + self.cog = information.Information(self.bot) + + self.moderator_role = helpers.MockRole("Moderators", role_id=2, position=10) + self.flautist_role = helpers.MockRole("Flautists", role_id=3, position=2) + self.bassist_role = helpers.MockRole("Bassists", role_id=4, position=3) + + self.author = helpers.MockMember(user_id=1, name="syntaxaire") + self.moderator = helpers.MockMember(user_id=2, name="riffautae", roles=[self.moderator_role]) + self.target = helpers.MockMember(user_id=3, name="__fluzz__") + + def test_regular_member_cannot_target_another_member(self, constants): + """A regular user should not be able to use `!user` targeting another user.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + + ctx = helpers.MockContext(author=self.author) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + + ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") + + def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): + """A regular user should not be able to use this command outside of bot-commands.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=100)) + + msg = "Sorry, but you may only use this command within <#50>." + with self.assertRaises(InChannelCheckFailure, msg=msg): + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): + """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + create_embed.assert_called_once_with(ctx, self.author) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): + """A user should target itself with `!user` when a `user` argument was not provided.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) + + create_embed.assert_called_once_with(ctx, self.author) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): + """Staff members should be able to bypass the bot-commands channel restriction.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot = 50 + + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(channel_id=200)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + create_embed.assert_called_once_with(ctx, self.moderator) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + def test_moderators_can_target_another_member(self, create_embed, constants): + """A moderator should be able to use `!user` targeting another user.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + constants.STAFF_ROLES = [self.moderator_role.id] + + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(channel_id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + + create_embed.assert_called_once_with(ctx, self.target) + ctx.send.assert_called_once() -- cgit v1.2.3 From d1b35becef79540954adec4078ce4a2a47b34cfa Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 1 Nov 2019 20:49:10 +0100 Subject: Write tests for `bot.utils`. Closes #604. --- tests/bot/test_utils.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/bot/test_utils.py (limited to 'tests') diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py new file mode 100644 index 000000000..0a32b8411 --- /dev/null +++ b/tests/bot/test_utils.py @@ -0,0 +1,49 @@ +import unittest + +from bot import utils + + +class CaseInsensitiveDictTests(unittest.TestCase): + """Tests for the `CaseInsensitiveDict` container.""" + + def test_case_insensitive_key_access(self): + """Tests case insensitive key access and storage.""" + instance = utils.CaseInsensitiveDict() + + key = 'LEMON' + value = 'trees' + + instance[key] = value + self.assertIn(key, instance) + self.assertEqual(instance.get(key), value) + self.assertEqual(instance.pop(key), value) + + instance.setdefault(key, value) + del instance[key] + self.assertNotIn(key, instance) + + def test_initialization_from_kwargs(self): + """Tests creating the dictionary from keyword arguments.""" + instance = utils.CaseInsensitiveDict({'FOO': 'bar'}) + self.assertEqual(instance['foo'], 'bar') + + def test_update_from_other_mapping(self): + """Tests updating the dictionary from another mapping.""" + instance = utils.CaseInsensitiveDict() + instance.update({'FOO': 'bar'}) + self.assertEqual(instance['foo'], 'bar') + + +class ChunkTests(unittest.TestCase): + """Tests the `chunk` method.""" + + def test_empty_chunking(self): + """Tests chunking on an empty iterable.""" + generator = utils.chunks(iterable=[], size=5) + self.assertEqual(list(generator), []) + + def test_list_chunking(self): + """Tests chunking a non-empty list.""" + iterable = [1, 2, 3, 4, 5] + generator = utils.chunks(iterable=iterable, size=2) + self.assertEqual(list(generator), [[1, 2], [3, 4], [5]]) -- cgit v1.2.3 From 38d9e705c77003008f1562188cba079a4245061b Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 1 Nov 2019 23:01:45 +0100 Subject: Add unit test for links antispam rule --- tests/bot/rules/test_links.py | 93 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/bot/rules/test_links.py (limited to 'tests') diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py new file mode 100644 index 000000000..f71a8e6bb --- /dev/null +++ b/tests/bot/rules/test_links.py @@ -0,0 +1,93 @@ +import unittest +from typing import List, NamedTuple, Tuple + +from bot.rules import links +from tests.helpers import async_test + + +class FakeMessage(NamedTuple): + author: str + content: str + + +class Case(NamedTuple): + recent_messages: List[FakeMessage] + relevant_messages: Tuple[FakeMessage] + culprit: Tuple[str] + total_links: int + + +def msg(author: str, total_links: int) -> FakeMessage: + """Makes a message with `total_links` links.""" + content = " ".join(["https://pydis.com"] * total_links) + return FakeMessage(author=author, content=content) + + +class LinksTests(unittest.TestCase): + """Tests applying the `links` rule.""" + + def setUp(self): + self.config = { + "max": 2, + "interval": 10 + } + + @async_test + async def test_links_within_limit(self): + """Messages with an allowed amount of links.""" + cases = ( + [msg("bob", 0)], + [msg("bob", 2)], + [msg("bob", 3)], + [msg("bob", 3), msg("alice", 3)] + ) + + for recent_messages in cases: + last_message = recent_messages[0] + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + config=self.config + ): + self.assertIsNone( + await links.apply(last_message, recent_messages, self.config) + ) + + @async_test + async def test_links_exceeding_limit(self): + """Messages with a a higher than allowed amount of links.""" + cases = ( + Case( + [msg("bob", 1), msg("bob", 2)], + (msg("bob", 1), msg("bob", 2)), + ("bob",), + 3 + ), + Case( + [msg("alice", 2), msg("bob", 3), msg("alice", 1)], + (msg("alice", 2), msg("alice", 1)), + ("alice",), + 3 + ) + ) + + for recent_messages, relevant_messages, culprit, total_links in cases: + last_message = recent_messages[0] + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + relevant_messages=relevant_messages, + culprit=culprit, + total_links=total_links, + config=self.config + ): + desired_output = ( + f"sent {total_links} links in {self.config['interval']}s", + culprit, + relevant_messages + ) + self.assertTupleEqual( + await links.apply(last_message, recent_messages, self.config), + desired_output + ) -- cgit v1.2.3 From a21670459953599c8f13286595520e033da4199a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 1 Nov 2019 23:24:20 +0100 Subject: Add two more test cases for links rule unit test --- tests/bot/rules/test_links.py | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'tests') diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index f71a8e6bb..f043495cf 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -39,6 +39,7 @@ class LinksTests(unittest.TestCase): [msg("bob", 0)], [msg("bob", 2)], [msg("bob", 3)], + [msg("bob", 1), msg("bob", 1)], [msg("bob", 3), msg("alice", 3)] ) @@ -63,6 +64,12 @@ class LinksTests(unittest.TestCase): ("bob",), 3 ), + Case( + [msg("alice", 1), msg("alice", 1), msg("alice", 1)], + (msg("alice", 1), msg("alice", 1), msg("alice", 1)), + ("alice",), + 3 + ), Case( [msg("alice", 2), msg("bob", 3), msg("alice", 1)], (msg("alice", 2), msg("alice", 1)), -- cgit v1.2.3 From 9e16b0cf17e93295167956d31ad9f92396ca3402 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 2 Nov 2019 01:19:07 +0100 Subject: Annotate unclear test cases with inline comments --- tests/bot/rules/test_links.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index f043495cf..a04b90c13 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -38,9 +38,9 @@ class LinksTests(unittest.TestCase): cases = ( [msg("bob", 0)], [msg("bob", 2)], - [msg("bob", 3)], + [msg("bob", 3)], # Filter only applies if len(messages_with_links) > 1 [msg("bob", 1), msg("bob", 1)], - [msg("bob", 3), msg("alice", 3)] + [msg("bob", 3), msg("alice", 3)] # Only messages from latest author count ) for recent_messages in cases: -- cgit v1.2.3 From f6ed29c8692759dc0c8b003ebf2d4ce3d5ca6ed9 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 2 Nov 2019 01:34:46 +0100 Subject: Adjust case to only test a single aspect --- tests/bot/rules/test_links.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index a04b90c13..95f107f91 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -40,7 +40,7 @@ class LinksTests(unittest.TestCase): [msg("bob", 2)], [msg("bob", 3)], # Filter only applies if len(messages_with_links) > 1 [msg("bob", 1), msg("bob", 1)], - [msg("bob", 3), msg("alice", 3)] # Only messages from latest author count + [msg("bob", 2), msg("alice", 2)] # Only messages from latest author count ) for recent_messages in cases: -- cgit v1.2.3 From 9e825ed657cebc9f47208af7dc5fa46f31d9ef41 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 2 Nov 2019 11:04:57 +0100 Subject: Use `casefold` in some cases. --- tests/bot/test_utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py index 0a32b8411..58ae2a81a 100644 --- a/tests/bot/test_utils.py +++ b/tests/bot/test_utils.py @@ -16,7 +16,10 @@ class CaseInsensitiveDictTests(unittest.TestCase): instance[key] = value self.assertIn(key, instance) self.assertEqual(instance.get(key), value) - self.assertEqual(instance.pop(key), value) + self.assertEqual(instance.get(key.casefold()), value) + self.assertEqual(instance.pop(key.casefold()), value) + self.assertNotIn(key, instance) + self.assertNotIn(key.casefold(), instance) instance.setdefault(key, value) del instance[key] -- cgit v1.2.3 From acb937dc24b30c84b4978f651a642208f562c36e Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 3 Nov 2019 22:34:16 +0100 Subject: Test is_staff and has_green_checkmark. --- tests/bot/cogs/test_duck_pond.py | 52 +++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 17 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 79f11843b..31c7e9f89 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -1,35 +1,53 @@ import logging import unittest -from unittest.mock import MagicMock from bot.cogs import duck_pond -from tests.helpers import MockBot, MockMessage +from tests.helpers import MockBot, MockMember, MockMessage, MockReaction, MockRole class DuckPondTest(unittest.TestCase): """Tests the `DuckPond` cog.""" def setUp(self): - """Adds the cog, a bot, and a message to the instance for usage in tests.""" + """Adds the cog, a bot, and the mocks we'll need for our tests.""" self.bot = MockBot() self.cog = duck_pond.DuckPond(bot=self.bot) - self.msg = MockMessage(message_id=555, content='') - self.msg.author.__str__ = MagicMock() - self.msg.author.__str__.return_value = 'lemon' - self.msg.author.bot = False - self.msg.author.avatar_url_as.return_value = 'picture-lemon.png' - self.msg.author.id = 42 - self.msg.author.mention = '@lemon' - self.msg.channel.mention = "#lemonade-stand" + # Set up some roles + self.admin_role = MockRole(name="Admins", role_id=476190234653229056) + self.contrib_role = MockRole(name="Contributor", role_id=476190302659543061) - def test_is_staff_correctly_identifies_staff(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + # Set up some users + self.admin_member = MockMember(roles=(self.admin_role,)) + self.contrib_member = MockMember(roles=(self.contrib_role,)) + self.no_role_member = MockMember() - def test_has_green_checkmark(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + # Set up emojis + self.checkmark_emoji = "✅" + self.thumbs_up_emoji = "👍" + + # Set up reactions + self.checkmark_reaction = MockReaction(emoji=self.checkmark_emoji) + self.thumbs_up_reaction = MockReaction(emoji=self.thumbs_up_emoji) + + # Set up a messages + self.checkmark_message = MockMessage(reactions=(self.checkmark_reaction,)) + self.thumbs_up_message = MockMessage(reactions=(self.thumbs_up_reaction,)) + self.no_reaction_message = MockMessage() + + def test_is_staff_correctly_identifies_staff(self): + """Test that is_staff correctly identifies a staff member.""" + with self.subTest(): + self.assertTrue(duck_pond.DuckPond.is_staff(self.admin_member)) + self.assertFalse(duck_pond.DuckPond.is_staff(self.contrib_member)) + self.assertFalse(duck_pond.DuckPond.is_staff(self.no_role_member)) + + def test_has_green_checkmark_correctly_identifies_messages(self): + """Test that has_green_checkmark recognizes messages with checkmarks.""" + with self.subTest(): + self.assertTrue(duck_pond.DuckPond.has_green_checkmark(self.checkmark_message)) + self.assertFalse(duck_pond.DuckPond.has_green_checkmark(self.thumbs_up_message)) + self.assertFalse(duck_pond.DuckPond.has_green_checkmark(self.no_reaction_message)) def test_count_custom_duck_emojis(self): """A string decoding to numeric characters is a valid user ID.""" -- cgit v1.2.3 From 9000d92ab8ceaf855d23c0e6dfdb09d05a40e59a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 8 Nov 2019 22:07:00 +0100 Subject: Add whitespace for readability, consistency & allure --- tests/bot/rules/test_links.py | 1 + 1 file changed, 1 insertion(+) (limited to 'tests') diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index 95f107f91..40336beb0 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -45,6 +45,7 @@ class LinksTests(unittest.TestCase): for recent_messages in cases: last_message = recent_messages[0] + with self.subTest( last_message=last_message, recent_messages=recent_messages, -- cgit v1.2.3 From 760f265339b1462b60ea7d5af1fd8d4476c134f0 Mon Sep 17 00:00:00 2001 From: kwzrd <44734341+kwzrd@users.noreply.github.com> Date: Fri, 8 Nov 2019 22:52:47 +0100 Subject: Update docstring to use asterisks when referring to argument names Co-Authored-By: Kyle Stanley --- tests/bot/rules/test_links.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index 40336beb0..be832843b 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -18,7 +18,7 @@ class Case(NamedTuple): def msg(author: str, total_links: int) -> FakeMessage: - """Makes a message with `total_links` links.""" + """Makes a message with *total_links* links.""" content = " ".join(["https://pydis.com"] * total_links) return FakeMessage(author=author, content=content) -- cgit v1.2.3 From 0eade4697bd76b9cc99e74777531ba00df558ff5 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 10:45:24 +0100 Subject: Add unit test for mentions antispam rule --- tests/bot/rules/test_mentions.py | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/bot/rules/test_mentions.py (limited to 'tests') diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py new file mode 100644 index 000000000..520184c2f --- /dev/null +++ b/tests/bot/rules/test_mentions.py @@ -0,0 +1,98 @@ +import unittest +from typing import List, NamedTuple, Tuple + +from bot.rules import mentions +from tests.helpers import async_test + + +class FakeMessage(NamedTuple): + author: str + mentions: List[None] + + +class Case(NamedTuple): + recent_messages: List[FakeMessage] + relevant_messages: Tuple[FakeMessage] + culprit: str + total_mentions: int + + +def msg(author: str, total_mentions: int) -> FakeMessage: + return FakeMessage(author=author, mentions=[None] * total_mentions) + + +class TestMentions(unittest.TestCase): + """Tests applying the `mentions` antispam rule.""" + + def setUp(self): + self.config = { + "max": 2, + "interval": 10 + } + + @async_test + async def test_mentions_within_limit(self): + """Messages with an allowed amount of mentions.""" + cases = ( + [msg("bob", 0)], + [msg("bob", 2)], + [msg("bob", 1), msg("bob", 1)], + [msg("bob", 1), msg("alice", 2)] + ) + + for recent_messages in cases: + last_message = recent_messages[0] + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + config=self.config + ): + self.assertIsNone( + await mentions.apply(last_message, recent_messages, self.config) + ) + + @async_test + async def test_mentions_exceeding_limit(self): + """Messages with a higher than allowed amount of mentions.""" + cases = ( + Case( + [msg("bob", 3)], + (msg("bob", 3),), + ("bob",), + 3 + ), + Case( + [msg("alice", 2), msg("alice", 0), msg("alice", 1)], + (msg("alice", 2), msg("alice", 0), msg("alice", 1)), + ("alice",), + 3 + ), + Case( + [msg("bob", 2), msg("alice", 3), msg("bob", 2)], + (msg("bob", 2), msg("bob", 2)), + ("bob",), + 4 + ) + ) + + for recent_messages, relevant_messages, culprit, total_mentions in cases: + last_message = recent_messages[0] + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + relevant_messages=relevant_messages, + culprit=culprit, + total_mentions=total_mentions, + cofig=self.config + ): + desired_output = ( + f"sent {total_mentions} mentions in {self.config['interval']}s", + culprit, + relevant_messages + ) + self.assertTupleEqual( + await mentions.apply(last_message, recent_messages, self.config), + desired_output + ) -- cgit v1.2.3 From 187d419810759992e19a792fd746f26960f3831a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 10:46:34 +0100 Subject: Add missing docstring --- tests/bot/rules/test_mentions.py | 1 + 1 file changed, 1 insertion(+) (limited to 'tests') diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index 520184c2f..987a42c0a 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -18,6 +18,7 @@ class Case(NamedTuple): def msg(author: str, total_mentions: int) -> FakeMessage: + """Makes a message with `total_mentions` mentions.""" return FakeMessage(author=author, mentions=[None] * total_mentions) -- cgit v1.2.3 From 32caead77e4bad04689892a29005faa3ffde2e83 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 10:50:54 +0100 Subject: Adjust docstring asterisk to backtick for consistency --- tests/bot/rules/test_links.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index be832843b..40336beb0 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -18,7 +18,7 @@ class Case(NamedTuple): def msg(author: str, total_links: int) -> FakeMessage: - """Makes a message with *total_links* links.""" + """Makes a message with `total_links` links.""" content = " ".join(["https://pydis.com"] * total_links) return FakeMessage(author=author, content=content) -- cgit v1.2.3 From b87a98c7749edfeb9fbc368f2bf9a7ecb9434662 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 10:54:26 +0100 Subject: Use range to build mock mentions list --- tests/bot/rules/test_mentions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index 987a42c0a..e1a971dbb 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -19,7 +19,7 @@ class Case(NamedTuple): def msg(author: str, total_mentions: int) -> FakeMessage: """Makes a message with `total_mentions` mentions.""" - return FakeMessage(author=author, mentions=[None] * total_mentions) + return FakeMessage(author=author, mentions=list(range(total_mentions))) class TestMentions(unittest.TestCase): -- cgit v1.2.3 From b1c6b4e20578395a5766ac116fd4def1df777de7 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 13:49:55 +0100 Subject: Adjust type hint to correctly represent internal type --- tests/bot/rules/test_mentions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index e1a971dbb..08dd1d6d5 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -7,7 +7,7 @@ from tests.helpers import async_test class FakeMessage(NamedTuple): author: str - mentions: List[None] + mentions: List[int] class Case(NamedTuple): -- cgit v1.2.3 From f915782192fd7b631b23fa31e524cacdc8e72614 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 16:25:23 +0100 Subject: Use MockMessage instead of custom FakeMessage --- tests/bot/rules/test_mentions.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index 08dd1d6d5..e377f2164 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -2,24 +2,18 @@ import unittest from typing import List, NamedTuple, Tuple from bot.rules import mentions -from tests.helpers import async_test - - -class FakeMessage(NamedTuple): - author: str - mentions: List[int] +from tests.helpers import MockMessage, async_test class Case(NamedTuple): - recent_messages: List[FakeMessage] - relevant_messages: Tuple[FakeMessage] - culprit: str + recent_messages: List[MockMessage, ...] + culprit: Tuple[str] total_mentions: int -def msg(author: str, total_mentions: int) -> FakeMessage: +def msg(author: str, total_mentions: int) -> MockMessage: """Makes a message with `total_mentions` mentions.""" - return FakeMessage(author=author, mentions=list(range(total_mentions))) + return MockMessage(author=author, mentions=list(range(total_mentions))) class TestMentions(unittest.TestCase): @@ -59,26 +53,28 @@ class TestMentions(unittest.TestCase): cases = ( Case( [msg("bob", 3)], - (msg("bob", 3),), ("bob",), 3 ), Case( [msg("alice", 2), msg("alice", 0), msg("alice", 1)], - (msg("alice", 2), msg("alice", 0), msg("alice", 1)), ("alice",), 3 ), Case( [msg("bob", 2), msg("alice", 3), msg("bob", 2)], - (msg("bob", 2), msg("bob", 2)), ("bob",), 4 ) ) - for recent_messages, relevant_messages, culprit, total_mentions in cases: + for recent_messages, culprit, total_mentions in cases: last_message = recent_messages[0] + relevant_messages = tuple( + msg + for msg in recent_messages + if msg.author == last_message.author + ) with self.subTest( last_message=last_message, -- cgit v1.2.3 From 16e6c36c6b370300ef7507d2e01421ad0d83f407 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 16:27:02 +0100 Subject: Adjust incorrect type hint --- tests/bot/rules/test_mentions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index e377f2164..ad49ead32 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -6,7 +6,7 @@ from tests.helpers import MockMessage, async_test class Case(NamedTuple): - recent_messages: List[MockMessage, ...] + recent_messages: List[MockMessage] culprit: Tuple[str] total_mentions: int -- cgit v1.2.3 From aac8404f65b419e212e5372015b63871fab7f3d1 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 11 Nov 2019 07:19:07 +0100 Subject: Adding ducky count tests and a new AsyncIteratorMock --- tests/bot/cogs/test_duck_pond.py | 70 ++++++++++++++++++++++++++++++++-------- tests/helpers.py | 38 +++++++++++++++++++++- 2 files changed, 93 insertions(+), 15 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 31c7e9f89..af8ef0e4d 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -1,8 +1,10 @@ +import asyncio import logging import unittest +from bot import constants from bot.cogs import duck_pond -from tests.helpers import MockBot, MockMember, MockMessage, MockReaction, MockRole +from tests.helpers import MockBot, MockEmoji, MockMember, MockMessage, MockReaction, MockRole class DuckPondTest(unittest.TestCase): @@ -13,49 +15,89 @@ class DuckPondTest(unittest.TestCase): self.bot = MockBot() self.cog = duck_pond.DuckPond(bot=self.bot) + # Override the constants we'll be needing + constants.STAFF_ROLES = (123,) + constants.DuckPond.custom_emojis = (789,) + constants.DuckPond.threshold = 1 + # Set up some roles - self.admin_role = MockRole(name="Admins", role_id=476190234653229056) - self.contrib_role = MockRole(name="Contributor", role_id=476190302659543061) + self.admin_role = MockRole(name="Admins", role_id=123) + self.contrib_role = MockRole(name="Contributor", role_id=456) # Set up some users - self.admin_member = MockMember(roles=(self.admin_role,)) + self.admin_member_1 = MockMember(roles=(self.admin_role,), id=1) + self.admin_member_2 = MockMember(roles=(self.admin_role,), id=2) self.contrib_member = MockMember(roles=(self.contrib_role,)) self.no_role_member = MockMember() # Set up emojis self.checkmark_emoji = "✅" self.thumbs_up_emoji = "👍" + self.unicode_duck_emoji = "🦆" + self.yellow_ducky_emoji = MockEmoji(id=789) # Set up reactions - self.checkmark_reaction = MockReaction(emoji=self.checkmark_emoji) - self.thumbs_up_reaction = MockReaction(emoji=self.thumbs_up_emoji) + self.checkmark_reaction = MockReaction( + emoji=self.checkmark_emoji, + user_list=[self.admin_member_1] + ) + self.thumbs_up_reaction = MockReaction( + emoji=self.thumbs_up_emoji, + user_list=[self.admin_member_1, self.contrib_member] + ) + self.yellow_ducky_reaction = MockReaction( + emoji=self.yellow_ducky_emoji, + user_list=[self.admin_member_1, self.contrib_member] + ) + self.unicode_duck_reaction_1 = MockReaction( + emoji=self.unicode_duck_emoji, + user_list=[self.admin_member_1] + ) + self.unicode_duck_reaction_2 = MockReaction( + emoji=self.unicode_duck_emoji, + user_list=[self.admin_member_2] + ) # Set up a messages self.checkmark_message = MockMessage(reactions=(self.checkmark_reaction,)) self.thumbs_up_message = MockMessage(reactions=(self.thumbs_up_reaction,)) + self.yellow_ducky_message = MockMessage(reactions=(self.yellow_ducky_reaction,)) + self.unicode_duck_message = MockMessage(reactions=(self.unicode_duck_reaction_1,)) + self.double_duck_message = MockMessage(reactions=(self.unicode_duck_reaction_1, self.unicode_duck_reaction_2)) self.no_reaction_message = MockMessage() def test_is_staff_correctly_identifies_staff(self): """Test that is_staff correctly identifies a staff member.""" with self.subTest(): - self.assertTrue(duck_pond.DuckPond.is_staff(self.admin_member)) - self.assertFalse(duck_pond.DuckPond.is_staff(self.contrib_member)) - self.assertFalse(duck_pond.DuckPond.is_staff(self.no_role_member)) + self.assertTrue(self.cog.is_staff(self.admin_member_1)) + self.assertFalse(self.cog.is_staff(self.contrib_member)) + self.assertFalse(self.cog.is_staff(self.no_role_member)) def test_has_green_checkmark_correctly_identifies_messages(self): """Test that has_green_checkmark recognizes messages with checkmarks.""" with self.subTest(): - self.assertTrue(duck_pond.DuckPond.has_green_checkmark(self.checkmark_message)) - self.assertFalse(duck_pond.DuckPond.has_green_checkmark(self.thumbs_up_message)) - self.assertFalse(duck_pond.DuckPond.has_green_checkmark(self.no_reaction_message)) + self.assertTrue(self.cog.has_green_checkmark(self.checkmark_message)) + self.assertFalse(self.cog.has_green_checkmark(self.thumbs_up_message)) + self.assertFalse(self.cog.has_green_checkmark(self.no_reaction_message)) def test_count_custom_duck_emojis(self): """A string decoding to numeric characters is a valid user ID.""" - pass + count_one_duck = self.cog.count_ducks(self.yellow_ducky_message) + count_no_ducks = self.cog.count_ducks(self.thumbs_up_message) + with self.subTest(): + self.assertEqual(asyncio.run(count_one_duck), 1) + self.assertEqual(asyncio.run(count_no_ducks), 0) def test_count_unicode_duck_emojis(self): """A string decoding to numeric characters is a valid user ID.""" - pass + count_no_ducks = self.cog.count_ducks(self.thumbs_up_message) + count_one_duck = self.cog.count_ducks(self.unicode_duck_message) + count_two_ducks = self.cog.count_ducks(self.double_duck_message) + + with self.subTest(): + self.assertEqual(asyncio.run(count_no_ducks), 0) + self.assertEqual(asyncio.run(count_one_duck), 1) + self.assertEqual(asyncio.run(count_two_ducks), 2) def test_count_mixed_duck_emojis(self): """A string decoding to numeric characters is a valid user ID.""" diff --git a/tests/helpers.py b/tests/helpers.py index 8496ba031..fd79141ec 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -102,8 +102,30 @@ class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): Python 3.8 will introduce an AsyncMock class in the standard library that will have some more features; this stand-in only overwrites the `__call__` method to an async version. """ + async def __call__(self, *args, **kwargs): - return super(AsyncMock, self).__call__(*args, **kwargs) + return super().__call__(*args, **kwargs) + + +class AsyncIteratorMock: + """ + A class to mock asyncronous iterators. + + This allows async for, which is used in certain Discord.py objects. For example, + an async iterator is returned by the Reaction.users() coroutine. + """ + + def __init__(self, sequence): + self.iter = iter(sequence) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.iter) + except StopIteration: + raise StopAsyncIteration # Create a guild instance to get a realistic Mock of `discord.Guild` @@ -155,6 +177,7 @@ class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ + def __init__( self, guild_id: int = 1, @@ -187,6 +210,7 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): Instances of this class will follow the specifications of `discord.Role` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: super().__init__(spec=role_instance, **kwargs) @@ -213,6 +237,7 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin Instances of this class will follow the specifications of `discord.Member` instances. For more information, see the `MockGuild` docstring. """ + def __init__( self, name: str = "member", @@ -243,6 +268,7 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec=bot_instance, **kwargs) @@ -279,6 +305,7 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): Instances of this class will follow the specifications of `discord.TextChannel` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: super().__init__(spec=channel_instance, **kwargs) self.id = channel_id @@ -320,6 +347,7 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.ext.commands.Context` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec=context_instance, **kwargs) self.bot = kwargs.get('bot', MockBot()) @@ -336,6 +364,7 @@ class MockMessage(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Message` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec=message_instance, **kwargs) self.author = kwargs.get('author', MockMember()) @@ -353,6 +382,7 @@ class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Emoji` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec=emoji_instance, **kwargs) self.guild = kwargs.get('guild', MockGuild()) @@ -371,6 +401,7 @@ class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.PartialEmoji` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec=partial_emoji_instance, **kwargs) @@ -385,7 +416,12 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Reaction` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec=reaction_instance, **kwargs) self.emoji = kwargs.get('emoji', MockEmoji()) self.message = kwargs.get('message', MockMessage()) + self.user_list = AsyncIteratorMock(kwargs.get('user_list', [])) + + def users(self): + return self.user_list -- cgit v1.2.3 From 98ccfbc218dc762e45f0146d0503dba1fe06fdb9 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 11 Nov 2019 14:55:40 +0100 Subject: Implement a mixed duck test. Also gets started setting up for the final tests, which will require more mockwork. --- tests/bot/cogs/test_duck_pond.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index af8ef0e4d..211e8b084 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -20,6 +20,10 @@ class DuckPondTest(unittest.TestCase): constants.DuckPond.custom_emojis = (789,) constants.DuckPond.threshold = 1 + # Mock bot.get_all_channels() + CHANNEL_ID = 555 + USER_ID = 666 + # Set up some roles self.admin_role = MockRole(name="Admins", role_id=123) self.contrib_role = MockRole(name="Contributor", role_id=456) @@ -63,7 +67,12 @@ class DuckPondTest(unittest.TestCase): self.thumbs_up_message = MockMessage(reactions=(self.thumbs_up_reaction,)) self.yellow_ducky_message = MockMessage(reactions=(self.yellow_ducky_reaction,)) self.unicode_duck_message = MockMessage(reactions=(self.unicode_duck_reaction_1,)) - self.double_duck_message = MockMessage(reactions=(self.unicode_duck_reaction_1, self.unicode_duck_reaction_2)) + self.double_unicode_duck_message = MockMessage( + reactions=(self.unicode_duck_reaction_1, self.unicode_duck_reaction_2) + ) + self.double_mixed_duck_message = MockMessage( + reactions=(self.unicode_duck_reaction_1, self.yellow_ducky_reaction) + ) self.no_reaction_message = MockMessage() def test_is_staff_correctly_identifies_staff(self): @@ -81,27 +90,28 @@ class DuckPondTest(unittest.TestCase): self.assertFalse(self.cog.has_green_checkmark(self.no_reaction_message)) def test_count_custom_duck_emojis(self): - """A string decoding to numeric characters is a valid user ID.""" - count_one_duck = self.cog.count_ducks(self.yellow_ducky_message) + """Test that count_ducks counts custom ducks correctly.""" count_no_ducks = self.cog.count_ducks(self.thumbs_up_message) + count_one_duck = self.cog.count_ducks(self.yellow_ducky_message) with self.subTest(): - self.assertEqual(asyncio.run(count_one_duck), 1) self.assertEqual(asyncio.run(count_no_ducks), 0) + self.assertEqual(asyncio.run(count_one_duck), 1) def test_count_unicode_duck_emojis(self): - """A string decoding to numeric characters is a valid user ID.""" - count_no_ducks = self.cog.count_ducks(self.thumbs_up_message) + """Test that count_ducks counts unicode ducks correctly.""" count_one_duck = self.cog.count_ducks(self.unicode_duck_message) - count_two_ducks = self.cog.count_ducks(self.double_duck_message) + count_two_ducks = self.cog.count_ducks(self.double_unicode_duck_message) with self.subTest(): - self.assertEqual(asyncio.run(count_no_ducks), 0) self.assertEqual(asyncio.run(count_one_duck), 1) self.assertEqual(asyncio.run(count_two_ducks), 2) def test_count_mixed_duck_emojis(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + """Test that count_ducks counts mixed ducks correctly.""" + count_two_ducks = self.cog.count_ducks(self.double_mixed_duck_message) + + with self.subTest(): + self.assertEqual(asyncio.run(count_two_ducks), 2) def test_raw_reaction_add_rejects_bot(self): """A string decoding to numeric characters is a valid user ID.""" -- cgit v1.2.3 From a89349ee32bbf2b3506cc278999575db1fbfde74 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Tue, 12 Nov 2019 22:05:28 +0100 Subject: Add tests for on_raw_reaction_add. Basically I suck at this and I can't get this return_value thing to work. I'll have Ves look at it to resolve it. As of right now, multiple tests are failing. --- tests/bot/cogs/test_duck_pond.py | 84 +++++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 14 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 211e8b084..088d8ac79 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -1,10 +1,11 @@ import asyncio import logging import unittest +from unittest.mock import MagicMock from bot import constants from bot.cogs import duck_pond -from tests.helpers import MockBot, MockEmoji, MockMember, MockMessage, MockReaction, MockRole +from tests.helpers import MockBot, MockEmoji, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel class DuckPondTest(unittest.TestCase): @@ -15,23 +16,27 @@ class DuckPondTest(unittest.TestCase): self.bot = MockBot() self.cog = duck_pond.DuckPond(bot=self.bot) + # Set up some constants + self.CHANNEL_ID = 555 + self.MESSAGE_ID = 666 + self.BOT_ID = 777 + self.CONTRIB_ID = 888 + self.ADMIN_ID = 999 + # Override the constants we'll be needing constants.STAFF_ROLES = (123,) constants.DuckPond.custom_emojis = (789,) constants.DuckPond.threshold = 1 - # Mock bot.get_all_channels() - CHANNEL_ID = 555 - USER_ID = 666 - # Set up some roles self.admin_role = MockRole(name="Admins", role_id=123) self.contrib_role = MockRole(name="Contributor", role_id=456) # Set up some users - self.admin_member_1 = MockMember(roles=(self.admin_role,), id=1) - self.admin_member_2 = MockMember(roles=(self.admin_role,), id=2) - self.contrib_member = MockMember(roles=(self.contrib_role,)) + self.admin_member_1 = MockMember(roles=(self.admin_role,), id=self.ADMIN_ID) + self.admin_member_2 = MockMember(roles=(self.admin_role,), id=911) + self.contrib_member = MockMember(roles=(self.contrib_role,), id=self.CONTRIB_ID) + self.bot_member = MockMember(roles=(self.contrib_role,), id=self.BOT_ID, bot=True) self.no_role_member = MockMember() # Set up emojis @@ -61,6 +66,14 @@ class DuckPondTest(unittest.TestCase): emoji=self.unicode_duck_emoji, user_list=[self.admin_member_2] ) + self.bot_reaction = MockReaction( + emoji=self.yellow_ducky_emoji, + user_list=[self.bot_member] + ) + self.contrib_reaction = MockReaction( + emoji=self.yellow_ducky_emoji, + user_list=[self.contrib_member] + ) # Set up a messages self.checkmark_message = MockMessage(reactions=(self.checkmark_reaction,)) @@ -73,8 +86,18 @@ class DuckPondTest(unittest.TestCase): self.double_mixed_duck_message = MockMessage( reactions=(self.unicode_duck_reaction_1, self.yellow_ducky_reaction) ) + + self.bot_message = MockMessage(reactions=(self.bot_reaction,)) + self.contrib_message = MockMessage(reactions=(self.contrib_reaction,)) self.no_reaction_message = MockMessage() + # Set up some channels + self.text_channel = MockTextChannel(id=self.CHANNEL_ID) + + @staticmethod + def _mock_send_webhook(content, username, avatar_url, embed): + """Mock for the send_webhook method in DuckPond""" + def test_is_staff_correctly_identifies_staff(self): """Test that is_staff correctly identifies a staff member.""" with self.subTest(): @@ -114,16 +137,49 @@ class DuckPondTest(unittest.TestCase): self.assertEqual(asyncio.run(count_two_ducks), 2) def test_raw_reaction_add_rejects_bot(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + """Test that send_webhook is not called if the user is a bot.""" + self.text_channel.fetch_message.return_value = self.bot_message + self.bot.get_all_channels.return_value = (self.text_channel,) + + payload = MagicMock( # RawReactionActionEvent + channel_id=self.CHANNEL_ID, + message_id=self.MESSAGE_ID, + user_id=self.BOT_ID, + ) + + with self.subTest(): + asyncio.run(self.cog.on_raw_reaction_add(payload)) + self.bot.cog.send_webhook.assert_not_called() def test_raw_reaction_add_rejects_non_staff(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + """Test that send_webhook is not called if the user is not a member of staff.""" + self.text_channel.fetch_message.return_value = self.contrib_message + self.bot.get_all_channels.return_value = (self.text_channel,) + + payload = MagicMock( # RawReactionActionEvent + channel_id=self.CHANNEL_ID, + message_id=self.MESSAGE_ID, + user_id=self.CONTRIB_ID, + ) + + with self.subTest(): + asyncio.run(self.cog.on_raw_reaction_add(payload)) + self.bot.cog.send_webhook.assert_not_called() def test_raw_reaction_add_sends_message_on_valid_input(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + """Test that send_webhook is called if payload is valid.""" + self.text_channel.fetch_message.return_value = self.unicode_duck_message + self.bot.get_all_channels.return_value = (self.text_channel,) + + payload = MagicMock( # RawReactionActionEvent + channel_id=self.CHANNEL_ID, + message_id=self.MESSAGE_ID, + user_id=self.ADMIN_ID, + ) + + with self.subTest(): + asyncio.run(self.cog.on_raw_reaction_add(payload)) + self.bot.cog.send_webhook.assert_called_once() def test_raw_reaction_remove_rejects_non_checkmarks(self): """A string decoding to numeric characters is a valid user ID.""" -- cgit v1.2.3 From dceafb83e829548638e8589c88f80364e8009821 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 13 Nov 2019 15:32:13 +0100 Subject: Prevent unwanted logging while running tests Previously, logging messages would output to std.out. when running individual test files (instead of running the entire suite). To prevent this, I've added a `for`-loop to `tests.helpers` that sets the level of all registered loggers to `CRITICAL`. The reason for adding this to `tests.helpers` is simple: It's the most common file to be imported in individual tests, increasing the chance of the code being run for individual test files. A small downside of this way of handling logging is that when we are trying to assert logging messages are being emitted, we need to set the logger explicitly in the `self.assertLogs` context manager. This is a small downside, though, and probably good practice anyway. There was one test in `tests.bot.test_api` that did not do this, so I have changed this to make the test compatible with the new set-up. --- tests/bot/test_api.py | 4 +++- tests/helpers.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py index e0ede0eb1..5a88adc5c 100644 --- a/tests/bot/test_api.py +++ b/tests/bot/test_api.py @@ -121,7 +121,9 @@ class LoggingHandlerTests(LoggingTestCase): def test_schedule_queued_tasks_for_nonempty_queue(self): """`APILoggingHandler` should schedule logs when the queue is not empty.""" - with self.assertLogs(level=logging.DEBUG) as logs, patch('asyncio.create_task') as create_task: + log = logging.getLogger("bot.api") + + with self.assertLogs(logger=log, level=logging.DEBUG) as logs, patch('asyncio.create_task') as create_task: self.log_handler.queue = [555] self.log_handler.schedule_queued_tasks() self.assertListEqual(self.log_handler.queue, []) diff --git a/tests/helpers.py b/tests/helpers.py index 8496ba031..8d661513d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import functools import inspect +import logging import unittest.mock from typing import Any, Iterable, Optional @@ -10,6 +11,16 @@ import discord from discord.ext.commands import Bot, Context +for logger in logging.Logger.manager.loggerDict.values(): + # Set all loggers to CRITICAL by default to prevent screen clutter during testing + + if not isinstance(logger, logging.Logger): + # There might be some logging.PlaceHolder objects in there + continue + + logger.setLevel(logging.CRITICAL) + + def async_test(wrapped): """ Run a test case via asyncio. -- cgit v1.2.3 From bf7720f16fa69716f15b16e3dcd0f20c186958b8 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 13 Nov 2019 16:06:51 +0100 Subject: Allow `name` attribute to be set during Mock init The `name` keyword argument has a special meaning for the default mockobjects provided by `unittest.mock`. This means that by default, the common d.py `name` attribute can't be set during initalization of one of our custom Mock-objects by passing it to the constructor. Since it's unlikely for us to make use of the special `name` feature of mocks and more likely to want to set the d.py `name` attribute, I added special handling of the `name` kwarg. --- tests/helpers.py | 3 +++ 1 file changed, 3 insertions(+) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 8d661513d..5dc7a0d2f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -74,7 +74,10 @@ class CustomMockMixin: child_mock_type = unittest.mock.MagicMock def __init__(self, spec: Any = None, **kwargs): + name = kwargs.pop('name', None) # `name` has special meaning for Mock classes, so we need to set it manually. super().__init__(spec=spec, **kwargs) + if name: + self.name = name if spec: self._extract_coroutine_methods_from_spec_instance(spec) -- cgit v1.2.3 From 36e9de480dcabb3c844090a4fd87561534536c04 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 13 Nov 2019 11:24:58 +0100 Subject: Prevent setting unknown attributes on d.py mocks Our custom `discord.py` now follow the specifications of the object they are mocking more strictly by using the `spec_set` instead of the `spec` kwarg to initialize the specifications. This means that trying to set an attribute that does not follow the specifications will now also result in an `AttributeError`. To make sure we are not trying to set illegal attributes during the default initialization of the mock objects, I've changed the way we handle default values of parameters. This does introduce a breaking change: Instead of passing a `suffix_id`, the `id` attribute should now be passed using the exact name. `id`. This commit also makes sure existing tests follow this change. --- tests/bot/cogs/test_information.py | 58 ++++++++++----------- tests/bot/cogs/test_token_remover.py | 2 +- tests/bot/utils/test_checks.py | 6 +-- tests/helpers.py | 94 +++++++++++++--------------------- tests/test_helpers.py | 99 ++++++++++++------------------------ 5 files changed, 101 insertions(+), 158 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 5c34541d8..4496a2ae0 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -19,7 +19,7 @@ class InformationCogTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.moderator_role = helpers.MockRole(name="Moderator", role_id=constants.Roles.moderator) + cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderator) def setUp(self): """Sets up fresh objects for each test.""" @@ -54,7 +54,7 @@ class InformationCogTests(unittest.TestCase): """Tests the `role info` command.""" dummy_role = helpers.MockRole( name="Dummy", - role_id=112233445566778899, + id=112233445566778899, colour=discord.Colour.blurple(), position=10, members=[self.ctx.author], @@ -63,7 +63,7 @@ class InformationCogTests(unittest.TestCase): admin_role = helpers.MockRole( name="Admins", - role_id=998877665544332211, + id=998877665544332211, colour=discord.Colour.red(), position=3, members=[self.ctx.author], @@ -176,7 +176,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): self.bot = helpers.MockBot() self.bot.api_client.get = helpers.AsyncMock() self.cog = information.Information(self.bot) - self.member = helpers.MockMember(user_id=1234) + self.member = helpers.MockMember(id=1234) def test_user_command_helper_method_get_requests(self): """The helper methods should form the correct get requests.""" @@ -351,7 +351,7 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): """The embed should use the string representation of the user if they don't have a nick.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) user = helpers.MockMember() user.nick = None user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") @@ -363,7 +363,7 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) def test_create_user_embed_uses_nick_in_title_if_available(self): """The embed should use the nick if it's available.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) user = helpers.MockMember() user.nick = "Cat lover" user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") @@ -375,8 +375,8 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) def test_create_user_embed_ignores_everyone_role(self): """Created `!user` embeds should not contain mention of the @everyone-role.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) - admins_role = helpers.MockRole('Admins') + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) + admins_role = helpers.MockRole(name='Admins') admins_role.colour = 100 # A `MockMember` has the @Everyone role by default; we add the Admins to that. @@ -391,15 +391,15 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=helpers.AsyncMock) def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): """The embed should contain expanded infractions and nomination info in mod channels.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=50)) + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) - moderators_role = helpers.MockRole('Moderators') + moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 infraction_counts.return_value = "expanded infractions info" nomination_counts.return_value = "nomination info" - user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) infraction_counts.assert_called_once_with(user) @@ -426,14 +426,14 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=helpers.AsyncMock) def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): """The embed should contain only basic infraction data outside of mod channels.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=100)) + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) - moderators_role = helpers.MockRole('Moderators') + moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 infraction_counts.return_value = "basic infractions info" - user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) infraction_counts.assert_called_once_with(user) @@ -459,10 +459,10 @@ class UserEmbedTests(unittest.TestCase): """The embed should be created with the colour of the top role, if a top role is available.""" ctx = helpers.MockContext() - moderators_role = helpers.MockRole('Moderators') + moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 - user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) @@ -472,7 +472,7 @@ class UserEmbedTests(unittest.TestCase): """The embed should be created with a blurple colour if the user has no assigned roles.""" ctx = helpers.MockContext() - user = helpers.MockMember(user_id=217) + user = helpers.MockMember(id=217) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) self.assertEqual(embed.colour, discord.Colour.blurple()) @@ -482,7 +482,7 @@ class UserEmbedTests(unittest.TestCase): """The embed thumbnail should be set to the user's avatar in `png` format.""" ctx = helpers.MockContext() - user = helpers.MockMember(user_id=217) + user = helpers.MockMember(id=217) user.avatar_url_as.return_value = "avatar url" embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -499,13 +499,13 @@ class UserCommandTests(unittest.TestCase): self.bot = helpers.MockBot() self.cog = information.Information(self.bot) - self.moderator_role = helpers.MockRole("Moderators", role_id=2, position=10) - self.flautist_role = helpers.MockRole("Flautists", role_id=3, position=2) - self.bassist_role = helpers.MockRole("Bassists", role_id=4, position=3) + self.moderator_role = helpers.MockRole(name="Moderators", id=2, position=10) + self.flautist_role = helpers.MockRole(name="Flautists", id=3, position=2) + self.bassist_role = helpers.MockRole(name="Bassists", id=4, position=3) - self.author = helpers.MockMember(user_id=1, name="syntaxaire") - self.moderator = helpers.MockMember(user_id=2, name="riffautae", roles=[self.moderator_role]) - self.target = helpers.MockMember(user_id=3, name="__fluzz__") + self.author = helpers.MockMember(id=1, name="syntaxaire") + self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role]) + self.target = helpers.MockMember(id=3, name="__fluzz__") def test_regular_member_cannot_target_another_member(self, constants): """A regular user should not be able to use `!user` targeting another user.""" @@ -523,7 +523,7 @@ class UserCommandTests(unittest.TestCase): constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot = 50 - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=100)) + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) msg = "Sorry, but you may only use this command within <#50>." with self.assertRaises(InChannelCheckFailure, msg=msg): @@ -535,7 +535,7 @@ class UserCommandTests(unittest.TestCase): constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot = 50 - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=50)) + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) asyncio.run(self.cog.user_info.callback(self.cog, ctx)) @@ -548,7 +548,7 @@ class UserCommandTests(unittest.TestCase): constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot = 50 - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=50)) + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) @@ -561,7 +561,7 @@ class UserCommandTests(unittest.TestCase): constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot = 50 - ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(channel_id=200)) + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) asyncio.run(self.cog.user_info.callback(self.cog, ctx)) @@ -574,7 +574,7 @@ class UserCommandTests(unittest.TestCase): constants.MODERATION_ROLES = [self.moderator_role.id] constants.STAFF_ROLES = [self.moderator_role.id] - ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(channel_id=50)) + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index dfb1bafc9..3276cf5a5 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -24,7 +24,7 @@ class TokenRemoverTests(unittest.TestCase): self.bot.get_cog.return_value.send_log_message = AsyncMock() self.cog = TokenRemover(bot=self.bot) - self.msg = MockMessage(message_id=555, content='') + self.msg = MockMessage(id=555, content='') self.msg.author.__str__ = MagicMock() self.msg.author.__str__.return_value = 'lemon' self.msg.author.bot = False diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py index 19b758336..9610771e5 100644 --- a/tests/bot/utils/test_checks.py +++ b/tests/bot/utils/test_checks.py @@ -22,7 +22,7 @@ class ChecksTests(unittest.TestCase): def test_with_role_check_with_guild_and_required_role(self): """`with_role_check` returns `True` if `Context.author` has the required role.""" - self.ctx.author.roles.append(MockRole(role_id=10)) + self.ctx.author.roles.append(MockRole(id=10)) self.assertTrue(checks.with_role_check(self.ctx, 10)) def test_without_role_check_without_guild(self): @@ -33,13 +33,13 @@ class ChecksTests(unittest.TestCase): def test_without_role_check_returns_false_with_unwanted_role(self): """`without_role_check` returns `False` if `Context.author` has unwanted role.""" role_id = 42 - self.ctx.author.roles.append(MockRole(role_id=role_id)) + self.ctx.author.roles.append(MockRole(id=role_id)) self.assertFalse(checks.without_role_check(self.ctx, role_id)) def test_without_role_check_returns_true_without_unwanted_role(self): """`without_role_check` returns `True` if `Context.author` does not have unwanted role.""" role_id = 42 - self.ctx.author.roles.append(MockRole(role_id=role_id)) + self.ctx.author.roles.append(MockRole(id=role_id)) self.assertTrue(checks.without_role_check(self.ctx, role_id + 10)) def test_in_channel_check_for_correct_channel(self): diff --git a/tests/helpers.py b/tests/helpers.py index 5dc7a0d2f..35f2c288c 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,8 +1,10 @@ from __future__ import annotations import asyncio +import collections import functools import inspect +import itertools import logging import unittest.mock from typing import Any, Iterable, Optional @@ -72,14 +74,16 @@ class CustomMockMixin: """ child_mock_type = unittest.mock.MagicMock + discord_id = itertools.count(0) - def __init__(self, spec: Any = None, **kwargs): + def __init__(self, spec_set: Any = None, **kwargs): name = kwargs.pop('name', None) # `name` has special meaning for Mock classes, so we need to set it manually. - super().__init__(spec=spec, **kwargs) + super().__init__(spec_set=spec_set, **kwargs) + if name: self.name = name - if spec: - self._extract_coroutine_methods_from_spec_instance(spec) + if spec_set: + self._extract_coroutine_methods_from_spec_instance(spec_set) def _get_child_mock(self, **kw): """ @@ -169,25 +173,14 @@ class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ - def __init__( - self, - guild_id: int = 1, - roles: Optional[Iterable[MockRole]] = None, - members: Optional[Iterable[MockMember]] = None, - **kwargs, - ) -> None: - super().__init__(spec=guild_instance, **kwargs) - - self.id = guild_id - - self.roles = [MockRole("@everyone", 1)] + def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: + default_kwargs = {'id': next(self.discord_id), 'members': []} + super().__init__(spec_set=guild_instance, **collections.ChainMap(kwargs, default_kwargs)) + + self.roles = [MockRole(name="@everyone", position=1, id=0)] if roles: self.roles.extend(roles) - self.members = [] - if members: - self.members.extend(members) - # Create a Role instance to get a realistic Mock of `discord.Role` role_data = {'name': 'role', 'id': 1} @@ -201,13 +194,12 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): Instances of this class will follow the specifications of `discord.Role` instances. For more information, see the `MockGuild` docstring. """ - def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: - super().__init__(spec=role_instance, **kwargs) + def __init__(self, **kwargs) -> None: + default_kwargs = {'id': next(self.discord_id), 'name': 'role', 'position': 1} + super().__init__(spec_set=role_instance, **collections.ChainMap(kwargs, default_kwargs)) - self.name = name - self.id = role_id - self.position = position - self.mention = f'&{self.name}' + if 'mention' not in kwargs: + self.mention = f'&{self.name}' def __lt__(self, other): """Simplified position-based comparisons similar to those of `discord.Role`.""" @@ -227,19 +219,11 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin Instances of this class will follow the specifications of `discord.Member` instances. For more information, see the `MockGuild` docstring. """ - def __init__( - self, - name: str = "member", - user_id: int = 1, - roles: Optional[Iterable[MockRole]] = None, - **kwargs, - ) -> None: - super().__init__(spec=member_instance, **kwargs) - - self.name = name - self.id = user_id - - self.roles = [MockRole("@everyone", 1)] + def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: + default_kwargs = {'name': 'member', 'id': next(self.discord_id)} + super().__init__(spec_set=member_instance, **collections.ChainMap(kwargs, default_kwargs)) + + self.roles = [MockRole(name="@everyone", position=1, id=0)] if roles: self.roles.extend(roles) @@ -248,6 +232,8 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) +bot_instance.http_session = None +bot_instance.api_client = None class MockBot(CustomMockMixin, unittest.mock.MagicMock): @@ -258,11 +244,7 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): For more information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - super().__init__(spec=bot_instance, **kwargs) - - # Our custom attributes and methods - self.http_session = unittest.mock.MagicMock() - self.api_client = unittest.mock.MagicMock() + super().__init__(spec_set=bot_instance, **kwargs) # self.wait_for is *not* a coroutine function, but returns a coroutine nonetheless and # and should therefore be awaited. (The documentation calls it a coroutine as well, which @@ -294,11 +276,11 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): more information, see the `MockGuild` docstring. """ def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: - super().__init__(spec=channel_instance, **kwargs) - self.id = channel_id - self.name = name - self.guild = kwargs.get('guild', MockGuild()) - self.mention = f"#{self.name}" + default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()} + super().__init__(spec_set=channel_instance, **collections.ChainMap(kwargs, default_kwargs)) + + if 'mention' not in kwargs: + self.mention = f"#{self.name}" # Create a Message instance to get a realistic MagicMock of `discord.Message` @@ -335,12 +317,11 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): instances. For more information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - super().__init__(spec=context_instance, **kwargs) + super().__init__(spec_set=context_instance, **kwargs) self.bot = kwargs.get('bot', MockBot()) self.guild = kwargs.get('guild', MockGuild()) self.author = kwargs.get('author', MockMember()) self.channel = kwargs.get('channel', MockTextChannel()) - self.command = kwargs.get('command', unittest.mock.MagicMock()) class MockMessage(CustomMockMixin, unittest.mock.MagicMock): @@ -351,7 +332,7 @@ class MockMessage(CustomMockMixin, unittest.mock.MagicMock): information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - super().__init__(spec=message_instance, **kwargs) + super().__init__(spec_set=message_instance, **kwargs) self.author = kwargs.get('author', MockMember()) self.channel = kwargs.get('channel', MockTextChannel()) @@ -368,12 +349,9 @@ class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - super().__init__(spec=emoji_instance, **kwargs) + super().__init__(spec_set=emoji_instance, **kwargs) self.guild = kwargs.get('guild', MockGuild()) - # Get all coroutine functions and set them as AsyncMock attributes - self._extract_coroutine_methods_from_spec_instance(emoji_instance) - partial_emoji_instance = discord.PartialEmoji(animated=False, name='guido') @@ -386,7 +364,7 @@ class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): more information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - super().__init__(spec=partial_emoji_instance, **kwargs) + super().__init__(spec_set=partial_emoji_instance, **kwargs) reaction_instance = discord.Reaction(message=MockMessage(), data={'me': True}, emoji=MockEmoji()) @@ -400,6 +378,6 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): more information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - super().__init__(spec=reaction_instance, **kwargs) + super().__init__(spec_set=reaction_instance, **kwargs) self.emoji = kwargs.get('emoji', MockEmoji()) self.message = kwargs.get('message', MockMessage()) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 2b58634dd..e879ef97a 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -19,7 +19,6 @@ class DiscordMocksTests(unittest.TestCase): self.assertIsInstance(role, discord.Role) self.assertEqual(role.name, "role") - self.assertEqual(role.id, 1) self.assertEqual(role.position, 1) self.assertEqual(role.mention, "&role") @@ -27,7 +26,7 @@ class DiscordMocksTests(unittest.TestCase): """Test if MockRole initializes with the arguments provided.""" role = helpers.MockRole( name="Admins", - role_id=90210, + id=90210, position=10, ) @@ -67,22 +66,21 @@ class DiscordMocksTests(unittest.TestCase): self.assertIsInstance(member, discord.Member) self.assertEqual(member.name, "member") - self.assertEqual(member.id, 1) - self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1)]) + self.assertListEqual(member.roles, [helpers.MockRole(name="@everyone", position=1, id=0)]) self.assertEqual(member.mention, "@member") def test_mock_member_alternative_arguments(self): """Test if MockMember initializes with the arguments provided.""" - core_developer = helpers.MockRole("Core Developer", 2) + core_developer = helpers.MockRole(name="Core Developer", position=2) member = helpers.MockMember( name="Mark", - user_id=12345, + id=12345, roles=[core_developer] ) self.assertEqual(member.name, "Mark") self.assertEqual(member.id, 12345) - self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1), core_developer]) + self.assertListEqual(member.roles, [helpers.MockRole(name="@everyone", position=1, id=0), core_developer]) self.assertEqual(member.mention, "@Mark") def test_mock_member_accepts_dynamic_arguments(self): @@ -102,19 +100,19 @@ class DiscordMocksTests(unittest.TestCase): # The `spec` argument makes sure `isistance` checks with `discord.Guild` pass self.assertIsInstance(guild, discord.Guild) - self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1)]) + self.assertListEqual(guild.roles, [helpers.MockRole(name="@everyone", position=1, id=0)]) self.assertListEqual(guild.members, []) def test_mock_guild_alternative_arguments(self): """Test if MockGuild initializes with the arguments provided.""" - core_developer = helpers.MockRole("Core Developer", 2) + core_developer = helpers.MockRole(name="Core Developer", position=2) guild = helpers.MockGuild( roles=[core_developer], - members=[helpers.MockMember(user_id=54321)], + members=[helpers.MockMember(id=54321)], ) - self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1), core_developer]) - self.assertListEqual(guild.members, [helpers.MockMember(user_id=54321)]) + self.assertListEqual(guild.roles, [helpers.MockRole(name="@everyone", position=1, id=0), core_developer]) + self.assertListEqual(guild.members, [helpers.MockMember(id=54321)]) def test_mock_guild_accepts_dynamic_arguments(self): """Test if MockGuild accepts and sets abitrary keyword arguments.""" @@ -191,51 +189,18 @@ class DiscordMocksTests(unittest.TestCase): with self.assertRaises(AttributeError): mock.the_cake_is_a_lie - def test_custom_mock_methods_are_valid_discord_object_methods(self): - """The `AsyncMock` attributes of the mocks should be valid for the class they're mocking.""" - mocks = ( - (helpers.MockGuild, helpers.guild_instance), - (helpers.MockRole, helpers.role_instance), - (helpers.MockMember, helpers.member_instance), - (helpers.MockBot, helpers.bot_instance), - (helpers.MockContext, helpers.context_instance), - (helpers.MockTextChannel, helpers.channel_instance), - (helpers.MockMessage, helpers.message_instance), + def test_mocks_use_mention_when_provided_as_kwarg(self): + """The mock should use the passed `mention` instead of the default one if present.""" + test_cases = ( + (helpers.MockRole, "role mention"), + (helpers.MockMember, "member mention"), + (helpers.MockTextChannel, "channel mention"), ) - for mock_class, instance in mocks: - mock = mock_class() - async_methods = ( - attr for attr in dir(mock) if isinstance(getattr(mock, attr), helpers.AsyncMock) - ) - - # spec_mock = unittest.mock.MagicMock(spec=instance) - for method in async_methods: - with self.subTest(mock_class=mock_class, method=method): - try: - getattr(instance, method) - except AttributeError: - msg = f"method {method} is not a method attribute of {instance.__class__}" - self.fail(msg) - - @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') - def test_the_custom_mock_methods_test(self, subtest_mock): - """The custom method test should raise AssertionError for invalid methods.""" - class FakeMockBot(helpers.CustomMockMixin, unittest.mock.MagicMock): - """Fake MockBot class with invalid attribute/method `release_the_walrus`.""" - - child_mock_type = unittest.mock.MagicMock - - def __init__(self, **kwargs): - super().__init__(spec=helpers.bot_instance, **kwargs) - - # Fake attribute - self.release_the_walrus = helpers.AsyncMock() - - with unittest.mock.patch("tests.helpers.MockBot", new=FakeMockBot): - msg = "method release_the_walrus is not a valid method of " - with self.assertRaises(AssertionError, msg=msg): - self.test_custom_mock_methods_are_valid_discord_object_methods() + for mock_type, mention in test_cases: + with self.subTest(mock_type=mock_type, mention=mention): + mock = mock_type(mention=mention) + self.assertEqual(mock.mention, mention) class MockObjectTests(unittest.TestCase): @@ -266,14 +231,14 @@ class MockObjectTests(unittest.TestCase): def test_hashable_mixin_uses_id_for_equality_comparison(self): """Test if the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + class MockScragly(helpers.HashableMixin): pass - scragly = MockScragly(spec=object) + scragly = MockScragly() scragly.id = 10 - eevee = MockScragly(spec=object) + eevee = MockScragly() eevee.id = 10 - python = MockScragly(spec=object) + python = MockScragly() python.id = 20 self.assertTrue(scragly == eevee) @@ -281,14 +246,14 @@ class MockObjectTests(unittest.TestCase): def test_hashable_mixin_uses_id_for_nonequality_comparison(self): """Test if the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + class MockScragly(helpers.HashableMixin): pass - scragly = MockScragly(spec=object) + scragly = MockScragly() scragly.id = 10 - eevee = MockScragly(spec=object) + eevee = MockScragly() eevee.id = 10 - python = MockScragly(spec=object) + python = MockScragly() python.id = 20 self.assertTrue(scragly != python) @@ -298,7 +263,7 @@ class MockObjectTests(unittest.TestCase): """Test if the MagicMock subclasses that implement the HashableMixin use id for hash.""" for mock in self.hashable_mocks: with self.subTest(mock_class=mock): - instance = helpers.MockRole(role_id=100) + instance = helpers.MockRole(id=100) self.assertEqual(hash(instance), instance.id) def test_mock_class_with_hashable_mixin_uses_id_for_equality(self): @@ -396,11 +361,11 @@ class MockObjectTests(unittest.TestCase): @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") def test_custom_mock_mixin_init_with_spec(self, extract_method_mock): """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" - spec = "pydis" + spec_set = "pydis" - helpers.CustomMockMixin(spec=spec) + helpers.CustomMockMixin(spec_set=spec_set) - extract_method_mock.assert_called_once_with(spec) + extract_method_mock.assert_called_once_with(spec_set) @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") -- cgit v1.2.3 From 7f4829e9fab007690d48188f499bfcc1a7baa437 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 13 Nov 2019 17:29:09 +0100 Subject: Prevent await warnings for MockBot's create_task Previously, the coroutine object passed to `MockBot.loop.create_task` would trigger a `RuntimeWarning` for not being awaited as we do not actually create a task for it. To prevent these warnings, coroutine objects passed will now automatically be closed. --- tests/helpers.py | 8 +++++++- tests/test_helpers.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 35f2c288c..8a14aeef4 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -227,7 +227,8 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin if roles: self.roles.extend(roles) - self.mention = f"@{self.name}" + if 'mention' not in kwargs: + self.mention = f"@{self.name}" # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` @@ -251,6 +252,11 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): # is technically incorrect, since it's a regular def.) self.wait_for = AsyncMock() + # Since calling `create_task` on our MockBot does not actually schedule the coroutine object + # as a task in the asyncio loop, this `side_effect` calls `close()` on the coroutine object + # to prevent "has not been awaited"-warnings. + self.loop.create_task.side_effect = lambda coroutine: coroutine.close() + # Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` channel_data = { diff --git a/tests/test_helpers.py b/tests/test_helpers.py index e879ef97a..7894e104a 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -202,6 +202,18 @@ class DiscordMocksTests(unittest.TestCase): mock = mock_type(mention=mention) self.assertEqual(mock.mention, mention) + def test_create_test_on_mock_bot_closes_passed_coroutine(self): + """`bot.loop.create_task` should close the passed coroutine object to prevent warnings.""" + async def dementati(): + """Dummy coroutine for testing purposes.""" + + coroutine_object = dementati() + + bot = helpers.MockBot() + bot.loop.create_task(coroutine_object) + with self.assertRaises(RuntimeError, msg="cannot reuse already awaited coroutine"): + asyncio.run(coroutine_object) + class MockObjectTests(unittest.TestCase): """Tests the mock objects and mixins we've defined.""" -- cgit v1.2.3 From 2a9b1fc24ffe9679a565c0f9f4678357e9c80e44 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 13 Nov 2019 22:17:18 +0100 Subject: Adjust links rule to use proper MockMessage --- tests/bot/rules/test_links.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index 40336beb0..02a5d5501 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -2,25 +2,19 @@ import unittest from typing import List, NamedTuple, Tuple from bot.rules import links -from tests.helpers import async_test - - -class FakeMessage(NamedTuple): - author: str - content: str +from tests.helpers import MockMessage, async_test class Case(NamedTuple): - recent_messages: List[FakeMessage] - relevant_messages: Tuple[FakeMessage] + recent_messages: List[MockMessage] culprit: Tuple[str] total_links: int -def msg(author: str, total_links: int) -> FakeMessage: +def msg(author: str, total_links: int) -> MockMessage: """Makes a message with `total_links` links.""" content = " ".join(["https://pydis.com"] * total_links) - return FakeMessage(author=author, content=content) + return MockMessage(author=author, content=content) class LinksTests(unittest.TestCase): @@ -61,26 +55,28 @@ class LinksTests(unittest.TestCase): cases = ( Case( [msg("bob", 1), msg("bob", 2)], - (msg("bob", 1), msg("bob", 2)), ("bob",), 3 ), Case( [msg("alice", 1), msg("alice", 1), msg("alice", 1)], - (msg("alice", 1), msg("alice", 1), msg("alice", 1)), ("alice",), 3 ), Case( [msg("alice", 2), msg("bob", 3), msg("alice", 1)], - (msg("alice", 2), msg("alice", 1)), ("alice",), 3 ) ) - for recent_messages, relevant_messages, culprit, total_links in cases: + for recent_messages, culprit, total_links in cases: last_message = recent_messages[0] + relevant_messages = tuple( + msg + for msg in recent_messages + if msg.author == last_message.author + ) with self.subTest( last_message=last_message, -- cgit v1.2.3 From a2617d197f4863123caa33076d89b7612a902d60 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 13 Nov 2019 22:42:25 +0100 Subject: Adjust attachments rule to use MockMessage, restructure test cases --- tests/bot/rules/test_attachments.py | 43 ++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 20 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index 4bb0acf7c..2f8294922 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -1,26 +1,17 @@ import asyncio import unittest -from dataclasses import dataclass -from typing import Any, List from bot.rules import attachments +from tests.helpers import MockMessage -# Using `MagicMock` sadly doesn't work for this usecase -# since it's __eq__ compares the MagicMock's ID. We just -# want to compare the actual attributes we set. -@dataclass -class FakeMessage: - author: str - attachments: List[Any] - - -def msg(total_attachments: int) -> FakeMessage: - return FakeMessage(author='lemon', attachments=list(range(total_attachments))) +def msg(total_attachments: int) -> MockMessage: + """Builds a message with `total_attachments` attachments.""" + return MockMessage(author='lemon', attachments=list(range(total_attachments))) class AttachmentRuleTests(unittest.TestCase): - """Tests applying the `attachment` antispam rule.""" + """Tests applying the `attachments` antispam rule.""" def test_allows_messages_without_too_many_attachments(self): """Messages without too many attachments are allowed as-is.""" @@ -38,13 +29,25 @@ class AttachmentRuleTests(unittest.TestCase): def test_disallows_messages_with_too_many_attachments(self): """Messages with too many attachments trigger the rule.""" cases = ( - ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), - ((msg(6),), [msg(6)], 6), - ((msg(1),) * 6, [msg(1)] * 6, 6), + ([msg(4), msg(0), msg(6)], 10), + ([msg(6)], 6), + ([msg(1)] * 6, 6), ) - for messages, relevant_messages, total in cases: - with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): - last_message, *recent_messages = messages + for messages, total in cases: + last_message, *recent_messages = messages + relevant_messages = [last_message] + [ + msg + for msg in recent_messages + if msg.author == last_message.author + and len(msg.attachments) > 0 + ] + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + relevant_messages=relevant_messages, + total=total + ): coro = attachments.apply(last_message, recent_messages, {'max': 5}) self.assertEqual( asyncio.run(coro), -- cgit v1.2.3 From dd098d91e35c2e333af14919d7405fe47f298ac2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 13 Nov 2019 22:48:59 +0100 Subject: Use async_test helper to simplify coro testing --- tests/bot/rules/test_attachments.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index 2f8294922..770dd3201 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -1,8 +1,7 @@ -import asyncio import unittest from bot.rules import attachments -from tests.helpers import MockMessage +from tests.helpers import MockMessage, async_test def msg(total_attachments: int) -> MockMessage: @@ -13,7 +12,8 @@ def msg(total_attachments: int) -> MockMessage: class AttachmentRuleTests(unittest.TestCase): """Tests applying the `attachments` antispam rule.""" - def test_allows_messages_without_too_many_attachments(self): + @async_test + async def test_allows_messages_without_too_many_attachments(self): """Messages without too many attachments are allowed as-is.""" cases = ( (msg(0), msg(0), msg(0)), @@ -22,17 +22,23 @@ class AttachmentRuleTests(unittest.TestCase): ) for last_message, *recent_messages in cases: - with self.subTest(last_message=last_message, recent_messages=recent_messages): - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - self.assertIsNone(asyncio.run(coro)) + with self.subTest( + last_message=last_message, + recent_messages=recent_messages + ): + self.assertIsNone( + await attachments.apply(last_message, recent_messages, {'max': 5}) + ) - def test_disallows_messages_with_too_many_attachments(self): + @async_test + async def test_disallows_messages_with_too_many_attachments(self): """Messages with too many attachments trigger the rule.""" cases = ( ([msg(4), msg(0), msg(6)], 10), ([msg(6)], 6), ([msg(1)] * 6, 6), ) + for messages, total in cases: last_message, *recent_messages = messages relevant_messages = [last_message] + [ @@ -48,8 +54,7 @@ class AttachmentRuleTests(unittest.TestCase): relevant_messages=relevant_messages, total=total ): - coro = attachments.apply(last_message, recent_messages, {'max': 5}) self.assertEqual( - asyncio.run(coro), + await attachments.apply(last_message, recent_messages, {'max': 5}), (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) ) -- cgit v1.2.3 From ccda39c5e42e94011c9c1bd14080d004d3d61f02 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 14 Nov 2019 10:50:49 +0100 Subject: Add bot=False default value to MockMember By default, a mocked value is considered `truthy` in Python, like all non-empty/non-zero/non-None values in Python. This means that if an attribute is not explicitly set on a mock, it will evaluate at as truthy in a boolean context, since the mock will provide a truthy mocked value by default. This is not the best default value for the `bot` attribute of our MockMember type, since members are rarely bots. It makes much more intuitive sense to me to consider a member to not be a bot, unless we explicitly set `bot=True`. This commit sets that sensible default value that can be overwritten by passing `bot=False` to the constructor or setting the `object.bot` attribute to `False` after the creation of the mock. --- tests/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 22f07934f..199d45700 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -242,7 +242,7 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin information, see the `MockGuild` docstring. """ def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: - default_kwargs = {'name': 'member', 'id': next(self.discord_id)} + default_kwargs = {'name': 'member', 'id': next(self.discord_id), 'bot': False} super().__init__(spec_set=member_instance, **collections.ChainMap(kwargs, default_kwargs)) self.roles = [MockRole(name="@everyone", position=1, id=0)] -- cgit v1.2.3 From 61051f9cc5abbf571dfa13c49324109ef16f78fc Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 14 Nov 2019 10:58:40 +0100 Subject: Add MockAttachment type and attachments default for MockMessage As stated from the start, our intention is to add custom mock types as we need them for testing. While writing tests for DuckPond, I noticed that we did not have a mock type for Attachments, so I added one with this commit. In addition, I think it's a very sensible for MockMessage to have an empty list as a default value for the `attachements` attribute. This is equal to what `discord.Message` returns for a message without attachments and makes sure that if you don't explicitely add an attachment to a message, `MockMessage.attachments` tests as falsey. --- tests/helpers.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 199d45700..3e43679fe 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -355,6 +355,20 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): self.channel = kwargs.get('channel', MockTextChannel()) +attachment_instance = discord.Attachment(data=unittest.mock.MagicMock(id=1), state=unittest.mock.MagicMock()) + + +class MockAttachment(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Attachment objects. + + Instances of this class will follow the specifications of `discord.Attachment` instances. For + more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec_set=attachment_instance, **kwargs) + + class MockMessage(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Message objects. @@ -364,7 +378,8 @@ class MockMessage(CustomMockMixin, unittest.mock.MagicMock): """ def __init__(self, **kwargs) -> None: - super().__init__(spec_set=message_instance, **kwargs) + default_kwargs = {'attachments': []} + super().__init__(spec_set=message_instance, **collections.ChainMap(kwargs, default_kwargs)) self.author = kwargs.get('author', MockMember()) self.channel = kwargs.get('channel', MockTextChannel()) -- cgit v1.2.3 From eef447a2c4e237a56b8f3cb72ee3e4bc54e7961c Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 14 Nov 2019 20:16:23 +0100 Subject: Adjust attachments rule unit test to correcty build the arguments for the tested rule --- tests/bot/rules/test_attachments.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index 770dd3201..a43741fcc 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -21,7 +21,9 @@ class AttachmentRuleTests(unittest.TestCase): (msg(0),), ) - for last_message, *recent_messages in cases: + for recent_messages in cases: + last_message = recent_messages[0] + with self.subTest( last_message=last_message, recent_messages=recent_messages @@ -39,14 +41,16 @@ class AttachmentRuleTests(unittest.TestCase): ([msg(1)] * 6, 6), ) - for messages, total in cases: - last_message, *recent_messages = messages - relevant_messages = [last_message] + [ + for recent_messages, total in cases: + last_message = recent_messages[0] + relevant_messages = tuple( msg for msg in recent_messages - if msg.author == last_message.author - and len(msg.attachments) > 0 - ] + if ( + msg.author == last_message.author + and len(msg.attachments) > 0 + ) + ) with self.subTest( last_message=last_message, -- cgit v1.2.3 From 37b526f372ebc981f5691c5aca1ca8c721da77f6 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 14 Nov 2019 20:18:57 +0100 Subject: Hold recent_messages in a list to respect type hint, set config in setUp --- tests/bot/rules/test_attachments.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index a43741fcc..fa6b63654 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -12,13 +12,16 @@ def msg(total_attachments: int) -> MockMessage: class AttachmentRuleTests(unittest.TestCase): """Tests applying the `attachments` antispam rule.""" + def setUp(self): + self.config = {"max": 5} + @async_test async def test_allows_messages_without_too_many_attachments(self): """Messages without too many attachments are allowed as-is.""" cases = ( - (msg(0), msg(0), msg(0)), - (msg(2), msg(2)), - (msg(0),), + [msg(0), msg(0), msg(0)], + [msg(2), msg(2)], + [msg(0)], ) for recent_messages in cases: @@ -29,7 +32,7 @@ class AttachmentRuleTests(unittest.TestCase): recent_messages=recent_messages ): self.assertIsNone( - await attachments.apply(last_message, recent_messages, {'max': 5}) + await attachments.apply(last_message, recent_messages, self.config) ) @async_test @@ -59,6 +62,6 @@ class AttachmentRuleTests(unittest.TestCase): total=total ): self.assertEqual( - await attachments.apply(last_message, recent_messages, {'max': 5}), + await attachments.apply(last_message, recent_messages, self.config), (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) ) -- cgit v1.2.3 From 01731a8873f13cc8a85d08147941ffba7284cf20 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 14 Nov 2019 20:33:12 +0100 Subject: Make complex test cases namedtuples, recognize between various authors, pass config to subTest --- tests/bot/rules/test_attachments.py | 55 +++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 12 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index fa6b63654..d8d1b341f 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -1,12 +1,19 @@ import unittest +from typing import List, NamedTuple, Tuple from bot.rules import attachments from tests.helpers import MockMessage, async_test -def msg(total_attachments: int) -> MockMessage: +class Case(NamedTuple): + recent_messages: List[MockMessage] + culprit: Tuple[str] + total_attachments: int + + +def msg(author: str, total_attachments: int) -> MockMessage: """Builds a message with `total_attachments` attachments.""" - return MockMessage(author='lemon', attachments=list(range(total_attachments))) + return MockMessage(author=author, attachments=list(range(total_attachments))) class AttachmentRuleTests(unittest.TestCase): @@ -19,9 +26,9 @@ class AttachmentRuleTests(unittest.TestCase): async def test_allows_messages_without_too_many_attachments(self): """Messages without too many attachments are allowed as-is.""" cases = ( - [msg(0), msg(0), msg(0)], - [msg(2), msg(2)], - [msg(0)], + [msg("bob", 0), msg("bob", 0), msg("bob", 0)], + [msg("bob", 2), msg("bob", 2)], + [msg("bob", 2), msg("alice", 2), msg("bob", 2)], ) for recent_messages in cases: @@ -29,7 +36,8 @@ class AttachmentRuleTests(unittest.TestCase): with self.subTest( last_message=last_message, - recent_messages=recent_messages + recent_messages=recent_messages, + config=self.config ): self.assertIsNone( await attachments.apply(last_message, recent_messages, self.config) @@ -39,12 +47,29 @@ class AttachmentRuleTests(unittest.TestCase): async def test_disallows_messages_with_too_many_attachments(self): """Messages with too many attachments trigger the rule.""" cases = ( - ([msg(4), msg(0), msg(6)], 10), - ([msg(6)], 6), - ([msg(1)] * 6, 6), + Case( + [msg("bob", 4), msg("bob", 0), msg("bob", 6)], + ("bob",), + 10 + ), + Case( + [msg("bob", 4), msg("alice", 6), msg("bob", 2)], + ("bob",), + 6 + ), + Case( + [msg("alice", 6)], + ("alice",), + 6 + ), + ( + [msg("alice", 1) for _ in range(6)], + ("alice",), + 6 + ), ) - for recent_messages, total in cases: + for recent_messages, culprit, total_attachments in cases: last_message = recent_messages[0] relevant_messages = tuple( msg @@ -59,9 +84,15 @@ class AttachmentRuleTests(unittest.TestCase): last_message=last_message, recent_messages=recent_messages, relevant_messages=relevant_messages, - total=total + total_attachments=total_attachments, + config=self.config ): + desired_output = ( + f"sent {total_attachments} attachments in {self.config['max']}s", + culprit, + relevant_messages + ) self.assertEqual( await attachments.apply(last_message, recent_messages, self.config), - (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) + desired_output ) -- cgit v1.2.3 From c74a6c4fb16052c00041b94c3a3e2ef10efe9827 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 14 Nov 2019 20:34:00 +0100 Subject: Specify assertion to be a tuple comparison --- tests/bot/rules/test_attachments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index d8d1b341f..d7187f315 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -92,7 +92,7 @@ class AttachmentRuleTests(unittest.TestCase): culprit, relevant_messages ) - self.assertEqual( + self.assertTupleEqual( await attachments.apply(last_message, recent_messages, self.config), desired_output ) -- cgit v1.2.3 From 2779a912a8fbe29453543a8fd2888a842c3beb47 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 15 Nov 2019 01:21:33 +0100 Subject: Add `return_value` support and assertions to AsyncIteratorMock The AsyncIteratorMock included in Python 3.8 will work similarly to the mocks of callabes. This means that it allows you to set the items it will yield using the `return_value` attribute. It will also have support for the common Mock-specific assertions. This commit introduces some backports of those features in a slightly simplified way to make the transition to Python 3.8 easier in the future. --- tests/helpers.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 4 deletions(-) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 3e43679fe..50652ef9a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -127,14 +127,20 @@ class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): class AsyncIteratorMock: """ - A class to mock asyncronous iterators. + A class to mock asynchronous iterators. This allows async for, which is used in certain Discord.py objects. For example, - an async iterator is returned by the Reaction.users() coroutine. + an async iterator is returned by the Reaction.users() method. """ - def __init__(self, sequence): - self.iter = iter(sequence) + def __init__(self, iterable: Iterable = None): + if iterable is None: + iterable = [] + + self.iter = iter(iterable) + self.iterable = iterable + + self.call_count = 0 def __aiter__(self): return self @@ -145,6 +151,50 @@ class AsyncIteratorMock: except StopIteration: raise StopAsyncIteration + def __call__(self): + """ + Keeps track of the number of times an instance has been called. + + This is useful, since it typically shows that the iterator has actually been used somewhere after we have + instantiated the mock for an attribute that normally returns an iterator when called. + """ + self.call_count += 1 + return self + + @property + def return_value(self): + """Makes `self.iterable` accessible as self.return_value.""" + return self.iterable + + @return_value.setter + def return_value(self, iterable): + """Stores the `return_value` as `self.iterable` and its iterator as `self.iter`.""" + self.iter = iter(iterable) + self.iterable = iterable + + def assert_called(self): + """Asserts if the AsyncIteratorMock instance has been called at least once.""" + if self.call_count == 0: + raise AssertionError("Expected AsyncIteratorMock to have been called.") + + def assert_called_once(self): + """Asserts if the AsyncIteratorMock instance has been called exactly once.""" + if self.call_count != 1: + raise AssertionError( + f"Expected AsyncIteratorMock to have been called once. Called {self.call_count} times." + ) + + def assert_not_called(self): + """Asserts if the AsyncIteratorMock instance has not been called.""" + if self.call_count != 0: + raise AssertionError( + f"Expected AsyncIteratorMock to not have been called once. Called {self.call_count} times." + ) + + def reset_mock(self): + """Resets the call count, but not the return value or iterator.""" + self.call_count = 0 + # Create a guild instance to get a realistic Mock of `discord.Guild` guild_data = { -- cgit v1.2.3 From 2c77288eb3ff081e70508094bb8d030900860259 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 15 Nov 2019 01:28:56 +0100 Subject: Add MockUser to mock `discord.User` objects I have added a special mock that follows the specifications of a `discord.User` instance. This is useful, since `Users` have less attributes available than `discord.Members`. Since this difference in availability of information can be important, we should not use a `MockMember` to mock a `discord.user`. --- tests/helpers.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 50652ef9a..4da6bf84d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -303,6 +303,25 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin self.mention = f"@{self.name}" +# Create a User instance to get a realistic Mock of `discord.User` +user_instance = discord.User(data=unittest.mock.MagicMock(), state=unittest.mock.MagicMock()) + + +class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): + """ + A Mock subclass to mock User objects. + + Instances of this class will follow the specifications of `discord.User` instances. For more + information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + default_kwargs = {'name': 'user', 'id': next(self.discord_id), 'bot': False} + super().__init__(spec_set=user_instance, **collections.ChainMap(kwargs, default_kwargs)) + + if 'mention' not in kwargs: + self.mention = f"@{self.name}" + + # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) bot_instance.http_session = None -- cgit v1.2.3 From 647370d7881d1ab242186599adb76a56a0815150 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 15 Nov 2019 01:34:46 +0100 Subject: Adjust MockReaction for new AsyncIteratorMock protocol The new AsyncIteratorMock no longer needs an additional method to be used with a Mock object. --- tests/helpers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 4da6bf84d..13852397f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -500,7 +500,5 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): super().__init__(spec_set=reaction_instance, **kwargs) self.emoji = kwargs.get('emoji', MockEmoji()) self.message = kwargs.get('message', MockMessage()) - self.user_list = AsyncIteratorMock(kwargs.get('user_list', [])) + self.users = AsyncIteratorMock(kwargs.get('users', [])) - def users(self): - return self.user_list -- cgit v1.2.3 From b42a7b5b7f2c1c9f9924eeb9d39f7767306824ec Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 15 Nov 2019 01:37:02 +0100 Subject: Add MockAsyncWebhook to mock `discord.Webhook` objects I have added a mock type to mock `discord.Webhook` instances. Note that the current type is specifically meant to mock webhooks that use an AsyncAdaptor and therefore has AsyncMock/coroutine mocks for the "maybe-coroutine" methods specified in the `discord.py` docs. --- tests/helpers.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 13852397f..b2daae92d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -502,3 +502,24 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): self.message = kwargs.get('message', MockMessage()) self.users = AsyncIteratorMock(kwargs.get('users', [])) + +webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), adapter=unittest.mock.MagicMock()) + + +class MockAsyncWebhook(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Webhook objects using an AsyncWebhookAdapter. + + Instances of this class will follow the specifications of `discord.Webhook` instances. For + more information, see the `MockGuild` docstring. + """ + + def __init__(self, **kwargs) -> None: + super().__init__(spec_set=webhook_instance, **kwargs) + + # Because Webhooks can also use a synchronous "WebhookAdapter", the methods are not defined + # as coroutines. That's why we need to set the methods manually. + self.send = AsyncMock() + self.edit = AsyncMock() + self.delete = AsyncMock() + self.execute = AsyncMock() -- cgit v1.2.3 From a692a95896328adf1d52c5a5548e0c72540d6cbc Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 15 Nov 2019 01:39:51 +0100 Subject: Add unit tests with full coverage for `bot.cogs.duck_pond` This commit adds unit tests that provide a full branch coverage of the `bot.cogs.duck_pond` file. --- tests/bot/cogs/test_duck_pond.py | 649 +++++++++++++++++++++++++++++---------- 1 file changed, 490 insertions(+), 159 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 088d8ac79..ceefc286f 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -1,193 +1,524 @@ import asyncio import logging +import typing import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch + +import discord from bot import constants from bot.cogs import duck_pond -from tests.helpers import MockBot, MockEmoji, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel +from tests import base +from tests import helpers + +MODULE_PATH = "bot.cogs.duck_pond" + + +class DuckPondTests(base.LoggingTestCase): + """Tests for DuckPond functionality.""" + + @classmethod + def setUpClass(cls): + """Sets up the objects that only have to be initialized once.""" + cls.nonstaff_member = helpers.MockMember(name="Non-staffer") + cls.staff_role = helpers.MockRole(name="Staff role", id=constants.STAFF_ROLES[0]) + cls.staff_member = helpers.MockMember(name="staffer", roles=[cls.staff_role]) -class DuckPondTest(unittest.TestCase): - """Tests the `DuckPond` cog.""" + cls.checkmark_emoji = "\N{White Heavy Check Mark}" + cls.thumbs_up_emoji = "\N{Thumbs Up Sign}" + cls.unicode_duck_emoji = "\N{Duck}" + cls.duck_pond_emoji = helpers.MockPartialEmoji(id=constants.DuckPond.custom_emojis[0]) + cls.non_duck_custom_emoji = helpers.MockPartialEmoji(id=123) def setUp(self): - """Adds the cog, a bot, and the mocks we'll need for our tests.""" - self.bot = MockBot() + """Sets up the objects that need to be refreshed before each test.""" + self.bot = helpers.MockBot(user=helpers.MockMember(id=46692)) self.cog = duck_pond.DuckPond(bot=self.bot) - # Set up some constants - self.CHANNEL_ID = 555 - self.MESSAGE_ID = 666 - self.BOT_ID = 777 - self.CONTRIB_ID = 888 - self.ADMIN_ID = 999 - - # Override the constants we'll be needing - constants.STAFF_ROLES = (123,) - constants.DuckPond.custom_emojis = (789,) - constants.DuckPond.threshold = 1 - - # Set up some roles - self.admin_role = MockRole(name="Admins", role_id=123) - self.contrib_role = MockRole(name="Contributor", role_id=456) - - # Set up some users - self.admin_member_1 = MockMember(roles=(self.admin_role,), id=self.ADMIN_ID) - self.admin_member_2 = MockMember(roles=(self.admin_role,), id=911) - self.contrib_member = MockMember(roles=(self.contrib_role,), id=self.CONTRIB_ID) - self.bot_member = MockMember(roles=(self.contrib_role,), id=self.BOT_ID, bot=True) - self.no_role_member = MockMember() - - # Set up emojis - self.checkmark_emoji = "✅" - self.thumbs_up_emoji = "👍" - self.unicode_duck_emoji = "🦆" - self.yellow_ducky_emoji = MockEmoji(id=789) - - # Set up reactions - self.checkmark_reaction = MockReaction( - emoji=self.checkmark_emoji, - user_list=[self.admin_member_1] - ) - self.thumbs_up_reaction = MockReaction( - emoji=self.thumbs_up_emoji, - user_list=[self.admin_member_1, self.contrib_member] - ) - self.yellow_ducky_reaction = MockReaction( - emoji=self.yellow_ducky_emoji, - user_list=[self.admin_member_1, self.contrib_member] - ) - self.unicode_duck_reaction_1 = MockReaction( - emoji=self.unicode_duck_emoji, - user_list=[self.admin_member_1] + def test_duck_pond_correctly_initializes(self): + """`__init__ should set `bot` and `webhook_id` attributes and schedule `fetch_webhook`.""" + bot = helpers.MockBot() + cog = MagicMock() + + duck_pond.DuckPond.__init__(cog, bot) + + self.assertEqual(cog.bot, bot) + self.assertEqual(cog.webhook_id, constants.Webhooks.duck_pond) + bot.loop.create_loop.called_once_with(cog.fetch_webhook()) + + def test_fetch_webhook_succeeds_without_connectivity_issues(self): + """The `fetch_webhook` method waits until `READY` event and sets the `webhook` attribute.""" + self.bot.fetch_webhook.return_value = "dummy webhook" + self.cog.webhook_id = 1 + + asyncio.run(self.cog.fetch_webhook()) + + self.bot.wait_until_ready.assert_called_once() + self.bot.fetch_webhook.assert_called_once_with(1) + self.assertEqual(self.cog.webhook, "dummy webhook") + + def test_fetch_webhook_logs_when_unable_to_fetch_webhook(self): + """The `fetch_webhook` method should log an exception when it fails to fetch the webhook.""" + self.bot.fetch_webhook.side_effect = discord.HTTPException(response=MagicMock(), message="Not found.") + self.cog.webhook_id = 1 + + log = logging.getLogger('bot.cogs.duck_pond') + with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: + asyncio.run(self.cog.fetch_webhook()) + + self.bot.wait_until_ready.assert_called_once() + self.bot.fetch_webhook.assert_called_once_with(1) + + self.assertEqual(len(log_watcher.records), 1) + + [record] = log_watcher.records + self.assertEqual(record.message, f"Failed to fetch webhook with id `{self.cog.webhook_id}`") + self.assertEqual(record.levelno, logging.ERROR) + + def test_is_staff_returns_correct_values_based_on_instance_passed(self): + """The `is_staff` method should return correct values based on the instance passed.""" + test_cases = ( + (helpers.MockUser(name="User instance"), False), + (helpers.MockMember(name="Member instance without staff role"), False), + (helpers.MockMember(name="Member instance with staff role", roles=[self.staff_role]), True) ) - self.unicode_duck_reaction_2 = MockReaction( - emoji=self.unicode_duck_emoji, - user_list=[self.admin_member_2] + + for user, expected_return in test_cases: + actual_return = self.cog.is_staff(user) + with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return): + self.assertEqual(expected_return, actual_return) + + @helpers.async_test + async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self): + """The `has_green_checkmark` method should only return `True` if one is present.""" + test_cases = ( + ( + "No reactions", helpers.MockMessage(), False + ), + ( + "No green check mark reactions", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji), + helpers.MockReaction(emoji=self.thumbs_up_emoji) + ]), + False + ), + ( + "Green check mark reaction, but not from our bot", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji), + helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member]) + ]), + False + ), + ( + "Green check mark reaction, with one from the bot", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji), + helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user]) + ]), + True + ) ) - self.bot_reaction = MockReaction( - emoji=self.yellow_ducky_emoji, - user_list=[self.bot_member] + + for description, message, expected_return in test_cases: + actual_return = await self.cog.has_green_checkmark(message) + with self.subTest( + test_case=description, + expected_return=expected_return, + actual_return=actual_return + ): + self.assertEqual(expected_return, actual_return) + + def test_send_webhook_correctly_passes_on_arguments(self): + """The `send_webhook` method should pass the arguments to the webhook correctly.""" + self.cog.webhook = helpers.MockAsyncWebhook() + + content = "fake content" + username = "fake username" + avatar_url = "fake avatar_url" + embed = "fake embed" + + asyncio.run(self.cog.send_webhook(content, username, avatar_url, embed)) + + self.cog.webhook.send.assert_called_once_with( + content=content, + username=username, + avatar_url=avatar_url, + embed=embed ) - self.contrib_reaction = MockReaction( - emoji=self.yellow_ducky_emoji, - user_list=[self.contrib_member] + + def test_send_webhook_logs_when_sending_message_fails(self): + """The `send_webhook` method should catch a `discord.HTTPException` and log accordingly.""" + self.cog.webhook = helpers.MockAsyncWebhook() + self.cog.webhook.send.side_effect = discord.HTTPException(response=MagicMock(), message="Something failed.") + + log = logging.getLogger('bot.cogs.duck_pond') + with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: + asyncio.run(self.cog.send_webhook()) + + self.assertEqual(len(log_watcher.records), 1) + + [record] = log_watcher.records + self.assertEqual(record.message, "Failed to send a message to the Duck Pool webhook") + self.assertEqual(record.levelno, logging.ERROR) + + def _get_reaction( + self, + emoji: typing.Union[str, helpers.MockEmoji], + staff: int = 0, + nonstaff: int = 0 + ) -> helpers.MockReaction: + staffers = [helpers.MockMember(roles=[self.staff_role]) for _ in range(staff)] + nonstaffers = [helpers.MockMember() for _ in range(nonstaff)] + return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers) + + @helpers.async_test + async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self): + """The `count_ducks` method should return the number of unique staffers who gave a duck.""" + test_cases = ( + # Simple test cases + # A message without reactions should return 0 + ( + "No reactions", + helpers.MockMessage(), + 0 + ), + # A message with a non-duck reaction from a non-staffer should return 0 + ( + "Non-duck reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, nonstaff=1)]), + 0 + ), + # A message with a non-duck reaction from a staffer should return 0 + ( + "Non-duck reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.non_duck_custom_emoji, staff=1)]), + 0 + ), + # A message with a non-duck reaction from a non-staffer and staffer should return 0 + ( + "Non-duck reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, staff=1, nonstaff=1)]), + 0 + ), + # A message with a unicode duck reaction from a non-staffer should return 0 + ( + "Unicode Duck Reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, nonstaff=1)]), + 0 + ), + # A message with a unicode duck reaction from a staffer should return 1 + ( + "Unicode Duck Reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1)]), + 1 + ), + # A message with a unicode duck reaction from a non-staffer and staffer should return 1 + ( + "Unicode Duck Reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1, nonstaff=1)]), + 1 + ), + # A message with a duckpond duck reaction from a non-staffer should return 0 + ( + "Duckpond Duck Reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, nonstaff=1)]), + 0 + ), + # A message with a duckpond duck reaction from a staffer should return 1 + ( + "Duckpond Duck Reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1)]), + 1 + ), + # A message with a duckpond duck reaction from a non-staffer and staffer should return 1 + ( + "Duckpond Duck Reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1, nonstaff=1)]), + 1 + ), + + # Complex test cases + # A message with duckpond duck reactions from 3 staffers and 2 non-staffers returns 3 + ( + "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2)]), + 3 + ), + # A staffer with multiple duck reactions only counts once + ( + "Two different duck reactions from the same staffer", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), + ]), + 1 + ), + # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif) + ( + "Reaction with non-Emoji/str emoij from 3 staffers + 2 non-staffers", + helpers.MockMessage(reactions=[self._get_reaction(emoji=100, staff=3, nonstaff=2)]), + 0 + ), + # We correctly sum when multiple reactions are provided. + ( + "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", + helpers.MockMessage(reactions=[ + self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), + self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), + ]), + 3+4 + ), ) - # Set up a messages - self.checkmark_message = MockMessage(reactions=(self.checkmark_reaction,)) - self.thumbs_up_message = MockMessage(reactions=(self.thumbs_up_reaction,)) - self.yellow_ducky_message = MockMessage(reactions=(self.yellow_ducky_reaction,)) - self.unicode_duck_message = MockMessage(reactions=(self.unicode_duck_reaction_1,)) - self.double_unicode_duck_message = MockMessage( - reactions=(self.unicode_duck_reaction_1, self.unicode_duck_reaction_2) + for description, message, expected_count in test_cases: + actual_count = await self.cog.count_ducks(message) + with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count): + self.assertEqual(expected_count, actual_count) + + @helpers.async_test + async def test_relay_message_to_duck_pond_correctly_relays_content_and_attachments(self): + """The `relay_message_to_duck_pond` method should correctly relay message content and attachments.""" + send_webhook_path = f"{MODULE_PATH}.DuckPond.send_webhook" + send_attachments_path = f"{MODULE_PATH}.send_attachments" + + self.cog.webhook = helpers.MockAsyncWebhook() + + test_values = ( + (helpers.MockMessage(clean_content="", attachments=[]), False, False), + (helpers.MockMessage(clean_content="message", attachments=[]), True, False), + (helpers.MockMessage(clean_content="", attachments=["attachment"]), False, True), + (helpers.MockMessage(clean_content="message", attachments=["attachment"]), True, True), ) - self.double_mixed_duck_message = MockMessage( - reactions=(self.unicode_duck_reaction_1, self.yellow_ducky_reaction) + + for message, expect_webhook_call, expect_attachment_call in test_values: + with patch(send_webhook_path, new_callable=helpers.AsyncMock) as send_webhook: + with patch(send_attachments_path, new_callable=helpers.AsyncMock) as send_attachments: + with self.subTest(clean_content=message.clean_content, attachments=message.attachments): + await self.cog.relay_message_to_duck_pond(message) + + self.assertEqual(expect_webhook_call, send_webhook.called) + self.assertEqual(expect_attachment_call, send_attachments.called) + + message.add_reaction.assert_called_once_with(self.checkmark_emoji) + message.reset_mock() + + @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) + @helpers.async_test + async def test_relay_message_to_duck_pond_handles_send_attachments_exceptions(self, send_attachments, send_webhook): + """The `relay_message_to_duck_pond` method should handle exceptions when calling `send_attachment`.""" + + message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) + side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) + + self.cog.webhook = helpers.MockAsyncWebhook() + log = logging.getLogger("bot.cogs.duck_pond") + + # Subtests for the first `except` block + for side_effect in side_effects: + send_attachments.side_effect = side_effect + with self.subTest(side_effect=type(side_effect).__name__): + with self.assertNotLogs(logger=log, level=logging.ERROR): + await self.cog.relay_message_to_duck_pond(message) + + self.assertEqual(send_webhook.call_count, 2) + send_webhook.reset_mock() + + # Subtests for the second `except` block + side_effect = discord.HTTPException(MagicMock(), "") + send_attachments.side_effect = side_effect + with self.subTest(side_effect=type(side_effect).__name__): + with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: + await self.cog.relay_message_to_duck_pond(message) + + send_webhook.assert_called_once_with( + content=message.clean_content, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + + self.assertEqual(len(log_watcher.records), 1) + + [record] = log_watcher.records + self.assertEqual(record.message, "Failed to send an attachment to the webhook") + self.assertEqual(record.levelno, logging.ERROR) + + def _raw_reaction_mocks(self, channel_id, message_id, user_id): + """Sets up mocks for tests of the `on_raw_reaction_add` event listener.""" + channel = helpers.MockTextChannel(id=channel_id) + self.bot.get_all_channels.return_value = (channel,) + + message = helpers.MockMessage(id=message_id) + + channel.fetch_message.return_value = message + + member = helpers.MockMember(id=user_id, roles=[self.staff_role]) + message.guild.members = (member,) + + payload = MagicMock(channel_id=channel_id, message_id=message_id, user_id=user_id) + + return channel, message, member, payload + + @helpers.async_test + async def test_on_raw_reaction_add_returns_for_non_relevant_emojis(self): + """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" + payload_custom_emoji = MagicMock(label="Non-Duck Custom Emoji") + payload_custom_emoji.emoji.is_custom_emoji.return_value = True + payload_custom_emoji.emoji.id = 12345 + + payload_unicode_emoji = MagicMock(label="Non-Duck Unicode Emoji") + payload_unicode_emoji.emoji.is_custom_emoji.return_value = False + payload_unicode_emoji.emoji.name = self.thumbs_up_emoji + + for payload in (payload_custom_emoji, payload_unicode_emoji): + with self.subTest(case=payload.label), patch(f"{MODULE_PATH}.discord.utils.get") as discord_utils_get: + self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) + discord_utils_get.assert_not_called() + + @helpers.async_test + async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): + """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" + channel_id = 1234 + message_id = 2345 + user_id = 3456 + + channel, message, _, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) + + test_cases = ( + ("non-staff member", helpers.MockMember(id=user_id)), + ("bot staff member", helpers.MockMember(id=user_id, roles=[self.staff_role], bot=True)), ) - self.bot_message = MockMessage(reactions=(self.bot_reaction,)) - self.contrib_message = MockMessage(reactions=(self.contrib_reaction,)) - self.no_reaction_message = MockMessage() - - # Set up some channels - self.text_channel = MockTextChannel(id=self.CHANNEL_ID) - - @staticmethod - def _mock_send_webhook(content, username, avatar_url, embed): - """Mock for the send_webhook method in DuckPond""" - - def test_is_staff_correctly_identifies_staff(self): - """Test that is_staff correctly identifies a staff member.""" - with self.subTest(): - self.assertTrue(self.cog.is_staff(self.admin_member_1)) - self.assertFalse(self.cog.is_staff(self.contrib_member)) - self.assertFalse(self.cog.is_staff(self.no_role_member)) - - def test_has_green_checkmark_correctly_identifies_messages(self): - """Test that has_green_checkmark recognizes messages with checkmarks.""" - with self.subTest(): - self.assertTrue(self.cog.has_green_checkmark(self.checkmark_message)) - self.assertFalse(self.cog.has_green_checkmark(self.thumbs_up_message)) - self.assertFalse(self.cog.has_green_checkmark(self.no_reaction_message)) - - def test_count_custom_duck_emojis(self): - """Test that count_ducks counts custom ducks correctly.""" - count_no_ducks = self.cog.count_ducks(self.thumbs_up_message) - count_one_duck = self.cog.count_ducks(self.yellow_ducky_message) - with self.subTest(): - self.assertEqual(asyncio.run(count_no_ducks), 0) - self.assertEqual(asyncio.run(count_one_duck), 1) - - def test_count_unicode_duck_emojis(self): - """Test that count_ducks counts unicode ducks correctly.""" - count_one_duck = self.cog.count_ducks(self.unicode_duck_message) - count_two_ducks = self.cog.count_ducks(self.double_unicode_duck_message) - - with self.subTest(): - self.assertEqual(asyncio.run(count_one_duck), 1) - self.assertEqual(asyncio.run(count_two_ducks), 2) - - def test_count_mixed_duck_emojis(self): - """Test that count_ducks counts mixed ducks correctly.""" - count_two_ducks = self.cog.count_ducks(self.double_mixed_duck_message) - - with self.subTest(): - self.assertEqual(asyncio.run(count_two_ducks), 2) - - def test_raw_reaction_add_rejects_bot(self): - """Test that send_webhook is not called if the user is a bot.""" - self.text_channel.fetch_message.return_value = self.bot_message - self.bot.get_all_channels.return_value = (self.text_channel,) - - payload = MagicMock( # RawReactionActionEvent - channel_id=self.CHANNEL_ID, - message_id=self.MESSAGE_ID, - user_id=self.BOT_ID, + payload.emoji = self.duck_pond_emoji + + for description, member in test_cases: + message.guild.members = (member, ) + with self.subTest(test_case=description), patch(f"{MODULE_PATH}.DuckPond.has_green_checkmark") as checkmark: + checkmark.side_effect = AssertionError( + "Expected method to return before calling `self.has_green_checkmark`." + ) + self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) + + # Check that we did make it past the payload checks + channel.fetch_message.assert_called_once() + channel.fetch_message.reset_mock() + + @patch(f"{MODULE_PATH}.DuckPond.is_staff") + @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) + def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff): + """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot.""" + channel_id = 31415926535 + message_id = 27182818284 + user_id = 16180339887 + + channel, message, member, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) + + payload.emoji = helpers.MockPartialEmoji(name=self.unicode_duck_emoji) + payload.emoji.is_custom_emoji.return_value = False + + message.reactions = [helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.bot.user])] + + is_staff.return_value = True + count_ducks.side_effect = AssertionError("Expected method to return before calling `self.count_ducks`") + + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload))) + + # Assert that we've made it past `self.is_staff` + is_staff.assert_called_once() + + @patch(f"{MODULE_PATH}.DuckPond.relay_message_to_duck_pond", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) + @helpers.async_test + async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self, count_ducks, message_relay): + """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" + test_cases = ( + (constants.DuckPond.threshold-1, False), + (constants.DuckPond.threshold, True), + (constants.DuckPond.threshold+1, True), ) - with self.subTest(): - asyncio.run(self.cog.on_raw_reaction_add(payload)) - self.bot.cog.send_webhook.assert_not_called() + channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5) + + payload.emoji = self.duck_pond_emoji + + for duck_count, should_relay in test_cases: + count_ducks.return_value = duck_count + with self.subTest(duck_count=duck_count, should_relay=should_relay): + await self.cog.on_raw_reaction_add(payload) + + # Confirm that we've made it past counting + count_ducks.assert_called_once() + count_ducks.reset_mock() + + # Did we relay a message? + has_relayed = message_relay.called + self.assertEqual(has_relayed, should_relay) + + if should_relay: + message_relay.assert_called_once_with(message) + message_relay.reset_mock() - def test_raw_reaction_add_rejects_non_staff(self): - """Test that send_webhook is not called if the user is not a member of staff.""" - self.text_channel.fetch_message.return_value = self.contrib_message - self.bot.get_all_channels.return_value = (self.text_channel,) + @helpers.async_test + async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): + """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks.""" + checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji) - payload = MagicMock( # RawReactionActionEvent - channel_id=self.CHANNEL_ID, - message_id=self.MESSAGE_ID, - user_id=self.CONTRIB_ID, + message = helpers.MockMessage(id=1234) + + channel = helpers.MockTextChannel(id=98765) + channel.fetch_message.return_value = message + + self.bot.get_all_channels.return_value = (channel, ) + + payload = MagicMock(channel_id=channel.id, message_id=message.id, emoji=checkmark) + + test_cases = ( + (constants.DuckPond.threshold - 1, False), + (constants.DuckPond.threshold, True), + (constants.DuckPond.threshold + 1, True), ) + for duck_count, should_readd_checkmark in test_cases: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) as count_ducks: + count_ducks.return_value = duck_count + with self.subTest(duck_count=duck_count, should_readd_checkmark=should_readd_checkmark): + await self.cog.on_raw_reaction_remove(payload) + + # Check if we fetched the message + channel.fetch_message.assert_called_once_with(message.id) - with self.subTest(): - asyncio.run(self.cog.on_raw_reaction_add(payload)) - self.bot.cog.send_webhook.assert_not_called() + # Check if we actually counted the number of ducks + count_ducks.assert_called_once_with(message) - def test_raw_reaction_add_sends_message_on_valid_input(self): - """Test that send_webhook is called if payload is valid.""" - self.text_channel.fetch_message.return_value = self.unicode_duck_message - self.bot.get_all_channels.return_value = (self.text_channel,) + has_readded_checkmark = message.add_reaction.called + self.assertEqual(should_readd_checkmark, has_readded_checkmark) - payload = MagicMock( # RawReactionActionEvent - channel_id=self.CHANNEL_ID, - message_id=self.MESSAGE_ID, - user_id=self.ADMIN_ID, + if should_readd_checkmark: + message.add_reaction.assert_called_once_with(self.checkmark_emoji) + message.add_reaction.reset_mock() + + # reset mocks + channel.fetch_message.reset_mock() + count_ducks.reset_mock() + message.reset_mock() + + def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self): + """The `on_raw_reaction_remove` listener should ignore the removal of non-check mark emojis.""" + channel = helpers.MockTextChannel(id=98765) + + channel.fetch_message.side_effect = AssertionError( + "Expected method to return before calling `channel.fetch_message`" ) - with self.subTest(): - asyncio.run(self.cog.on_raw_reaction_add(payload)) - self.bot.cog.send_webhook.assert_called_once() + self.bot.get_all_channels.return_value = (channel, ) + + payload = MagicMock(emoji=helpers.MockPartialEmoji(name=self.thumbs_up_emoji), channel_id=channel.id) - def test_raw_reaction_remove_rejects_non_checkmarks(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_remove(payload))) - def test_raw_reaction_remove_prevents_checkmark_removal(self): - """A string decoding to numeric characters is a valid user ID.""" - pass + channel.fetch_message.assert_not_called() class DuckPondSetupTests(unittest.TestCase): @@ -195,7 +526,7 @@ class DuckPondSetupTests(unittest.TestCase): def test_setup(self): """Setup of the cog should log a message at `INFO` level.""" - bot = MockBot() + bot = helpers.MockBot() log = logging.getLogger('bot.cogs.duck_pond') with self.assertLogs(logger=log, level=logging.INFO) as log_watcher: -- cgit v1.2.3 From e67822a46621d20ff0a1a27de1322b14432e4eb9 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sat, 16 Nov 2019 17:04:27 +0100 Subject: Apply suggestions from code review Co-Authored-By: Mark --- tests/bot/cogs/test_duck_pond.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index ceefc286f..8f0c4f068 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -269,7 +269,7 @@ class DuckPondTests(base.LoggingTestCase): self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), ]), - 3+4 + 3 + 4 ), ) @@ -310,7 +310,6 @@ class DuckPondTests(base.LoggingTestCase): @helpers.async_test async def test_relay_message_to_duck_pond_handles_send_attachments_exceptions(self, send_attachments, send_webhook): """The `relay_message_to_duck_pond` method should handle exceptions when calling `send_attachment`.""" - message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) @@ -435,9 +434,9 @@ class DuckPondTests(base.LoggingTestCase): async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self, count_ducks, message_relay): """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" test_cases = ( - (constants.DuckPond.threshold-1, False), + (constants.DuckPond.threshold - 1, False), (constants.DuckPond.threshold, True), - (constants.DuckPond.threshold+1, True), + (constants.DuckPond.threshold + 1, True), ) channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5) -- cgit v1.2.3 From 80d84e19d6877d2cbf2a6ce5029c1fd96b286b1e Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 27 Nov 2019 17:46:56 +0100 Subject: Apply review comments to duckpond's unit tests https://github.com/python-discord/bot/pull/621 I've changed to unit tests according to the comments made on the issue. Most changes are straightforward enough, but, for context, see the PR linked above. --- tests/bot/cogs/test_duck_pond.py | 200 +++++++++++++++++++++++++-------------- 1 file changed, 128 insertions(+), 72 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 8f0c4f068..b801e86f1 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -72,8 +72,7 @@ class DuckPondTests(base.LoggingTestCase): self.assertEqual(len(log_watcher.records), 1) - [record] = log_watcher.records - self.assertEqual(record.message, f"Failed to fetch webhook with id `{self.cog.webhook_id}`") + record = log_watcher.records[0] self.assertEqual(record.levelno, logging.ERROR) def test_is_staff_returns_correct_values_based_on_instance_passed(self): @@ -99,15 +98,15 @@ class DuckPondTests(base.LoggingTestCase): ( "No green check mark reactions", helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji), - helpers.MockReaction(emoji=self.thumbs_up_emoji) + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), + helpers.MockReaction(emoji=self.thumbs_up_emoji, users=[self.bot.user]) ]), False ), ( "Green check mark reaction, but not from our bot", helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji), + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member]) ]), False @@ -115,7 +114,7 @@ class DuckPondTests(base.LoggingTestCase): ( "Green check mark reaction, with one from the bot", helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji), + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user]) ]), True @@ -160,8 +159,7 @@ class DuckPondTests(base.LoggingTestCase): self.assertEqual(len(log_watcher.records), 1) - [record] = log_watcher.records - self.assertEqual(record.message, "Failed to send a message to the Duck Pool webhook") + record = log_watcher.records[0] self.assertEqual(record.levelno, logging.ERROR) def _get_reaction( @@ -250,10 +248,12 @@ class DuckPondTests(base.LoggingTestCase): # A staffer with multiple duck reactions only counts once ( "Two different duck reactions from the same staffer", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), - ]), + helpers.MockMessage( + reactions=[ + helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), + ] + ), 1 ), # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif) @@ -265,10 +265,12 @@ class DuckPondTests(base.LoggingTestCase): # We correctly sum when multiple reactions are provided. ( "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", - helpers.MockMessage(reactions=[ - self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), - self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), - ]), + helpers.MockMessage( + reactions=[ + self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), + self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), + ] + ), 3 + 4 ), ) @@ -279,8 +281,8 @@ class DuckPondTests(base.LoggingTestCase): self.assertEqual(expected_count, actual_count) @helpers.async_test - async def test_relay_message_to_duck_pond_correctly_relays_content_and_attachments(self): - """The `relay_message_to_duck_pond` method should correctly relay message content and attachments.""" + async def test_relay_message_correctly_relays_content_and_attachments(self): + """The `relay_message` method should correctly relay message content and attachments.""" send_webhook_path = f"{MODULE_PATH}.DuckPond.send_webhook" send_attachments_path = f"{MODULE_PATH}.send_attachments" @@ -297,41 +299,47 @@ class DuckPondTests(base.LoggingTestCase): with patch(send_webhook_path, new_callable=helpers.AsyncMock) as send_webhook: with patch(send_attachments_path, new_callable=helpers.AsyncMock) as send_attachments: with self.subTest(clean_content=message.clean_content, attachments=message.attachments): - await self.cog.relay_message_to_duck_pond(message) + await self.cog.relay_message(message) self.assertEqual(expect_webhook_call, send_webhook.called) self.assertEqual(expect_attachment_call, send_attachments.called) message.add_reaction.assert_called_once_with(self.checkmark_emoji) - message.reset_mock() - @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) @helpers.async_test - async def test_relay_message_to_duck_pond_handles_send_attachments_exceptions(self, send_attachments, send_webhook): - """The `relay_message_to_duck_pond` method should handle exceptions when calling `send_attachment`.""" + async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): + """The `relay_message` method should handle irretrievable attachments.""" message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) self.cog.webhook = helpers.MockAsyncWebhook() log = logging.getLogger("bot.cogs.duck_pond") - # Subtests for the first `except` block for side_effect in side_effects: send_attachments.side_effect = side_effect - with self.subTest(side_effect=type(side_effect).__name__): - with self.assertNotLogs(logger=log, level=logging.ERROR): - await self.cog.relay_message_to_duck_pond(message) + with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) as send_webhook: + with self.subTest(side_effect=type(side_effect).__name__): + with self.assertNotLogs(logger=log, level=logging.ERROR): + await self.cog.relay_message(message) + + self.assertEqual(send_webhook.call_count, 2) + + @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) + @helpers.async_test + async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): + """The `relay_message` method should handle irretrievable attachments.""" + message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) - self.assertEqual(send_webhook.call_count, 2) - send_webhook.reset_mock() + self.cog.webhook = helpers.MockAsyncWebhook() + log = logging.getLogger("bot.cogs.duck_pond") - # Subtests for the second `except` block side_effect = discord.HTTPException(MagicMock(), "") send_attachments.side_effect = side_effect with self.subTest(side_effect=type(side_effect).__name__): with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - await self.cog.relay_message_to_duck_pond(message) + await self.cog.relay_message(message) send_webhook.assert_called_once_with( content=message.clean_content, @@ -341,10 +349,75 @@ class DuckPondTests(base.LoggingTestCase): self.assertEqual(len(log_watcher.records), 1) - [record] = log_watcher.records - self.assertEqual(record.message, "Failed to send an attachment to the webhook") + record = log_watcher.records[0] self.assertEqual(record.levelno, logging.ERROR) + def _mock_payload(self, label: str, is_custom_emoji: bool, id_: int, emoji_name: str): + """Creates a mock `on_raw_reaction_add` payload with the specified emoji data.""" + payload = MagicMock(name=label) + payload.emoji.is_custom_emoji.return_value = is_custom_emoji + payload.emoji.id = id_ + payload.emoji.name = emoji_name + return payload + + @helpers.async_test + async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self): + """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" + test_values = ( + # Custom Emojis + ( + self._mock_payload( + label="Custom Duckpond Emoji", + is_custom_emoji=True, + id_=constants.DuckPond.custom_emojis[0], + emoji_name="" + ), + True + ), + ( + self._mock_payload( + label="Custom Non-Duckpond Emoji", + is_custom_emoji=True, + id_=123, + emoji_name="" + ), + False + ), + # Unicode Emojis + ( + self._mock_payload( + label="Unicode Duck Emoji", + is_custom_emoji=False, + id_=1, + emoji_name=self.unicode_duck_emoji + ), + True + ), + ( + self._mock_payload( + label="Unicode Non-Duck Emoji", + is_custom_emoji=False, + id_=1, + emoji_name=self.thumbs_up_emoji + ), + False + ), + ) + + for payload, expected_return in test_values: + actual_return = self.cog._payload_has_duckpond_emoji(payload) + with self.subTest(case=payload._mock_name, expected_return=expected_return, actual_return=actual_return): + self.assertEqual(expected_return, actual_return) + + @patch(f"{MODULE_PATH}.discord.utils.get") + @patch(f"{MODULE_PATH}.DuckPond._payload_has_duckpond_emoji", new=MagicMock(return_value=False)) + def test_on_raw_reaction_add_returns_early_with_payload_without_duck_emoji(self, utils_get): + """The `on_raw_reaction_add` method should return early if the payload does not contain a duck emoji.""" + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload=MagicMock()))) + + # Ensure we've returned before making an unnecessary API call in the lines of code after the emoji check + utils_get.assert_not_called() + def _raw_reaction_mocks(self, channel_id, message_id, user_id): """Sets up mocks for tests of the `on_raw_reaction_add` event listener.""" channel = helpers.MockTextChannel(id=channel_id) @@ -361,22 +434,6 @@ class DuckPondTests(base.LoggingTestCase): return channel, message, member, payload - @helpers.async_test - async def test_on_raw_reaction_add_returns_for_non_relevant_emojis(self): - """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" - payload_custom_emoji = MagicMock(label="Non-Duck Custom Emoji") - payload_custom_emoji.emoji.is_custom_emoji.return_value = True - payload_custom_emoji.emoji.id = 12345 - - payload_unicode_emoji = MagicMock(label="Non-Duck Unicode Emoji") - payload_unicode_emoji.emoji.is_custom_emoji.return_value = False - payload_unicode_emoji.emoji.name = self.thumbs_up_emoji - - for payload in (payload_custom_emoji, payload_unicode_emoji): - with self.subTest(case=payload.label), patch(f"{MODULE_PATH}.discord.utils.get") as discord_utils_get: - self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) - discord_utils_get.assert_not_called() - @helpers.async_test async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" @@ -428,10 +485,8 @@ class DuckPondTests(base.LoggingTestCase): # Assert that we've made it past `self.is_staff` is_staff.assert_called_once() - @patch(f"{MODULE_PATH}.DuckPond.relay_message_to_duck_pond", new_callable=helpers.AsyncMock) - @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) @helpers.async_test - async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self, count_ducks, message_relay): + async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self): """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" test_cases = ( (constants.DuckPond.threshold - 1, False), @@ -444,21 +499,21 @@ class DuckPondTests(base.LoggingTestCase): payload.emoji = self.duck_pond_emoji for duck_count, should_relay in test_cases: - count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_relay=should_relay): - await self.cog.on_raw_reaction_add(payload) + with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=helpers.AsyncMock) as relay_message: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) as count_ducks: + count_ducks.return_value = duck_count + with self.subTest(duck_count=duck_count, should_relay=should_relay): + await self.cog.on_raw_reaction_add(payload) - # Confirm that we've made it past counting - count_ducks.assert_called_once() - count_ducks.reset_mock() + # Confirm that we've made it past counting + count_ducks.assert_called_once() - # Did we relay a message? - has_relayed = message_relay.called - self.assertEqual(has_relayed, should_relay) + # Did we relay a message? + has_relayed = relay_message.called + self.assertEqual(has_relayed, should_relay) - if should_relay: - message_relay.assert_called_once_with(message) - message_relay.reset_mock() + if should_relay: + relay_message.assert_called_once_with(message) @helpers.async_test async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): @@ -479,10 +534,10 @@ class DuckPondTests(base.LoggingTestCase): (constants.DuckPond.threshold, True), (constants.DuckPond.threshold + 1, True), ) - for duck_count, should_readd_checkmark in test_cases: + for duck_count, should_re_add_checkmark in test_cases: with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) as count_ducks: count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_readd_checkmark=should_readd_checkmark): + with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark): await self.cog.on_raw_reaction_remove(payload) # Check if we fetched the message @@ -491,16 +546,15 @@ class DuckPondTests(base.LoggingTestCase): # Check if we actually counted the number of ducks count_ducks.assert_called_once_with(message) - has_readded_checkmark = message.add_reaction.called - self.assertEqual(should_readd_checkmark, has_readded_checkmark) + has_re_added_checkmark = message.add_reaction.called + self.assertEqual(should_re_add_checkmark, has_re_added_checkmark) - if should_readd_checkmark: + if should_re_add_checkmark: message.add_reaction.assert_called_once_with(self.checkmark_emoji) message.add_reaction.reset_mock() # reset mocks channel.fetch_message.reset_mock() - count_ducks.reset_mock() message.reset_mock() def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self): @@ -530,7 +584,9 @@ class DuckPondSetupTests(unittest.TestCase): with self.assertLogs(logger=log, level=logging.INFO) as log_watcher: duck_pond.setup(bot) - line = log_watcher.output[0] + + self.assertEqual(len(log_watcher.records), 1) + record = log_watcher.records[0] + self.assertEqual(record.levelno, logging.INFO) bot.add_cog.assert_called_once() - self.assertIn("Cog loaded: DuckPond", line) -- cgit v1.2.3 From e07cf7342184b769d8c0655bc9b84be02809319a Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 4 Dec 2019 23:38:46 +0700 Subject: Added `unittest` for `bot.utils.time` --- tests/bot/utils/test_time.py | 87 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/bot/utils/test_time.py (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py new file mode 100644 index 000000000..0ef59292e --- /dev/null +++ b/tests/bot/utils/test_time.py @@ -0,0 +1,87 @@ +import asyncio +import unittest +from datetime import datetime, timezone +from unittest.mock import patch + +from dateutil.relativedelta import relativedelta + +from bot.utils import time +from tests.helpers import AsyncMock + + +class TimeTests(unittest.TestCase): + """Test helper functions in bot.utils.time.""" + + def setUp(self): + pass + + def test_humanize_delta(self): + """Testing humanize delta.""" + test_cases = ( + (relativedelta(days=2), 'seconds', 1, '2 days'), + (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), + (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), + (relativedelta(days=2, hours=2), 'days', 2, '2 days'), + + # Does not abort for unknown units, as the unit name is checked + # against the attribute of the relativedelta instance. + (relativedelta(days=2, hours=2), 'elephants', 2, '2 days and 2 hours'), + + # Very high maximum units, but it only ever iterates over + # each value the relativedelta might have. + (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), + ) + + for delta, precision, max_units, expected in test_cases: + self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + + def test_humanize_delta_raises_for_invalid_max_units(self): + test_cases = (-1, 0) + + for max_units in test_cases: + with self.assertRaises(ValueError) as error: + time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) + self.assertEqual(str(error), 'max_units must be positive') + + def test_parse_rfc1123(self): + """Testing parse_rfc1123.""" + test_cases = ( + ('Sun, 15 Sep 2019 12:00:00 GMT', datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)), + ) + + for stamp, expected in test_cases: + self.assertEqual(time.parse_rfc1123(stamp), expected) + + @patch('asyncio.sleep', new_callable=AsyncMock) + def test_wait_until(self, mock): + """Testing wait_until.""" + start = datetime(2019, 1, 1, 0, 0) + then = datetime(2019, 1, 1, 0, 10) + + # No return value + assert asyncio.run(time.wait_until(then, start)) is None + + mock.assert_called_once_with(10 * 60) + + def test_format_infraction_with_duration(self): + """Testing format_infraction_with_duration.""" + test_cases = ( + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '2019-12-12 00:01 (12 hours and 55 seconds)'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '2019-12-12 00:01 (12 hours)'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, + '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'), + ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '2019-12-12 00:00 (1 minute)'), + ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '2019-11-23 20:09 (7 days and 23 hours)'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '2019-11-23 20:09 (6 months and 28 days)'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 6, + '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)'), + ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '2019-11-23 20:58 (5 minutes)'), + ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '2019-11-24 00:00 (1 minute)'), + ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2019-11-23 23:59 (2 years and 4 months)'), + ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, + '2019-11-23 23:59 (9 minutes and 55 seconds)'), + (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), + ) + + for expiry, date_from, max_units, expected in test_cases: + self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) -- cgit v1.2.3 From b17dbe5e3e0dfa6ae44d660924455f709abefd0d Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:34:58 +0700 Subject: Splitting test cases for `humanize_delta` into proper, independent tests. --- tests/bot/utils/test_time.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 0ef59292e..5e5f2bf2f 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -15,18 +15,20 @@ class TimeTests(unittest.TestCase): def setUp(self): pass - def test_humanize_delta(self): - """Testing humanize delta.""" + def test_humanize_delta_handle_unknown_units(self): + """humanize_delta should be able to handle unknown units, and will not abort.""" test_cases = ( - (relativedelta(days=2), 'seconds', 1, '2 days'), - (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), - (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), - (relativedelta(days=2, hours=2), 'days', 2, '2 days'), - # Does not abort for unknown units, as the unit name is checked # against the attribute of the relativedelta instance. (relativedelta(days=2, hours=2), 'elephants', 2, '2 days and 2 hours'), + ) + for delta, precision, max_units, expected in test_cases: + self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + + def test_humanize_delta_handle_high_units(self): + """humanize_delta should be able to handle very high units.""" + test_cases = ( # Very high maximum units, but it only ever iterates over # each value the relativedelta might have. (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), @@ -35,6 +37,18 @@ class TimeTests(unittest.TestCase): for delta, precision, max_units, expected in test_cases: self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + def test_humanize_delta_should_work_normally(self): + """Testing humanize delta.""" + test_cases = ( + (relativedelta(days=2), 'seconds', 1, '2 days'), + (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), + (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), + (relativedelta(days=2, hours=2), 'days', 2, '2 days'), + ) + + for delta, precision, max_units, expected in test_cases: + self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + def test_humanize_delta_raises_for_invalid_max_units(self): test_cases = (-1, 0) -- cgit v1.2.3 From 0aee728d6d23ef24f51834f39016f938f3f1b8a9 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:36:29 +0700 Subject: Added missing docstring for `test_humanize_delta_raises_for_invalid_max_units` --- tests/bot/utils/test_time.py | 1 + 1 file changed, 1 insertion(+) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 5e5f2bf2f..a929bee89 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -50,6 +50,7 @@ class TimeTests(unittest.TestCase): self.assertEqual(time.humanize_delta(delta, precision, max_units), 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.""" test_cases = (-1, 0) for max_units in test_cases: -- cgit v1.2.3 From beed21355e7f0e25b69637768843c53d510b8969 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:40:38 +0700 Subject: Changed `assert` to `self.assertIs` for `test_wait_until` --- tests/bot/utils/test_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index a929bee89..0afabe400 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -74,7 +74,7 @@ class TimeTests(unittest.TestCase): then = datetime(2019, 1, 1, 0, 10) # No return value - assert asyncio.run(time.wait_until(then, start)) is None + self.assertIs(asyncio.run(time.wait_until(then, start)), None) mock.assert_called_once_with(10 * 60) -- cgit v1.2.3 From ccdd8363d75846f0841791ba54763dae28243c62 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:45:36 +0700 Subject: Splitting test cases for `format_infraction_with_duration` into proper, independent tests. --- tests/bot/utils/test_time.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 0afabe400..2a2a707d8 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -37,7 +37,7 @@ class TimeTests(unittest.TestCase): for delta, precision, max_units, expected in test_cases: self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) - def test_humanize_delta_should_work_normally(self): + def test_humanize_delta_should_normal_usage(self): """Testing humanize delta.""" test_cases = ( (relativedelta(days=2), 'seconds', 1, '2 days'), @@ -78,18 +78,38 @@ class TimeTests(unittest.TestCase): mock.assert_called_once_with(10 * 60) - def test_format_infraction_with_duration(self): - """Testing format_infraction_with_duration.""" + def test_format_infraction_with_duration_none_expiry(self): + """format_infraction_with_duration should work for None expiry.""" + self.assertEqual(time.format_infraction_with_duration(None), None) + + # To make sure that date_from and max_units are not touched + self.assertEqual(time.format_infraction_with_duration(None, date_from='Why hello there!'), None) + self.assertEqual(time.format_infraction_with_duration(None, max_units=float('inf')), None) + self.assertEqual( + time.format_infraction_with_duration(None, date_from='Why hello there!', max_units=float('inf')), + None + ) + + def test_format_infraction_with_duration_custom_units(self): + """format_infraction_with_duration should work for custom max_units.""" + self.assertEqual( + time.format_infraction_with_duration('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6), + '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)' + ) + + self.assertEqual( + time.format_infraction_with_duration('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20), + '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)' + ) + + def test_format_infraction_with_duration_normal_usage(self): + """format_infraction_with_duration should work for normal usage, across various durations.""" test_cases = ( ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '2019-12-12 00:01 (12 hours and 55 seconds)'), ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '2019-12-12 00:01 (12 hours)'), - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, - '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'), ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '2019-12-12 00:00 (1 minute)'), ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '2019-11-23 20:09 (7 days and 23 hours)'), ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '2019-11-23 20:09 (6 months and 28 days)'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 6, - '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)'), ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '2019-11-23 20:58 (5 minutes)'), ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '2019-11-24 00:00 (1 minute)'), ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2019-11-23 23:59 (2 years and 4 months)'), -- cgit v1.2.3 From fa66195dbb6f79bb7174084835499a61e8cb03a3 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:52:15 +0700 Subject: Introduced test for `test_format_infraction`, refactored `test_parse_rfc1123`, fixed typo. --- tests/bot/utils/test_time.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 2a2a707d8..09fb824e4 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -50,7 +50,7 @@ class TimeTests(unittest.TestCase): self.assertEqual(time.humanize_delta(delta, precision, max_units), 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.""" + """humanize_delta should raises ValueError('max_units must be positive') for invalid max_units.""" test_cases = (-1, 0) for max_units in test_cases: @@ -60,12 +60,14 @@ class TimeTests(unittest.TestCase): def test_parse_rfc1123(self): """Testing parse_rfc1123.""" - test_cases = ( - ('Sun, 15 Sep 2019 12:00:00 GMT', datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)), + self.assertEqual( + time.parse_rfc1123('Sun, 15 Sep 2019 12:00:00 GMT'), + datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc) ) - for stamp, expected in test_cases: - self.assertEqual(time.parse_rfc1123(stamp), expected) + def test_format_infraction(self): + """Testing format_infraction.""" + self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '2019-12-12 00:01') @patch('asyncio.sleep', new_callable=AsyncMock) def test_wait_until(self, mock): -- cgit v1.2.3 From 5e0b19ae841f3f355931ad331f7aa861fbafc4d9 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 01:05:41 +0700 Subject: Added `self.subTest` for tests with multiple test cases & simplified single test case tests. --- tests/bot/utils/test_time.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 09fb824e4..c47a306f0 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -17,25 +17,15 @@ class TimeTests(unittest.TestCase): def test_humanize_delta_handle_unknown_units(self): """humanize_delta should be able to handle unknown units, and will not abort.""" - test_cases = ( - # Does not abort for unknown units, as the unit name is checked - # against the attribute of the relativedelta instance. - (relativedelta(days=2, hours=2), 'elephants', 2, '2 days and 2 hours'), - ) - - for delta, precision, max_units, expected in test_cases: - self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + # 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') def test_humanize_delta_handle_high_units(self): """humanize_delta should be able to handle very high units.""" - test_cases = ( - # Very high maximum units, but it only ever iterates over - # each value the relativedelta might have. - (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), - ) - - for delta, precision, max_units, expected in test_cases: - self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + # 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') def test_humanize_delta_should_normal_usage(self): """Testing humanize delta.""" @@ -47,14 +37,15 @@ class TimeTests(unittest.TestCase): ) for delta, precision, max_units, expected in test_cases: - self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + with self.subTest(delta=delta, precision=precision, max_units=max_units, expected=expected): + self.assertEqual(time.humanize_delta(delta, precision, max_units), 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.""" test_cases = (-1, 0) for max_units in test_cases: - with self.assertRaises(ValueError) as error: + 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), 'max_units must be positive') @@ -121,4 +112,5 @@ class TimeTests(unittest.TestCase): ) for expiry, date_from, max_units, expected in test_cases: - self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + 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) -- cgit v1.2.3 From db341d927aab42c2e874cb499ab1c2e6c0e7647b Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 01:17:56 +0700 Subject: Moved all individual test cases into iterables and test with `self.subTest` context manager. --- tests/bot/utils/test_time.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index c47a306f0..25cd3f69f 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -73,27 +73,31 @@ class TimeTests(unittest.TestCase): def test_format_infraction_with_duration_none_expiry(self): """format_infraction_with_duration should work for None expiry.""" - self.assertEqual(time.format_infraction_with_duration(None), None) + test_cases = ( + (None, None, None, None), - # To make sure that date_from and max_units are not touched - self.assertEqual(time.format_infraction_with_duration(None, date_from='Why hello there!'), None) - self.assertEqual(time.format_infraction_with_duration(None, max_units=float('inf')), None) - self.assertEqual( - time.format_infraction_with_duration(None, date_from='Why hello there!', max_units=float('inf')), - None + # To make sure that date_from and max_units are not touched + (None, 'Why hello there!', None, None), + (None, None, float('inf'), None), + (None, 'Why hello there!', float('inf'), None), ) + 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) + def test_format_infraction_with_duration_custom_units(self): """format_infraction_with_duration should work for custom max_units.""" - self.assertEqual( - time.format_infraction_with_duration('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6), - '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)' + test_cases = ( + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, + '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, + '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)') ) - self.assertEqual( - time.format_infraction_with_duration('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20), - '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)' - ) + 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) def test_format_infraction_with_duration_normal_usage(self): """format_infraction_with_duration should work for normal usage, across various durations.""" -- cgit v1.2.3 From 323306776b0312e2a32ada213a35159311a93a7f Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 01:42:23 +0700 Subject: Removed `setUp()` from `TimeTests` since it is not being used for anything. --- tests/bot/utils/test_time.py | 3 --- 1 file changed, 3 deletions(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 25cd3f69f..7f55dc3ec 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -12,9 +12,6 @@ from tests.helpers import AsyncMock class TimeTests(unittest.TestCase): """Test helper functions in bot.utils.time.""" - def setUp(self): - pass - def test_humanize_delta_handle_unknown_units(self): """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 -- cgit v1.2.3 From 6fe61e5919cb541a1651312a01ddf7e7f10d0f86 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Dec 2019 20:11:50 -0800 Subject: Change all Bot imports to use the subclass --- bot/cogs/alias.py | 3 ++- bot/cogs/antimalware.py | 3 ++- bot/cogs/antispam.py | 3 ++- bot/cogs/bot.py | 3 ++- bot/cogs/clean.py | 3 ++- bot/cogs/defcon.py | 3 ++- bot/cogs/doc.py | 5 +++-- bot/cogs/duck_pond.py | 3 ++- bot/cogs/error_handler.py | 3 ++- bot/cogs/eval.py | 3 ++- bot/cogs/extensions.py | 3 ++- bot/cogs/filtering.py | 3 ++- bot/cogs/free.py | 3 ++- bot/cogs/help.py | 3 ++- bot/cogs/information.py | 3 ++- bot/cogs/jams.py | 5 +++-- bot/cogs/logging.py | 3 ++- bot/cogs/moderation/__init__.py | 3 +-- bot/cogs/moderation/infractions.py | 3 ++- bot/cogs/moderation/management.py | 3 ++- bot/cogs/moderation/modlog.py | 3 ++- bot/cogs/moderation/scheduler.py | 3 ++- bot/cogs/moderation/superstarify.py | 3 ++- bot/cogs/off_topic_names.py | 3 ++- bot/cogs/reddit.py | 3 ++- bot/cogs/reminders.py | 3 ++- bot/cogs/security.py | 4 +++- bot/cogs/site.py | 3 ++- bot/cogs/snekbox.py | 3 ++- bot/cogs/sync/__init__.py | 3 +-- bot/cogs/sync/cog.py | 3 ++- bot/cogs/sync/syncers.py | 7 ++++--- bot/cogs/tags.py | 3 ++- bot/cogs/token_remover.py | 3 ++- bot/cogs/utils.py | 3 ++- bot/cogs/verification.py | 3 ++- bot/cogs/watchchannels/__init__.py | 3 +-- bot/cogs/watchchannels/bigbrother.py | 3 ++- bot/cogs/watchchannels/talentpool.py | 3 ++- bot/cogs/watchchannels/watchchannel.py | 3 ++- bot/cogs/wolfram.py | 7 ++++--- bot/interpreter.py | 4 +++- tests/helpers.py | 4 +++- 43 files changed, 92 insertions(+), 52 deletions(-) (limited to 'tests') diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 5190c559b..4ee5a2aed 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -3,8 +3,9 @@ import logging from typing import Union from discord import Colour, Embed, Member, User -from discord.ext.commands import Bot, Cog, Command, Context, clean_content, command, group +from discord.ext.commands import Cog, Command, Context, clean_content, command, group +from bot.bot import Bot from bot.cogs.extensions import Extension from bot.cogs.watchchannels.watchchannel import proxy_user from bot.converters import TagNameConverter diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 602819191..03c1e28a1 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -1,8 +1,9 @@ import logging from discord import Embed, Message, NotFound -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog +from bot.bot import Bot from bot.constants import AntiMalware as AntiMalwareConfig, Channels, URLs log = logging.getLogger(__name__) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 1340eb608..88912038a 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -7,9 +7,10 @@ from operator import itemgetter from typing import Dict, Iterable, List, Set from discord import Colour, Member, Message, NotFound, Object, TextChannel -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog from bot import rules +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( AntiSpam as AntiSpamConfig, Channels, diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index ee0a463de..a2edb7576 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -5,8 +5,9 @@ import time from typing import Optional, Tuple from discord import Embed, Message, RawMessageUpdateEvent, TextChannel -from discord.ext.commands import Bot, Cog, Context, command, group +from discord.ext.commands import Cog, Context, command, group +from bot.bot import Bot from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs from bot.decorators import with_role from bot.utils.messages import wait_for_deletion diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index dca411d01..3365d0934 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -4,8 +4,9 @@ import re from typing import Optional from discord import Colour, Embed, Message, User -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( Channels, CleanMessages, Colours, Event, diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index bedd70c86..f062a7546 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -6,8 +6,9 @@ from datetime import datetime, timedelta from enum import Enum from discord import Colour, Embed, Member -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles from bot.decorators import with_role diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index e5b3a4062..7df159fd9 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -17,6 +17,7 @@ from requests import ConnectTimeout, ConnectionError, HTTPError from sphinx.ext import intersphinx from urllib3.exceptions import ProtocolError +from bot.bot import Bot from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role @@ -147,7 +148,7 @@ class InventoryURL(commands.Converter): class Doc(commands.Cog): """A set of commands for querying & displaying documentation.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Bot): self.base_urls = {} self.bot = bot self.inventories = {} @@ -506,7 +507,7 @@ class Doc(commands.Cog): return tag.name == "table" -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None: """Doc cog load.""" bot.add_cog(Doc(bot)) log.info("Cog loaded: Doc") diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 2d25cd17e..879071d1b 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -3,9 +3,10 @@ from typing import Optional, Union import discord from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog from bot import constants +from bot.bot import Bot from bot.utils.messages import send_attachments log = logging.getLogger(__name__) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 49411814c..cf90e9f48 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -14,9 +14,10 @@ from discord.ext.commands import ( NoPrivateMessage, UserInputError, ) -from discord.ext.commands import Bot, Cog, Context +from discord.ext.commands import Cog, Context from bot.api import ResponseCodeError +from bot.bot import Bot from bot.constants import Channels from bot.decorators import InChannelCheckFailure diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 00b988dde..5daec3e39 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -9,8 +9,9 @@ from io import StringIO from typing import Any, Optional, Tuple import discord -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot from bot.constants import Roles from bot.decorators import with_role from bot.interpreter import Interpreter diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index bb66e0b8e..4d77d8205 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -6,8 +6,9 @@ from pkgutil import iter_modules from discord import Colour, Embed from discord.ext import commands -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Context, group +from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs from bot.pagination import LinePaginator from bot.utils.checks import with_role_check diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 1e7521054..2e54ccecb 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -5,8 +5,9 @@ from typing import Optional, Union import discord.errors from dateutil.relativedelta import relativedelta from discord import Colour, DMChannel, Member, Message, TextChannel -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( Channels, Colours, diff --git a/bot/cogs/free.py b/bot/cogs/free.py index 82285656b..bbc9f063b 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -3,8 +3,9 @@ from datetime import datetime from operator import itemgetter from discord import Colour, Embed, Member, utils -from discord.ext.commands import Bot, Cog, Context, command +from discord.ext.commands import Cog, Context, command +from bot.bot import Bot from bot.constants import Categories, Channels, Free, STAFF_ROLES from bot.decorators import redirect_output diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 9607dbd8d..6385fa467 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -6,10 +6,11 @@ from typing import Union from discord import Colour, Embed, HTTPException, Message, Reaction, User from discord.ext import commands -from discord.ext.commands import Bot, CheckFailure, Cog as DiscordCog, Command, Context +from discord.ext.commands import CheckFailure, Cog as DiscordCog, Command, Context from fuzzywuzzy import fuzz, process from bot import constants +from bot.bot import Bot from bot.constants import Channels, STAFF_ROLES from bot.decorators import redirect_output from bot.pagination import ( diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 530453600..56bd37bec 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -9,10 +9,11 @@ from typing import Any, Mapping, Optional import discord from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils from discord.ext import commands -from discord.ext.commands import Bot, BucketType, Cog, Context, command, group +from discord.ext.commands import BucketType, Cog, Context, command, group from discord.utils import escape_markdown from bot import constants +from bot.bot import Bot from bot.decorators import InChannelCheckFailure, in_channel, with_role from bot.utils.checks import cooldown_with_role_bypass, with_role_check from bot.utils.time import time_since diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index be9d33e3e..0c82e7962 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -4,6 +4,7 @@ from discord import Member, PermissionOverwrite, utils from discord.ext import commands from more_itertools import unique_everseen +from bot.bot import Bot from bot.constants import Roles from bot.decorators import with_role @@ -13,7 +14,7 @@ log = logging.getLogger(__name__) class CodeJams(commands.Cog): """Manages the code-jam related parts of our server.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Bot): self.bot = bot @commands.command() @@ -108,7 +109,7 @@ class CodeJams(commands.Cog): ) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None: """Code Jams cog load.""" bot.add_cog(CodeJams(bot)) log.info("Cog loaded: CodeJams") diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index c92b619ff..44c771b42 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -1,8 +1,9 @@ import logging from discord import Embed -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog +from bot.bot import Bot from bot.constants import Channels, DEBUG_MODE diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 7383ed44e..0cbdb3aa6 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -1,7 +1,6 @@ import logging -from discord.ext.commands import Bot - +from bot.bot import Bot from .infractions import Infractions from .management import ModManagement from .modlog import ModLog diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 2713a1b68..7478e19ef 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -7,6 +7,7 @@ from discord.ext import commands from discord.ext.commands import Context, command from bot import constants +from bot.bot import Bot from bot.constants import Event from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check @@ -25,7 +26,7 @@ class Infractions(InfractionScheduler, commands.Cog): category = "Moderation" category_description = "Server moderation tools." - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Bot): super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning"}) self.category = "Moderation" diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index abfe5c2b3..feae00b7c 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -9,6 +9,7 @@ from discord.ext import commands from discord.ext.commands import Context from bot import constants +from bot.bot import Bot from bot.converters import InfractionSearchQuery from bot.pagination import LinePaginator from bot.utils import time @@ -36,7 +37,7 @@ class ModManagement(commands.Cog): category = "Moderation" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Bot): self.bot = bot @property diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 0df752a97..35ef6cbcc 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -10,8 +10,9 @@ from dateutil.relativedelta import relativedelta from deepdiff import DeepDiff from discord import Colour from discord.abc import GuildChannel -from discord.ext.commands import Bot, Cog, Context +from discord.ext.commands import Cog, Context +from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs from bot.utils.time import humanize_delta from .utils import UserTypes diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 3e0968121..937113ef4 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -7,10 +7,11 @@ from gettext import ngettext import dateutil.parser import discord -from discord.ext.commands import Bot, Context +from discord.ext.commands import Context from bot import constants from bot.api import ResponseCodeError +from bot.bot import Bot from bot.constants import Colours, STAFF_CHANNELS from bot.utils import time from bot.utils.scheduling import Scheduler diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 9b3c62403..7631d9bbe 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -6,9 +6,10 @@ import typing as t from pathlib import Path from discord import Colour, Embed, Member -from discord.ext.commands import Bot, Cog, Context, command +from discord.ext.commands import Cog, Context, command from bot import constants +from bot.bot import Bot from bot.utils.checks import with_role_check from bot.utils.time import format_infraction from . import utils diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 78792240f..18d9cfb01 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -4,9 +4,10 @@ import logging from datetime import datetime, timedelta from discord import Colour, Embed -from discord.ext.commands import BadArgument, Bot, Cog, Context, Converter, group +from discord.ext.commands import BadArgument, Cog, Context, Converter, group from bot.api import ResponseCodeError +from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES from bot.decorators import with_role from bot.pagination import LinePaginator diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 0d06e9c26..c76fcd937 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -6,9 +6,10 @@ from datetime import datetime, timedelta from typing import List from discord import Colour, Embed, TextChannel -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group from discord.ext.tasks import loop +from bot.bot import Bot from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks from bot.converters import Subreddit from bot.decorators import with_role diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 81990704b..b805b24c5 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -8,8 +8,9 @@ from typing import Optional from dateutil.relativedelta import relativedelta from discord import Colour, Embed, Message -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator diff --git a/bot/cogs/security.py b/bot/cogs/security.py index 316b33d6b..45d0eb2f5 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -1,6 +1,8 @@ import logging -from discord.ext.commands import Bot, Cog, Context, NoPrivateMessage +from discord.ext.commands import Cog, Context, NoPrivateMessage + +from bot.bot import Bot log = logging.getLogger(__name__) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 683613788..1d7bd03e4 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -1,8 +1,9 @@ import logging from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot from bot.constants import URLs from bot.pagination import LinePaginator diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 55a187ac1..1ea61a8da 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -5,8 +5,9 @@ import textwrap from signal import Signals from typing import Optional, Tuple -from discord.ext.commands import Bot, Cog, Context, command, guild_only +from discord.ext.commands import Cog, Context, command, guild_only +from bot.bot import Bot from bot.constants import Channels, Roles, URLs from bot.decorators import in_channel from bot.utils.messages import wait_for_deletion diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py index d4565f848..0da81c60e 100644 --- a/bot/cogs/sync/__init__.py +++ b/bot/cogs/sync/__init__.py @@ -1,7 +1,6 @@ import logging -from discord.ext.commands import Bot - +from bot.bot import Bot from .cog import Sync log = logging.getLogger(__name__) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index aaa581f96..90d4c40fe 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -3,10 +3,11 @@ from typing import Callable, Iterable from discord import Guild, Member, Role from discord.ext import commands -from discord.ext.commands import Bot, Cog, Context +from discord.ext.commands import Cog, Context from bot import constants from bot.api import ResponseCodeError +from bot.bot import Bot from bot.cogs.sync import syncers log = logging.getLogger(__name__) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 2cc5a66e1..14cf51383 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -2,7 +2,8 @@ from collections import namedtuple from typing import Dict, Set, Tuple from discord import Guild -from discord.ext.commands import Bot + +from bot.bot import Bot # These objects are declared as namedtuples because tuples are hashable, # something that we make use of when diffing site roles against guild roles. @@ -52,7 +53,7 @@ async def sync_roles(bot: Bot, guild: Guild) -> Tuple[int, int, int]: Synchronize roles found on the given `guild` with the ones on the API. Arguments: - bot (discord.ext.commands.Bot): + bot (bot.bot.Bot): The bot instance that we're running with. guild (discord.Guild): @@ -169,7 +170,7 @@ async def sync_users(bot: Bot, guild: Guild) -> Tuple[int, int, None]: Synchronize users found in the given `guild` with the ones in the API. Arguments: - bot (discord.ext.commands.Bot): + bot (bot.bot.Bot): The bot instance that we're running with. guild (discord.Guild): diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index cd70e783a..2ece0095d 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -2,8 +2,9 @@ import logging import time from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot from bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles from bot.converters import TagContentConverter, TagNameConverter from bot.decorators import with_role diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 5a0d20e57..7af7ed63a 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -6,9 +6,10 @@ import struct from datetime import datetime from discord import Colour, Message -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog from discord.utils import snowflake_time +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import Channels, Colours, Event, Icons diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 793fe4c1a..0ed996430 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -8,8 +8,9 @@ from typing import Tuple from dateutil import relativedelta from discord import Colour, Embed, Message, Role -from discord.ext.commands import Bot, Cog, Context, command +from discord.ext.commands import Cog, Context, command +from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES from bot.decorators import in_channel, with_role from bot.utils.time import humanize_delta diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index b5e8d4357..74eb0dbf8 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -3,8 +3,9 @@ from datetime import datetime from discord import Colour, Message, NotFound, Object from discord.ext import tasks -from discord.ext.commands import Bot, Cog, Context, command +from discord.ext.commands import Cog, Context, command +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( Bot as BotConfig, diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py index 86e1050fa..e18aea88a 100644 --- a/bot/cogs/watchchannels/__init__.py +++ b/bot/cogs/watchchannels/__init__.py @@ -1,7 +1,6 @@ import logging -from discord.ext.commands import Bot - +from bot.bot import Bot from .bigbrother import BigBrother from .talentpool import TalentPool diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 49783bb09..306ed4c64 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -3,8 +3,9 @@ from collections import ChainMap from typing import Union from discord import User -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot from bot.cogs.moderation.utils import post_infraction from bot.constants import Channels, MODERATION_ROLES, Webhooks from bot.decorators import with_role diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 4ec42dcc1..cc8feeeee 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -4,9 +4,10 @@ from collections import ChainMap from typing import Union from discord import Color, Embed, Member, User -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError +from bot.bot import Bot from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks from bot.decorators import with_role from bot.pagination import LinePaginator diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 0bf75a924..bd0622554 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -10,9 +10,10 @@ from typing import Optional import dateutil.parser import discord from discord import Color, Embed, HTTPException, Message, Object, errors -from discord.ext.commands import BadArgument, Bot, Cog, Context +from discord.ext.commands import BadArgument, Cog, Context from bot.api import ResponseCodeError +from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons from bot.pagination import LinePaginator diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index ab0ed2472..c3c193cb9 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -7,8 +7,9 @@ import discord from dateutil.relativedelta import relativedelta from discord import Embed from discord.ext import commands -from discord.ext.commands import Bot, BucketType, Cog, Context, check, group +from discord.ext.commands import BucketType, Cog, Context, check, group +from bot.bot import Bot from bot.constants import Colours, STAFF_ROLES, Wolfram from bot.pagination import ImagePaginator from bot.utils.time import humanize_delta @@ -151,7 +152,7 @@ async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tup class Wolfram(Cog): """Commands for interacting with the Wolfram|Alpha API.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Bot): self.bot = bot @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) @@ -266,7 +267,7 @@ class Wolfram(Cog): await send_embed(ctx, message, color) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None: """Wolfram cog load.""" bot.add_cog(Wolfram(bot)) log.info("Cog loaded: Wolfram") diff --git a/bot/interpreter.py b/bot/interpreter.py index 76a3fc293..8b7268746 100644 --- a/bot/interpreter.py +++ b/bot/interpreter.py @@ -2,7 +2,9 @@ from code import InteractiveInterpreter from io import StringIO from typing import Any -from discord.ext.commands import Bot, Context +from discord.ext.commands import Context + +from bot.bot import Bot CODE_TEMPLATE = """ async def _func(): diff --git a/tests/helpers.py b/tests/helpers.py index b2daae92d..5df796c23 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -10,7 +10,9 @@ import unittest.mock from typing import Any, Iterable, Optional import discord -from discord.ext.commands import Bot, Context +from discord.ext.commands import Context + +from bot.bot import Bot for logger in logging.Logger.manager.loggerDict.values(): -- cgit v1.2.3 From 23acfce3521cd420a2df6eb51f036a2a54140ef6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 8 Dec 2019 02:01:01 -0800 Subject: Fix test failures for setup log messages --- tests/bot/cogs/test_duck_pond.py | 12 ++---------- tests/bot/cogs/test_security.py | 11 +++-------- tests/bot/cogs/test_token_remover.py | 8 ++------ 3 files changed, 7 insertions(+), 24 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index b801e86f1..d07b2bce1 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -578,15 +578,7 @@ class DuckPondSetupTests(unittest.TestCase): """Tests setup of the `DuckPond` cog.""" def test_setup(self): - """Setup of the cog should log a message at `INFO` level.""" + """Setup of the extension should call add_cog.""" bot = helpers.MockBot() - log = logging.getLogger('bot.cogs.duck_pond') - - with self.assertLogs(logger=log, level=logging.INFO) as log_watcher: - duck_pond.setup(bot) - - self.assertEqual(len(log_watcher.records), 1) - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.INFO) - + duck_pond.setup(bot) bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/test_security.py b/tests/bot/cogs/test_security.py index efa7a50b1..9d1a62f7e 100644 --- a/tests/bot/cogs/test_security.py +++ b/tests/bot/cogs/test_security.py @@ -1,4 +1,3 @@ -import logging import unittest from unittest.mock import MagicMock @@ -49,11 +48,7 @@ class SecurityCogLoadTests(unittest.TestCase): """Tests loading the `Security` cog.""" def test_security_cog_load(self): - """Cog loading logs a message at `INFO` level.""" + """Setup of the extension should call add_cog.""" bot = MagicMock() - with self.assertLogs(logger='bot.cogs.security', level=logging.INFO) as cm: - security.setup(bot) - bot.add_cog.assert_called_once() - - [line] = cm.output - self.assertIn("Cog loaded: Security", line) + security.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 3276cf5a5..a54b839d7 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -125,11 +125,7 @@ class TokenRemoverSetupTests(unittest.TestCase): """Tests setup of the `TokenRemover` cog.""" def test_setup(self): - """Setup of the cog should log a message at `INFO` level.""" + """Setup of the extension should call add_cog.""" bot = MockBot() - with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm: - setup_cog(bot) - - [line] = cm.output + setup_cog(bot) bot.add_cog.assert_called_once() - self.assertIn("Cog loaded: TokenRemover", line) -- cgit v1.2.3 From 520346d0b472e5cb6c9091a8323b871d2e3821cc Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Fri, 13 Dec 2019 09:18:49 +0700 Subject: Added tests for `until_expiration` Similar to `format_infraction_with_duration` ( if not outright copying it ), added 3 tests for `until_expiration`: - None `expiry`. - Custom `max_units`. - Normal use cases. --- tests/bot/utils/test_time.py | 45 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 7f55dc3ec..bd04de28b 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -115,3 +115,48 @@ 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) + + def test_until_expiration_with_duration_none_expiry(self): + """until_expiration should work for None expiry.""" + test_cases = ( + (None, None, None, None), + + # To make sure that date_from and max_units are not touched + (None, 'Why hello there!', None, None), + (None, None, float('inf'), None), + (None, 'Why hello there!', float('inf'), None), + ) + + for expiry, now, max_units, expected in test_cases: + with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): + self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + + def test_until_expiration_with_duration_custom_units(self): + """until_expiration should work for custom max_units.""" + test_cases = ( + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, '11 hours, 55 minutes and 55 seconds'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, '6 months, 28 days, 23 hours and 54 minutes') + ) + + for expiry, now, max_units, expected in test_cases: + with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): + self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + + def test_until_expiration_normal_usage(self): + """until_expiration should work for normal usage, across various durations.""" + test_cases = ( + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '12 hours and 55 seconds'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '12 hours'), + ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '1 minute'), + ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '7 days and 23 hours'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '6 months and 28 days'), + ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '5 minutes'), + ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '1 minute'), + ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2 years and 4 months'), + ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, '9 minutes and 55 seconds'), + (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), + ) + + for expiry, now, max_units, expected in test_cases: + with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): + self.assertEqual(time.until_expiration(expiry, now, max_units), expected) -- cgit v1.2.3 From 66d4b93593b95bfa6999b70aca53328d83710c44 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Fri, 13 Dec 2019 09:29:06 +0700 Subject: Fixed a typo ( due to poor copy pasta and eyeballing skills ) --- tests/bot/utils/test_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index bd04de28b..69f35f2f5 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -121,7 +121,7 @@ class TimeTests(unittest.TestCase): test_cases = ( (None, None, None, None), - # To make sure that date_from and max_units are not touched + # To make sure that now and max_units are not touched (None, 'Why hello there!', None, None), (None, None, float('inf'), None), (None, 'Why hello there!', float('inf'), None), -- cgit v1.2.3 From d169fbf9204b8a35ad09ab0141eedd873f8bdddf Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 30 Jan 2020 18:17:12 +0100 Subject: Add additional resources to the test readme --- tests/README.md | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'tests') diff --git a/tests/README.md b/tests/README.md index d052de2f6..c3551bd54 100644 --- a/tests/README.md +++ b/tests/README.md @@ -212,3 +212,9 @@ All in all, it's not only important to consider if all statements or branches we Another restriction of unit testing is that it tests, well, in units. Even if we can guarantee that the units work as they should independently, we have no guarantee that they will actually work well together. Even more, while the mocking described above gives us a lot of flexibility in factoring out external code, we are work under the implicit assumption that we fully understand those external parts and utilize it correctly. What if our mocked `Context` object works with a `send` method, but `discord.py` has changed it to a `send_message` method in a recent update? It could mean our tests are passing, but the code it's testing still doesn't work in production. The answer to this is that we also need to make sure that the individual parts come together into a working application. In addition, we will also need to make sure that the application communicates correctly with external applications. Since we currently have no automated integration tests or functional tests, that means **it's still very important to fire up the bot and test the code you've written manually** in addition to the unit tests you've written. + +## Additional resources + +* [Corey Schafer video about unittest](https://youtu.be/6tNS--WetLI) +* [RealPython tutorial on unittest testing](https://realpython.com/python-testing/) +* [RealPython tutorial on mocking](https://realpython.com/python-mock-library/) -- cgit v1.2.3 From e518dc2193f0f1ed31217e6097c2c6cdbc36e0e1 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 30 Jan 2020 19:04:21 +0100 Subject: Merge the note with the additional resources section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the link to Ned Batchelder’s talk and link the note to the section --- tests/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/README.md b/tests/README.md index c3551bd54..be78821bf 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,7 +2,7 @@ Our bot is one of the most important tools we have for running our community. As we don't want that tool break, we decided that we wanted to write unit tests for it. We hope that in the future, we'll have a 100% test coverage for the bot. This guide will help you get started with writing the tests needed to achieve that. -_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY)._ +_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you can take a look at the [Additional resources](#additional-resources) section at the bottom of this page._ ## Tools @@ -215,6 +215,7 @@ The answer to this is that we also need to make sure that the individual parts c ## Additional resources +* [Ned Batchelder's PyCon talk: Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY) * [Corey Schafer video about unittest](https://youtu.be/6tNS--WetLI) * [RealPython tutorial on unittest testing](https://realpython.com/python-testing/) * [RealPython tutorial on mocking](https://realpython.com/python-mock-library/) -- cgit v1.2.3