diff options
Diffstat (limited to 'tests')
-rw-r--r-- | tests/cogs/sync/test_roles.py | 81 | ||||
-rw-r--r-- | tests/cogs/test_antispam.py | 30 | ||||
-rw-r--r-- | tests/cogs/test_information.py | 211 | ||||
-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 | 33 | ||||
-rw-r--r-- | tests/rules/__init__.py | 0 | ||||
-rw-r--r-- | tests/rules/test_attachments.py | 52 | ||||
-rw-r--r-- | tests/test_api.py | 106 | ||||
-rw-r--r-- | tests/test_constants.py | 23 | ||||
-rw-r--r-- | tests/test_converters.py | 264 | ||||
-rw-r--r-- | tests/test_pagination.py | 29 | ||||
-rw-r--r-- | tests/test_resources.py | 13 | ||||
-rw-r--r-- | tests/utils/__init__.py | 0 | ||||
-rw-r--r-- | tests/utils/test_checks.py | 66 | ||||
-rw-r--r-- | tests/utils/test_time.py | 62 |
17 files changed, 1168 insertions, 21 deletions
diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py index 18682f39f..c561ba447 100644 --- a/tests/cogs/sync/test_roles.py +++ b/tests/cogs/sync/test_roles.py @@ -2,63 +2,102 @@ from bot.cogs.sync.syncers import Role, get_roles_for_sync def test_get_roles_for_sync_empty_return_for_equal_roles(): - api_roles = {Role(id=41, name='name', colour=33, permissions=0x8)} - guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8)} + api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} - assert get_roles_for_sync(guild_roles, api_roles) == (set(), set()) + assert get_roles_for_sync(guild_roles, api_roles) == (set(), set(), set()) def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(): - api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8)} - guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8)} + api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)} assert get_roles_for_sync(guild_roles, api_roles) == ( set(), - guild_roles + guild_roles, + set(), ) def test_get_roles_only_returns_roles_that_require_update(): api_roles = { - Role(id=41, name='old name', colour=33, permissions=0x8), - Role(id=53, name='other role', colour=55, permissions=0) + Role(id=41, name='old name', colour=33, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=3) } guild_roles = { - Role(id=41, name='new name', colour=35, permissions=0x8), - Role(id=53, name='other role', colour=55, permissions=0) + Role(id=41, name='new name', colour=35, permissions=0x8, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) } assert get_roles_for_sync(guild_roles, api_roles) == ( set(), - {Role(id=41, name='new name', colour=35, permissions=0x8)}, + {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, + set(), ) def test_get_roles_returns_new_roles_in_first_tuple_element(): api_roles = { - Role(id=41, name='name', colour=35, permissions=0x8), + Role(id=41, name='name', colour=35, permissions=0x8, position=1), } guild_roles = { - Role(id=41, name='name', colour=35, permissions=0x8), - Role(id=53, name='other role', colour=55, permissions=0) + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=2) } assert get_roles_for_sync(guild_roles, api_roles) == ( - {Role(id=53, name='other role', colour=55, permissions=0)}, - set() + {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, + set(), + set(), ) def test_get_roles_returns_roles_to_update_and_new_roles(): api_roles = { - Role(id=41, name='old name', colour=35, permissions=0x8), + Role(id=41, name='old name', colour=35, permissions=0x8, position=1), + } + guild_roles = { + Role(id=41, name='new name', colour=40, permissions=0x16, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + {Role(id=53, name='other role', colour=55, permissions=0, position=3)}, + {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)}, + set(), + ) + + +def test_get_roles_returns_roles_to_delete(): + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + set(), + set(), + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) + + +def test_get_roles_returns_roles_to_delete_update_and_new_roles(): + api_roles = { + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + Role(id=71, name='to update', colour=99, permissions=0x9, position=3), } guild_roles = { - Role(id=41, name='new name', colour=40, permissions=0x16), - Role(id=53, name='other role', colour=55, permissions=0) + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=81, name='to create', colour=99, permissions=0x9, position=4), + Role(id=71, name='updated', colour=101, permissions=0x5, position=3), } assert get_roles_for_sync(guild_roles, api_roles) == ( - {Role(id=53, name='other role', colour=55, permissions=0)}, - {Role(id=41, name='new name', colour=40, permissions=0x16)} + {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)}, + {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)}, + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, ) 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..184bd2595 --- /dev/null +++ b/tests/cogs/test_information.py @@ -0,0 +1,211 @@ +import asyncio +import logging +import textwrap +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest +from discord import ( + CategoryChannel, + Colour, + Permissions, + Role, + 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" + + +def test_role_info_command(cog, ctx): + dummy_role = MagicMock(spec=Role) + dummy_role.name = "Dummy" + dummy_role.colour = Colour.blurple() + dummy_role.id = 112233445566778899 + dummy_role.position = 10 + dummy_role.permissions = Permissions(0) + dummy_role.members = [ctx.author] + + admin_role = MagicMock(spec=Role) + admin_role.name = "Admin" + admin_role.colour = Colour.red() + admin_role.id = 998877665544332211 + admin_role.position = 3 + admin_role.permissions = Permissions(0) + admin_role.members = [ctx.author] + + ctx.guild.roles = [dummy_role, admin_role] + + cog.role_info.can_run = AsyncMock() + cog.role_info.can_run.return_value = True + + coroutine = cog.role_info.callback(cog, ctx, dummy_role, admin_role) + + assert asyncio.run(coroutine) is None + + assert ctx.send.call_count == 2 + + (_, dummy_kwargs), (_, admin_kwargs) = ctx.send.call_args_list + + dummy_embed = dummy_kwargs["embed"] + admin_embed = admin_kwargs["embed"] + + assert dummy_embed.title == "Dummy info" + assert dummy_embed.colour == Colour.blurple() + + assert dummy_embed.fields[0].value == str(dummy_role.id) + assert dummy_embed.fields[1].value == f"#{dummy_role.colour.value:0>6x}" + assert dummy_embed.fields[2].value == "0.63 0.48 218" + assert dummy_embed.fields[3].value == "1" + assert dummy_embed.fields[4].value == "10" + assert dummy_embed.fields[5].value == "0" + + assert admin_embed.title == "Admin info" + assert admin_embed.colour == Colour.red() + +# 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..25059fa3a --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,33 @@ +import asyncio +import functools +from unittest.mock import MagicMock + + +__all__ = ('AsyncMock', 'async_test') + + +# TODO: Remove me on 3.8 +# Allows you to mock a coroutine. Since the default `__call__` of `MagicMock` +# is not a coroutine, trying to mock a coroutine with it will result in errors +# as the default `__call__` is not awaitable. Use this class for monkeypatching +# coroutines instead. +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/rules/__init__.py b/tests/rules/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/rules/__init__.py diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py new file mode 100644 index 000000000..6f025b3cb --- /dev/null +++ b/tests/rules/test_attachments.py @@ -0,0 +1,52 @@ +import asyncio +from dataclasses import dataclass +from typing import Any, List + +import pytest + +from bot.rules import attachments + + +# 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): + return FakeMessage(author='lemon', attachments=list(range(total_attachments))) + + + 'messages', + ( + (msg(0), msg(0), msg(0)), + (msg(2), msg(2)), + (msg(0),), + ) +) +def test_allows_messages_without_too_many_attachments(messages): + last_message, *recent_messages = messages + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + assert asyncio.run(coro) is None + + + ('messages', 'relevant_messages', 'total'), + ( + ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), + ((msg(6),), [msg(6)], 6), + ((msg(1),) * 6, [msg(1)] * 6, 6), + ) +) +def test_disallows_messages_with_too_many_attachments(messages, relevant_messages, total): + last_message, *recent_messages = messages + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + assert asyncio.run(coro) == ( + f"sent {total} attachments in 5s", + ('lemon',), + relevant_messages + ) 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..f69995ec6 --- /dev/null +++ b/tests/test_converters.py @@ -0,0 +1,264 @@ +import asyncio +import datetime +from unittest.mock import MagicMock, patch + +import pytest +from dateutil.relativedelta import relativedelta +from discord.ext.commands import BadArgument + +from bot.converters import ( + Duration, + ISODateTime, + TagContentConverter, + TagNameConverter, + ValidPythonIdentifier, +) + + + ('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)) + + +FIXED_UTC_NOW = datetime.datetime.fromisoformat('2019-01-01T00:00:00') + + + params=( + # Simple duration strings + ('1Y', {"years": 1}), + ('1y', {"years": 1}), + ('1year', {"years": 1}), + ('1years', {"years": 1}), + ('1m', {"months": 1}), + ('1month', {"months": 1}), + ('1months', {"months": 1}), + ('1w', {"weeks": 1}), + ('1W', {"weeks": 1}), + ('1week', {"weeks": 1}), + ('1weeks', {"weeks": 1}), + ('1d', {"days": 1}), + ('1D', {"days": 1}), + ('1day', {"days": 1}), + ('1days', {"days": 1}), + ('1h', {"hours": 1}), + ('1H', {"hours": 1}), + ('1hour', {"hours": 1}), + ('1hours', {"hours": 1}), + ('1M', {"minutes": 1}), + ('1minute', {"minutes": 1}), + ('1minutes', {"minutes": 1}), + ('1s', {"seconds": 1}), + ('1S', {"seconds": 1}), + ('1second', {"seconds": 1}), + ('1seconds', {"seconds": 1}), + + # Complex duration strings + ( + '1y1m1w1d1H1M1S', + { + "years": 1, + "months": 1, + "weeks": 1, + "days": 1, + "hours": 1, + "minutes": 1, + "seconds": 1 + } + ), + ('5y100S', {"years": 5, "seconds": 100}), + ('2w28H', {"weeks": 2, "hours": 28}), + + # Duration strings with spaces + ('1 year 2 months', {"years": 1, "months": 2}), + ('1d 2H', {"days": 1, "hours": 2}), + ('1 week2 days', {"weeks": 1, "days": 2}), + ) +) +def create_future_datetime(request): + """Yields duration string and target datetime.datetime object.""" + duration, duration_dict = request.param + future_datetime = FIXED_UTC_NOW + relativedelta(**duration_dict) + yield duration, future_datetime + + +def test_duration_converter_for_valid(create_future_datetime: tuple): + converter = Duration() + duration, expected = create_future_datetime + with patch('bot.converters.datetime') as mock_datetime: + mock_datetime.utcnow.return_value = FIXED_UTC_NOW + assert asyncio.run(converter.convert(None, duration)) == expected + + + ('duration'), + ( + # Units in wrong order + ('1d1w'), + ('1s1y'), + + # Duplicated units + ('1 year 2 years'), + ('1 M 10 minutes'), + + # Unknown substrings + ('1MVes'), + ('1y3breads'), + + # Missing amount + ('ym'), + + # Incorrect whitespace + (" 1y"), + ("1S "), + ("1y 1m"), + + # Garbage + ('Guido van Rossum'), + ('lemon lemon lemon lemon lemon lemon lemon'), + ) +) +def test_duration_converter_for_invalid(duration: str): + converter = Duration() + with pytest.raises(BadArgument, match=f'`{duration}` is not a valid duration string.'): + asyncio.run(converter.convert(None, duration)) + + + ("datetime_string", "expected_dt"), + ( + + # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` + ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` + ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` + ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` + ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` + ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` + ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)), + ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)), + + # `YYYY-mm-dd` + ('2019-04-01', datetime.datetime(2019, 4, 1)), + + # `YYYY-mm` + ('2019-02-01', datetime.datetime(2019, 2, 1)), + + # `YYYY` + ('2025', datetime.datetime(2025, 1, 1)), + ), +) +def test_isodatetime_converter_for_valid(datetime_string: str, expected_dt: datetime.datetime): + converter = ISODateTime() + converted_dt = asyncio.run(converter.convert(None, datetime_string)) + assert converted_dt.tzinfo is None + assert converted_dt == expected_dt + + + ("datetime_string"), + ( + # Make sure it doesn't interfere with the Duration converter + ('1Y'), + ('1d'), + ('1H'), + + # Check if it fails when only providing the optional time part + ('10:10:10'), + ('10:00'), + + # Invalid date format + ('19-01-01'), + + # Other non-valid strings + ('fisk the tag master'), + ), +) +def test_isodatetime_converter_for_invalid(datetime_string: str): + converter = ISODateTime() + with pytest.raises( + BadArgument, + match=f"`{datetime_string}` is not a valid ISO-8601 datetime string", + ): + asyncio.run(converter.convert(None, datetime_string)) 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..bcf124f05 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,13 @@ +import json +from pathlib import Path + + +def test_stars_valid(): + """Validates that `bot/resources/stars.json` contains a list of strings.""" + + path = Path('bot', 'resources', 'stars.json') + content = path.read_text() + data = json.loads(content) + + for name in data: + assert type(name) is str 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) diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py new file mode 100644 index 000000000..4baa6395c --- /dev/null +++ b/tests/utils/test_time.py @@ -0,0 +1,62 @@ +import asyncio +from datetime import datetime, timezone +from unittest.mock import patch + +import pytest +from dateutil.relativedelta import relativedelta + +from bot.utils import time +from tests.helpers import AsyncMock + + + ('delta', 'precision', 'max_units', 'expected'), + ( + (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'), + ) +) +def test_humanize_delta( + delta: relativedelta, + precision: str, + max_units: int, + expected: str +): + assert time.humanize_delta(delta, precision, max_units) == expected + + [email protected]('max_units', (-1, 0)) +def test_humanize_delta_raises_for_invalid_max_units(max_units: int): + with pytest.raises(ValueError, match='max_units must be positive'): + time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) + + + ('stamp', 'expected'), + ( + ('Sun, 15 Sep 2019 12:00:00 GMT', datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)), + ) +) +def test_parse_rfc1123(stamp: str, expected: str): + assert time.parse_rfc1123(stamp) == expected + + +@patch('asyncio.sleep', new_callable=AsyncMock) +def test_wait_until(sleep_patch): + 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 + + sleep_patch.assert_called_once_with(10 * 60) |