diff options
Diffstat (limited to 'tests')
-rw-r--r-- | tests/cogs/test_antispam.py | 30 | ||||
-rw-r--r-- | tests/cogs/test_information.py | 163 | ||||
-rw-r--r-- | tests/cogs/test_security.py | 54 | ||||
-rw-r--r-- | tests/cogs/test_token_remover.py | 133 | ||||
-rw-r--r-- | tests/conftest.py | 32 | ||||
-rw-r--r-- | tests/helpers.py | 29 | ||||
-rw-r--r-- | tests/test_api.py | 106 | ||||
-rw-r--r-- | tests/test_constants.py | 23 | ||||
-rw-r--r-- | tests/test_converters.py | 93 | ||||
-rw-r--r-- | tests/test_pagination.py | 29 | ||||
-rw-r--r-- | tests/test_resources.py | 18 | ||||
-rw-r--r-- | tests/utils/__init__.py | 0 | ||||
-rw-r--r-- | tests/utils/test_checks.py | 66 |
13 files changed, 776 insertions, 0 deletions
diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py new file mode 100644 index 000000000..67900b275 --- /dev/null +++ b/tests/cogs/test_antispam.py @@ -0,0 +1,30 @@ +import pytest + +from bot.cogs import antispam + + +def test_default_antispam_config_is_valid(): + validation_errors = antispam.validate_config() + assert not validation_errors + + + ('config', 'expected'), + ( + ( + {'invalid-rule': {}}, + {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} + ), + ( + {'burst': {'interval': 10}}, + {'burst': "Key `max` is required but not set for rule `burst`"} + ), + ( + {'burst': {'max': 10}}, + {'burst': "Key `interval` is required but not set for rule `burst`"} + ) + ) +) +def test_invalid_antispam_config_returns_validation_errors(config, expected): + validation_errors = antispam.validate_config(config) + assert validation_errors == expected diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py new file mode 100644 index 000000000..85b2d092e --- /dev/null +++ b/tests/cogs/test_information.py @@ -0,0 +1,163 @@ +import asyncio +import logging +import textwrap +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest +from discord import ( + CategoryChannel, + Colour, + TextChannel, + VoiceChannel, +) + +from bot.cogs import information +from bot.constants import Emojis +from bot.decorators import InChannelCheckFailure +from tests.helpers import AsyncMock + + +def cog(simple_bot): + return information.Information(simple_bot) + + +def role(name: str, id_: int): + r = MagicMock() + r.name = name + r.id = id_ + r.mention = f'&{name}' + return r + + +def member(status: str): + m = MagicMock() + m.status = status + return m + + +def ctx(moderator_role, simple_ctx): + simple_ctx.author.roles = [moderator_role] + simple_ctx.guild.created_at = datetime(2001, 1, 1) + simple_ctx.send = AsyncMock() + return simple_ctx + + +def test_roles_info_command(cog, ctx): + everyone_role = MagicMock() + everyone_role.name = '@everyone' # should be excluded in the output + ctx.author.roles.append(everyone_role) + ctx.guild.roles = ctx.author.roles + + cog.roles_info.can_run = AsyncMock() + cog.roles_info.can_run.return_value = True + + coroutine = cog.roles_info.callback(cog, ctx) + + assert asyncio.run(coroutine) is None # no rval + ctx.send.assert_called_once() + _, kwargs = ctx.send.call_args + embed = kwargs.pop('embed') + assert embed.title == "Role information" + assert embed.colour == Colour.blurple() + assert embed.description == f"`{ctx.guild.roles[0].id}` - {ctx.guild.roles[0].mention}\n" + assert embed.footer.text == "Total roles: 1" + + +# There is no argument passed in here that we can use to test, +# so the return value would change constantly. +@patch('bot.cogs.information.time_since') +def test_server_info_command(time_since_patch, cog, ctx, moderator_role): + time_since_patch.return_value = '2 days ago' + + ctx.guild.created_at = datetime(2001, 1, 1) + ctx.guild.features = ('lemons', 'apples') + ctx.guild.region = 'The Moon' + ctx.guild.roles = [moderator_role] + ctx.guild.channels = [ + TextChannel( + state={}, + guild=ctx.guild, + data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} + ), + CategoryChannel( + state={}, + guild=ctx.guild, + data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} + ), + VoiceChannel( + state={}, + guild=ctx.guild, + data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} + ) + ] + ctx.guild.members = [ + member('online'), member('online'), + member('idle'), + member('dnd'), member('dnd'), member('dnd'), member('dnd'), + member('offline'), member('offline'), member('offline') + ] + ctx.guild.member_count = 1_234 + ctx.guild.icon_url = 'a-lemon.png' + + coroutine = cog.server_info.callback(cog, ctx) + assert asyncio.run(coroutine) is None # no rval + + time_since_patch.assert_called_once_with(ctx.guild.created_at, precision='days') + _, kwargs = ctx.send.call_args + embed = kwargs.pop('embed') + assert embed.colour == Colour.blurple() + assert embed.description == textwrap.dedent(f""" + **Server information** + Created: {time_since_patch.return_value} + Voice region: {ctx.guild.region} + Features: {', '.join(ctx.guild.features)} + + **Counts** + Members: {ctx.guild.member_count:,} + Roles: {len(ctx.guild.roles)} + Text: 1 + Voice: 1 + Channel categories: 1 + + **Members** + {Emojis.status_online} 2 + {Emojis.status_idle} 1 + {Emojis.status_dnd} 4 + {Emojis.status_offline} 3 + """) + assert embed.thumbnail.url == 'a-lemon.png' + + +def test_user_info_on_other_users_from_non_moderator(ctx, cog): + ctx.author = MagicMock() + ctx.author.__eq__.return_value = False + ctx.author.roles = [] + coroutine = cog.user_info.callback(cog, ctx, user='scragly') # skip checks, pass args + + assert asyncio.run(coroutine) is None # no rval + ctx.send.assert_called_once_with( + "You may not use this command on users other than yourself." + ) + + +def test_user_info_in_wrong_channel_from_non_moderator(ctx, cog): + ctx.author = MagicMock() + ctx.author.__eq__.return_value = False + ctx.author.roles = [] + + coroutine = cog.user_info.callback(cog, ctx) + message = 'Sorry, but you may only use this command within <#267659945086812160>.' + with pytest.raises(InChannelCheckFailure, match=message): + assert asyncio.run(coroutine) is None # no rval + + +def test_setup(simple_bot, caplog): + information.setup(simple_bot) + simple_bot.add_cog.assert_called_once() + [record] = caplog.records + + assert record.message == "Cog loaded: Information" + assert record.levelno == logging.INFO diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py new file mode 100644 index 000000000..1efb460fe --- /dev/null +++ b/tests/cogs/test_security.py @@ -0,0 +1,54 @@ +import logging +from unittest.mock import MagicMock + +import pytest +from discord.ext.commands import NoPrivateMessage + +from bot.cogs import security + + +def cog(): + bot = MagicMock() + return security.Security(bot) + + +def context(): + return MagicMock() + + +def test_check_additions(cog): + cog.bot.check.assert_any_call(cog.check_on_guild) + cog.bot.check.assert_any_call(cog.check_not_bot) + + +def test_check_not_bot_for_humans(cog, context): + context.author.bot = False + assert cog.check_not_bot(context) + + +def test_check_not_bot_for_robots(cog, context): + context.author.bot = True + assert not cog.check_not_bot(context) + + +def test_check_on_guild_outside_of_guild(cog, context): + context.guild = None + + with pytest.raises(NoPrivateMessage, match="This command cannot be used in private messages."): + cog.check_on_guild(context) + + +def test_check_on_guild_on_guild(cog, context): + context.guild = "lemon's lemonade stand" + assert cog.check_on_guild(context) + + +def test_security_cog_load(caplog): + bot = MagicMock() + security.setup(bot) + bot.add_cog.assert_called_once() + [record] = caplog.records + assert record.message == "Cog loaded: Security" + assert record.levelno == logging.INFO diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py new file mode 100644 index 000000000..9d46b3a05 --- /dev/null +++ b/tests/cogs/test_token_remover.py @@ -0,0 +1,133 @@ +import asyncio +from unittest.mock import MagicMock + +import pytest +from discord import Colour + +from bot.cogs.token_remover import ( + DELETION_MESSAGE_TEMPLATE, + TokenRemover, + setup as setup_cog, +) +from bot.constants import Channels, Colours, Event, Icons +from tests.helpers import AsyncMock + + +def token_remover(): + bot = MagicMock() + bot.get_cog.return_value = MagicMock() + bot.get_cog.return_value.send_log_message = AsyncMock() + return TokenRemover(bot=bot) + + +def message(): + message = MagicMock() + message.author.__str__.return_value = 'lemon' + message.author.bot = False + message.author.avatar_url_as.return_value = 'picture-lemon.png' + message.author.id = 42 + message.author.mention = '@lemon' + message.channel.send = AsyncMock() + message.channel.mention = '#lemonade-stand' + message.content = '' + message.delete = AsyncMock() + message.id = 555 + return message + + + ('content', 'expected'), + ( + ('MTIz', True), # 123 + ('YWJj', False), # abc + ) +) +def test_is_valid_user_id(content: str, expected: bool): + assert TokenRemover.is_valid_user_id(content) is expected + + + ('content', 'expected'), + ( + ('DN9r_A', True), # stolen from dapi, thanks to the author of the 'token' tag! + ('MTIz', False), # 123 + ) +) +def test_is_valid_timestamp(content: str, expected: bool): + assert TokenRemover.is_valid_timestamp(content) is expected + + +def test_mod_log_property(token_remover): + token_remover.bot.get_cog.return_value = 'lemon' + assert token_remover.mod_log == 'lemon' + token_remover.bot.get_cog.assert_called_once_with('ModLog') + + +def test_ignores_bot_messages(token_remover, message): + message.author.bot = True + coroutine = token_remover.on_message(message) + assert asyncio.run(coroutine) is None + + [email protected]('content', ('', 'lemon wins')) +def test_ignores_messages_without_tokens(token_remover, message, content): + message.content = content + coroutine = token_remover.on_message(message) + assert asyncio.run(coroutine) is None + + [email protected]('content', ('foo.bar.baz', 'x.y.')) +def test_ignores_invalid_tokens(token_remover, message, content): + message.content = content + coroutine = token_remover.on_message(message) + assert asyncio.run(coroutine) is None + + + 'content, censored_token', + ( + ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), + ) +) +def test_censors_valid_tokens( + token_remover, message, content, censored_token, caplog +): + message.content = content + coroutine = token_remover.on_message(message) + assert asyncio.run(coroutine) is None # still no rval + + # asyncio logs some stuff about its reactor, discard it + [_, record] = caplog.records + assert record.message == ( + "Censored a seemingly valid token sent by lemon (`42`) in #lemonade-stand, " + f"token was `{censored_token}`" + ) + + message.delete.assert_called_once_with() + message.channel.send.assert_called_once_with( + DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') + ) + token_remover.bot.get_cog.assert_called_with('ModLog') + message.author.avatar_url_as.assert_called_once_with(static_format='png') + + mod_log = token_remover.bot.get_cog.return_value + mod_log.ignore.assert_called_once_with(Event.message_delete, message.id) + mod_log.send_log_message.assert_called_once_with( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=record.message, + thumbnail='picture-lemon.png', + channel_id=Channels.mod_alerts + ) + + +def test_setup(caplog): + bot = MagicMock() + setup_cog(bot) + [record] = caplog.records + + bot.add_cog.assert_called_once() + assert record.message == "Cog loaded: TokenRemover" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..d3de4484d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +from unittest.mock import MagicMock + +import pytest + +from bot.constants import Roles +from tests.helpers import AsyncMock + + +def moderator_role(): + mock = MagicMock() + mock.id = Roles.moderator + mock.name = 'Moderator' + mock.mention = f'&{mock.name}' + return mock + + +def simple_bot(): + mock = MagicMock() + mock._before_invoke = AsyncMock() + mock._after_invoke = AsyncMock() + mock.can_run = AsyncMock() + mock.can_run.return_value = True + return mock + + +def simple_ctx(simple_bot): + mock = MagicMock() + mock.bot = simple_bot + return mock diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 000000000..2908294f7 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,29 @@ +import asyncio +import functools +from unittest.mock import MagicMock + + +__all__ = ('AsyncMock', 'async_test') + + +# TODO: Remove me on 3.8 +class AsyncMock(MagicMock): + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +def async_test(wrapped): + """ + Run a test case via asyncio. + + Example: + + >>> @async_test + ... async def lemon_wins(): + ... assert True + """ + + @functools.wraps(wrapped) + def wrapper(*args, **kwargs): + return asyncio.run(wrapped(*args, **kwargs)) + return wrapper diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 000000000..ce69ef187 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,106 @@ +import logging +from unittest.mock import MagicMock, patch + +import pytest + +from bot import api +from tests.helpers import async_test + + +def test_loop_is_not_running_by_default(): + assert not api.loop_is_running() + + +@async_test +async def test_loop_is_running_in_async_test(): + assert api.loop_is_running() + + +def error_api_response(): + response = MagicMock() + response.status = 999 + return response + + +def api_log_handler(): + return api.APILoggingHandler(None) + + +def debug_log_record(): + return logging.LogRecord( + name='my.logger', level=logging.DEBUG, + pathname='my/logger.py', lineno=666, + msg="Lemon wins", args=(), + exc_info=None + ) + + +def test_response_code_error_default_initialization(error_api_response): + error = api.ResponseCodeError(response=error_api_response) + assert error.status is error_api_response.status + assert not error.response_json + assert not error.response_text + assert error.response is error_api_response + + +def test_response_code_error_default_representation(error_api_response): + error = api.ResponseCodeError(response=error_api_response) + assert str(error) == f"Status: {error_api_response.status} Response: " + + +def test_response_code_error_representation_with_nonempty_response_json(error_api_response): + error = api.ResponseCodeError( + response=error_api_response, + response_json={'hello': 'world'} + ) + assert str(error) == f"Status: {error_api_response.status} Response: {{'hello': 'world'}}" + + +def test_response_code_error_representation_with_nonempty_response_text(error_api_response): + error = api.ResponseCodeError( + response=error_api_response, + response_text='Lemon will eat your soul' + ) + assert str(error) == f"Status: {error_api_response.status} Response: Lemon will eat your soul" + + +@patch('bot.api.APILoggingHandler.ship_off') +def test_emit_appends_to_queue_with_stopped_event_loop( + ship_off_patch, api_log_handler, debug_log_record +): + # This is a coroutine so returns something we should await, + # but asyncio complains about that. To ease testing, we patch + # `ship_off` to just return a regular value instead. + ship_off_patch.return_value = 42 + api_log_handler.emit(debug_log_record) + + assert api_log_handler.queue == [42] + + +def test_emit_ignores_less_than_debug(debug_log_record, api_log_handler): + debug_log_record.levelno = logging.DEBUG - 5 + api_log_handler.emit(debug_log_record) + assert not api_log_handler.queue + + +def test_schedule_queued_tasks_for_empty_queue(api_log_handler, caplog): + api_log_handler.schedule_queued_tasks() + # Logs when tasks are scheduled + assert not caplog.records + + +@patch('asyncio.create_task') +def test_schedule_queued_tasks_for_nonempty_queue(create_task_patch, api_log_handler, caplog): + api_log_handler.queue = [555] + api_log_handler.schedule_queued_tasks() + assert not api_log_handler.queue + create_task_patch.assert_called_once_with(555) + + [record] = caplog.records + assert record.message == "Scheduled 1 pending logging tasks." + assert record.levelno == logging.DEBUG + assert record.name == 'bot.api' + assert record.__dict__['via_handler'] diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 000000000..e4a29d994 --- /dev/null +++ b/tests/test_constants.py @@ -0,0 +1,23 @@ +import inspect + +import pytest + +from bot import constants + + + 'section', + ( + cls + for (name, cls) in inspect.getmembers(constants) + if hasattr(cls, 'section') and isinstance(cls, type) + ) +) +def test_section_configuration_matches_typespec(section): + for (name, annotation) in section.__annotations__.items(): + value = getattr(section, name) + + if getattr(annotation, '_name', None) in ('Dict', 'List'): + pytest.skip("Cannot validate containers yet") + + assert isinstance(value, annotation) diff --git a/tests/test_converters.py b/tests/test_converters.py new file mode 100644 index 000000000..3cf774c80 --- /dev/null +++ b/tests/test_converters.py @@ -0,0 +1,93 @@ +import asyncio +from datetime import datetime +from unittest.mock import MagicMock + +import pytest +from discord.ext.commands import BadArgument + +from bot.converters import ( + ExpirationDate, + TagContentConverter, + TagNameConverter, + ValidPythonIdentifier, +) + + + ('value', 'expected'), + ( + # sorry aliens + ('2199-01-01T00:00:00', datetime(2199, 1, 1)), + ) +) +def test_expiration_date_converter_for_valid(value: str, expected: datetime): + converter = ExpirationDate() + assert asyncio.run(converter.convert(None, value)) == expected + + + ('value', 'expected'), + ( + ('hello', 'hello'), + (' h ello ', 'h ello') + ) +) +def test_tag_content_converter_for_valid(value: str, expected: str): + assert asyncio.run(TagContentConverter.convert(None, value)) == expected + + + ('value', 'expected'), + ( + ('', "Tag contents should not be empty, or filled with whitespace."), + (' ', "Tag contents should not be empty, or filled with whitespace.") + ) +) +def test_tag_content_converter_for_invalid(value: str, expected: str): + context = MagicMock() + context.author = 'bob' + + with pytest.raises(BadArgument, match=expected): + asyncio.run(TagContentConverter.convert(context, value)) + + + ('value', 'expected'), + ( + ('tracebacks', 'tracebacks'), + ('Tracebacks', 'tracebacks'), + (' Tracebacks ', 'tracebacks'), + ) +) +def test_tag_name_converter_for_valid(value: str, expected: str): + assert asyncio.run(TagNameConverter.convert(None, value)) == expected + + + ('value', 'expected'), + ( + ('👋', "Don't be ridiculous, you can't use that character!"), + ('', "Tag names should not be empty, or filled with whitespace."), + (' ', "Tag names should not be empty, or filled with whitespace."), + ('42', "Tag names can't be numbers."), + # Escape question mark as this is evaluated as regular expression. + ('x' * 128, r"Are you insane\? That's way too long!"), + ) +) +def test_tag_name_converter_for_invalid(value: str, expected: str): + context = MagicMock() + context.author = 'bob' + + with pytest.raises(BadArgument, match=expected): + asyncio.run(TagNameConverter.convert(context, value)) + + [email protected]('value', ('foo', 'lemon')) +def test_valid_python_identifier_for_valid(value: str): + assert asyncio.run(ValidPythonIdentifier.convert(None, value)) == value + + [email protected]('value', ('nested.stuff', '#####')) +def test_valid_python_identifier_for_invalid(value: str): + with pytest.raises(BadArgument, match=f'`{value}` is not a valid Python identifier'): + asyncio.run(ValidPythonIdentifier.convert(None, value)) diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 000000000..11d6541ae --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,29 @@ +from unittest import TestCase + +import pytest + +from bot import pagination + + +class LinePaginatorTests(TestCase): + def setUp(self): + self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) + + def test_add_line_raises_on_too_long_lines(self): + message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" + with pytest.raises(RuntimeError, match=message): + self.paginator.add_line('x' * self.paginator.max_size) + + def test_add_line_works_on_small_lines(self): + self.paginator.add_line('x' * (self.paginator.max_size - 3)) + + +class ImagePaginatorTests(TestCase): + def setUp(self): + self.paginator = pagination.ImagePaginator() + + def test_add_image_appends_image(self): + image = 'lemon' + self.paginator.add_image(image) + + assert self.paginator.images == [image] diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 000000000..2b17aea64 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,18 @@ +import json +import mimetypes +from pathlib import Path +from urllib.parse import urlparse + + +def test_stars_valid(): + """Validates that `bot/resources/stars.json` contains valid images.""" + + path = Path('bot', 'resources', 'stars.json') + content = path.read_text() + data = json.loads(content) + + for url in data.values(): + assert urlparse(url).scheme == 'https' + + mimetype, _ = mimetypes.guess_type(url) + assert mimetype in ('image/jpeg', 'image/png') diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/utils/__init__.py diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py new file mode 100644 index 000000000..7121acebd --- /dev/null +++ b/tests/utils/test_checks.py @@ -0,0 +1,66 @@ +from unittest.mock import MagicMock + +import pytest + +from bot.utils import checks + + +def context(): + return MagicMock() + + +def test_with_role_check_without_guild(context): + context.guild = None + + assert not checks.with_role_check(context) + + +def test_with_role_check_with_guild_without_required_role(context): + context.guild = True + context.author.roles = [] + + assert not checks.with_role_check(context) + + +def test_with_role_check_with_guild_with_required_role(context): + context.guild = True + role = MagicMock() + role.id = 42 + context.author.roles = (role,) + + assert checks.with_role_check(context, role.id) + + +def test_without_role_check_without_guild(context): + context.guild = None + + assert not checks.without_role_check(context) + + +def test_without_role_check_with_unwanted_role(context): + context.guild = True + role = MagicMock() + role.id = 42 + context.author.roles = (role,) + + assert not checks.without_role_check(context, role.id) + + +def test_without_role_check_without_unwanted_role(context): + context.guild = True + role = MagicMock() + role.id = 42 + context.author.roles = (role,) + + assert checks.without_role_check(context, role.id + 10) + + +def test_in_channel_check_for_correct_channel(context): + 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) |