diff options
| author | 2020-02-03 14:28:36 +0100 | |
|---|---|---|
| committer | 2020-02-03 14:28:36 +0100 | |
| commit | 90763fd761a70168e8752fa78729af40ad98144b (patch) | |
| tree | 53f242431a2ceb37295e4e90846bd1bfb66c5fd4 /tests | |
| parent | Replace constant attachment-repost by the actual ID of #attachment-log (diff) | |
| parent | Update CODEOWNERS (diff) | |
Merge branch 'master' into #549-show-attachments-staff
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/README.md | 9 | ||||
| -rw-r--r-- | tests/bot/rules/test_attachments.py | 110 | ||||
| -rw-r--r-- | tests/bot/rules/test_links.py | 26 | ||||
| -rw-r--r-- | tests/bot/rules/test_mentions.py | 95 | ||||
| -rw-r--r-- | tests/bot/utils/test_time.py | 162 | 
5 files changed, 354 insertions, 48 deletions
| diff --git a/tests/README.md b/tests/README.md index d052de2f6..be78821bf 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,7 +2,7 @@  Our bot is one of the most important tools we have for running our community. As we don't want that tool break, we decided that we wanted to write unit tests for it. We hope that in the future, we'll have a 100% test coverage for the bot. This guide will help you get started with writing the tests needed to achieve that. -_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY)._ +_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you can take a look at the [Additional resources](#additional-resources) section at the bottom of this page._  ## Tools @@ -212,3 +212,10 @@ All in all, it's not only important to consider if all statements or branches we  Another restriction of unit testing is that it tests, well, in units. Even if we can guarantee that the units work as they should independently, we have no guarantee that they will actually work well together. Even more, while the mocking described above gives us a lot of flexibility in factoring out external code, we are work under the implicit assumption that we fully understand those external parts and utilize it correctly. What if our mocked `Context` object works with a `send` method, but `discord.py` has changed it to a `send_message` method in a recent update? It could mean our tests are passing, but the code it's testing still doesn't work in production.  The answer to this is that we also need to make sure that the individual parts come together into a working application. In addition, we will also need to make sure that the application communicates correctly with external applications. Since we currently have no automated integration tests or functional tests, that means **it's still very important to fire up the bot and test the code you've written manually** in addition to the unit tests you've written. + +## Additional resources + +* [Ned Batchelder's PyCon talk: Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY) +* [Corey Schafer video about unittest](https://youtu.be/6tNS--WetLI) +* [RealPython tutorial on unittest testing](https://realpython.com/python-testing/) +* [RealPython tutorial on mocking](https://realpython.com/python-mock-library/) diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index 4bb0acf7c..d7187f315 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -1,52 +1,98 @@ -import asyncio  import unittest -from dataclasses import dataclass -from typing import Any, List +from typing import List, NamedTuple, Tuple  from bot.rules import attachments +from tests.helpers import MockMessage, async_test -# 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] +class Case(NamedTuple): +    recent_messages: List[MockMessage] +    culprit: Tuple[str] +    total_attachments: int -def msg(total_attachments: int) -> FakeMessage: -    return FakeMessage(author='lemon', attachments=list(range(total_attachments))) +def msg(author: str, total_attachments: int) -> MockMessage: +    """Builds a message with `total_attachments` attachments.""" +    return MockMessage(author=author, attachments=list(range(total_attachments)))  class AttachmentRuleTests(unittest.TestCase): -    """Tests applying the `attachment` antispam rule.""" +    """Tests applying the `attachments` antispam rule.""" -    def test_allows_messages_without_too_many_attachments(self): +    def setUp(self): +        self.config = {"max": 5} + +    @async_test +    async def test_allows_messages_without_too_many_attachments(self):          """Messages without too many attachments are allowed as-is."""          cases = ( -            (msg(0), msg(0), msg(0)), -            (msg(2), msg(2)), -            (msg(0),), +            [msg("bob", 0), msg("bob", 0), msg("bob", 0)], +            [msg("bob", 2), msg("bob", 2)], +            [msg("bob", 2), msg("alice", 2), msg("bob", 2)],          ) -        for last_message, *recent_messages in cases: -            with self.subTest(last_message=last_message, recent_messages=recent_messages): -                coro = attachments.apply(last_message, recent_messages, {'max': 5}) -                self.assertIsNone(asyncio.run(coro)) +        for recent_messages in cases: +            last_message = recent_messages[0] + +            with self.subTest( +                last_message=last_message, +                recent_messages=recent_messages, +                config=self.config +            ): +                self.assertIsNone( +                    await attachments.apply(last_message, recent_messages, self.config) +                ) -    def test_disallows_messages_with_too_many_attachments(self): +    @async_test +    async def test_disallows_messages_with_too_many_attachments(self):          """Messages with too many attachments trigger the rule."""          cases = ( -            ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), -            ((msg(6),), [msg(6)], 6), -            ((msg(1),) * 6, [msg(1)] * 6, 6), +            Case( +                [msg("bob", 4), msg("bob", 0), msg("bob", 6)], +                ("bob",), +                10 +            ), +            Case( +                [msg("bob", 4), msg("alice", 6), msg("bob", 2)], +                ("bob",), +                6 +            ), +            Case( +                [msg("alice", 6)], +                ("alice",), +                6 +            ), +            ( +                [msg("alice", 1) for _ in range(6)], +                ("alice",), +                6 +            ),          ) -        for messages, relevant_messages, total in cases: -            with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): -                last_message, *recent_messages = messages -                coro = attachments.apply(last_message, recent_messages, {'max': 5}) -                self.assertEqual( -                    asyncio.run(coro), -                    (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) + +        for recent_messages, culprit, total_attachments in cases: +            last_message = recent_messages[0] +            relevant_messages = tuple( +                msg +                for msg in recent_messages +                if ( +                    msg.author == last_message.author +                    and len(msg.attachments) > 0 +                ) +            ) + +            with self.subTest( +                last_message=last_message, +                recent_messages=recent_messages, +                relevant_messages=relevant_messages, +                total_attachments=total_attachments, +                config=self.config +            ): +                desired_output = ( +                    f"sent {total_attachments} attachments in {self.config['max']}s", +                    culprit, +                    relevant_messages +                ) +                self.assertTupleEqual( +                    await attachments.apply(last_message, recent_messages, self.config), +                    desired_output                  ) diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index be832843b..02a5d5501 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -2,25 +2,19 @@ import unittest  from typing import List, NamedTuple, Tuple  from bot.rules import links -from tests.helpers import async_test - - -class FakeMessage(NamedTuple): -    author: str -    content: str +from tests.helpers import MockMessage, async_test  class Case(NamedTuple): -    recent_messages: List[FakeMessage] -    relevant_messages: Tuple[FakeMessage] +    recent_messages: List[MockMessage]      culprit: Tuple[str]      total_links: int -def msg(author: str, total_links: int) -> FakeMessage: -    """Makes a message with *total_links* links.""" +def msg(author: str, total_links: int) -> MockMessage: +    """Makes a message with `total_links` links."""      content = " ".join(["https://pydis.com"] * total_links) -    return FakeMessage(author=author, content=content) +    return MockMessage(author=author, content=content)  class LinksTests(unittest.TestCase): @@ -61,26 +55,28 @@ class LinksTests(unittest.TestCase):          cases = (              Case(                  [msg("bob", 1), msg("bob", 2)], -                (msg("bob", 1), msg("bob", 2)),                  ("bob",),                  3              ),              Case(                  [msg("alice", 1), msg("alice", 1), msg("alice", 1)], -                (msg("alice", 1), msg("alice", 1), msg("alice", 1)),                  ("alice",),                  3              ),              Case(                  [msg("alice", 2), msg("bob", 3), msg("alice", 1)], -                (msg("alice", 2), msg("alice", 1)),                  ("alice",),                  3              )          ) -        for recent_messages, relevant_messages, culprit, total_links in cases: +        for recent_messages, culprit, total_links in cases:              last_message = recent_messages[0] +            relevant_messages = tuple( +                msg +                for msg in recent_messages +                if msg.author == last_message.author +            )              with self.subTest(                  last_message=last_message, diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py new file mode 100644 index 000000000..ad49ead32 --- /dev/null +++ b/tests/bot/rules/test_mentions.py @@ -0,0 +1,95 @@ +import unittest +from typing import List, NamedTuple, Tuple + +from bot.rules import mentions +from tests.helpers import MockMessage, async_test + + +class Case(NamedTuple): +    recent_messages: List[MockMessage] +    culprit: Tuple[str] +    total_mentions: int + + +def msg(author: str, total_mentions: int) -> MockMessage: +    """Makes a message with `total_mentions` mentions.""" +    return MockMessage(author=author, mentions=list(range(total_mentions))) + + +class TestMentions(unittest.TestCase): +    """Tests applying the `mentions` antispam rule.""" + +    def setUp(self): +        self.config = { +            "max": 2, +            "interval": 10 +        } + +    @async_test +    async def test_mentions_within_limit(self): +        """Messages with an allowed amount of mentions.""" +        cases = ( +            [msg("bob", 0)], +            [msg("bob", 2)], +            [msg("bob", 1), msg("bob", 1)], +            [msg("bob", 1), msg("alice", 2)] +        ) + +        for recent_messages in cases: +            last_message = recent_messages[0] + +            with self.subTest( +                last_message=last_message, +                recent_messages=recent_messages, +                config=self.config +            ): +                self.assertIsNone( +                    await mentions.apply(last_message, recent_messages, self.config) +                ) + +    @async_test +    async def test_mentions_exceeding_limit(self): +        """Messages with a higher than allowed amount of mentions.""" +        cases = ( +            Case( +                [msg("bob", 3)], +                ("bob",), +                3 +            ), +            Case( +                [msg("alice", 2), msg("alice", 0), msg("alice", 1)], +                ("alice",), +                3 +            ), +            Case( +                [msg("bob", 2), msg("alice", 3), msg("bob", 2)], +                ("bob",), +                4 +            ) +        ) + +        for recent_messages, culprit, total_mentions in cases: +            last_message = recent_messages[0] +            relevant_messages = tuple( +                msg +                for msg in recent_messages +                if msg.author == last_message.author +            ) + +            with self.subTest( +                last_message=last_message, +                recent_messages=recent_messages, +                relevant_messages=relevant_messages, +                culprit=culprit, +                total_mentions=total_mentions, +                cofig=self.config +            ): +                desired_output = ( +                    f"sent {total_mentions} mentions in {self.config['interval']}s", +                    culprit, +                    relevant_messages +                ) +                self.assertTupleEqual( +                    await mentions.apply(last_message, recent_messages, self.config), +                    desired_output +                ) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py new file mode 100644 index 000000000..69f35f2f5 --- /dev/null +++ b/tests/bot/utils/test_time.py @@ -0,0 +1,162 @@ +import asyncio +import unittest +from datetime import datetime, timezone +from unittest.mock import patch + +from dateutil.relativedelta import relativedelta + +from bot.utils import time +from tests.helpers import AsyncMock + + +class TimeTests(unittest.TestCase): +    """Test helper functions in bot.utils.time.""" + +    def test_humanize_delta_handle_unknown_units(self): +        """humanize_delta should be able to handle unknown units, and will not abort.""" +        # Does not abort for unknown units, as the unit name is checked +        # against the attribute of the relativedelta instance. +        self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'elephants', 2), '2 days and 2 hours') + +    def test_humanize_delta_handle_high_units(self): +        """humanize_delta should be able to handle very high units.""" +        # Very high maximum units, but it only ever iterates over +        # each value the relativedelta might have. +        self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'hours', 20), '2 days and 2 hours') + +    def test_humanize_delta_should_normal_usage(self): +        """Testing humanize delta.""" +        test_cases = ( +            (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'), +        ) + +        for delta, precision, max_units, expected in test_cases: +            with self.subTest(delta=delta, precision=precision, max_units=max_units, expected=expected): +                self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + +    def test_humanize_delta_raises_for_invalid_max_units(self): +        """humanize_delta should raises ValueError('max_units must be positive') for invalid max_units.""" +        test_cases = (-1, 0) + +        for max_units in test_cases: +            with self.subTest(max_units=max_units), self.assertRaises(ValueError) as error: +                time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) +                self.assertEqual(str(error), 'max_units must be positive') + +    def test_parse_rfc1123(self): +        """Testing parse_rfc1123.""" +        self.assertEqual( +            time.parse_rfc1123('Sun, 15 Sep 2019 12:00:00 GMT'), +            datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc) +        ) + +    def test_format_infraction(self): +        """Testing format_infraction.""" +        self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '2019-12-12 00:01') + +    @patch('asyncio.sleep', new_callable=AsyncMock) +    def test_wait_until(self, mock): +        """Testing wait_until.""" +        start = datetime(2019, 1, 1, 0, 0) +        then = datetime(2019, 1, 1, 0, 10) + +        # No return value +        self.assertIs(asyncio.run(time.wait_until(then, start)), None) + +        mock.assert_called_once_with(10 * 60) + +    def test_format_infraction_with_duration_none_expiry(self): +        """format_infraction_with_duration should work for None expiry.""" +        test_cases = ( +            (None, None, None, None), + +            # To make sure that date_from and max_units are not touched +            (None, 'Why hello there!', None, None), +            (None, None, float('inf'), None), +            (None, 'Why hello there!', float('inf'), None), +        ) + +        for expiry, date_from, max_units, expected in test_cases: +            with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): +                self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + +    def test_format_infraction_with_duration_custom_units(self): +        """format_infraction_with_duration should work for custom max_units.""" +        test_cases = ( +            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, +             '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'), +            ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, +             '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)') +        ) + +        for expiry, date_from, max_units, expected in test_cases: +            with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): +                self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + +    def test_format_infraction_with_duration_normal_usage(self): +        """format_infraction_with_duration should work for normal usage, across various durations.""" +        test_cases = ( +            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '2019-12-12 00:01 (12 hours and 55 seconds)'), +            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '2019-12-12 00:01 (12 hours)'), +            ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '2019-12-12 00:00 (1 minute)'), +            ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '2019-11-23 20:09 (7 days and 23 hours)'), +            ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '2019-11-23 20:09 (6 months and 28 days)'), +            ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '2019-11-23 20:58 (5 minutes)'), +            ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '2019-11-24 00:00 (1 minute)'), +            ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2019-11-23 23:59 (2 years and 4 months)'), +            ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, +             '2019-11-23 23:59 (9 minutes and 55 seconds)'), +            (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), +        ) + +        for expiry, date_from, max_units, expected in test_cases: +            with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): +                self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + +    def test_until_expiration_with_duration_none_expiry(self): +        """until_expiration should work for None expiry.""" +        test_cases = ( +            (None, None, None, None), + +            # To make sure that now and max_units are not touched +            (None, 'Why hello there!', None, None), +            (None, None, float('inf'), None), +            (None, 'Why hello there!', float('inf'), None), +        ) + +        for expiry, now, max_units, expected in test_cases: +            with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): +                self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + +    def test_until_expiration_with_duration_custom_units(self): +        """until_expiration should work for custom max_units.""" +        test_cases = ( +            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, '11 hours, 55 minutes and 55 seconds'), +            ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, '6 months, 28 days, 23 hours and 54 minutes') +        ) + +        for expiry, now, max_units, expected in test_cases: +            with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): +                self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + +    def test_until_expiration_normal_usage(self): +        """until_expiration should work for normal usage, across various durations.""" +        test_cases = ( +            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '12 hours and 55 seconds'), +            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '12 hours'), +            ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '1 minute'), +            ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '7 days and 23 hours'), +            ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '6 months and 28 days'), +            ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '5 minutes'), +            ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '1 minute'), +            ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2 years and 4 months'), +            ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, '9 minutes and 55 seconds'), +            (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), +        ) + +        for expiry, now, max_units, expected in test_cases: +            with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): +                self.assertEqual(time.until_expiration(expiry, now, max_units), expected) | 
