aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/cogs/sync/test_roles.py81
-rw-r--r--tests/cogs/test_antispam.py30
-rw-r--r--tests/cogs/test_information.py211
-rw-r--r--tests/cogs/test_security.py54
-rw-r--r--tests/cogs/test_token_remover.py133
-rw-r--r--tests/conftest.py32
-rw-r--r--tests/helpers.py33
-rw-r--r--tests/rules/__init__.py0
-rw-r--r--tests/rules/test_attachments.py52
-rw-r--r--tests/test_api.py106
-rw-r--r--tests/test_constants.py23
-rw-r--r--tests/test_converters.py264
-rw-r--r--tests/test_pagination.py29
-rw-r--r--tests/test_resources.py13
-rw-r--r--tests/utils/__init__.py0
-rw-r--r--tests/utils/test_checks.py66
-rw-r--r--tests/utils/test_time.py62
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)