diff options
Diffstat (limited to '')
| -rw-r--r-- | tests/__init__.py | 0 | ||||
| -rw-r--r-- | tests/cogs/__init__.py | 0 | ||||
| -rw-r--r-- | tests/cogs/sync/__init__.py | 0 | ||||
| -rw-r--r-- | tests/cogs/sync/test_roles.py | 103 | ||||
| -rw-r--r-- | tests/cogs/sync/test_users.py | 69 | ||||
| -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/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 | 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 | 
20 files changed, 1000 insertions, 0 deletions
| diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/__init__.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/cogs/__init__.py diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/cogs/sync/__init__.py diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py new file mode 100644 index 000000000..c561ba447 --- /dev/null +++ b/tests/cogs/sync/test_roles.py @@ -0,0 +1,103 @@ +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, 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(), 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, 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, +        set(), +    ) + + +def test_get_roles_only_returns_roles_that_require_update(): +    api_roles = { +        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, 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, 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, position=1), +    } +    guild_roles = { +        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, 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, 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='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=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/sync/test_users.py b/tests/cogs/sync/test_users.py new file mode 100644 index 000000000..a863ae35b --- /dev/null +++ b/tests/cogs/sync/test_users.py @@ -0,0 +1,69 @@ +from bot.cogs.sync.syncers import User, get_users_for_sync + + +def fake_user(**kwargs): +    kwargs.setdefault('id', 43) +    kwargs.setdefault('name', 'bob the test man') +    kwargs.setdefault('discriminator', 1337) +    kwargs.setdefault('avatar_hash', None) +    kwargs.setdefault('roles', (666,)) +    kwargs.setdefault('in_guild', True) +    return User(**kwargs) + + +def test_get_users_for_sync_returns_nothing_for_empty_params(): +    assert get_users_for_sync({}, {}) == (set(), set()) + + +def test_get_users_for_sync_returns_nothing_for_equal_users(): +    api_users = {43: fake_user()} +    guild_users = {43: fake_user()} + +    assert get_users_for_sync(guild_users, api_users) == (set(), set()) + + +def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(): +    api_users = {43: fake_user()} +    guild_users = {43: fake_user(name='new fancy name')} + +    assert get_users_for_sync(guild_users, api_users) == ( +        set(), +        {fake_user(name='new fancy name')} +    ) + + +def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(): +    api_users = {43: fake_user()} +    guild_users = {43: fake_user(), 63: fake_user(id=63)} + +    assert get_users_for_sync(guild_users, api_users) == ( +        {fake_user(id=63)}, +        set() +    ) + + +def test_get_users_for_sync_updates_in_guild_field_on_user_leave(): +    api_users = {43: fake_user(), 63: fake_user(id=63)} +    guild_users = {43: fake_user()} + +    assert get_users_for_sync(guild_users, api_users) == ( +        set(), +        {fake_user(id=63, in_guild=False)} +    ) + + +def test_get_users_for_sync_updates_and_creates_users_as_needed(): +    api_users = {43: fake_user()} +    guild_users = {63: fake_user(id=63)} + +    assert get_users_for_sync(guild_users, api_users) == ( +        {fake_user(id=63)}, +        {fake_user(in_guild=False)} +    ) + + +def test_get_users_for_sync_does_not_duplicate_update_users(): +    api_users = {43: fake_user(in_guild=False)} +    guild_users = {} + +    assert get_users_for_sync(guild_users, api_users) == (set(), set()) 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/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..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) | 
