aboutsummaryrefslogtreecommitdiffstats
path: root/tests/bot
diff options
context:
space:
mode:
authorGravatar Sebastiaan Zeeff <[email protected]>2019-10-02 16:59:03 +0200
committerGravatar Sebastiaan Zeeff <[email protected]>2019-10-11 17:42:21 +0200
commitc4213744c18be23e3e4484f126ae0b2d0eba4437 (patch)
treefa26b8d115eac7b9d46fd2abae966c3030f32e78 /tests/bot
parentMerge pull request #505 from python-discord/user-log-display-name-changes (diff)
Migrate pytest to unittest
After a discussion in the core developers channel, we have decided to migrate from `pytest` to `unittest` as the testing framework. This commit sets up the repository to use `unittest` and migrates the first couple of tests files to the new framework. What I have done to migrate to `unitest`: - Removed all `pytest` test files, since they are incompatible. - Removed `pytest`-related dependencies from the Pipfile. - Added `coverage.py` to the Pipfile dev-packages and relocked. - Added convenience scripts to Pipfile for running the test suite. - Adjust to `azure-pipelines.yml` to use `coverage.py` and `unittest`. - Migrated four test files from `pytest` to `unittest` format. In addition, I've added five helper Mock subclasses in `helpers.py` and created a `TestCase` subclass in `base.py` to add an assertion that asserts that no log records were logged within the context of the context manager. Obviously, these new utility functions and classes are fully tested in their respective `test_` files. Finally, I've started with an introductory guide for writing tests for our bot in `README.md`.
Diffstat (limited to 'tests/bot')
-rw-r--r--tests/bot/__init__.py0
-rw-r--r--tests/bot/cogs/__init__.py0
-rw-r--r--tests/bot/cogs/test_information.py164
-rw-r--r--tests/bot/patches/__init__.py0
-rw-r--r--tests/bot/resources/__init__.py0
-rw-r--r--tests/bot/rules/__init__.py0
-rw-r--r--tests/bot/test_api.py134
-rw-r--r--tests/bot/test_converters.py273
-rw-r--r--tests/bot/utils/__init__.py0
-rw-r--r--tests/bot/utils/test_checks.py43
10 files changed, 614 insertions, 0 deletions
diff --git a/tests/bot/__init__.py b/tests/bot/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/__init__.py
diff --git a/tests/bot/cogs/__init__.py b/tests/bot/cogs/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/cogs/__init__.py
diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py
new file mode 100644
index 000000000..9bbd35a91
--- /dev/null
+++ b/tests/bot/cogs/test_information.py
@@ -0,0 +1,164 @@
+import asyncio
+import textwrap
+import unittest
+import unittest.mock
+
+import discord
+
+from bot import constants
+from bot.cogs import information
+from tests.helpers import AsyncMock, MockBot, MockContext, MockGuild, MockMember, MockRole
+
+
+class InformationCogTests(unittest.TestCase):
+ """Tests the Information cog."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.moderator_role = MockRole(name="Moderator", role_id=constants.Roles.moderator)
+
+ def setUp(self):
+ """Sets up fresh objects for each test."""
+ self.bot = MockBot()
+
+ self.cog = information.Information(self.bot)
+
+ self.ctx = MockContext()
+ self.ctx.author.roles.append(self.moderator_role)
+
+ def test_roles_command_command(self):
+ """Test if the `role_info` command correctly returns the `moderator_role`."""
+ self.ctx.guild.roles.append(self.moderator_role)
+
+ self.cog.roles_info.can_run = AsyncMock()
+ self.cog.roles_info.can_run.return_value = True
+
+ coroutine = self.cog.roles_info.callback(self.cog, self.ctx)
+
+ self.assertIsNone(asyncio.run(coroutine))
+ self.ctx.send.assert_called_once()
+
+ _, kwargs = self.ctx.send.call_args
+ embed = kwargs.pop('embed')
+
+ self.assertEqual(embed.title, "Role information")
+ self.assertEqual(embed.colour, discord.Colour.blurple())
+ self.assertEqual(embed.description, f"`{self.moderator_role.id}` - {self.moderator_role.mention}\n")
+ self.assertEqual(embed.footer.text, "Total roles: 1")
+
+ def test_role_info_command(self):
+ """Tests the `role info` command."""
+ dummy_role = MockRole(
+ name="Dummy",
+ role_id=112233445566778899,
+ colour=discord.Colour.blurple(),
+ position=10,
+ members=[self.ctx.author],
+ permissions=discord.Permissions(0)
+ )
+
+ admin_role = MockRole(
+ name="Admins",
+ role_id=998877665544332211,
+ colour=discord.Colour.red(),
+ position=3,
+ members=[self.ctx.author],
+ permissions=discord.Permissions(0),
+ )
+
+ self.ctx.guild.roles.append([dummy_role, admin_role])
+
+ self.cog.role_info.can_run = AsyncMock()
+ self.cog.role_info.can_run.return_value = True
+
+ coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role)
+
+ self.assertIsNone(asyncio.run(coroutine))
+
+ self.assertEqual(self.ctx.send.call_count, 2)
+
+ (_, dummy_kwargs), (_, admin_kwargs) = self.ctx.send.call_args_list
+
+ dummy_embed = dummy_kwargs["embed"]
+ admin_embed = admin_kwargs["embed"]
+
+ self.assertEqual(dummy_embed.title, "Dummy info")
+ self.assertEqual(dummy_embed.colour, discord.Colour.blurple())
+
+ self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id))
+ self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}")
+ self.assertEqual(dummy_embed.fields[2].value, "0.63 0.48 218")
+ self.assertEqual(dummy_embed.fields[3].value, "1")
+ self.assertEqual(dummy_embed.fields[4].value, "10")
+ self.assertEqual(dummy_embed.fields[5].value, "0")
+
+ self.assertEqual(admin_embed.title, "Admins info")
+ self.assertEqual(admin_embed.colour, discord.Colour.red())
+
+ @unittest.mock.patch('bot.cogs.information.time_since')
+ def test_server_info_command(self, time_since_patch):
+ time_since_patch.return_value = '2 days ago'
+
+ self.ctx.guild = MockGuild(
+ features=('lemons', 'apples'),
+ region="The Moon",
+ roles=[self.moderator_role],
+ channels=[
+ discord.TextChannel(
+ state={},
+ guild=self.ctx.guild,
+ data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'}
+ ),
+ discord.CategoryChannel(
+ state={},
+ guild=self.ctx.guild,
+ data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'}
+ ),
+ discord.VoiceChannel(
+ state={},
+ guild=self.ctx.guild,
+ data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'}
+ )
+ ],
+ members=[
+ *(MockMember(status='online') for _ in range(2)),
+ *(MockMember(status='idle') for _ in range(1)),
+ *(MockMember(status='dnd') for _ in range(4)),
+ *(MockMember(status='offline') for _ in range(3)),
+ ],
+ member_count=1_234,
+ icon_url='a-lemon.jpg',
+ )
+
+ coroutine = self.cog.server_info.callback(self.cog, self.ctx)
+ self.assertIsNone(asyncio.run(coroutine))
+
+ time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days')
+ _, kwargs = self.ctx.send.call_args
+ embed = kwargs.pop('embed')
+ self.assertEqual(embed.colour, discord.Colour.blurple())
+ self.assertEqual(
+ embed.description,
+ textwrap.dedent(
+ f"""
+ **Server information**
+ Created: {time_since_patch.return_value}
+ Voice region: {self.ctx.guild.region}
+ Features: {', '.join(self.ctx.guild.features)}
+
+ **Counts**
+ Members: {self.ctx.guild.member_count:,}
+ Roles: {len(self.ctx.guild.roles)}
+ Text: 1
+ Voice: 1
+ Channel categories: 1
+
+ **Members**
+ {constants.Emojis.status_online} 2
+ {constants.Emojis.status_idle} 1
+ {constants.Emojis.status_dnd} 4
+ {constants.Emojis.status_offline} 3
+ """
+ )
+ )
+ self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg')
diff --git a/tests/bot/patches/__init__.py b/tests/bot/patches/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/patches/__init__.py
diff --git a/tests/bot/resources/__init__.py b/tests/bot/resources/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/resources/__init__.py
diff --git a/tests/bot/rules/__init__.py b/tests/bot/rules/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/rules/__init__.py
diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py
new file mode 100644
index 000000000..e0ede0eb1
--- /dev/null
+++ b/tests/bot/test_api.py
@@ -0,0 +1,134 @@
+import logging
+import unittest
+from unittest.mock import MagicMock, patch
+
+from bot import api
+from tests.base import LoggingTestCase
+from tests.helpers import async_test
+
+
+class APIClientTests(unittest.TestCase):
+ """Tests for the bot's API client."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Sets up the shared fixtures for the tests."""
+ cls.error_api_response = MagicMock()
+ cls.error_api_response.status = 999
+
+ def test_loop_is_not_running_by_default(self):
+ """The event loop should not be running by default."""
+ self.assertFalse(api.loop_is_running())
+
+ @async_test
+ async def test_loop_is_running_in_async_context(self):
+ """The event loop should be running in an async context."""
+ self.assertTrue(api.loop_is_running())
+
+ def test_response_code_error_default_initialization(self):
+ """Test the default initialization of `ResponseCodeError` without `text` or `json`"""
+ error = api.ResponseCodeError(response=self.error_api_response)
+
+ self.assertIs(error.status, self.error_api_response.status)
+ self.assertEqual(error.response_json, {})
+ self.assertEqual(error.response_text, "")
+ self.assertIs(error.response, self.error_api_response)
+
+ def test_responde_code_error_string_representation_default_initialization(self):
+ """Test the string representation of `ResponseCodeError` initialized without text or json."""
+ error = api.ResponseCodeError(response=self.error_api_response)
+ self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: ")
+
+ def test_response_code_error_initialization_with_json(self):
+ """Test the initialization of `ResponseCodeError` with json."""
+ json_data = {'hello': 'world'}
+ error = api.ResponseCodeError(
+ response=self.error_api_response,
+ response_json=json_data,
+ )
+ self.assertEqual(error.response_json, json_data)
+ self.assertEqual(error.response_text, "")
+
+ def test_response_code_error_string_representation_with_nonempty_response_json(self):
+ """Test the string representation of `ResponseCodeError` initialized with json."""
+ json_data = {'hello': 'world'}
+ error = api.ResponseCodeError(
+ response=self.error_api_response,
+ response_json=json_data
+ )
+ self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: {json_data}")
+
+ def test_response_code_error_initialization_with_text(self):
+ """Test the initialization of `ResponseCodeError` with text."""
+ text_data = 'Lemon will eat your soul'
+ error = api.ResponseCodeError(
+ response=self.error_api_response,
+ response_text=text_data,
+ )
+ self.assertEqual(error.response_text, text_data)
+ self.assertEqual(error.response_json, {})
+
+ def test_response_code_error_string_representation_with_nonempty_response_text(self):
+ """Test the string representation of `ResponseCodeError` initialized with text."""
+ text_data = 'Lemon will eat your soul'
+ error = api.ResponseCodeError(
+ response=self.error_api_response,
+ response_text=text_data
+ )
+ self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: {text_data}")
+
+
+class LoggingHandlerTests(LoggingTestCase):
+ """Tests the bot's API Log Handler."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.debug_log_record = logging.LogRecord(
+ name='my.logger', level=logging.DEBUG,
+ pathname='my/logger.py', lineno=666,
+ msg="Lemon wins", args=(),
+ exc_info=None
+ )
+
+ cls.trace_log_record = logging.LogRecord(
+ name='my.logger', level=logging.TRACE,
+ pathname='my/logger.py', lineno=666,
+ msg="This will not be logged", args=(),
+ exc_info=None
+ )
+
+ def setUp(self):
+ self.log_handler = api.APILoggingHandler(None)
+
+ def test_emit_appends_to_queue_with_stopped_event_loop(self):
+ """Test if `APILoggingHandler.emit` appends to queue when the event loop is not running."""
+ with patch("bot.api.APILoggingHandler.ship_off") as ship_off:
+ # Patch `ship_off` to ease testing against the return value of this coroutine.
+ ship_off.return_value = 42
+ self.log_handler.emit(self.debug_log_record)
+
+ self.assertListEqual(self.log_handler.queue, [42])
+
+ def test_emit_ignores_less_than_debug(self):
+ """`APILoggingHandler.emit` should not queue logs with a log level lower than DEBUG."""
+ self.log_handler.emit(self.trace_log_record)
+ self.assertListEqual(self.log_handler.queue, [])
+
+ def test_schedule_queued_tasks_for_empty_queue(self):
+ """`APILoggingHandler` should not schedule anything when the queue is empty."""
+ with self.assertNotLogs(level=logging.DEBUG):
+ self.log_handler.schedule_queued_tasks()
+
+ def test_schedule_queued_tasks_for_nonempty_queue(self):
+ """`APILoggingHandler` should schedule logs when the queue is not empty."""
+ with self.assertLogs(level=logging.DEBUG) as logs, patch('asyncio.create_task') as create_task:
+ self.log_handler.queue = [555]
+ self.log_handler.schedule_queued_tasks()
+ self.assertListEqual(self.log_handler.queue, [])
+ create_task.assert_called_once_with(555)
+
+ [record] = logs.records
+ self.assertEqual(record.message, "Scheduled 1 pending logging tasks.")
+ self.assertEqual(record.levelno, logging.DEBUG)
+ self.assertEqual(record.name, 'bot.api')
+ self.assertIn('via_handler', record.__dict__)
diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py
new file mode 100644
index 000000000..b2b78d9dd
--- /dev/null
+++ b/tests/bot/test_converters.py
@@ -0,0 +1,273 @@
+import asyncio
+import datetime
+import unittest
+from unittest.mock import MagicMock, patch
+
+from dateutil.relativedelta import relativedelta
+from discord.ext.commands import BadArgument
+
+from bot.converters import (
+ Duration,
+ ISODateTime,
+ TagContentConverter,
+ TagNameConverter,
+ ValidPythonIdentifier,
+)
+
+
+class ConverterTests(unittest.TestCase):
+ """Tests our custom argument converters."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.context = MagicMock
+ cls.context.author = 'bob'
+
+ cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00')
+
+ def test_tag_content_converter_for_valid(self):
+ """TagContentConverter should return correct values for valid input."""
+ test_values = (
+ ('hello', 'hello'),
+ (' h ello ', 'h ello'),
+ )
+
+ for content, expected_conversion in test_values:
+ with self.subTest(content=content, expected_conversion=expected_conversion):
+ conversion = asyncio.run(TagContentConverter.convert(self.context, content))
+ self.assertEqual(conversion, expected_conversion)
+
+ def test_tag_content_converter_for_invalid(self):
+ """TagContentConverter should raise the proper exception for invalid input."""
+ test_values = (
+ ('', "Tag contents should not be empty, or filled with whitespace."),
+ (' ', "Tag contents should not be empty, or filled with whitespace."),
+ )
+
+ for value, exception_message in test_values:
+ with self.subTest(tag_content=value, exception_message=exception_message):
+ with self.assertRaises(BadArgument, msg=exception_message):
+ asyncio.run(TagContentConverter.convert(self.context, value))
+
+ def test_tag_name_converter_for_valid(self):
+ """TagNameConverter should return the correct values for valid tag names."""
+ test_values = (
+ ('tracebacks', 'tracebacks'),
+ ('Tracebacks', 'tracebacks'),
+ (' Tracebacks ', 'tracebacks'),
+ )
+
+ for name, expected_conversion in test_values:
+ with self.subTest(name=name, expected_conversion=expected_conversion):
+ conversion = asyncio.run(TagNameConverter.convert(self.context, name))
+ self.assertEqual(conversion, expected_conversion)
+
+ def test_tag_name_converter_for_invalid(self):
+ """TagNameConverter should raise the correct exception for invalid tag names."""
+ test_values = (
+ ('👋', "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."),
+ ('x' * 128, "Are you insane? That's way too long!"),
+ )
+
+ for invalid_name, exception_message in test_values:
+ with self.subTest(invalid_name=invalid_name, exception_message=exception_message):
+ with self.assertRaises(BadArgument, msg=exception_message):
+ asyncio.run(TagNameConverter.convert(self.context, invalid_name))
+
+ def test_valid_python_identifier_for_valid(self):
+ """ValidPythonIdentifier returns valid identifiers unchanged."""
+ test_values = ('foo', 'lemon')
+
+ for name in test_values:
+ with self.subTest(identifier=name):
+ conversion = asyncio.run(ValidPythonIdentifier.convert(self.context, name))
+ self.assertEqual(name, conversion)
+
+ def test_valid_python_identifier_for_invalid(self):
+ """ValidPythonIdentifier raises the proper exception for invalid identifiers."""
+ test_values = ('nested.stuff', '#####')
+
+ for name in test_values:
+ with self.subTest(identifier=name):
+ exception_message = f'`{name}` is not a valid Python identifier'
+ with self.assertRaises(BadArgument, msg=exception_message):
+ asyncio.run(ValidPythonIdentifier.convert(self.context, name))
+
+ def test_duration_converter_for_valid(self):
+ """Duration returns the correct `datetime` for valid duration strings."""
+ test_values = (
+ # 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}),
+ )
+
+ converter = Duration()
+
+ for duration, duration_dict in test_values:
+ expected_datetime = self.fixed_utc_now + relativedelta(**duration_dict)
+
+ with patch('bot.converters.datetime') as mock_datetime:
+ mock_datetime.utcnow.return_value = self.fixed_utc_now
+
+ with self.subTest(duration=duration, duration_dict=duration_dict):
+ converted_datetime = asyncio.run(converter.convert(self.context, duration))
+ self.assertEqual(converted_datetime, expected_datetime)
+
+ def test_duration_converter_for_invalid(self):
+ """Duration raises the right exception for invalid duration strings."""
+ test_values = (
+ # 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'),
+ )
+
+ converter = Duration()
+
+ for invalid_duration in test_values:
+ with self.subTest(invalid_duration=invalid_duration):
+ exception_message = f'`{invalid_duration}` is not a valid duration string.'
+ with self.assertRaises(BadArgument, msg=exception_message):
+ asyncio.run(converter.convert(self.context, invalid_duration))
+
+ def test_isodatetime_converter_for_valid(self):
+ """ISODateTime converter returns correct datetime for valid datetime string."""
+ test_values = (
+ # `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)),
+ )
+
+ converter = ISODateTime()
+
+ for datetime_string, expected_dt in test_values:
+ with self.subTest(datetime_string=datetime_string, expected_dt=expected_dt):
+ converted_dt = asyncio.run(converter.convert(self.context, datetime_string))
+ self.assertIsNone(converted_dt.tzinfo)
+ self.assertEqual(converted_dt, expected_dt)
+
+ def test_isodatetime_converter_for_invalid(self):
+ """ISODateTime converter raises the correct exception for invalid datetime strings."""
+ test_values = (
+ # 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'),
+ )
+
+ converter = ISODateTime()
+ for datetime_string in test_values:
+ with self.subTest(datetime_string=datetime_string):
+ exception_message = f"`{datetime_string}` is not a valid ISO-8601 datetime string"
+ with self.assertRaises(BadArgument, msg=exception_message):
+ asyncio.run(converter.convert(self.context, datetime_string))
diff --git a/tests/bot/utils/__init__.py b/tests/bot/utils/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/utils/__init__.py
diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py
new file mode 100644
index 000000000..22dc93073
--- /dev/null
+++ b/tests/bot/utils/test_checks.py
@@ -0,0 +1,43 @@
+import unittest
+
+from bot.utils import checks
+from tests.helpers import MockContext, MockRole
+
+
+class ChecksTests(unittest.TestCase):
+ """Tests the check functions defined in `bot.checks`."""
+
+ def setUp(self):
+ self.ctx = MockContext()
+
+ def test_with_role_check_without_guild(self):
+ """`with_role_check` returns `False` if `Context.guild` is None."""
+ self.ctx.guild = None
+ self.assertFalse(checks.with_role_check(self.ctx))
+
+ def test_with_role_check_without_required_roles(self):
+ """`with_role_check` returns `False` if `Context.author` lacks the required role."""
+ self.ctx.author.roles = []
+ self.assertFalse(checks.with_role_check(self.ctx))
+
+ def test_with_role_check_with_guild_and_required_role(self):
+ """`with_role_check` returns `True` if `Context.author` has the required role."""
+ self.ctx.author.roles.append(MockRole(role_id=10))
+ self.assertTrue(checks.with_role_check(self.ctx, 10))
+
+ def test_without_role_check_without_guild(self):
+ """`without_role_check` should return `False` when `Context.guild` is None."""
+ self.ctx.guild = None
+ self.assertFalse(checks.without_role_check(self.ctx))
+
+ def test_without_role_check_returns_false_with_unwanted_role(self):
+ """`without_role_check` returns `False` if `Context.author` has unwanted role."""
+ role_id = 42
+ self.ctx.author.roles.append(MockRole(role_id=role_id))
+ self.assertFalse(checks.without_role_check(self.ctx, role_id))
+
+ def test_without_role_check_returns_true_without_unwanted_role(self):
+ """`without_role_check` returns `True` if `Context.author` does not have unwanted role."""
+ role_id = 42
+ self.ctx.author.roles.append(MockRole(role_id=role_id))
+ self.assertTrue(checks.without_role_check(self.ctx, role_id + 10))